Celery integration

This commit is contained in:
Sonny 2019-07-14 18:44:15 +02:00
parent 1b774a7208
commit a74ffae9a7
64 changed files with 829 additions and 221 deletions

View file

@ -10,6 +10,7 @@ python tests:
stage: test stage: test
variables: variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
DJANGO_SETTINGS_MODULE: "newsreader.conf.gitlab"
cache: cache:
key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG" key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG"
paths: paths:
@ -20,6 +21,6 @@ python tests:
- source env/bin/activate - source env/bin/activate
- pip install -r requirements/gitlab.txt - pip install -r requirements/gitlab.txt
script: script:
- python src/manage.py test newsreader --settings=newsreader.conf.gitlab - python src/manage.py test newsreader
- isort -rc src/ --check-only - isort -rc src/ --check-only
- black -l 100 --check src/ - black -l 90 --check src/

View file

@ -1,6 +1,6 @@
[settings] [settings]
include_trailing_comma = true include_trailing_comma = true
line_length = 100 line_length = 90
multi_line_output = 3 multi_line_output = 3
skip = env/, venv/ skip = env/, venv/
default_section = THIRDPARTY default_section = THIRDPARTY

View file

@ -1,8 +1,10 @@
bleach==3.1.0 bleach==3.1.0
beautifulsoup4==4.7.1 beautifulsoup4==4.7.1
celery==4.3.0
certifi==2019.3.9 certifi==2019.3.9
chardet==3.0.4 chardet==3.0.4
Django==2.2 Django==2.2
django-celery-beat==1.5.0
djangorestframework==3.9.4 djangorestframework==3.9.4
lxml==4.3.4 lxml==4.3.4
feedparser==5.2.1 feedparser==5.2.1

View file

@ -0,0 +1,4 @@
from .celery import app as celery_app
__all__ = ["celery_app"]

View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = "accounts"

View file

@ -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())],
)
]

View file

@ -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")]

View file

@ -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())]
)
]

View file

@ -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",
),
)
]

View file

@ -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)

View file

@ -1,11 +1,10 @@
from django.contrib.auth.models import User
import factory import factory
from newsreader.accounts.models import User
class UserFactory(factory.django.DjangoModelFactory): class UserFactory(factory.django.DjangoModelFactory):
username = factory.Sequence(lambda n: f"user-{n}") email = factory.Faker("email")
email = factory.LazyAttribute(lambda o: f"{o.username}@example.org")
password = factory.Faker("password") password = factory.Faker("password")
is_staff = False is_staff = False

View file

@ -1,5 +0,0 @@
from django.apps import AppConfig
class AuthConfig(AppConfig):
name = "auth"

View file

@ -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

12
src/newsreader/celery.py Normal file
View file

@ -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()

View file

@ -38,7 +38,10 @@ INSTALLED_APPS = [
"django.contrib.staticfiles", "django.contrib.staticfiles",
# third party apps # third party apps
"rest_framework", "rest_framework",
"celery",
"django_celery_beat",
# app modules # app modules
"newsreader.accounts",
"newsreader.news.core", "newsreader.news.core",
"newsreader.news.collection", "newsreader.news.collection",
] ]
@ -92,8 +95,8 @@ AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
] ]
# Authentication # Authentication user model
AUTHENTICATION_BACKENDS = ["newsreader.auth.backends.EmailBackend"] AUTH_USER_MODEL = "accounts.User"
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/ # https://docs.djangoproject.com/en/2.2/topics/i18n/
@ -110,10 +113,12 @@ STATIC_URL = "/static/"
# Third party settings # Third party settings
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework.authentication.SessionAuthentication",), "DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication",
),
"DEFAULT_PERMISSION_CLASSES": ( "DEFAULT_PERMISSION_CLASSES": (
"rest_framework.permissions.IsAuthenticated", "rest_framework.permissions.IsAuthenticated",
"newsreader.auth.permissions.IsOwner", "newsreader.accounts.permissions.IsOwner",
), ),
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
} }

View file

@ -8,5 +8,10 @@ class CollectionRuleAdmin(admin.ModelAdmin):
list_display = ("name", "category", "url", "last_suceeded", "succeeded") 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) admin.site.register(CollectionRule, CollectionRuleAdmin)

View file

@ -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 from bs4 import BeautifulSoup
@ -67,11 +69,13 @@ class Collector:
client = None client = None
builder = 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.client = client if client else self.client
self.builder = builder if builder else self.builder 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: with self.client(rules=rules) as client:
for data, stream in client: for data, stream in client:
with self.builder((data, stream)) as builder: with self.builder((data, stream)) as builder:

View file

