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