@ -78,7 +78,9 @@ class FaviconClient(Client):
def __enter__(self) -> ContextManager: def __enter__(self) -> ContextManager:
with ThreadPoolExecutor(max_workers=10) as executor: 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): for future in as_completed(futures):
rule = futures[future] rule = futures[future]

View file

@ -156,7 +156,7 @@ class FeedCollector(Collector):
class FeedDuplicateHandler: class FeedDuplicateHandler:
def __init__(self, rule: CollectionRule) -> None: def __init__(self, rule: CollectionRule) -> None:
self.queryset = rule.post_set.all() self.queryset = rule.posts.all()
def __enter__(self) -> ContextManager: def __enter__(self) -> ContextManager:
self.existing_identifiers = self.queryset.filter( self.existing_identifiers = self.queryset.filter(
@ -202,7 +202,9 @@ class FeedDuplicateHandler:
def handle_duplicate(self, instance: Post) -> Optional[Post]: def handle_duplicate(self, instance: Post) -> Optional[Post]:
try: try:
existing_instance = self.queryset.get(remote_identifier=instance.remote_identifier) existing_instance = self.queryset.get(
remote_identifier=instance.remote_identifier
)
except ObjectDoesNotExist: except ObjectDoesNotExist:
return return

View file

@ -1,14 +1,11 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from newsreader.news.collection.feed import FeedCollector from newsreader.news.collection.feed import FeedCollector
from newsreader.news.collection.models import CollectionRule
class Command(BaseCommand): class Command(BaseCommand):
help = "Collects Atom/RSS feeds" help = "Collects Atom/RSS feeds"
def handle(self, *args, **options): def handle(self, *args, **options):
CollectionRule.objects.all()
collector = FeedCollector() collector = FeedCollector()
collector.collect() collector.collect()

View file

@ -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 import django.utils.timezone
@ -18,7 +18,10 @@ class Migration(migrations.Migration):
( (
"id", "id",
models.AutoField( 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)), ("created", models.DateTimeField(default=django.utils.timezone.now)),
@ -27,7 +30,9 @@ class Migration(migrations.Migration):
("url", models.URLField(max_length=1024)), ("url", models.URLField(max_length=1024)),
( (
"website_url", "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)), ("favicon", models.URLField(blank=True, null=True)),
( (
@ -93,8 +98,14 @@ class Migration(migrations.Migration):
("America/Anguilla", "America/Anguilla"), ("America/Anguilla", "America/Anguilla"),
("America/Antigua", "America/Antigua"), ("America/Antigua", "America/Antigua"),
("America/Araguaina", "America/Araguaina"), ("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",
"America/Argentina/ComodRivadavia", "America/Argentina/ComodRivadavia",
@ -103,7 +114,10 @@ class Migration(migrations.Migration):
("America/Argentina/Jujuy", "America/Argentina/Jujuy"), ("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/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/Salta", "America/Argentina/Salta"),
("America/Argentina/San_Juan", "America/Argentina/San_Juan"), ("America/Argentina/San_Juan", "America/Argentina/San_Juan"),
("America/Argentina/San_Luis", "America/Argentina/San_Luis"), ("America/Argentina/San_Luis", "America/Argentina/San_Luis"),
@ -163,7 +177,10 @@ class Migration(migrations.Migration):
("America/Halifax", "America/Halifax"), ("America/Halifax", "America/Halifax"),
("America/Havana", "America/Havana"), ("America/Havana", "America/Havana"),
("America/Hermosillo", "America/Hermosillo"), ("America/Hermosillo", "America/Hermosillo"),
("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"), (
"America/Indiana/Indianapolis",
"America/Indiana/Indianapolis",
),
("America/Indiana/Knox", "America/Indiana/Knox"), ("America/Indiana/Knox", "America/Indiana/Knox"),
("America/Indiana/Marengo", "America/Indiana/Marengo"), ("America/Indiana/Marengo", "America/Indiana/Marengo"),
("America/Indiana/Petersburg", "America/Indiana/Petersburg"), ("America/Indiana/Petersburg", "America/Indiana/Petersburg"),
@ -177,8 +194,14 @@ class Migration(migrations.Migration):
("America/Jamaica", "America/Jamaica"), ("America/Jamaica", "America/Jamaica"),
("America/Jujuy", "America/Jujuy"), ("America/Jujuy", "America/Jujuy"),
("America/Juneau", "America/Juneau"), ("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/Knox_IN", "America/Knox_IN"),
("America/Kralendijk", "America/Kralendijk"), ("America/Kralendijk", "America/Kralendijk"),
("America/La_Paz", "America/La_Paz"), ("America/La_Paz", "America/La_Paz"),
@ -209,9 +232,18 @@ class Migration(migrations.Migration):
("America/Nipigon", "America/Nipigon"), ("America/Nipigon", "America/Nipigon"),
("America/Nome", "America/Nome"), ("America/Nome", "America/Nome"),
("America/Noronha", "America/Noronha"), ("America/Noronha", "America/Noronha"),
("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"), (
("America/North_Dakota/Center", "America/North_Dakota/Center"), "America/North_Dakota/Beulah",
("America/North_Dakota/New_Salem", "America/North_Dakota/New_Salem"), "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/Ojinaga", "America/Ojinaga"),
("America/Panama", "America/Panama"), ("America/Panama", "America/Panama"),
("America/Pangnirtung", "America/Pangnirtung"), ("America/Pangnirtung", "America/Pangnirtung"),

View file

@ -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),
),
]

View file

@ -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
),
)
]

View file

@ -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,
)
]

View file

@ -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 import django.db.models.deletion
@ -7,12 +7,10 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True dependencies = [("collection", "0003_auto_20190714_1417")]
dependencies = [("collection", "0001_initial"), ("core", "0001_initial")]
operations = [ operations = [
migrations.AddField( migrations.AlterField(
model_name="collectionrule", model_name="collectionrule",
name="category", name="category",
field=models.ForeignKey( field=models.ForeignKey(
@ -20,6 +18,7 @@ class Migration(migrations.Migration):
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, null=True,
on_delete=django.db.models.deletion.SET_NULL, on_delete=django.db.models.deletion.SET_NULL,
related_name="rules",
to="core.Category", to="core.Category",
verbose_name="Category", verbose_name="Category",
), ),

View file

@ -24,6 +24,7 @@ class CollectionRule(TimeStampedModel):
blank=True, blank=True,
null=True, null=True,
verbose_name=_("Category"), verbose_name=_("Category"),
related_name="rules",
help_text=_("Posts from this rule will be tagged with this category"), help_text=_("Posts from this rule will be tagged with this category"),
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
) )
@ -32,7 +33,7 @@ class CollectionRule(TimeStampedModel):
succeeded = models.BooleanField(default=False) succeeded = models.BooleanField(default=False)
error = models.CharField(max_length=255, blank=True, null=True) 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): def __str__(self):
return self.name return self.name

View file

@ -10,9 +10,11 @@ class CollectionRuleSerializer(serializers.HyperlinkedModelSerializer):
def get_posts(self, instance): def get_posts(self, instance):
request = self.context.get("request") 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 return serializer.data
class Meta: class Meta:

View file

@ -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)

View file

@ -5,7 +5,7 @@ from urllib.parse import urljoin
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse 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.collection.tests.factories import CollectionRuleFactory
from newsreader.news.core.tests.factories import CategoryFactory from newsreader.news.core.tests.factories import CategoryFactory
@ -130,14 +130,18 @@ class CollectionRuleDetailViewTestCase(TestCase):
self.client.force_login(self.user) self.client.force_login(self.user)
response = self.client.patch( response = self.client.patch(
reverse("api:rules-detail", args=[rule.pk]), 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", content_type="application/json",
) )
data = response.json() data = response.json()
url = data["category"] url = data["category"]
self.assertEquals(response.status_code, 200) 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): def test_put(self):
rule = CollectionRuleFactory(name="BBC", user=self.user) rule = CollectionRuleFactory(name="BBC", user=self.user)

View file

@ -7,7 +7,7 @@ from django.urls import reverse
import pytz 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.collection.tests.factories import CollectionRuleFactory
from newsreader.news.core.tests.factories import CategoryFactory, PostFactory from newsreader.news.core.tests.factories import CategoryFactory, PostFactory
@ -100,7 +100,9 @@ class CollectionRuleListViewTestCase(TestCase):
self.client.force_login(self.user) self.client.force_login(self.user)
response = self.client.post( 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() data = response.json()
category_url = data["category"] category_url = data["category"]
@ -110,7 +112,9 @@ class CollectionRuleListViewTestCase(TestCase):
self.assertEquals(data["name"], "BBC") self.assertEquals(data["name"], "BBC")
self.assertEquals(data["url"], "https://www.bbc.co.uk") 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): def test_patch(self):
self.client.force_login(self.user) self.client.force_login(self.user)

View file

@ -1,6 +1,6 @@
import factory import factory
from newsreader.auth.tests.factories import UserFactory from newsreader.accounts.tests.factories import UserFactory
from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.models import CollectionRule

View file

@ -18,7 +18,9 @@ class FaviconBuilderTestCase(TestCase):
self.assertEquals(rule.favicon, "https://www.bbc.com/favicon.ico") self.assertEquals(rule.favicon, "https://www.bbc.com/favicon.ico")
def test_without_url(self): 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: with FaviconBuilder((rule, mock_without_url)) as builder:
builder.build() builder.build()
@ -39,7 +41,9 @@ class FaviconBuilderTestCase(TestCase):
with FaviconBuilder((rule, mock_with_weird_path)) as builder: with FaviconBuilder((rule, mock_with_weird_path)) as builder:
builder.build() 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): def test_other_url(self):
rule = CollectionRuleFactory(favicon=None) rule = CollectionRuleFactory(favicon=None)

View file

@ -137,7 +137,13 @@ feed_mock = {
"link": "https://www.bbc.co.uk/news/", "link": "https://www.bbc.co.uk/news/",
}, },
"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", "title": "BBC News - Home",
}, },
"href": "http://feeds.bbci.co.uk/news/rss.xml", "href": "http://feeds.bbci.co.uk/news/rss.xml",

View file

@ -22,10 +22,14 @@ class FaviconCollectorTestCase(TestCase):
def setUp(self): def setUp(self):
self.maxDiff = None 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.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() self.mocked_website_read = self.patched_website_read.start()
def tearDown(self): def tearDown(self):

View file

@ -57,7 +57,13 @@ simple_mock = {
"language": "en-gb", "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"}], "links": [
{
"href": "https://www.bbc.co.uk/news/",
"rel": "alternate",
"type": "text/html",
}
],
"title": "BBC News - Home", "title": "BBC News - Home",
}, },
"href": "http://feeds.bbci.co.uk/news/rss.xml", "href": "http://feeds.bbci.co.uk/news/rss.xml",
@ -201,7 +207,13 @@ multiple_mock = {
"language": "en-gb", "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"}], "links": [
{
"href": "https://www.bbc.co.uk/news/",
"rel": "alternate",
"type": "text/html",
}
],
"title": "BBC News - Home", "title": "BBC News - Home",
}, },
"href": "http://feeds.bbci.co.uk/news/rss.xml", "href": "http://feeds.bbci.co.uk/news/rss.xml",
@ -302,7 +314,13 @@ mock_without_identifier = {
"language": "en-gb", "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"}], "links": [
{
"href": "https://www.bbc.co.uk/news/",
"rel": "alternate",
"type": "text/html",
}
],
"title": "BBC News - Home", "title": "BBC News - Home",
}, },
"href": "http://feeds.bbci.co.uk/news/rss.xml", "href": "http://feeds.bbci.co.uk/news/rss.xml",
@ -402,7 +420,13 @@ mock_without_publish_date = {
"language": "en-gb", "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"}], "links": [
{
"href": "https://www.bbc.co.uk/news/",
"rel": "alternate",
"type": "text/html",
}
],
"title": "BBC News - Home", "title": "BBC News - Home",
}, },
"href": "http://feeds.bbci.co.uk/news/rss.xml", "href": "http://feeds.bbci.co.uk/news/rss.xml",
@ -492,7 +516,13 @@ mock_without_url = {
"language": "en-gb", "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"}], "links": [
{
"href": "https://www.bbc.co.uk/news/",
"rel": "alternate",
"type": "text/html",
}
],
"title": "BBC News - Home", "title": "BBC News - Home",
}, },
"href": "http://feeds.bbci.co.uk/news/rss.xml", "href": "http://feeds.bbci.co.uk/news/rss.xml",
@ -575,7 +605,13 @@ mock_without_body = {
"language": "en-gb", "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"}], "links": [
{
"href": "https://www.bbc.co.uk/news/",
"rel": "alternate",
"type": "text/html",
}
],
"title": "BBC News - Home", "title": "BBC News - Home",
}, },
"href": "http://feeds.bbci.co.uk/news/rss.xml", "href": "http://feeds.bbci.co.uk/news/rss.xml",
@ -676,7 +712,13 @@ mock_without_author = {
"language": "en-gb", "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"}], "links": [
{
"href": "https://www.bbc.co.uk/news/",
"rel": "alternate",
"type": "text/html",
}
],
"title": "BBC News - Home", "title": "BBC News - Home",
}, },
"href": "http://feeds.bbci.co.uk/news/rss.xml", "href": "http://feeds.bbci.co.uk/news/rss.xml",
@ -822,7 +864,13 @@ mock_with_update_entries = {
"language": "en-gb", "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"}], "links": [
{
"href": "https://www.bbc.co.uk/news/",
"rel": "alternate",
"type": "text/html",
}
],
"title": "BBC News - Home", "title": "BBC News - Home",
}, },
"href": "http://feeds.bbci.co.uk/news/rss.xml", "href": "http://feeds.bbci.co.uk/news/rss.xml",
@ -883,7 +931,13 @@ mock_with_html = {
"language": "en-gb", "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"}], "links": [
{
"href": "https://www.bbc.co.uk/news/",
"rel": "alternate",
"type": "text/html",
}
],
"title": "BBC News - Home", "title": "BBC News - Home",
}, },
"href": "http://feeds.bbci.co.uk/news/rss.xml", "href": "http://feeds.bbci.co.uk/news/rss.xml",
@ -945,7 +999,13 @@ mock_with_long_author = {
"language": "en-gb", "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"}], "links": [
{
"href": "https://www.bbc.co.uk/news/",
"rel": "alternate",
"type": "text/html",
}
],
"title": "BBC News - Home", "title": "BBC News - Home",
}, },
"href": "http://feeds.bbci.co.uk/news/rss.xml", "href": "http://feeds.bbci.co.uk/news/rss.xml",
@ -1012,7 +1072,13 @@ mock_with_long_title = {
"language": "en-gb", "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"}], "links": [
{
"href": "https://www.bbc.co.uk/news/",
"rel": "alternate",
"type": "text/html",
}
],
"title": "BBC News - Home", "title": "BBC News - Home",
}, },
"href": "http://feeds.bbci.co.uk/news/rss.xml", "href": "http://feeds.bbci.co.uk/news/rss.xml",

View file

@ -42,7 +42,9 @@ class FeedBuilderTestCase(TestCase):
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): def test_multiple_entries(self):
builder = FeedBuilder builder = FeedBuilder
@ -64,12 +66,17 @@ class FeedBuilderTestCase(TestCase):
self.assertEquals(first_post.publication_date, aware_date) self.assertEquals(first_post.publication_date, aware_date)
self.assertEquals( 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)) d = datetime.combine(date(2019, 5, 20), time(hour=12, minute=19, second=19))
aware_date = pytz.utc.localize(d) 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.publication_date, aware_date)
self.assertEquals( 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")
@ -104,9 +114,13 @@ class FeedBuilderTestCase(TestCase):
self.assertEquals(first_post.remote_identifier, None) 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") @freeze_time("2019-10-30 12:30:00")
def test_entry_without_publication_date(self): 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.created, timezone.now())
self.assertEquals( 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.created, timezone.now())
self.assertEquals( 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") @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.created, timezone.now())
self.assertEquals( 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.created, timezone.now())
self.assertEquals( 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") @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.created, timezone.now())
self.assertEquals( 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.created, timezone.now())
@ -201,12 +220,14 @@ class FeedBuilderTestCase(TestCase):
self.assertEquals(first_post.created, timezone.now()) self.assertEquals(first_post.created, timezone.now())
self.assertEquals( 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.created, timezone.now())
self.assertEquals( 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): def test_empty_entries(self):
@ -241,10 +262,13 @@ class FeedBuilderTestCase(TestCase):
existing_second_post.refresh_from_db() existing_second_post.refresh_from_db()
self.assertEquals( 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): def test_html_sanitizing(self):
builder = FeedBuilder builder = FeedBuilder

View file

@ -54,7 +54,13 @@ simple_mock = {
"language": "en-gb", "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"}], "links": [
{
"href": "https://www.bbc.co.uk/news/",
"rel": "alternate",
"type": "text/html",
}
],
"title": "BBC News - Home", "title": "BBC News - Home",
}, },
"href": "http://feeds.bbci.co.uk/news/rss.xml", "href": "http://feeds.bbci.co.uk/news/rss.xml",

View file

@ -134,7 +134,13 @@ multiple_mock = {
"language": "en-gb", "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"}], "links": [
{
"href": "https://www.bbc.co.uk/news/",
"rel": "alternate",
"type": "text/html",
}
],
"title": "BBC News - Home", "title": "BBC News - Home",
}, },
"href": "http://feeds.bbci.co.uk/news/rss.xml", "href": "http://feeds.bbci.co.uk/news/rss.xml",
@ -154,7 +160,13 @@ empty_mock = {
"language": "en-gb", "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"}], "links": [
{
"href": "https://www.bbc.co.uk/news/",
"rel": "alternate",
"type": "text/html",
}
],
"title": "BBC News - Home", "title": "BBC News - Home",
}, },
"href": "http://feeds.bbci.co.uk/news/rss.xml", "href": "http://feeds.bbci.co.uk/news/rss.xml",
@ -292,7 +304,13 @@ duplicate_mock = {
"language": "en-gb", "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"}], "links": [
{
"href": "https://www.bbc.co.uk/news/",
"rel": "alternate",
"type": "text/html",
}
],
"title": "BBC News - Home", "title": "BBC News - Home",
}, },
"href": "http://feeds.bbci.co.uk/news/rss.xml", "href": "http://feeds.bbci.co.uk/news/rss.xml",
@ -433,7 +451,13 @@ multiple_update_mock = {
"language": "en-gb", "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"}], "links": [
{
"href": "https://www.bbc.co.uk/news/",
"rel": "alternate",
"type": "text/html",
}
],
"title": "BBC News - Home", "title": "BBC News - Home",
}, },
"href": "http://feeds.bbci.co.uk/news/rss.xml", "href": "http://feeds.bbci.co.uk/news/rss.xml",

View file

@ -234,8 +234,12 @@ class FeedCollectorTestCase(TestCase):
self.assertEquals(rule.last_suceeded, timezone.now()) self.assertEquals(rule.last_suceeded, timezone.now())
self.assertEquals(rule.error, None) 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"
)

View file

@ -54,7 +54,13 @@ simple_mock = {
"language": "en-gb", "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"}], "links": [
{
"href": "https://www.bbc.co.uk/news/",
"rel": "alternate",
"type": "text/html",
}
],
"title": "BBC News - Home", "title": "BBC News - Home",
}, },
"href": "http://feeds.bbci.co.uk/news/rss.xml", "href": "http://feeds.bbci.co.uk/news/rss.xml",

View file

@ -20,7 +20,13 @@ simple_feed_mock = {
"link": "https://www.bbc.co.uk/news/", "link": "https://www.bbc.co.uk/news/",
}, },
"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", "title": "BBC News - Home",
}, },
"href": "http://feeds.bbci.co.uk/news/rss.xml", "href": "http://feeds.bbci.co.uk/news/rss.xml",

View file

@ -118,7 +118,9 @@ class URLBuilderTestCase(TestCase):
def test_no_link(self): def test_no_link(self):
initial_rule = CollectionRuleFactory() 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() rule, url = builder.build()
self.assertEquals(rule.pk, initial_rule.pk) self.assertEquals(rule.pk, initial_rule.pk)

View file

@ -1,6 +1,9 @@
from django.urls import path from django.urls import path
from newsreader.news.collection.views import CollectionRuleAPIListView, CollectionRuleDetailView from newsreader.news.collection.views import (
CollectionRuleAPIListView,
CollectionRuleDetailView,
)
endpoints = [ endpoints = [

View file

@ -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.db.models.deletion
import django.utils.timezone import django.utils.timezone
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@ -10,43 +11,36 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [("collection", "0001_initial")] dependencies = [
("collection", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [ 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( migrations.CreateModel(
name="Post", name="Post",
fields=[ fields=[
( (
"id", "id",
models.AutoField( 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)), ("created", models.DateTimeField(default=django.utils.timezone.now)),
("modified", models.DateTimeField(auto_now=True)), ("modified", models.DateTimeField(auto_now=True)),
("title", models.CharField(blank=True, max_length=200, null=True)), ("title", models.CharField(blank=True, max_length=200, null=True)),
("body", models.TextField(blank=True, 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)), ("publication_date", models.DateTimeField(blank=True, null=True)),
("url", models.URLField(blank=True, max_length=1024, null=True)), ("url", models.URLField(blank=True, max_length=1024, null=True)),
( (
"remote_identifier", "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", "rule",
@ -59,4 +53,26 @@ class Migration(migrations.Migration):
], ],
options={"abstract": False}, 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"},
),
] ]

View file

@ -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",
),
),
]

View file

@ -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,
)
]

View file

@ -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),
)
]

View file

@ -12,8 +12,12 @@ class Post(TimeStampedModel):
publication_date = models.DateTimeField(blank=True, null=True) publication_date = models.DateTimeField(blank=True, null=True)
url = models.URLField(max_length=1024, blank=True, null=True) url = models.URLField(max_length=1024, blank=True, null=True)
rule = models.ForeignKey(CollectionRule, on_delete=models.CASCADE, editable=False) rule = models.ForeignKey(
remote_identifier = models.CharField(max_length=500, blank=True, null=True, editable=False) 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): def __str__(self):
return "Post-{}".format(self.pk) return "Post-{}".format(self.pk)
@ -21,7 +25,7 @@ class Post(TimeStampedModel):
class Category(TimeStampedModel): class Category(TimeStampedModel):
name = models.CharField(max_length=50, unique=True) 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: class Meta:
verbose_name = _("Category") verbose_name = _("Category")

View file

@ -7,7 +7,7 @@ class CategorySerializer(serializers.ModelSerializer):
rules = serializers.SerializerMethodField() rules = serializers.SerializerMethodField()
def get_rules(self, instance): 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) serializer = CollectionRuleSerializer(rules, many=True)
return serializer.data return serializer.data

View file

@ -26,7 +26,7 @@ class CategorySerializer(serializers.HyperlinkedModelSerializer):
def get_rules(self, instance): def get_rules(self, instance):
request = self.context.get("request") request = self.context.get("request")
rules = instance.collectionrule_set.order_by("-modified", "-created") rules = instance.rules.order_by("-modified", "-created")
serializer = collection.serializers.CollectionRuleSerializer( serializer = collection.serializers.CollectionRuleSerializer(
rules, context={"request": request}, many=True rules, context={"request": request}, many=True

View file

@ -3,7 +3,7 @@ import json
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse 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.collection.tests.factories import CollectionRuleFactory
from newsreader.news.core.tests.factories import CategoryFactory, PostFactory from newsreader.news.core.tests.factories import CategoryFactory, PostFactory
@ -91,13 +91,17 @@ class CategoryDetailViewTestCase(TestCase):
category = CategoryFactory(user=self.user) category = CategoryFactory(user=self.user)
self.client.force_login(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) self.assertEquals(response.status_code, 204)
def test_rules(self): def test_rules(self):
category = CategoryFactory(user=self.user) 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) self.client.force_login(self.user)
response = self.client.get(reverse("api:categories-detail", args=[category.pk])) response = self.client.get(reverse("api:categories-detail", args=[category.pk]))
@ -149,7 +153,9 @@ class CategoryDetailViewTestCase(TestCase):
if count < 1: if count < 1:
continue 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): def test_category_with_unauthenticated_user(self):
category = CategoryFactory(user=self.user) category = CategoryFactory(user=self.user)

View file

@ -7,7 +7,7 @@ from django.urls import reverse
import pytz 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.collection.tests.factories import CollectionRuleFactory
from newsreader.news.core.tests.factories import CategoryFactory, PostFactory from newsreader.news.core.tests.factories import CategoryFactory, PostFactory
@ -83,7 +83,9 @@ class CategoryListViewTestCase(TestCase):
self.client.force_login(self.user) self.client.force_login(self.user)
response = self.client.post( 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() response_data = response.json()

View file

@ -3,7 +3,7 @@ import json
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse 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.collection.tests.factories import CollectionRuleFactory
from newsreader.news.core.tests.factories import CategoryFactory, PostFactory from newsreader.news.core.tests.factories import CategoryFactory, PostFactory
@ -17,7 +17,9 @@ class PostDetailViewTestCase(TestCase):
self.user = UserFactory(is_staff=True, password="test") self.user = UserFactory(is_staff=True, password="test")
def test_simple(self): 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) post = PostFactory(rule=rule)
self.client.force_login(self.user) self.client.force_login(self.user)
@ -44,7 +46,9 @@ class PostDetailViewTestCase(TestCase):
self.assertEquals(data["detail"], "Not found.") self.assertEquals(data["detail"], "Not found.")
def test_post(self): 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) post = PostFactory(rule=rule)
self.client.force_login(self.user) self.client.force_login(self.user)
@ -55,7 +59,9 @@ class PostDetailViewTestCase(TestCase):
self.assertEquals(data["detail"], 'Method "POST" not allowed.') self.assertEquals(data["detail"], 'Method "POST" not allowed.')
def test_patch(self): 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) post = PostFactory(title="This is clickbait for sure", rule=rule)
self.client.force_login(self.user) self.client.force_login(self.user)
@ -70,7 +76,9 @@ class PostDetailViewTestCase(TestCase):
self.assertEquals(data["title"], "This title is very accurate") self.assertEquals(data["title"], "This title is very accurate")
def test_identifier_cannot_be_changed(self): 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) post = PostFactory(title="This is clickbait for sure", rule=rule)
self.client.force_login(self.user) self.client.force_login(self.user)
@ -85,8 +93,12 @@ class PostDetailViewTestCase(TestCase):
self.assertEquals(data["id"], post.pk) self.assertEquals(data["id"], post.pk)
def test_rule_cannot_be_changed(self): def test_rule_cannot_be_changed(self):
rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) rule = CollectionRuleFactory(
new_rule = CollectionRuleFactory(user=self.user, category=CategoryFactory(user=self.user)) 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) post = PostFactory(title="This is clickbait for sure", rule=rule)
self.client.force_login(self.user) 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]))) self.assertTrue(rule_url.endswith(reverse("api:rules-detail", args=[rule.pk])))
def test_put(self): 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) post = PostFactory(title="This is clickbait for sure", rule=rule)
self.client.force_login(self.user) self.client.force_login(self.user)
@ -118,7 +132,9 @@ class PostDetailViewTestCase(TestCase):
self.assertEquals(data["title"], "This title is very accurate") self.assertEquals(data["title"], "This title is very accurate")
def test_delete(self): 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) post = PostFactory(rule=rule)
self.client.force_login(self.user) self.client.force_login(self.user)
@ -137,7 +153,9 @@ class PostDetailViewTestCase(TestCase):
self.assertEquals(response.status_code, 403) self.assertEquals(response.status_code, 403)
def test_post_with_unauthenticated_user_with_category(self): 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) post = PostFactory(rule=rule)
response = self.client.get(reverse("api:posts-detail", args=[post.pk])) 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): def test_post_with_unauthorized_user_with_category(self):
other_user = UserFactory() 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) post = PostFactory(rule=rule)
self.client.force_login(self.user) self.client.force_login(self.user)
@ -166,7 +186,9 @@ class PostDetailViewTestCase(TestCase):
def test_post_with_different_user_for_category_and_rule(self): def test_post_with_different_user_for_category_and_rule(self):
other_user = UserFactory() 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) post = PostFactory(rule=rule)
self.client.force_login(self.user) self.client.force_login(self.user)

View file

@ -5,7 +5,7 @@ from django.urls import reverse
import pytz 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.collection.tests.factories import CollectionRuleFactory
from newsreader.news.core.tests.factories import CategoryFactory, PostFactory from newsreader.news.core.tests.factories import CategoryFactory, PostFactory
@ -18,7 +18,9 @@ class PostListViewTestCase(TestCase):
self.user = UserFactory(is_staff=True, password="test") self.user = UserFactory(is_staff=True, password="test")
def test_simple(self): 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) PostFactory.create_batch(size=3, rule=rule)
self.client.force_login(self.user) self.client.force_login(self.user)
@ -31,7 +33,9 @@ class PostListViewTestCase(TestCase):
self.assertEquals(data["count"], 3) self.assertEquals(data["count"], 3)
def test_ordering(self): 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 = [ posts = [
PostFactory( PostFactory(
@ -71,7 +75,9 @@ class PostListViewTestCase(TestCase):
self.assertEquals(data["results"][2]["id"], posts[0].pk) self.assertEquals(data["results"][2]["id"], posts[0].pk)
def test_pagination_count(self): 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) PostFactory.create_batch(size=80, rule=rule)
page_size = 50 page_size = 50
@ -180,7 +186,9 @@ class PostListViewTestCase(TestCase):
def test_posts_with_authorized_rule_unauthorized_category(self): def test_posts_with_authorized_rule_unauthorized_category(self):
other_user = UserFactory() 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) PostFactory.create_batch(size=3, rule=rule)
self.client.force_login(self.user) self.client.force_login(self.user)

View file

@ -1,7 +1,7 @@
import factory import factory
import pytz import pytz
from newsreader.auth.tests.factories import UserFactory from newsreader.accounts.tests.factories import UserFactory
from newsreader.news.core.models import Category, Post from newsreader.news.core.models import Category, Post
@ -21,7 +21,9 @@ class PostFactory(factory.django.DjangoModelFactory):
url = factory.Faker("url") url = factory.Faker("url")
remote_identifier = 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: class Meta:
model = Post model = Post

View file

@ -12,5 +12,7 @@ endpoints = [
path("posts/", ListPostAPIView.as_view(), name="posts-list"), path("posts/", ListPostAPIView.as_view(), name="posts-list"),
path("posts/<int:pk>/", DetailPostAPIView.as_view(), name="posts-detail"), path("posts/<int:pk>/", DetailPostAPIView.as_view(), name="posts-detail"),
path("categories/", ListCategoryAPIView.as_view(), name="categories-list"), path("categories/", ListCategoryAPIView.as_view(), name="categories-list"),
path("categories/<int:pk>/", DetailCategoryAPIView.as_view(), name="categories-detail"), path(
"categories/<int:pk>/", DetailCategoryAPIView.as_view(), name="categories-detail"
),
] ]

View file

@ -8,7 +8,7 @@ from rest_framework.generics import (
) )
from rest_framework.permissions import IsAuthenticated 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.core.pagination import LargeResultSetPagination, ResultSetPagination
from newsreader.news.core.models import Category, Post from newsreader.news.core.models import Category, Post
from newsreader.news.core.serializers import CategorySerializer, PostSerializer from newsreader.news.core.serializers import CategorySerializer, PostSerializer