0.2 release

This commit is contained in:
Sonny 2020-04-15 22:07:12 +02:00
parent 747c6416d4
commit 18479a3f56
340 changed files with 27295 additions and 0 deletions

5
src/entrypoint.sh Executable file
View file

@ -0,0 +1,5 @@
#!/bin/bash
# This file should only be used in conjuction with docker-compose
poetry run /app/src/manage.py migrate
poetry run /app/src/manage.py runserver 0.0.0.0:8000

21
src/manage.py Executable file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.dev")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

View file

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

View file

View file

@ -0,0 +1 @@
# Register your models here.

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=models.SET_NULL,
to="django_celery_beat.PeriodicTask",
),
),
(
"task_interval",
models.ForeignKey(
blank=True,
null=True,
on_delete=models.SET_NULL,
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,17 @@
# 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=models.SET_NULL,
to="django_celery_beat.PeriodicTask",
),
)
]

View file

@ -0,0 +1,10 @@
# Generated by Django 2.2.6 on 2019-11-16 11:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("accounts", "0004_auto_20190714_1501")]
operations = [migrations.RemoveField(model_name="user", name="task_interval")]

View file

@ -0,0 +1,24 @@
# Generated by Django 2.2.6 on 2019-11-16 11:53
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("accounts", "0005_remove_user_task_interval")]
operations = [
migrations.AlterField(
model_name="user",
name="task",
field=models.OneToOneField(
blank=True,
editable=False,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="django_celery_beat.PeriodicTask",
),
)
]

View file

@ -0,0 +1,25 @@
# Generated by Django 2.2.6 on 2019-11-16 11:55
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("accounts", "0006_auto_20191116_1253")]
operations = [
migrations.AlterField(
model_name="user",
name="task",
field=models.OneToOneField(
blank=True,
editable=False,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="django_celery_beat.PeriodicTask",
verbose_name="collection task",
),
)
]

View file

@ -0,0 +1,80 @@
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,
on_delete=models.SET_NULL,
null=True,
blank=True,
editable=False,
verbose_name="collection task",
)
username = None
objects = UserManager()
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if not self.task:
task_interval, _ = IntervalSchedule.objects.get_or_create(
every=1, period=IntervalSchedule.HOURS
)
self.task, _ = PeriodicTask.objects.get_or_create(
enabled=True,
interval=task_interval,
name=f"{self.email}-collection-task",
task="newsreader.news.collection.tasks.collect",
args=json.dumps([self.pk]),
)
self.save()
def delete(self, *args, **kwargs):
self.task.delete()
return super().delete(*args, **kwargs)

View file

@ -0,0 +1,23 @@
from rest_framework.permissions import BasePermission
class IsOwner(BasePermission):
def has_object_permission(self, request, view, obj):
if hasattr(obj, "user"):
return obj.user == request.user
class IsPostOwner(BasePermission):
def has_object_permission(self, request, view, obj):
is_category_user = False
is_rule_user = False
rule = obj.rule
if rule and rule.user:
is_rule_user = bool(rule.user == request.user)
if rule.category and rule.category.user:
is_category_user = bool(rule.category.user == request.user)
return bool(is_category_user and is_rule_user)
return is_rule_user

View file

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<main id="login--page" class="main">
<form class="form login-form" method="POST" action="{% url 'accounts:login' %}">
{% csrf_token %}
<div class="form__header">
<h1 class="form__title">Login</h1>
</div>
<fieldset class="login-form__fieldset">
{{ form }}
</fieldset>
<fieldset class="login-form__fieldset">
<button class="button button--confirm" type="submit">Login</button>
<a class="link" href="{% url 'accounts:password-reset' %}">
<small class="small">I forgot my password</small>
</a>
</fieldset>
</form>
</main>
{% endblock %}

View file

@ -0,0 +1,39 @@
import hashlib
import string
from django.utils.crypto import get_random_string
import factory
from registration.models import RegistrationProfile
from newsreader.accounts.models import User
def get_activation_key():
random_string = get_random_string(length=32, allowed_chars=string.printable)
return hashlib.sha1(random_string.encode("utf-8")).hexdigest()
class UserFactory(factory.django.DjangoModelFactory):
email = factory.Faker("email")
password = factory.Faker("password")
is_staff = False
is_active = True
@classmethod
def _create(cls, model_class, *args, **kwargs):
manager = cls._get_manager(model_class)
return manager.create_user(*args, **kwargs)
class Meta:
model = User
class RegistrationProfileFactory(factory.django.DjangoModelFactory):
user = factory.SubFactory(UserFactory)
activation_key = factory.LazyFunction(get_activation_key)
class Meta:
model = RegistrationProfile

View file

@ -0,0 +1,99 @@
import datetime
from django.conf import settings
from django.test import TestCase
from django.urls import reverse
from django.utils.translation import gettext as _
from registration.models import RegistrationProfile
from newsreader.accounts.models import User
class ActivationTestCase(TestCase):
def setUp(self):
self.register_url = reverse("accounts:register")
self.register_success_url = reverse("accounts:register-complete")
self.success_url = reverse("accounts:activate-complete")
def test_activation(self):
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.register_url, data)
self.assertRedirects(response, self.register_success_url)
register_profile = RegistrationProfile.objects.get()
kwargs = {"activation_key": register_profile.activation_key}
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
self.assertRedirects(response, self.success_url)
def test_expired_key(self):
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.register_url, data)
register_profile = RegistrationProfile.objects.get()
user = register_profile.user
user.date_joined -= datetime.timedelta(days=settings.ACCOUNT_ACTIVATION_DAYS)
user.save()
kwargs = {"activation_key": register_profile.activation_key}
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
self.assertEqual(200, response.status_code)
self.assertContains(response, _("Account activation failed"))
user.refresh_from_db()
self.assertFalse(user.is_active)
def test_invalid_key(self):
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.register_url, data)
self.assertRedirects(response, self.register_success_url)
kwargs = {"activation_key": "not-a-valid-key"}
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
self.assertContains(response, _("Account activation failed"))
user = User.objects.get()
self.assertEquals(user.is_active, False)
def test_activated_key(self):
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.register_url, data)
self.assertRedirects(response, self.register_success_url)
register_profile = RegistrationProfile.objects.get()
kwargs = {"activation_key": register_profile.activation_key}
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
self.assertRedirects(response, self.success_url)
# try this a second time
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
self.assertRedirects(response, self.success_url)

View file

@ -0,0 +1,160 @@
from typing import Dict
from django.core import mail
from django.test import TestCase
from django.urls import reverse
from django.utils.translation import gettext as _
from newsreader.accounts.tests.factories import UserFactory
class PasswordResetTestCase(TestCase):
def setUp(self):
self.url = reverse("accounts:password-reset")
self.success_url = reverse("accounts:password-reset-done")
self.user = UserFactory(email="test@test.com")
def test_simple(self):
response = self.client.get(self.url)
self.assertEquals(response.status_code, 200)
def test_password_change(self):
data = {"email": "test@test.com"}
response = self.client.post(self.url, data)
self.assertRedirects(response, self.success_url)
self.assertEquals(len(mail.outbox), 1)
def test_unkown_email(self):
data = {"email": "unknown@test.com"}
response = self.client.post(self.url, data)
self.assertRedirects(response, self.success_url)
self.assertEquals(len(mail.outbox), 0)
def test_repeatedly(self):
data = {"email": "test@test.com"}
response = self.client.post(self.url, data)
self.assertRedirects(response, self.success_url)
response = self.client.post(self.url, data)
self.assertRedirects(response, self.success_url)
self.assertEquals(len(mail.outbox), 2)
class PasswordResetConfirmTestCase(TestCase):
def setUp(self):
self.success_url = reverse("accounts:password-reset-complete")
self.user = UserFactory(email="test@test.com")
def _get_reset_credentials(self) -> Dict:
data = {"email": self.user.email}
response = self.client.post(reverse("accounts:password-reset"), data)
return {
"uidb64": response.context[0]["uid"],
"token": response.context[0]["token"],
}
def test_simple(self):
kwargs = self._get_reset_credentials()
response = self.client.get(
reverse("accounts:password-reset-confirm", kwargs=kwargs)
)
self.assertRedirects(
response, f"/accounts/password-reset/{kwargs['uidb64']}/set-password/"
)
def test_confirm_password(self):
kwargs = self._get_reset_credentials()
response = self.client.get(
reverse("accounts:password-reset-confirm", kwargs=kwargs)
)
data = {"new_password1": "jabbadabadoe", "new_password2": "jabbadabadoe"}
response = self.client.post(response.url, data)
self.assertRedirects(response, self.success_url)
self.user.refresh_from_db()
self.assertTrue(self.user.check_password("jabbadabadoe"))
def test_wrong_uuid(self):
correct_kwargs = self._get_reset_credentials()
wrong_kwargs = {"uidb64": "burp", "token": correct_kwargs["token"]}
response = self.client.get(
reverse("accounts:password-reset-confirm", kwargs=wrong_kwargs)
)
self.assertContains(response, _("Password reset unsuccessful"))
def test_wrong_token(self):
correct_kwargs = self._get_reset_credentials()
wrong_kwargs = {"uidb64": correct_kwargs["uidb64"], "token": "token"}
response = self.client.get(
reverse("accounts:password-reset-confirm", kwargs=wrong_kwargs)
)
self.assertContains(response, _("Password reset unsuccessful"))
def test_wrong_url_args(self):
kwargs = {"uidb64": "burp", "token": "token"}
response = self.client.get(
reverse("accounts:password-reset-confirm", kwargs=kwargs)
)
self.assertContains(response, _("Password reset unsuccessful"))
def test_token_repeatedly(self):
kwargs = self._get_reset_credentials()
response = self.client.get(
reverse("accounts:password-reset-confirm", kwargs=kwargs)
)
data = {"new_password1": "jabbadabadoe", "new_password2": "jabbadabadoe"}
self.client.post(response.url, data)
response = self.client.get(
reverse("accounts:password-reset-confirm", kwargs=kwargs)
)
self.assertContains(response, _("Password reset unsuccessful"))
def test_change_form_repeatedly(self):
kwargs = self._get_reset_credentials()
response = self.client.get(
reverse("accounts:password-reset-confirm", kwargs=kwargs)
)
data = {"new_password1": "new-password", "new_password2": "new-password"}
self.client.post(response.url, data)
data = {"new_password1": "jabbadabadoe", "new_password2": "jabbadabadoe"}
response = self.client.post(
reverse("accounts:password-reset-confirm", kwargs=kwargs)
)
self.assertContains(response, _("Password reset unsuccessful"))
self.user.refresh_from_db()
self.assertTrue(self.user.check_password("new-password"))

View file

@ -0,0 +1,110 @@
from django.core import mail
from django.test import TransactionTestCase as TestCase
from django.test.utils import override_settings
from django.urls import reverse
from django.utils.translation import gettext as _
from registration.models import RegistrationProfile
from newsreader.accounts.models import User
from newsreader.accounts.tests.factories import UserFactory
class RegistrationTestCase(TestCase):
def setUp(self):
self.url = reverse("accounts:register")
self.success_url = reverse("accounts:register-complete")
self.disallowed_url = reverse("accounts:register-closed")
def test_simple(self):
response = self.client.get(self.url)
self.assertEquals(response.status_code, 200)
def test_registration(self):
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.url, data)
self.assertRedirects(response, self.success_url)
self.assertEquals(User.objects.count(), 1)
self.assertEquals(RegistrationProfile.objects.count(), 1)
user = User.objects.get()
self.assertEquals(user.is_active, False)
self.assertEquals(len(mail.outbox), 1)
def test_existing_email(self):
UserFactory(email="test@test.com")
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.url, data)
self.assertEquals(response.status_code, 200)
self.assertEquals(User.objects.count(), 1)
self.assertContains(response, _("User with this Email address already exists"))
def test_pending_registration(self):
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.url, data)
self.assertRedirects(response, self.success_url)
self.assertEquals(User.objects.count(), 1)
self.assertEquals(RegistrationProfile.objects.count(), 1)
user = User.objects.get()
self.assertEquals(user.is_active, False)
self.assertEquals(len(mail.outbox), 1)
response = self.client.post(self.url, data)
self.assertEquals(response.status_code, 200)
self.assertContains(response, _("User with this Email address already exists"))
def test_disabled_account(self):
UserFactory(email="test@test.com", is_active=False)
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.url, data)
self.assertEquals(response.status_code, 200)
self.assertEquals(User.objects.count(), 1)
self.assertContains(response, _("User with this Email address already exists"))
@override_settings(REGISTRATION_OPEN=False)
def test_registration_closed(self):
response = self.client.get(self.url)
self.assertRedirects(response, self.disallowed_url)
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.url, data)
self.assertRedirects(response, self.disallowed_url)
self.assertEquals(User.objects.count(), 0)
self.assertEquals(RegistrationProfile.objects.count(), 0)

View file

@ -0,0 +1,77 @@
from django.core import mail
from django.test import TransactionTestCase as TestCase
from django.urls import reverse
from django.utils.translation import gettext as _
from registration.models import RegistrationProfile
from newsreader.accounts.tests.factories import RegistrationProfileFactory, UserFactory
class ResendActivationTestCase(TestCase):
def setUp(self):
self.url = reverse("accounts:activate-resend")
self.success_url = reverse("accounts:activate-complete")
self.register_url = reverse("accounts:register")
def test_simple(self):
response = self.client.get(self.url)
self.assertEquals(response.status_code, 200)
def test_resent_form(self):
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.register_url, data)
register_profile = RegistrationProfile.objects.get()
original_kwargs = {"activation_key": register_profile.activation_key}
response = self.client.post(self.url, {"email": "test@test.com"})
self.assertContains(response, _("We have sent an email to"))
self.assertEquals(len(mail.outbox), 2)
register_profile.refresh_from_db()
kwargs = {"activation_key": register_profile.activation_key}
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
self.assertRedirects(response, self.success_url)
register_profile.refresh_from_db()
user = register_profile.user
self.assertEquals(user.is_active, True)
# test the old activation code
response = self.client.get(reverse("accounts:activate", kwargs=original_kwargs))
self.assertEquals(response.status_code, 200)
self.assertContains(response, _("Account activation failed"))
def test_existing_account(self):
user = UserFactory(is_active=True)
profile = RegistrationProfileFactory(user=user, activated=True)
response = self.client.post(self.url, {"email": user.email})
self.assertEquals(response.status_code, 200)
# default behaviour is to show success page but not send an email
self.assertContains(response, _("We have sent an email to"))
self.assertEquals(len(mail.outbox), 0)
def test_no_account(self):
response = self.client.post(self.url, {"email": "fake@mail.com"})
self.assertEquals(response.status_code, 200)
# default behaviour is to show success page but not send an email
self.assertContains(response, _("We have sent an email to"))
self.assertEquals(len(mail.outbox), 0)

View file

@ -0,0 +1,22 @@
from django.test import TestCase
from django_celery_beat.models import PeriodicTask
from newsreader.accounts.models import User
class UserTestCase(TestCase):
def test_task_is_created(self):
user = User.objects.create(email="durp@burp.nl", task=None)
task = PeriodicTask.objects.get(name=f"{user.email}-collection-task")
user.refresh_from_db()
self.assertEquals(task, user.task)
self.assertEquals(PeriodicTask.objects.count(), 1)
def test_task_is_deleted(self):
user = User.objects.create(email="durp@burp.nl", task=None)
user.delete()
self.assertEquals(PeriodicTask.objects.count(), 0)

View file

@ -0,0 +1,56 @@
from django.urls import path
from newsreader.accounts.views import (
ActivationCompleteView,
ActivationResendView,
ActivationView,
LoginView,
LogoutView,
PasswordResetCompleteView,
PasswordResetConfirmView,
PasswordResetDoneView,
PasswordResetView,
RegistrationClosedView,
RegistrationCompleteView,
RegistrationView,
)
urlpatterns = [
path("login/", LoginView.as_view(), name="login"),
path("logout/", LogoutView.as_view(), name="logout"),
path("register/", RegistrationView.as_view(), name="register"),
path(
"register/complete/",
RegistrationCompleteView.as_view(),
name="register-complete",
),
path("register/closed/", RegistrationClosedView.as_view(), name="register-closed"),
path(
"activate/complete/", ActivationCompleteView.as_view(), name="activate-complete"
),
path("activate/resend/", ActivationResendView.as_view(), name="activate-resend"),
path(
# This URL should be placed after all activate/ url's (see arg)
"activate/<str:activation_key>/",
ActivationView.as_view(),
name="activate",
),
path("password-reset/", PasswordResetView.as_view(), name="password-reset"),
path(
"password-reset/done/",
PasswordResetDoneView.as_view(),
name="password-reset-done",
),
path(
"password-reset/<uidb64>/<token>/",
PasswordResetConfirmView.as_view(),
name="password-reset-confirm",
),
path(
"password-reset/done/",
PasswordResetCompleteView.as_view(),
name="password-reset-complete",
),
# TODO: create password change views
]

View file

@ -0,0 +1,91 @@
from django.contrib.auth import views as django_views
from django.shortcuts import render
from django.urls import reverse_lazy
from django.views.generic import TemplateView
from registration.backends.default import views as registration_views
class LoginView(django_views.LoginView):
template_name = "accounts/login.html"
def get_success_url(self):
return reverse_lazy("index")
class LogoutView(django_views.LogoutView):
next_page = reverse_lazy("accounts:login")
# RegistrationView shows a registration form and sends the email
# RegistrationCompleteView shows after filling in the registration form
# ActivationView is send within the activation email and activates the account
# ActivationCompleteView shows the success screen when activation was succesful
# ActivationResendView can be used when activation links are expired
# RegistrationClosedView shows when registration is disabled
class RegistrationView(registration_views.RegistrationView):
disallowed_url = reverse_lazy("accounts:register-closed")
template_name = "registration/registration_form.html"
success_url = reverse_lazy("accounts:register-complete")
class RegistrationCompleteView(TemplateView):
template_name = "registration/registration_complete.html"
class RegistrationClosedView(TemplateView):
template_name = "registration/registration_closed.html"
# Redirects or renders failed activation template
class ActivationView(registration_views.ActivationView):
template_name = "registration/activation_failure.html"
def get_success_url(self, user):
return ("accounts:activate-complete", (), {})
class ActivationCompleteView(TemplateView):
template_name = "registration/activation_complete.html"
# Renders activation form resend or resend_activation_complete
class ActivationResendView(registration_views.ResendActivationView):
template_name = "registration/activation_resend_form.html"
def render_form_submitted_template(self, form):
"""
Renders resend activation complete template with the submitted email.
"""
email = form.cleaned_data["email"]
context = {"email": email}
return render(
self.request, "registration/activation_resend_complete.html", context
)
# PasswordResetView sends the mail
# PasswordResetDoneView shows a success message for the above
# PasswordResetConfirmView checks the link the user clicked and
# prompts for a new password
# PasswordResetCompleteView shows a success message for the above
class PasswordResetView(django_views.PasswordResetView):
template_name = "password-reset/password_reset_form.html"
subject_template_name = "password-reset/password_reset_subject.txt"
email_template_name = "password-reset/password_reset_email.html"
success_url = reverse_lazy("accounts:password-reset-done")
class PasswordResetDoneView(django_views.PasswordResetDoneView):
template_name = "password-reset/password_reset_done.html"
class PasswordResetConfirmView(django_views.PasswordResetConfirmView):
template_name = "password-reset/password_reset_confirm.html"
success_url = reverse_lazy("accounts:password-reset-complete")
class PasswordResetCompleteView(django_views.PasswordResetCompleteView):
template_name = "password-reset/password_reset_complete.html"

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

@ -0,0 +1,14 @@
import os
from celery import Celery
# note: this should be consistent with the setting from manage.py
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.dev")
# note: use the --workdir flag when running from different directories
app = Celery("newsreader")
app.config_from_object("django.conf:settings")
app.autodiscover_tasks()

View file

160
src/newsreader/conf/base.py Normal file
View file

@ -0,0 +1,160 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent
DJANGO_PROJECT_DIR = os.path.join(BASE_DIR, "src", "newsreader")
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
# SECURITY WARNING: don"t run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ["127.0.0.1"]
INTERNAL_IPS = ["127.0.0.1"]
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# third party apps
"rest_framework",
"drf_yasg",
"celery",
"django_celery_beat",
"registration",
"axes",
# app modules
"newsreader.accounts",
"newsreader.news.core",
"newsreader.news.collection",
]
AUTHENTICATION_BACKENDS = [
"axes.backends.AxesBackend",
"django.contrib.auth.backends.ModelBackend",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"axes.middleware.AxesMiddleware",
]
ROOT_URLCONF = "newsreader.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]
},
}
]
WSGI_APPLICATION = "newsreader.wsgi.application"
# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"HOST": os.environ.get("POSTGRES_HOST", ""),
"NAME": os.environ.get("POSTGRES_NAME", "newsreader"),
"USER": os.environ.get("POSTGRES_USER"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD"),
}
}
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"LOCATION": "localhost:11211",
},
"axes": {
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"LOCATION": "localhost:11211",
},
}
# Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
# Authentication user model
AUTH_USER_MODEL = "accounts.User"
# Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "Europe/Amsterdam"
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.2/howto/static-files/
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(BASE_DIR, "static")
STATICFILES_DIRS = [os.path.join(DJANGO_PROJECT_DIR, "static")]
# https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-STATICFILES_FINDERS
STATICFILES_FINDERS = [
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
]
DEFAULT_FROM_EMAIL = "newsreader@rss.fudiggity.nl"
# Third party settings
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
AXES_CACHE = "axes"
AXES_FAILURE_LIMIT = 5
AXES_COOLOFF_TIME = 3 # in hours
AXES_RESET_ON_SUCCESS = True
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication",
),
"DEFAULT_PERMISSION_CLASSES": (
"rest_framework.permissions.IsAuthenticated",
"newsreader.accounts.permissions.IsOwner",
),
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
}
SWAGGER_SETTINGS = {
"LOGIN_URL": "rest_framework:login",
"LOGOUT_URL": "rest_framework:logout",
"DOC_EXPANSION": "list",
}
REGISTRATION_OPEN = True
REGISTRATION_AUTO_LOGIN = True
ACCOUNT_ACTIVATION_DAYS = 7

View file

@ -0,0 +1,35 @@
from .base import * # isort:skip
SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl"
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
INSTALLED_APPS += ["debug_toolbar", "django_extensions"]
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]
},
}
]
# Third party settings
AXES_FAILURE_LIMIT = 50
AXES_COOLOFF_TIME = None
try:
from .local import * # noqa
except ImportError:
pass

View file

@ -0,0 +1,19 @@
from .dev import * # isort:skip
SECRET_KEY = "=q(ztyo)b6noom#a164g&s9vcj1aawa^g#ing_ir99=_zl4g&$"
# Celery
# https://docs.celeryproject.org/en/latest/userguide/configuration.html
BROKER_URL = "amqp://guest:guest@rabbitmq:5672//"
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"LOCATION": "memcached:11211",
},
"axes": {
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"LOCATION": "memcached:11211",
},
}

View file

@ -0,0 +1,19 @@
from .base import * # isort:skip
SECRET_KEY = "29%lkw+&n%^w4k#@_db2mo%*tc&xzb)x7xuq*(0$eucii%4r0c"
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
AXES_ENABLED = False
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"LOCATION": "memcached:11211",
},
"axes": {
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"LOCATION": "memcached:11211",
},
}

View file

@ -0,0 +1,45 @@
import os
from dotenv import load_dotenv
from .base import * # isort:skip
load_dotenv()
DEBUG = False
ALLOWED_HOSTS = ["rss.fudiggity.nl"]
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"HOST": os.environ["POSTGRES_HOST"],
"PORT": os.environ["POSTGRES_PORT"],
"NAME": os.environ["POSTGRES_NAME"],
"USER": os.environ["POSTGRES_USER"],
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
}
}
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]
},
}
]
# Third party settings
AXES_HANDLER = "axes.handlers.database.DatabaseHandler"
REGISTRATION_OPEN = False

View file

View file

@ -0,0 +1 @@
# Register your models here.

View file

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

View file

@ -0,0 +1,15 @@
from django.db import models
from django.utils import timezone
class TimeStampedModel(models.Model):
"""
An abstract base class model that provides self-
updating ``created`` and ``modified`` fields.
"""
created = models.DateTimeField(default=timezone.now)
modified = models.DateTimeField(auto_now=True)
class Meta:
abstract = True

View file

@ -0,0 +1,12 @@
from rest_framework.pagination import PageNumberPagination
class ResultSetPagination(PageNumberPagination):
page_size_query_param = "count"
max_page_size = 50
page_size = 30
class LargeResultSetPagination(ResultSetPagination):
max_page_size = 100
page_size = 50

View file

@ -0,0 +1,6 @@
from rest_framework.permissions import BasePermission
class IsOwner(BasePermission):
def has_object_permission(self, request, view, obj):
return obj.user == request.user

View file

@ -0,0 +1 @@
# Create your tests here.

View file

@ -0,0 +1 @@
# Create your views here.

View file

@ -0,0 +1,298 @@
[
{
"model": "django_celery_beat.periodictask",
"pk": 10,
"fields": {
"name": "sonny@bakker.nl-collection-task",
"task": "newsreader.news.collection.tasks.collect",
"interval": 4,
"crontab": null,
"solar": null,
"clocked": null,
"args": "[2]",
"kwargs": "{}",
"queue": null,
"exchange": null,
"routing_key": null,
"headers": "{}",
"priority": null,
"expires": null,
"one_off": false,
"start_time": null,
"enabled": true,
"last_run_at": "2019-11-29T22:29:08.345Z",
"total_run_count": 290,
"date_changed": "2019-11-29T22:29:18.378Z",
"description": ""
}
},
{
"model": "django_celery_beat.periodictask",
"pk": 26,
"fields": {
"name": "sonnyba871@gmail.com-collection-task",
"task": "newsreader.news.collection.tasks.collect",
"interval": 4,
"crontab": null,
"solar": null,
"clocked": null,
"args": "[18]",
"kwargs": "{}",
"queue": null,
"exchange": null,
"routing_key": null,
"headers": "{}",
"priority": null,
"expires": null,
"one_off": false,
"start_time": null,
"enabled": true,
"last_run_at": "2019-11-29T22:35:19.134Z",
"total_run_count": 103,
"date_changed": "2019-11-29T22:38:19.464Z",
"description": ""
}
},
{
"model": "django_celery_beat.crontabschedule",
"pk": 1,
"fields": {
"minute": "0",
"hour": "4",
"day_of_week": "*",
"day_of_month": "*",
"month_of_year": "*",
"timezone": "UTC"
}
},
{
"model": "django_celery_beat.intervalschedule",
"pk": 1,
"fields": {
"every": 5,
"period": "minutes"
}
},
{
"model": "django_celery_beat.intervalschedule",
"pk": 2,
"fields": {
"every": 15,
"period": "minutes"
}
},
{
"model": "django_celery_beat.intervalschedule",
"pk": 3,
"fields": {
"every": 30,
"period": "minutes"
}
},
{
"model": "django_celery_beat.intervalschedule",
"pk": 4,
"fields": {
"every": 1,
"period": "hours"
}
},
{
"model": "accounts.user",
"fields": {
"password": "pbkdf2_sha256$150000$5lBD7JemxYfE$B+lM5wWUW2n/ZulPFaWHtzWjyQ/QZ6iwjAC2I0R/VzU=",
"last_login": "2019-11-27T18:57:36.686Z",
"is_superuser": true,
"first_name": "",
"last_name": "",
"is_staff": true,
"is_active": true,
"date_joined": "2019-07-18T18:52:36.080Z",
"email": "sonny@bakker.nl",
"task": 10,
"groups": [],
"user_permissions": []
}
},
{
"model": "accounts.user",
"fields": {
"password": "pbkdf2_sha256$150000$vUwxT8T25R8C$S+Eq2tMRbSDE31/X5KGJ/M+Nblh7kKfzuM/z7HraR/Q=",
"last_login": null,
"is_superuser": false,
"first_name": "",
"last_name": "",
"is_staff": false,
"is_active": false,
"date_joined": "2019-11-25T15:35:14.051Z",
"email": "sonnyba871@gmail.com",
"task": 26,
"groups": [],
"user_permissions": []
}
},
{
"model": "core.category",
"pk": 8,
"fields": {
"created": "2019-11-17T19:37:24.671Z",
"modified": "2019-11-18T19:59:55.010Z",
"name": "World news",
"user": [
"sonny@bakker.nl"
]
}
},
{
"model": "core.category",
"pk": 9,
"fields": {
"created": "2019-11-17T19:37:26.161Z",
"modified": "2019-11-18T19:59:45.010Z",
"name": "Tech",
"user": [
"sonny@bakker.nl"
]
}
},
{
"model": "collection.collectionrule",
"pk": 3,
"fields": {
"created": "2019-07-14T13:08:10.374Z",
"modified": "2019-11-29T22:35:20.346Z",
"name": "Hackers News",
"url": "https://news.ycombinator.com/rss",
"website_url": "https://news.ycombinator.com/",
"favicon": "https://news.ycombinator.com/favicon.ico",
"timezone": "UTC",
"category": 9,
"last_suceeded": "2019-11-29T22:35:20.235Z",
"succeeded": true,
"error": null,
"user": [
"sonny@bakker.nl"
]
}
},
{
"model": "collection.collectionrule",
"pk": 4,
"fields": {
"created": "2019-07-20T11:24:32.745Z",
"modified": "2019-11-29T22:35:19.525Z",
"name": "BBC",
"url": "http://feeds.bbci.co.uk/news/world/rss.xml",
"website_url": "https://www.bbc.co.uk/news/",
"favicon": "https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png",
"timezone": "UTC",
"category": 8,
"last_suceeded": "2019-11-29T22:35:19.241Z",
"succeeded": true,
"error": null,
"user": [
"sonny@bakker.nl"
]
}
},
{
"model": "collection.collectionrule",
"pk": 5,
"fields": {
"created": "2019-07-20T11:24:50.411Z",
"modified": "2019-11-29T22:35:20.010Z",
"name": "Ars Technica",
"url": "http://feeds.arstechnica.com/arstechnica/index?fmt=xml",
"website_url": "https://arstechnica.com",
"favicon": "https://cdn.arstechnica.net/favicon.ico",
"timezone": "UTC",
"category": 9,
"last_suceeded": "2019-11-29T22:35:19.808Z",
"succeeded": true,
"error": null,
"user": [
"sonny@bakker.nl"
]
}
},
{
"model": "collection.collectionrule",
"pk": 6,
"fields": {
"created": "2019-07-20T11:25:02.089Z",
"modified": "2019-11-29T22:35:20.233Z",
"name": "The Guardian",
"url": "https://www.theguardian.com/world/rss",
"website_url": "https://www.theguardian.com/world",
"favicon": "https://assets.guim.co.uk/images/favicons/873381bf11d58e20f551905d51575117/72x72.png",
"timezone": "UTC",
"category": 8,
"last_suceeded": "2019-11-29T22:35:20.076Z",
"succeeded": true,
"error": null,
"user": [
"sonny@bakker.nl"
]
}
},
{
"model": "collection.collectionrule",
"pk": 7,
"fields": {
"created": "2019-07-20T11:25:30.121Z",
"modified": "2019-11-29T22:35:19.695Z",
"name": "Tweakers",
"url": "http://feeds.feedburner.com/tweakers/mixed?fmt=xml",
"website_url": "https://tweakers.net/",
"favicon": null,
"timezone": "UTC",
"category": 9,
"last_suceeded": "2019-11-29T22:35:19.528Z",
"succeeded": true,
"error": null,
"user": [
"sonny@bakker.nl"
]
}
},
{
"model": "collection.collectionrule",
"pk": 8,
"fields": {
"created": "2019-07-20T11:25:46.256Z",
"modified": "2019-11-29T22:35:20.074Z",
"name": "The Verge",
"url": "https://www.theverge.com/rss/index.xml",
"website_url": "https://www.theverge.com/",
"favicon": "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395367/favicon-16x16.0.png",
"timezone": "UTC",
"category": 9,
"last_suceeded": "2019-11-29T22:35:20.012Z",
"succeeded": true,
"error": null,
"user": [
"sonny@bakker.nl"
]
}
},
{
"model": "collection.collectionrule",
"pk": 9,
"fields": {
"created": "2019-11-24T15:28:41.399Z",
"modified": "2019-11-29T22:35:19.807Z",
"name": "NOS",
"url": "http://feeds.nos.nl/nosnieuwsalgemeen",
"website_url": null,
"favicon": null,
"timezone": "Europe/Amsterdam",
"category": 8,
"last_suceeded": "2019-11-29T22:35:19.697Z",
"succeeded": true,
"error": null,
"user": [
"sonny@bakker.nl"
]
}
}
]

View file

@ -0,0 +1,168 @@
[
{
"fields" : {
"is_active" : true,
"is_superuser" : true,
"task_interval" : null,
"user_permissions" : [],
"is_staff" : true,
"last_name" : "",
"first_name" : "",
"groups" : [],
"date_joined" : "2019-07-14T10:44:35.228Z",
"password" : "pbkdf2_sha256$150000$vAOYP6XgN40C$bvW265Is2toKzEnbMmLVufd+DA6z1kIhUv/bhtUiDcA=",
"task" : null,
"last_login" : "2019-07-14T12:28:05.473Z",
"email" : "sonnyba871@gmail.com"
},
"pk" : 1,
"model" : "accounts.user"
},
{
"model" : "accounts.user",
"fields" : {
"task" : null,
"email" : "sonny@bakker.nl",
"last_login" : "2019-07-20T07:52:59.491Z",
"first_name" : "",
"groups" : [],
"last_name" : "",
"password" : "pbkdf2_sha256$150000$SMI9E7GFkJQk$usX0YN3q0ArqAd6bUQ9sUm6Ugms3XRxaiizHGIa3Pk4=",
"date_joined" : "2019-07-18T18:52:36.080Z",
"is_staff" : true,
"task_interval" : null,
"user_permissions" : [],
"is_active" : true,
"is_superuser" : true
},
"pk" : 2
},
{
"pk" : 3,
"fields" : {
"favicon" : null,
"category" : null,
"url" : "https://news.ycombinator.com/rss",
"error" : null,
"user" : 2,
"succeeded" : true,
"modified" : "2019-07-20T11:28:16.473Z",
"last_suceeded" : "2019-07-20T11:28:16.316Z",
"name" : "Hackers News",
"website_url" : null,
"created" : "2019-07-14T13:08:10.374Z",
"timezone" : "UTC"
},
"model" : "collection.collectionrule"
},
{
"model" : "collection.collectionrule",
"pk" : 4,
"fields" : {
"favicon" : null,
"category" : 2,
"url" : "http://feeds.bbci.co.uk/news/world/rss.xml",
"error" : null,
"user" : 2,
"succeeded" : true,
"last_suceeded" : "2019-07-20T11:28:15.691Z",
"name" : "BBC",
"modified" : "2019-07-20T12:07:49.164Z",
"timezone" : "UTC",
"website_url" : null,
"created" : "2019-07-20T11:24:32.745Z"
}
},
{
"pk" : 5,
"fields" : {
"error" : null,
"category" : null,
"url" : "http://feeds.arstechnica.com/arstechnica/index?fmt=xml",
"favicon" : null,
"timezone" : "UTC",
"created" : "2019-07-20T11:24:50.411Z",
"website_url" : null,
"name" : "Ars Technica",
"succeeded" : true,
"last_suceeded" : "2019-07-20T11:28:15.986Z",
"modified" : "2019-07-20T11:28:16.033Z",
"user" : 2
},
"model" : "collection.collectionrule"
},
{
"model" : "collection.collectionrule",
"pk" : 6,
"fields" : {
"favicon" : null,
"category" : 2,
"url" : "https://www.theguardian.com/world/rss",
"error" : null,
"user" : 2,
"name" : "The Guardian",
"succeeded" : true,
"last_suceeded" : "2019-07-20T11:28:16.078Z",
"modified" : "2019-07-20T12:07:44.292Z",
"created" : "2019-07-20T11:25:02.089Z",
"website_url" : null,
"timezone" : "UTC"
}
},
{
"fields" : {
"url" : "http://feeds.feedburner.com/tweakers/mixed?fmt=xml",
"category" : 1,
"error" : null,
"favicon" : null,
"timezone" : "UTC",
"website_url" : null,
"created" : "2019-07-20T11:25:30.121Z",
"user" : 2,
"last_suceeded" : "2019-07-20T11:28:15.860Z",
"succeeded" : true,
"modified" : "2019-07-20T12:07:28.473Z",
"name" : "Tweakers"
},
"pk" : 7,
"model" : "collection.collectionrule"
},
{
"model" : "collection.collectionrule",
"pk" : 8,
"fields" : {
"category" : 1,
"url" : "https://www.theverge.com/rss/index.xml",
"error" : null,
"favicon" : null,
"created" : "2019-07-20T11:25:46.256Z",
"website_url" : null,
"timezone" : "UTC",
"user" : 2,
"last_suceeded" : "2019-07-20T11:28:16.034Z",
"succeeded" : true,
"modified" : "2019-07-20T12:07:21.704Z",
"name" : "The Verge"
}
},
{
"pk" : 1,
"fields" : {
"user" : 2,
"name" : "Tech",
"modified" : "2019-07-20T12:07:17.396Z",
"created" : "2019-07-20T12:07:10Z"
},
"model" : "core.category"
},
{
"model" : "core.category",
"pk" : 2,
"fields" : {
"user" : 2,
"modified" : "2019-07-20T12:07:42.329Z",
"name" : "World News",
"created" : "2019-07-20T12:07:34Z"
}
}
]

View file

@ -0,0 +1,13 @@
import React from 'react';
const Card = props => {
return (
<div className="card">
<div className="card__header">{props.header}</div>
<div className="card__content">{props.content}</div>
<div className="card__footer">{props.footer}</div>
</div>
);
};
export default Card;

View file

@ -0,0 +1,13 @@
import React from 'react';
const LoadingIndicator = props => {
return (
<div className="loading-indicator">
<div />
<div />
<div />
</div>
);
};
export default LoadingIndicator;

View file

@ -0,0 +1,29 @@
import React from 'react';
class Messages extends React.Component {
state = { messages: this.props.messages };
close = ::this.close;
close(index) {
const newMessages = this.state.messages.filter((message, currentIndex) => {
return currentIndex != index;
});
this.setState({ messages: newMessages });
}
render() {
const messages = this.state.messages.map((message, index) => {
return (
<li key={index} className={`messages__item messages__item--${message.type}`}>
{message.text} <i className="gg-close" onClick={() => this.close(index)} />
</li>
);
});
return <ul className="list messages">{messages}</ul>;
}
}
export default Messages;

View file

@ -0,0 +1,11 @@
import React from 'react';
const Modal = props => {
return (
<div className="modal">
<div className="modal__item">{props.content}</div>
</div>
);
};
export default Modal;

View file

@ -0,0 +1,3 @@
import './pages/homepage/index.js';
import './pages/rules/index.js';
import './pages/categories/index.js';

View file

@ -0,0 +1,106 @@
import React from 'react';
import Cookies from 'js-cookie';
import Card from '../../components/Card.js';
import CategoryCard from './components/CategoryCard.js';
import CategoryModal from './components/CategoryModal.js';
import Messages from '../../components/Messages.js';
class App extends React.Component {
selectCategory = ::this.selectCategory;
deselectCategory = ::this.deselectCategory;
deleteCategory = ::this.deleteCategory;
constructor(props) {
super(props);
this.token = Cookies.get('csrftoken');
this.state = {
categories: props.categories,
selectedCategoryId: null,
message: null,
};
}
selectCategory(categoryId) {
this.setState({ selectedCategoryId: categoryId });
}
deselectCategory() {
this.setState({ selectedCategoryId: null });
}
deleteCategory(categoryId) {
const url = `/api/categories/${categoryId}/`;
const options = {
method: 'DELETE',
headers: {
'X-CSRFToken': this.token,
},
};
fetch(url, options).then(response => {
if (response.ok) {
const categories = this.state.categories.filter(category => {
return category.pk != categoryId;
});
return this.setState({
categories: categories,
selectedCategoryId: null,
message: null,
});
}
});
const message = {
type: 'error',
text: 'Unable to remove category, try again later',
};
return this.setState({ selectedCategoryId: null, message: message });
}
render() {
const { categories } = this.state;
const cards = categories.map(category => {
return (
<CategoryCard
key={category.pk}
category={category}
showDialog={this.selectCategory}
/>
);
});
const selectedCategory = categories.find(category => {
return category.pk === this.state.selectedCategoryId;
});
const pageHeader = (
<>
<h1 className="h1">Categories</h1>
<a className="link button button--confirm" href="/categories/create/">
Create category
</a>
</>
);
return (
<>
{this.state.message && <Messages messages={[this.state.message]} />}
<Card header={pageHeader} />
{cards}
{selectedCategory && (
<CategoryModal
category={selectedCategory}
handleCancel={this.deselectCategory}
handleDelete={this.deleteCategory}
/>
)}
</>
);
}
}
export default App;

View file

@ -0,0 +1,51 @@
import React from 'react';
import Card from '../../../components/Card.js';
const CategoryCard = props => {
const { category } = props;
const categoryRules = category.rules.map(rule => {
let favicon = null;
if (rule.favicon) {
favicon = <img className="favicon" src={rule.favicon} />;
} else {
favicon = <i className="gg-image" />;
}
return (
<li key={rule.pk} className="list__item">
{favicon}
{rule.name}
</li>
);
});
const cardHeader = (
<>
<h2 className="h2">{category.name}</h2>
<small className="small">{category.created}</small>
</>
);
const cardContent = <>{category.rules && <ul className="list">{categoryRules}</ul>}</>;
const cardFooter = (
<>
<a className="link button button--primary" href={`/categories/${category.pk}/`}>
Edit
</a>
<button
id="category-delete"
className="button button--error"
onClick={() => props.showDialog(category.pk)}
data-id={`${category.pk}`}
>
Delete
</button>
</>
);
return <Card header={cardHeader} content={cardContent} footer={cardFooter} />;
};
export default CategoryCard;

View file

@ -0,0 +1,37 @@
import React from 'react';
import Modal from '../../../components/Modal.js';
const CategoryModal = props => {
const content = (
<>
<div className="modal__header">
<h1 className="h1 modal__title">Delete category</h1>
</div>
<div className="modal__content">
<p className="p">Are you sure you want to delete {props.category.name}?</p>
<small className="small">
Collection rules coupled to this category will not be deleted but will have no
category
</small>
</div>
<div className="modal__footer">
<button className="button button--confirm" onClick={props.handleCancel}>
Cancel
</button>
<button
className="button button--error"
onClick={() => props.handleDelete(props.category.pk)}
>
Delete category
</button>
</div>
</>
);
return <Modal content={content} />;
};
export default CategoryModal;

View file

@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App.js';
const page = document.getElementById('categories--page');
if (page) {
const dataScript = document.getElementById('categories-data');
const categories = JSON.parse(dataScript.textContent);
ReactDOM.render(<App categories={categories} />, page);
}

View file

@ -0,0 +1,64 @@
import React from 'react';
import { connect } from 'react-redux';
import { isEqual } from 'lodash';
import { fetchCategories } from './actions/categories';
import Sidebar from './components/sidebar/Sidebar.js';
import FeedList from './components/feedlist/FeedList.js';
import PostModal from './components/PostModal.js';
import Messages from '../../components/Messages.js';
class App extends React.Component {
componentDidMount() {
this.props.fetchCategories();
}
render() {
return (
<>
<Sidebar />
<FeedList />
{this.props.error && (
<Messages messages={[{ type: 'error', text: this.props.error.message }]} />
)}
{!isEqual(this.props.post, {}) && (
<PostModal
post={this.props.post}
rule={this.props.rule}
category={this.props.category}
/>
)}
</>
);
}
}
const mapStateToProps = state => {
const { error } = state.error;
if (!isEqual(state.selected.post, {})) {
const ruleId = state.selected.post.rule;
const rule = state.rules.items[ruleId];
const category = state.categories.items[rule.category];
return {
category,
error,
rule,
post: state.selected.post,
};
}
return { error, post: state.selected.post };
};
const mapDispatchToProps = dispatch => ({
fetchCategories: () => dispatch(fetchCategories()),
});
export default connect(mapStateToProps, mapDispatchToProps)(App);

View file

@ -0,0 +1,87 @@
import { requestRules, receiveRules, fetchRulesByCategory } from './rules.js';
import { handleAPIError } from './error.js';
import { CATEGORY_TYPE } from '../constants.js';
export const SELECT_CATEGORY = 'SELECT_CATEGORY';
export const RECEIVE_CATEGORY = 'RECEIVE_CATEGORY';
export const RECEIVE_CATEGORIES = 'RECEIVE_CATEGORIES';
export const REQUEST_CATEGORY = 'REQUEST_CATEGORY';
export const REQUEST_CATEGORIES = 'REQUEST_CATEGORIES';
export const selectCategory = category => ({
type: SELECT_CATEGORY,
section: { ...category, type: CATEGORY_TYPE },
});
export const receiveCategory = category => ({
type: RECEIVE_CATEGORY,
category,
});
export const receiveCategories = categories => ({
type: RECEIVE_CATEGORIES,
categories,
});
export const requestCategory = () => ({ type: REQUEST_CATEGORY });
export const requestCategories = () => ({ type: REQUEST_CATEGORIES });
export const fetchCategory = category => {
return (dispatch, getState) => {
const { selected } = getState();
const selectedSection = { ...selected.item };
if (selectedSection.type === CATEGORY_TYPE && selectedSection.clicks <= 1) {
return;
}
dispatch(requestCategory());
return fetch(`/api/categories/${category.id}`)
.then(response => response.json())
.then(json => {
dispatch(receiveCategory({ ...json }));
if (category.unread === 0) {
return dispatch(fetchRulesByCategory(category));
}
})
.catch(error => {
dispatch(receiveCategory({}));
dispatch(handleAPIError(error));
});
};
};
export const fetchCategories = () => {
return dispatch => {
dispatch(requestCategories());
return fetch('/api/categories/')
.then(response => response.json())
.then(categories => {
dispatch(receiveCategories(categories));
return categories;
})
.then(categories => {
dispatch(requestRules());
const promises = categories.map(category => {
return fetch(`/api/categories/${category.id}/rules/`);
});
return Promise.all(promises);
})
.then(responses => Promise.all(responses.map(response => response.json())))
.then(nestedRules => dispatch(receiveRules(nestedRules.flat())))
.catch(error => {
dispatch(receiveCategories([]));
dispatch(receiveRules([]));
dispatch(handleAPIError(error));
});
};
};

View file

@ -0,0 +1,6 @@
export const RECEIVE_API_ERROR = 'RECEIVE_API_ERROR';
export const handleAPIError = error => ({
type: RECEIVE_API_ERROR,
error,
});

View file

@ -0,0 +1,89 @@
import { handleAPIError } from './error.js';
import { RULE_TYPE, CATEGORY_TYPE } from '../constants.js';
export const SELECT_POST = 'SELECT_POST';
export const UNSELECT_POST = 'UNSELECT_POST';
export const RECEIVE_POSTS = 'RECEIVE_POSTS';
export const RECEIVE_POST = 'RECEIVE_POST';
export const REQUEST_POSTS = 'REQUEST_POSTS';
export const MARK_POST_READ = 'MARK_POST_READ';
export const requestPosts = () => ({ type: REQUEST_POSTS });
export const receivePosts = (posts, next) => ({
type: RECEIVE_POSTS,
posts,
next,
});
export const receivePost = post => ({ type: RECEIVE_POST, post });
export const selectPost = post => ({ type: SELECT_POST, post });
export const unSelectPost = () => ({ type: UNSELECT_POST });
export const postRead = (post, section) => ({
type: MARK_POST_READ,
post,
section,
});
export const markPostRead = (post, token) => {
return (dispatch, getState) => {
const { selected } = getState();
const url = `/api/posts/${post.id}/`;
const options = {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': token,
},
body: JSON.stringify({ read: true }),
};
const section = { ...selected.item };
return fetch(url, options)
.then(response => response.json())
.then(updatedPost => {
dispatch(receivePost({ ...updatedPost }));
dispatch(postRead({ ...updatedPost }, section));
})
.catch(error => {
dispatch(receivePost({}));
dispatch(handleAPIError(error));
});
};
};
export const fetchPostsBySection = (section, page = false) => {
return dispatch => {
if (section.unread === 0) {
return;
}
dispatch(requestPosts());
let url = null;
switch (section.type) {
case RULE_TYPE:
url = page ? page : `/api/rules/${section.id}/posts/?read=false`;
break;
case CATEGORY_TYPE:
url = page ? page : `/api/categories/${section.id}/posts/?read=false`;
break;
}
return fetch(url)
.then(response => response.json())
.then(posts => dispatch(receivePosts(posts.results, posts.next)))
.catch(error => {
dispatch(receivePosts([]));
dispatch(handleAPIError(error));
});
};
};

View file

@ -0,0 +1,75 @@
import { fetchCategory } from './categories.js';
import { RULE_TYPE } from '../constants.js';
import { handleAPIError } from './error.js';
export const SELECT_RULE = 'SELECT_RULE';
export const SELECT_RULES = 'SELECT_RULES';
export const RECEIVE_RULE = 'RECEIVE_RULE';
export const RECEIVE_RULES = 'RECEIVE_RULES';
export const REQUEST_RULE = 'REQUEST_RULE';
export const REQUEST_RULES = 'REQUEST_RULES';
export const selectRule = rule => ({
type: SELECT_RULE,
section: { ...rule, type: RULE_TYPE },
});
export const requestRule = () => ({ type: REQUEST_RULE });
export const requestRules = () => ({ type: REQUEST_RULES });
export const receiveRule = rule => ({
type: RECEIVE_RULE,
rule,
});
export const receiveRules = rules => ({
type: RECEIVE_RULES,
rules,
});
export const fetchRule = rule => {
return (dispatch, getState) => {
const { selected } = getState();
const selectedSection = { ...selected.item };
if (selectedSection.type === RULE_TYPE && selectedSection.clicks <= 1) {
return;
}
dispatch(requestRule());
const { categories } = getState();
const category = categories['items'][rule.category];
return fetch(`/api/rules/${rule.id}`)
.then(response => response.json())
.then(receivedRule => {
dispatch(receiveRule({ ...receivedRule }));
// fetch & update category info when the rule is read
if (rule.unread === 0) {
return dispatch(fetchCategory({ ...category }));
}
})
.catch(error => {
dispatch(receiveRule({}));
dispatch(handleAPIError(error));
});
};
};
export const fetchRulesByCategory = category => {
return dispatch => {
dispatch(requestRules());
return fetch(`/api/categories/${category.id}/rules/`)
.then(response => response.json())
.then(rules => dispatch(receiveRules(rules)))
.catch(error => {
dispatch(receiveRules([]));
dispatch(handleAPIError(error));
});
};
};

View file

@ -0,0 +1,89 @@
import { handleAPIError } from './error.js';
import { receiveCategory, requestCategory } from './categories.js';
import { receiveRule, requestRule } from './rules.js';
import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js';
export const MARK_SECTION_READ = 'MARK_SECTION_READ';
export const markSectionRead = section => ({
type: MARK_SECTION_READ,
section,
});
const markCategoryRead = (category, token) => {
return (dispatch, getState) => {
dispatch(requestCategory(category));
const { rules } = getState();
const categoryRules = Object.values({ ...rules.items }).filter(rule => {
return rule.category === category.id;
});
const ruleMapping = {};
categoryRules.forEach(rule => {
ruleMapping[rule.id] = { ...rule };
});
const url = `/api/categories/${category.id}/read/`;
const options = {
method: 'POST',
headers: {
'X-CSRFToken': token,
},
};
return fetch(url, options)
.then(response => response.json())
.then(updatedCategory => {
dispatch(receiveCategory({ ...updatedCategory }));
return dispatch(
markSectionRead({
...category,
...updatedCategory,
rules: ruleMapping,
type: CATEGORY_TYPE,
})
);
})
.catch(error => {
dispatch(receiveCategory({}));
dispatch(handleAPIError(error));
});
};
};
const markRuleRead = (rule, token) => {
return (dispatch, getState) => {
dispatch(requestRule(rule));
const url = `/api/rules/${rule.id}/read/`;
const options = {
method: 'POST',
headers: {
'X-CSRFToken': token,
},
};
return fetch(url, options)
.then(response => response.json())
.then(updatedRule => {
dispatch(receiveRule({ ...updatedRule }));
// Use the old rule to decrement category with old unread count
dispatch(markSectionRead({ ...rule, type: RULE_TYPE }));
})
.catch(error => {
dispatch(receiveRule({}));
dispatch(handleAPIError(error));
});
};
};
export const markRead = (section, token) => {
switch (section.type) {
case RULE_TYPE:
return markRuleRead(section, token);
case CATEGORY_TYPE:
return markCategoryRead(section, token);
}
};

View file

@ -0,0 +1,91 @@
import React from 'react';
import { connect } from 'react-redux';
import Cookies from 'js-cookie';
import { unSelectPost, markPostRead } from '../actions/posts.js';
import { formatDatetime } from '../../../utils.js';
class PostModal extends React.Component {
modalListener = ::this.modalListener;
readTimer = null;
componentDidMount() {
const post = { ...this.props.post };
const markPostRead = this.props.markPostRead;
const token = Cookies.get('csrftoken');
if (!post.read) {
this.readTimer = setTimeout(markPostRead, 3000, post, token);
}
window.addEventListener('click', this.modalListener);
}
componentWillUnmount() {
if (this.readTimer) {
clearTimeout(this.readTimer);
}
this.readTimer = null;
window.removeEventListener('click', this.modalListener);
}
modalListener(e) {
const targetClassName = e.target.className;
if (this.props.post && targetClassName == 'modal post-modal') {
this.props.unSelectPost();
}
}
render() {
const post = this.props.post;
const publicationDate = formatDatetime(post.publicationDate);
const titleClassName = post.read ? 'post__title post__title--read' : 'post__title';
return (
<div className="modal post-modal">
<div className="post">
<button
className="button post__close-button"
onClick={() => this.props.unSelectPost()}
>
Close <i className="gg-close"></i>
</button>
<div className="post__header">
<h1 className={titleClassName}>{`${post.title} `}</h1>
<div className="post__meta-info">
<span className="post__date">{publicationDate}</span>
<a
className="post__link"
href={post.url}
target="_blank"
rel="noopener noreferrer"
>
<i className="gg-link" />
</a>
</div>
</div>
<aside className="post__section-info">
{this.props.category && (
<h5 title={this.props.category.name}>{this.props.category.name}</h5>
)}
<h5 title={this.props.rule.name}>{this.props.rule.name}</h5>
</aside>
{/* HTML is sanitized by the collectors */}
<div className="post__body" dangerouslySetInnerHTML={{ __html: post.body }} />
</div>
</div>
);
}
}
const mapDispatchToProps = dispatch => ({
unSelectPost: () => dispatch(unSelectPost()),
markPostRead: (post, token) => dispatch(markPostRead(post, token)),
});
export default connect(null, mapDispatchToProps)(PostModal);

View file

@ -0,0 +1,86 @@
import React from 'react';
import { connect } from 'react-redux';
import { isEqual } from 'lodash';
import { fetchPostsBySection } from '../../actions/posts.js';
import { filterPosts } from './filters.js';
import LoadingIndicator from '../../../../components/LoadingIndicator.js';
import RuleItem from './RuleItem.js';
class FeedList extends React.Component {
checkScrollHeight = ::this.checkScrollHeight;
componentDidMount() {
window.addEventListener('scroll', this.checkScrollHeight);
}
componentWillUnmount() {
window.removeEventListener('scroll', this.checkScrollHeight);
}
checkScrollHeight(e) {
const currentHeight = window.scrollY + window.innerHeight;
const totalHeight = document.body.offsetHeight;
const currentPercentage = (currentHeight / totalHeight) * 100;
if (this.props.next && !this.props.lastReached) {
if (currentPercentage > 60 && !this.props.isFetching) {
this.paginate();
}
}
}
paginate() {
this.props.fetchPostsBySection(this.props.selected, this.props.next);
}
render() {
const ruleItems = this.props.posts.map((item, index) => {
return <RuleItem key={index} posts={item.posts} rule={item.rule} />;
});
if (isEqual(this.props.selected, {})) {
return (
<div className="post-message">
<div className="post-message__block">
<i className="gg-arrow-left" />
<p className="post-message__text">Select an item to show its unread posts</p>
</div>
</div>
);
} else if (ruleItems.length === 0 && !this.props.isFetching) {
return (
<div className="post-message">
<div className="post-message__block">
<p className="post-message__text">
No unread posts from the selected section at this moment, try again later
</p>
</div>
</div>
);
} else {
return (
<div className="post-block">
{ruleItems}
{this.props.isFetching && <LoadingIndicator />}
</div>
);
}
}
}
const mapStateToProps = state => ({
isFetching: state.posts.isFetching,
posts: filterPosts(state),
next: state.selected.next,
lastReached: state.selected.lastReached,
selected: state.selected.item,
});
const mapDispatchToProps = dispatch => ({
fetchPostsBySection: (rule, page = false) => dispatch(fetchPostsBySection(rule, page)),
});
export default connect(mapStateToProps, mapDispatchToProps)(FeedList);

View file

@ -0,0 +1,49 @@
import React from 'react';
import { connect } from 'react-redux';
import { selectPost } from '../../actions/posts.js';
import { formatDatetime } from '../../../../utils.js';
class PostItem extends React.Component {
render() {
const post = this.props.post;
const publicationDate = formatDatetime(post.publicationDate);
const titleClassName = post.read
? 'posts-header__title posts-header__title--read'
: 'posts-header__title';
return (
<li
className="posts__item"
onClick={() => {
this.props.selectPost(post);
}}
>
<h5 className={titleClassName} title={post.title}>
{post.title}
</h5>
<div className="posts-info">
<span className="posts-info__date" title={publicationDate}>
{publicationDate}
</span>
<a
className="posts-info__link"
href={post.url}
target="_blank"
rel="noopener noreferrer"
>
<i className="gg-link" />
</a>
</div>
</li>
);
}
}
const mapDispatchToProps = dispatch => ({
selectPost: post => dispatch(selectPost(post)),
});
export default connect(null, mapDispatchToProps)(PostItem);

View file

@ -0,0 +1,24 @@
import React from 'react';
import PostItem from './PostItem.js';
class RuleItem extends React.Component {
render() {
const posts = Object.values(this.props.posts).sort((firstEl, secondEl) => {
return new Date(secondEl.publicationDate) - new Date(firstEl.publicationDate);
});
const postItems = posts.map(post => {
return <PostItem key={post.id} post={post} />;
});
return (
<section className="posts-section">
<h3 className="posts-section__name">{this.props.rule.name}</h3>
<ul className="posts">{postItems}</ul>
</section>
);
}
}
export default RuleItem;

View file

@ -0,0 +1,46 @@
import { CATEGORY_TYPE, RULE_TYPE } from '../../constants.js';
const isEmpty = (object = {}) => {
return Object.keys(object).length === 0;
};
export const filterPostsByRule = (rule = {}, posts = []) => {
const filteredPosts = posts.filter(post => {
return post.rule === rule.id;
});
return filteredPosts.length > 0 ? [{ rule, posts: filteredPosts }] : [];
};
export const filterPostsByCategory = (category = {}, rules = [], posts = []) => {
const filteredRules = rules.filter(rule => {
return rule.category === category.id;
});
const filteredData = filteredRules.map(rule => {
const filteredPosts = posts.filter(post => {
return post.rule === rule.id;
});
return {
rule: { ...rule },
posts: filteredPosts,
};
});
return filteredData.filter(rule => rule.posts.length > 0);
};
export const filterPosts = state => {
const posts = Object.values({ ...state.posts.items });
switch (state.selected.item.type) {
case CATEGORY_TYPE:
const rules = Object.values({ ...state.rules.items });
return filterPostsByCategory({ ...state.selected.item }, rules, posts);
case RULE_TYPE:
return filterPostsByRule({ ...state.selected.item }, posts);
}
return [];
};

View file

@ -0,0 +1,62 @@
import React from 'react';
import { connect } from 'react-redux';
import { isEqual } from 'lodash';
import { CATEGORY_TYPE } from '../../constants.js';
import { selectCategory, fetchCategory } from '../../actions/categories.js';
import { fetchPostsBySection } from '../../actions/posts.js';
import { isSelected } from './functions.js';
import RuleItem from './RuleItem.js';
class CategoryItem extends React.Component {
state = { open: false };
toggleRules() {
this.setState({ open: !this.state.open });
}
handleSelect() {
const category = this.props.category;
this.props.selectCategory(category);
this.props.fetchPostsBySection({ ...category, type: CATEGORY_TYPE });
this.props.fetchCategory(category);
}
render() {
const chevronClass = this.state.open ? 'gg-chevron-down' : 'gg-chevron-right';
const selected = isSelected(this.props.category, this.props.selected, CATEGORY_TYPE);
const className = selected ? 'category category--selected' : 'category';
const ruleItems = this.props.rules.map(rule => {
return <RuleItem key={rule.id} rule={rule} selected={this.props.selected} />;
});
return (
<li className="sidebar__item">
<div className={className}>
<div className="category__menu" onClick={() => this.toggleRules()}>
<i className={chevronClass} />
</div>
<div className="category__info" onClick={() => this.handleSelect()}>
<h4>{this.props.category.name}</h4>
<span className="badge">{this.props.category.unread}</span>
</div>
</div>
{ruleItems.length > 0 && this.state.open && (
<ul className="rules">{ruleItems}</ul>
)}
</li>
);
}
}
const mapDispatchToProps = dispatch => ({
selectCategory: category => dispatch(selectCategory(category)),
fetchPostsBySection: section => dispatch(fetchPostsBySection(section)),
fetchCategory: category => dispatch(fetchCategory(category)),
});
export default connect(null, mapDispatchToProps)(CategoryItem);

View file

@ -0,0 +1,33 @@
import React from 'react';
import { connect } from 'react-redux';
import Cookies from 'js-cookie';
import { markRead } from '../../actions/selected.js';
class ReadButton extends React.Component {
markSelectedRead = ::this.markSelectedRead;
markSelectedRead() {
const token = Cookies.get('csrftoken');
if (this.props.selected.unread > 0) {
this.props.markRead({ ...this.props.selected }, token);
}
}
render() {
return (
<button className="button read-button" onClick={this.markSelectedRead}>
Mark selected read
</button>
);
}
}
const mapDispatchToProps = dispatch => ({
markRead: (selected, token) => dispatch(markRead(selected, token)),
});
const mapStateToProps = state => ({ selected: state.selected.item });
export default connect(mapStateToProps, mapDispatchToProps)(ReadButton);

View file

@ -0,0 +1,50 @@
import React from 'react';
import { connect } from 'react-redux';
import { isEqual } from 'lodash';
import { RULE_TYPE } from '../../constants.js';
import { selectRule, fetchRule } from '../../actions/rules.js';
import { fetchPostsBySection } from '../../actions/posts.js';
import { isSelected } from './functions.js';
class RuleItem extends React.Component {
handleSelect() {
const rule = { ...this.props.rule };
this.props.selectRule(rule);
this.props.fetchPostsBySection({ ...rule, type: RULE_TYPE });
this.props.fetchRule(rule);
}
render() {
const selected = isSelected(this.props.rule, this.props.selected, RULE_TYPE);
const className = `rules__item ${selected ? 'rules__item--selected' : ''}`;
let favicon = null;
if (this.props.rule.favicon) {
favicon = <img className="favicon" width="20" src={this.props.rule.favicon} />;
} else {
favicon = <i className="gg-image" />;
}
return (
<li className={className} onClick={() => this.handleSelect()}>
<div className="rules__info">
{favicon}
<h5 className="rules__title" title={this.props.rule.name}>
{this.props.rule.name}
</h5>
</div>
<span className="badge">{this.props.rule.unread}</span>
</li>
);
}
}
const mapDispatchToProps = dispatch => ({
selectRule: rule => dispatch(selectRule(rule)),
fetchPostsBySection: section => dispatch(fetchPostsBySection(section)),
fetchRule: rule => dispatch(fetchRule(rule)),
});
export default connect(null, mapDispatchToProps)(RuleItem);

View file

@ -0,0 +1,49 @@
import React from 'react';
import { connect } from 'react-redux';
import { isEqual } from 'lodash';
import { filterCategories, filterRules } from './filters.js';
import LoadingIndicator from '../../../../components/LoadingIndicator.js';
import CategoryItem from './CategoryItem.js';
import ReadButton from './ReadButton.js';
// TODO: show empty category message
class Sidebar extends React.Component {
render() {
const items = this.props.categories.items.map(category => {
const rules = this.props.rules.items.filter(rule => {
return rule.category === category.id;
});
return (
<CategoryItem
key={category.id}
category={category}
rules={rules}
selected={this.props.selected.item}
/>
);
});
return (
<div className="sidebar">
{(this.props.categories.isFetching || this.props.rules.isFetching) && (
<LoadingIndicator />
)}
<ul className="sidebar__nav">{items}</ul>
{!isEqual(this.props.selected.item, {}) && <ReadButton />}
</div>
);
}
}
const mapStateToProps = state => ({
categories: { ...state.categories, items: filterCategories(state.categories.items) },
rules: { ...state.rules, items: filterRules(state.rules.items) },
selected: state.selected,
});
export default connect(mapStateToProps)(Sidebar);

View file

@ -0,0 +1,7 @@
export const filterCategories = (categories = {}) => {
return Object.values({ ...categories });
};
export const filterRules = (rules = {}) => {
return Object.values({ ...rules });
};

View file

@ -0,0 +1,7 @@
export const isSelected = (section, selected, type) => {
if (!selected || selected.type != type) {
return false;
}
return section.id === selected.id;
};

View file

@ -0,0 +1,18 @@
import { createStore, applyMiddleware } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { createLogger } from 'redux-logger';
import rootReducer from './reducers/index.js';
const loggerMiddleware = createLogger();
const configureStore = preloadedState => {
return createStore(
rootReducer,
preloadedState,
applyMiddleware(thunkMiddleware, loggerMiddleware)
);
};
export default configureStore;

View file

@ -0,0 +1,2 @@
export const RULE_TYPE = 'RULE';
export const CATEGORY_TYPE = 'CATEGORY';

View file

@ -0,0 +1,20 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import configureStore from './configureStore.js';
import App from './App.js';
const page = document.getElementById('homepage--page');
if (page) {
const store = configureStore();
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
page
);
}

View file

@ -0,0 +1,93 @@
import { isEqual } from 'lodash';
import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js';
import { objectsFromArray } from '../../../utils.js';
import {
RECEIVE_CATEGORY,
RECEIVE_CATEGORIES,
REQUEST_CATEGORY,
REQUEST_CATEGORIES,
} from '../actions/categories.js';
import { MARK_POST_READ } from '../actions/posts.js';
import { MARK_SECTION_READ } from '../actions/selected.js';
const defaultState = { items: {}, isFetching: false };
export const categories = (state = { ...defaultState }, action) => {
switch (action.type) {
case RECEIVE_CATEGORY:
return {
...state,
items: {
...state.items,
[action.category.id]: { ...action.category },
},
isFetching: false,
};
case RECEIVE_CATEGORIES:
const receivedCategories = objectsFromArray(action.categories, 'id');
return {
...state,
items: { ...state.items, ...receivedCategories },
isFetching: false,
};
case REQUEST_CATEGORIES:
case REQUEST_CATEGORY:
return { ...state, isFetching: true };
case MARK_POST_READ:
let category = {};
switch (action.section.type) {
case CATEGORY_TYPE:
category = { ...state.items[action.section.id] };
break;
case RULE_TYPE:
category = { ...state.items[action.section.category] };
break;
}
return {
...state,
items: {
...state.items,
[category.id]: { ...category, unread: category.unread - 1 },
},
};
case MARK_SECTION_READ:
category = {};
switch (action.section.type) {
case CATEGORY_TYPE:
category = { ...state.items[action.section.id] };
return {
...state,
items: {
...state.items,
[category.id]: { ...category, unread: 0 },
},
};
case RULE_TYPE:
category = { ...state.items[action.section.category] };
return {
...state,
items: {
...state.items,
[category.id]: {
...category,
unread: category.unread - action.section.unread,
},
},
};
}
return state;
default:
return state;
}
};

View file

@ -0,0 +1,12 @@
import { RECEIVE_API_ERROR } from '../actions/error.js';
const defaultState = {};
export const error = (state = { ...defaultState }, action) => {
switch (action.type) {
case RECEIVE_API_ERROR:
return { ...state, error: action.error };
default:
return {};
}
};

View file

@ -0,0 +1,11 @@
import { combineReducers } from 'redux';
import { categories } from './categories.js';
import { error } from './error.js';
import { rules } from './rules.js';
import { posts } from './posts.js';
import { selected } from './selected.js';
const rootReducer = combineReducers({ categories, error, rules, posts, selected });
export default rootReducer;

View file

@ -0,0 +1,68 @@
import { isEqual } from 'lodash';
import { objectsFromArray } from '../../../utils.js';
import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js';
import {
SELECT_POST,
RECEIVE_POST,
RECEIVE_POSTS,
REQUEST_POSTS,
} from '../actions/posts.js';
import { SELECT_CATEGORY } from '../actions/categories.js';
import { SELECT_RULE } from '../actions/rules.js';
import { MARK_SECTION_READ } from '../actions/selected.js';
const defaultState = { items: {}, isFetching: false };
export const posts = (state = { ...defaultState }, action) => {
switch (action.type) {
case REQUEST_POSTS:
return { ...state, isFetching: true };
case RECEIVE_POST:
return {
...state,
items: { ...state.items, [action.post.id]: { ...action.post } },
};
case RECEIVE_POSTS:
const receivedItems = objectsFromArray(action.posts, 'id');
return {
...state,
isFetching: false,
items: { ...state.items, ...receivedItems },
};
case MARK_SECTION_READ:
const updatedPosts = {};
let relatedPosts = [];
switch (action.section.type) {
case CATEGORY_TYPE:
relatedPosts = Object.values({ ...state.items }).filter(post => {
return post.rule in { ...action.section.rules };
});
break;
case RULE_TYPE:
relatedPosts = Object.values({ ...state.items }).filter(post => {
return post.rule === action.section.id;
});
break;
}
relatedPosts.forEach(post => {
updatedPosts[post.id] = { ...post, read: true };
});
return {
...state,
items: {
...state.items,
...updatedPosts,
},
};
default:
return state;
}
};

View file

@ -0,0 +1,82 @@
import { isEqual } from 'lodash';
import { objectsFromArray } from '../../../utils.js';
import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js';
import {
REQUEST_RULES,
REQUEST_RULE,
RECEIVE_RULES,
RECEIVE_RULE,
} from '../actions/rules.js';
import { MARK_POST_READ } from '../actions/posts.js';
import { MARK_SECTION_READ } from '../actions/selected.js';
const defaultState = { items: {}, isFetching: false };
export const rules = (state = { ...defaultState }, action) => {
switch (action.type) {
case REQUEST_RULE:
case REQUEST_RULES:
return { ...state, isFetching: true };
case RECEIVE_RULES:
const receivedItems = objectsFromArray(action.rules, 'id');
return {
...state,
items: { ...state.items, ...receivedItems },
isFetching: false,
};
case RECEIVE_RULE:
return {
...state,
items: { ...state.items, [action.rule.id]: { ...action.rule } },
isFetching: false,
};
case MARK_POST_READ:
const rule = { ...state.items[action.post.rule] };
return {
...state,
items: {
...state.items,
[rule.id]: { ...rule, unread: rule.unread - 1 },
},
};
case MARK_SECTION_READ:
switch (action.section.type) {
case RULE_TYPE:
const rule = { ...state.items[action.section.id] };
return {
...state,
items: {
...state.items,
[rule.id]: { ...rule, unread: 0 },
},
};
case CATEGORY_TYPE:
const updatedRules = {};
const categoryRules = Object.values({ ...state.items }).filter(rule => {
return rule.category === action.section.id;
});
categoryRules.forEach(rule => {
updatedRules[rule.id] = { ...rule, unread: 0 };
});
return {
...state,
items: {
...state.items,
...updatedRules,
},
};
}
return state;
default:
return state;
}
};

View file

@ -0,0 +1,86 @@
import { isEqual } from 'lodash';
import { SELECT_CATEGORY } from '../actions/categories.js';
import { SELECT_RULE } from '../actions/rules.js';
import {
RECEIVE_POST,
RECEIVE_POSTS,
SELECT_POST,
UNSELECT_POST,
} from '../actions/posts.js';
import { MARK_SECTION_READ } from '../actions/selected.js';
import { MARK_POST_READ } from '../actions/posts.js';
const defaultState = { item: {}, next: false, lastReached: false, post: {} };
export const selected = (state = { ...defaultState }, action) => {
switch (action.type) {
case SELECT_CATEGORY:
case SELECT_RULE:
if (state.item) {
if (
state.item.id === action.section.id &&
state.item.type === action.section.type
) {
if (state.item.clicks >= 2) {
return {
...state,
item: { ...action.section, clicks: 1 },
next: false,
lastReached: false,
};
}
return {
...state,
item: { ...action.section, clicks: state.item.clicks + 1 },
next: false,
lastReached: false,
};
}
}
return {
...state,
item: { ...action.section, clicks: 1 },
next: false,
lastReached: false,
};
case RECEIVE_POSTS:
return {
...state,
next: action.next,
lastReached: !action.next,
};
case RECEIVE_POST:
const isCurrentPost = !isEqual(state.post, {}) && state.post.id === action.post.id;
if (isCurrentPost) {
return {
...state,
post: { ...action.post },
};
}
return {
...state,
};
case SELECT_POST:
return { ...state, post: action.post };
case UNSELECT_POST:
return { ...state, post: {} };
case MARK_POST_READ:
return {
...state,
item: { ...action.section, unread: action.section.unread - 1 },
};
case MARK_SECTION_READ:
return {
...state,
item: { ...action.section, clicks: 0, unread: 0 },
};
default:
return state;
}
};

View file

@ -0,0 +1,106 @@
import React from 'react';
import Cookies from 'js-cookie';
import Card from '../../components/Card.js';
import RuleCard from './components/RuleCard.js';
import RuleModal from './components/RuleModal.js';
import Messages from '../../components/Messages.js';
class App extends React.Component {
selectRule = ::this.selectRule;
deselectRule = ::this.deselectRule;
deleteRule = ::this.deleteRule;
constructor(props) {
super(props);
this.token = Cookies.get('csrftoken');
this.state = {
rules: props.rules,
selectedRuleId: null,
message: null,
};
}
selectRule(ruleId) {
this.setState({ selectedRuleId: ruleId });
}
deselectRule() {
this.setState({ selectedRuleId: null });
}
deleteRule(ruleId) {
const url = `/api/rules/${ruleId}/`;
const options = {
method: 'DELETE',
headers: {
'X-CSRFToken': this.token,
},
};
fetch(url, options).then(response => {
if (response.ok) {
const rules = this.state.rules.filter(rule => {
return rule.pk != ruleId;
});
return this.setState({
rules: rules,
selectedRuleId: null,
message: null,
});
}
});
const message = {
type: 'error',
text: 'Unable to remove rule, try again later',
};
return this.setState({ selectedRuleId: null, message: message });
}
render() {
const { rules } = this.state;
const cards = rules.map(rule => {
return <RuleCard key={rule.pk} rule={rule} showDialog={this.selectRule} />;
});
const selectedRule = rules.find(rule => {
return rule.pk === this.state.selectedRuleId;
});
const pageHeader = (
<>
<h1 className="h1">Rules</h1>
<div className="card__header--action">
<a className="link button button--primary" href="/rules/import/">
Import rules
</a>
<a className="link button button--confirm" href="/rules/create/">
Create rule
</a>
</div>
</>
);
return (
<>
{this.state.message && <Messages messages={[this.state.message]} />}
<Card header={pageHeader} />
{cards}
{selectedRule && (
<RuleModal
rule={selectedRule}
handleCancel={this.deselectRule}
handleDelete={this.deleteRule}
/>
)}
</>
);
}
}
export default App;

View file

@ -0,0 +1,65 @@
import React from 'react';
import Card from '../../../components/Card.js';
const RuleCard = props => {
const { rule } = props;
let favicon = null;
if (rule.favicon) {
favicon = <img className="favicon" src={rule.favicon} />;
} else {
favicon = <i className="gg-image" />;
}
const stateIcon = !rule.error ? 'gg-check' : 'gg-danger';
const cardHeader = (
<>
<i className={stateIcon} />
<h2 className="h2">{rule.name}</h2>
{favicon}
</>
);
const cardContent = (
<>
<ul className="list rules">
{rule.error && (
<ul className="list errorlist">
<li className="list__item errorlist__item">{rule.error}</li>
</ul>
)}
{rule.category && <li className="list__item">{rule.category}</li>}
<li className="list__item">
<a className="link" target="_blank" rel="noopener noreferrer" href={rule.url}>
{rule.url}
</a>
</li>
<li className="list__item">{rule.created}</li>
<li className="list__item">{rule.timezone}</li>
</ul>
</>
);
const cardFooter = (
<>
<a className="link button button--primary" href={`/rules/${rule.pk}/`}>
Edit
</a>
<button
id="rule-delete"
className="button button--error"
onClick={() => props.showDialog(rule.pk)}
data-id={`${rule.pk}`}
>
Delete
</button>
</>
);
return <Card header={cardHeader} content={cardContent} footer={cardFooter} />;
};
export default RuleCard;

View file

@ -0,0 +1,35 @@
import React from 'react';
import Modal from '../../../components/Modal.js';
const RuleModal = props => {
const content = (
<>
<div>
<div className="modal__header">
<h1 className="h1 modal__title">Delete rule</h1>
</div>
<div className="modal__content">
<p className="p">Are you sure you want to delete {props.rule.name}?</p>
</div>
<div className="modal__footer">
<button className="button button--confirm" onClick={props.handleCancel}>
Cancel
</button>
<button
className="button button--error"
onClick={() => props.handleDelete(props.rule.pk)}
>
Delete rule
</button>
</div>
</div>
</>
);
return <Modal content={content} />;
};
export default RuleModal;

View file

@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App.js';
const page = document.getElementById('rules--page');
if (page) {
const dataScript = document.getElementById('rules-data');
const rules = JSON.parse(dataScript.textContent);
ReactDOM.render(<App rules={rules} />, page);
}

View file

@ -0,0 +1,316 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'fetch-mock';
import * as actions from '../../../pages/homepage/actions/categories.js';
import * as constants from '../../../pages/homepage/constants.js';
import * as ruleActions from '../../../pages/homepage/actions/rules.js';
import * as errorActions from '../../../pages/homepage/actions/error.js';
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('category actions', () => {
afterEach(() => {
fetchMock.restore();
});
it('should create an action to select a category', () => {
const category = { id: 1, name: 'Test category', unread: 100 };
const expectedAction = {
section: { ...category, type: constants.CATEGORY_TYPE },
type: actions.SELECT_CATEGORY,
};
expect(actions.selectCategory(category)).toEqual(expectedAction);
});
it('should create an action to receive a category', () => {
const category = { id: 1, name: 'Test category', unread: 100 };
const expectedAction = {
type: actions.RECEIVE_CATEGORY,
category,
};
expect(actions.receiveCategory(category)).toEqual(expectedAction);
});
it('should create an action to receive multiple categories', () => {
const categories = [
{ id: 1, name: 'Test category 1', unread: 200 },
{ id: 2, name: 'Test category 2', unread: 500 },
{ id: 3, name: 'Test category 3', unread: 600 },
];
const expectedAction = {
type: actions.RECEIVE_CATEGORIES,
categories,
};
expect(actions.receiveCategories(categories)).toEqual(expectedAction);
});
it('should create an action to request a category', () => {
const expectedAction = { type: actions.REQUEST_CATEGORY };
expect(actions.requestCategory()).toEqual(expectedAction);
});
it('should create an action to request multiple categories', () => {
const expectedAction = { type: actions.REQUEST_CATEGORIES };
expect(actions.requestCategories()).toEqual(expectedAction);
});
it('should create multiple actions when fetching a category', () => {
const category = {
id: 1,
name: 'Tech',
unread: 1138,
};
fetchMock.getOnce('/api/categories/1', {
body: { ...category, unread: 500 },
headers: { 'content-type': 'application/json' },
});
const expectedActions = [
{ type: actions.REQUEST_CATEGORY },
{
type: actions.RECEIVE_CATEGORY,
category: { ...category, unread: 500 },
},
];
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
});
return store.dispatch(actions.fetchCategory(category)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should create multiple actions when fetching categories', () => {
const categories = [
{ id: 1, name: 'Tech', unread: 29 },
{ id: 2, name: 'World news', unread: 956 },
];
const rules = [
{
id: 5,
name: 'Ars Technica',
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
category: 1,
unread: 7,
},
{
id: 6,
name: 'BBC',
url: 'http://feeds.bbci.co.uk/news/world/rss.xml',
favicon:
'https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png',
category: 2,
unread: 345,
},
];
fetchMock
.get('/api/categories/', {
body: categories,
headers: { 'content-type': 'application/json' },
})
.get('/api/categories/1/rules/', {
body: [{ ...rules[0] }],
headers: { 'content-type': 'application/json' },
})
.get('/api/categories/2/rules/', {
body: [{ ...rules[1] }],
headers: { 'content-type': 'application/json' },
});
const expectedActions = [
{ type: actions.REQUEST_CATEGORIES },
{ type: actions.RECEIVE_CATEGORIES, categories },
{ type: ruleActions.REQUEST_RULES },
{ type: ruleActions.RECEIVE_RULES, rules },
];
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
});
return store.dispatch(actions.fetchCategories()).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should create multiple actions when fetching a category which is read', () => {
const category = {
id: 1,
name: 'Tech',
unread: 0,
};
const rules = [
{
id: 1,
name: 'Ars Technica',
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
category: 1,
unread: 200,
},
{
id: 2,
name: 'Hacker News',
url: 'https://news.ycombinator.com/rss',
favicon: 'https://news.ycombinator.com/favicon.ico',
category: 1,
unread: 350,
},
];
fetchMock
.get('/api/categories/1', {
body: { ...category, unread: 500 },
headers: { 'content-type': 'application/json' },
})
.get('/api/categories/1/rules/', {
body: rules,
headers: { 'content-type': 'application/json' },
});
const expectedActions = [
{ type: actions.REQUEST_CATEGORY },
{
type: actions.RECEIVE_CATEGORY,
category: { ...category, unread: 500 },
},
{ type: ruleActions.REQUEST_RULES },
{ type: ruleActions.RECEIVE_RULES, rules },
];
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: {
item: { ...category, type: constants.CATEGORY_TYPE, clicks: 2 },
next: false,
lastReached: false,
post: {},
},
});
return store.dispatch(actions.fetchCategory(category)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should create no actions for a category which is selected less than x', () => {
const category = {
id: 1,
name: 'Tech',
unread: 200,
};
fetchMock.getOnce('/api/categories/1', {
body: { ...category, unread: 100 },
headers: { 'content-type': 'application/json' },
});
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: {
item: { ...category, type: constants.CATEGORY_TYPE, clicks: 1 },
next: false,
lastReached: false,
post: {},
},
});
const expectedActions = [];
store.dispatch(actions.fetchCategory(category));
expect(store.getActions()).toEqual(expectedActions);
});
it('should handle an unexpected response when fetching a category', () => {
const category = {
id: 1,
name: 'Tech',
unread: 1138,
};
const errorMessage = 'Key id not found';
fetchMock.getOnce('/api/categories/1', () => {
throw new TypeError(errorMessage);
});
const expectedActions = [
{ type: actions.REQUEST_CATEGORY },
{ type: actions.RECEIVE_CATEGORY, category: {} },
{ type: errorActions.RECEIVE_API_ERROR, error: TypeError(errorMessage) },
];
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
error: { error: {} },
});
return store.dispatch(actions.fetchCategory(category)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should handle an unexpected response when multiple categories', () => {
const category = {
id: 1,
name: 'Tech',
unread: 1138,
};
const errorMessage = 'URL not found';
fetchMock.getOnce('/api/categories/', () => {
throw new Error(errorMessage);
});
const expectedActions = [
{ type: actions.REQUEST_CATEGORIES },
{ type: actions.RECEIVE_CATEGORIES, categories: [] },
{ type: ruleActions.RECEIVE_RULES, rules: [] },
{ type: errorActions.RECEIVE_API_ERROR, error: Error(errorMessage) },
];
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
error: { error: {} },
});
return store.dispatch(actions.fetchCategories()).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});

View file

@ -0,0 +1,408 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'fetch-mock';
import * as actions from '../../../pages/homepage/actions/posts.js';
import * as errorActions from '../../../pages/homepage/actions/error.js';
import * as constants from '../../../pages/homepage/constants.js';
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('post actions', () => {
afterEach(() => {
fetchMock.restore();
});
it('should create an action request posts', () => {
const expectedAction = { type: actions.REQUEST_POSTS };
expect(actions.requestPosts()).toEqual(expectedAction);
});
it('should create an action receive a post', () => {
const post = {
id: 2067,
remoteIdentifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publicationDate: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 5,
read: false,
};
const expectedAction = {
type: actions.RECEIVE_POST,
post,
};
expect(actions.receivePost(post)).toEqual(expectedAction);
});
it('should create an action to select a post', () => {
const post = {
id: 2067,
remoteIdentifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publicationDate: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 5,
read: false,
};
const expectedAction = {
type: actions.SELECT_POST,
post,
};
expect(actions.selectPost(post)).toEqual(expectedAction);
});
it('should create an action to unselect a post', () => {
const expectedAction = { type: actions.UNSELECT_POST };
expect(actions.unSelectPost()).toEqual(expectedAction);
});
it('should create an action mark a post read', () => {
const post = {
id: 2067,
remoteIdentifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publicationDate: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 5,
read: false,
};
const rule = {
id: 4,
name: 'Ars Technica',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const expectedAction = {
type: actions.MARK_POST_READ,
section: rule,
post,
};
expect(actions.postRead(post, rule)).toEqual(expectedAction);
});
it('should create multiple actions to mark post read', () => {
const post = {
id: 2067,
remoteIdentifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publicationDate: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 5,
read: false,
};
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
fetchMock.patchOnce('/api/posts/2067/', {
body: { ...post, read: true },
headers: { 'content-type': 'application/json' },
});
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: {
item: rule,
next: false,
lastReached: false,
post: {},
},
});
const expectedActions = [
{
type: actions.RECEIVE_POST,
post: { ...post, read: true },
},
{
type: actions.MARK_POST_READ,
post: { ...post, read: true },
section: rule,
},
];
return store.dispatch(actions.markPostRead(post, 'TOKEN')).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should create multiple actions to fetch posts by rule', () => {
const posts = [
{
id: 2067,
remoteIdentifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publicationDate: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
},
{
id: 2141,
remoteIdentifier: 'https://arstechnica.com/?p=1648757',
title: 'The most complete brain map ever is here: A flys “connectome”',
body:
'It took 12 years and at least $40 million to chart a region about 250µm across.',
author: 'WIRED',
publicationDate: '2020-01-25T11:06:46Z',
url: 'https://arstechnica.com/?p=1648757',
rule: 4,
read: false,
},
];
const rule = {
id: 4,
name: 'Ars Technica',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
type: constants.RULE_TYPE,
};
fetchMock.getOnce('/api/rules/4/posts/?read=false', {
body: {
count: 2,
next: 'https://durp.com/api/rules/4/posts/?page=2&read=false',
previous: null,
results: posts,
},
headers: { 'content-type': 'application/json' },
});
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
});
const expectedActions = [
{ type: actions.REQUEST_POSTS },
{
type: actions.RECEIVE_POSTS,
next: 'https://durp.com/api/rules/4/posts/?page=2&read=false',
posts,
},
];
return store.dispatch(actions.fetchPostsBySection(rule)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should create multiple actions to fetch posts by category', () => {
const posts = [
{
id: 2067,
remoteIdentifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publicationDate: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
},
{
id: 2141,
remoteIdentifier: 'https://arstechnica.com/?p=1648757',
title: 'The most complete brain map ever is here: A flys “connectome”',
body:
'It took 12 years and at least $40 million to chart a region about 250µm across.',
author: 'WIRED',
publicationDate: '2020-01-25T11:06:46Z',
url: 'https://arstechnica.com/?p=1648757',
rule: 4,
read: false,
},
];
const category = {
id: 1,
name: 'Tech',
unread: 2,
type: constants.CATEGORY_TYPE,
};
fetchMock.getOnce('/api/categories/1/posts/?read=false', {
body: {
count: 2,
next: 'https://durp.com/api/categories/4/posts/?page=2&read=false',
previous: null,
results: posts,
},
headers: { 'content-type': 'application/json' },
});
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
});
const expectedActions = [
{ type: actions.REQUEST_POSTS },
{
type: actions.RECEIVE_POSTS,
next: 'https://durp.com/api/categories/4/posts/?page=2&read=false',
posts,
},
];
return store.dispatch(actions.fetchPostsBySection(category)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should create no actions when fetching posts and section is read', () => {
const rule = {
id: 4,
name: 'Ars Technica',
unread: 0,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
});
const expectedActions = [];
store.dispatch(actions.fetchPostsBySection(rule));
expect(store.getActions()).toEqual(expectedActions);
});
it('should handle exceptions when marking a post read', () => {
const post = {
id: 2067,
remoteIdentifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publicationDate: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 5,
read: false,
};
const rule = {
id: 4,
name: 'Ars Technica',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const errorMessage = 'Permission denied';
fetchMock.patch(`/api/posts/${post.id}/`, () => {
throw new Error(errorMessage);
});
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: { item: { ...rule }, next: false, lastReached: false, post: {} },
});
const expectedActions = [
{ type: actions.RECEIVE_POST, post: {} },
{ type: errorActions.RECEIVE_API_ERROR, error: TypeError(errorMessage) },
];
return store.dispatch(actions.markPostRead(post, 'FAKE_TOKEN')).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should handle exceptions when fetching posts by section', () => {
const rule = {
id: 4,
name: 'Ars Technica',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
type: constants.RULE_TYPE,
};
const errorMessage = 'Page not found';
fetchMock.getOnce(`/api/rules/${rule.id}/posts/?read=false`, () => {
throw new Error(errorMessage);
});
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: { item: { ...rule }, next: false, lastReached: false, post: {} },
});
const expectedActions = [
{ type: actions.REQUEST_POSTS },
{ type: actions.RECEIVE_POSTS, posts: [] },
{ type: errorActions.RECEIVE_API_ERROR, error: Error(errorMessage) },
];
return store.dispatch(actions.fetchPostsBySection(rule)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});

View file

@ -0,0 +1,341 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'fetch-mock';
import { objectsFromArray } from '../../../utils.js';
import * as actions from '../../../pages/homepage/actions/rules.js';
import * as constants from '../../../pages/homepage/constants.js';
import * as categoryActions from '../../../pages/homepage/actions/categories.js';
import * as errorActions from '../../../pages/homepage/actions/error.js';
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('rule actions', () => {
afterEach(() => {
fetchMock.restore();
});
it('should create an action to select a rule', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const expectedAction = {
section: { ...rule, type: constants.RULE_TYPE },
type: actions.SELECT_RULE,
};
expect(actions.selectRule(rule)).toEqual(expectedAction);
});
it('should create an action to request a rule', () => {
const expectedAction = { type: actions.REQUEST_RULE };
expect(actions.requestRule()).toEqual(expectedAction);
});
it('should create an action to request multiple rules', () => {
const expectedAction = { type: actions.REQUEST_RULES };
expect(actions.requestRules()).toEqual(expectedAction);
});
it('should create an action to receive a rule', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const expectedAction = {
type: actions.RECEIVE_RULE,
rule,
};
expect(actions.receiveRule(rule)).toEqual(expectedAction);
});
it('should create an action to receive multiple rules', () => {
const rules = [
{
id: 1,
name: 'Test rule',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
},
{
id: 2,
name: 'Test rule 2',
unread: 50,
category: 1,
url: 'https://xkcd.com/atom.xml',
favicon: null,
},
];
const expectedAction = {
type: actions.RECEIVE_RULES,
rules,
};
expect(actions.receiveRules(rules)).toEqual(expectedAction);
});
it('should create multiple actions to fetch a rule', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
fetchMock.getOnce('/api/rules/1', {
body: { ...rule, unread: 500 },
headers: { 'content-type': 'application/json' },
});
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
});
const expectedActions = [
{ type: actions.REQUEST_RULE },
{ type: actions.RECEIVE_RULE, rule: { ...rule, unread: 500 } },
];
return store.dispatch(actions.fetchRule(rule)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should not create not create actions when rule is clicked less then twice', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
fetchMock.getOnce('/api/rules/1', {
body: { ...rule, unread: 500 },
headers: { 'content-type': 'application/json' },
});
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: {
item: { ...rule, type: constants.RULE_TYPE, clicks: 1 },
next: false,
lastReached: false,
post: {},
},
});
const expectedActions = [];
store.dispatch(actions.fetchRule(rule));
expect(store.getActions()).toEqual(expectedActions);
});
it('should create multiple actions to fetch a rule wich is read', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 0,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const category = {
id: 1,
name: 'Test category',
unread: 500,
};
fetchMock
.get('/api/rules/1', {
body: { ...rule, unread: 500 },
headers: { 'content-type': 'application/json' },
})
.get('/api/categories/1', {
body: { ...category, unread: 2000 },
headers: { 'content-type': 'application/json' },
});
const store = mockStore({
categories: { items: { 1: { ...category } }, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
});
const expectedActions = [
{ type: actions.REQUEST_RULE },
{ type: actions.RECEIVE_RULE, rule: { ...rule, unread: 500 } },
{ type: categoryActions.REQUEST_CATEGORY },
{
type: categoryActions.RECEIVE_CATEGORY,
category: { ...category, unread: 2000 },
},
];
return store.dispatch(actions.fetchRule(rule)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should create multiple actions when fetching rules by category', () => {
const category = {
id: 1,
name: 'Tech',
unread: 0,
};
const rules = [
{
id: 1,
name: 'Ars Technica',
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
category: 1,
unread: 200,
},
{
id: 2,
name: 'Hacker News',
url: 'https://news.ycombinator.com/rss',
favicon: 'https://news.ycombinator.com/favicon.ico',
category: 1,
unread: 350,
},
];
fetchMock.getOnce('/api/categories/1/rules/', {
body: rules,
headers: { 'content-type': 'application/json' },
});
const expectedActions = [
{ type: actions.REQUEST_RULES },
{ type: actions.RECEIVE_RULES, rules },
];
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: {},
});
return store.dispatch(actions.fetchRulesByCategory(category)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should handle an unexpected response when fetching a rule', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const errorMessage = 'Too many requests';
fetchMock.getOnce('/api/rules/1', () => {
throw new TypeError(errorMessage);
});
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
});
const expectedActions = [
{ type: actions.REQUEST_RULE },
{ type: actions.RECEIVE_RULE, rule: {} },
{ type: errorActions.RECEIVE_API_ERROR, error: TypeError(errorMessage) },
];
return store.dispatch(actions.fetchRule(rule)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should handle an unexpected response when fetching rules by category', () => {
const category = {
id: 1,
name: 'Tech',
unread: 0,
};
const rules = [
{
id: 1,
name: 'Ars Technica',
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
category: 1,
unread: 200,
},
{
id: 2,
name: 'Hacker News',
url: 'https://news.ycombinator.com/rss',
favicon: 'https://news.ycombinator.com/favicon.ico',
category: 1,
unread: 350,
},
];
const errorMessage = 'Too many request';
fetchMock.getOnce('/api/categories/1/rules/', () => {
throw new Error(errorMessage);
});
const expectedActions = [
{ type: actions.REQUEST_RULES },
{ type: actions.RECEIVE_RULES, rules: [] },
{ type: errorActions.RECEIVE_API_ERROR, error: Error(errorMessage) },
];
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: {},
});
return store.dispatch(actions.fetchRulesByCategory(category)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});

View file

@ -0,0 +1,237 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'fetch-mock';
import * as actions from '../../../pages/homepage/actions/selected.js';
import * as categoryActions from '../../../pages/homepage/actions/categories.js';
import * as errorActions from '../../../pages/homepage/actions/error.js';
import * as ruleActions from '../../../pages/homepage/actions/rules.js';
import * as constants from '../../../pages/homepage/constants.js';
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('selected actions', () => {
afterEach(() => {
fetchMock.restore();
});
it('should create an action to mark a section read', () => {
const category = {
id: 1,
name: 'Test category',
unread: 100,
type: constants.CATEGORY_TYPE,
};
const expectedAction = {
section: { ...category, type: constants.CATEGORY_TYPE },
type: actions.MARK_SECTION_READ,
};
expect(actions.markSectionRead(category)).toEqual(expectedAction);
});
it('should mark a category as read', () => {
const category = { id: 1, name: 'Test category', unread: 100 };
const rules = {
1: {
id: 1,
name: 'Ars Technica',
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
category: 1,
unread: 200,
},
2: {
id: 2,
name: 'Hacker News',
url: 'https://news.ycombinator.com/rss',
favicon: 'https://news.ycombinator.com/favicon.ico',
category: 1,
unread: 350,
},
};
fetchMock.postOnce('/api/categories/1/read/', {
body: { ...category, unread: 0 },
headers: { 'content-type': 'application/json' },
});
const expectedActions = [
{ type: categoryActions.REQUEST_CATEGORY },
{
type: categoryActions.RECEIVE_CATEGORY,
category: { ...category, unread: 0 },
},
{
type: actions.MARK_SECTION_READ,
section: {
...category,
unread: 0,
rules: rules,
type: constants.CATEGORY_TYPE,
},
},
];
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: { ...rules }, isFetching: false },
posts: { items: {}, isFetching: false },
selected: {
item: { ...category, type: actions.CATEGORY_TYPE },
next: false,
lastReached: false,
post: {},
},
});
return store
.dispatch(actions.markRead({ ...category, type: constants.CATEGORY_TYPE }, 'TOKEN'))
.then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should mark a rule as read', () => {
const rule = {
id: 1,
name: 'Ars Technica',
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
category: 1,
unread: 200,
};
fetchMock.postOnce('/api/rules/1/read/', {
body: { ...rule, unread: 0 },
headers: { 'content-type': 'application/json' },
});
const expectedActions = [
{ type: ruleActions.REQUEST_RULE },
{
type: ruleActions.RECEIVE_RULE,
rule: { ...rule, unread: 0 },
},
{
type: actions.MARK_SECTION_READ,
section: { ...rule, type: constants.RULE_TYPE },
},
];
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: { [rule.id]: { ...rule } }, isFetching: false },
posts: { items: {}, isFetching: false },
selected: {
item: { ...rule, type: constants.RULE_TYPE },
next: false,
lastReached: false,
post: {},
},
});
return store
.dispatch(actions.markRead({ ...rule, type: constants.RULE_TYPE }, 'TOKEN'))
.then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should handle exceptions when marking a category as read', () => {
const category = { id: 1, name: 'Test category', unread: 100 };
const rules = {
1: {
id: 1,
name: 'Ars Technica',
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
category: 1,
unread: 200,
},
2: {
id: 2,
name: 'Hacker News',
url: 'https://news.ycombinator.com/rss',
favicon: 'https://news.ycombinator.com/favicon.ico',
category: 1,
unread: 350,
},
};
const errorMessage = 'Page not found';
fetchMock.postOnce('/api/categories/1/read/', () => {
throw new Error(errorMessage);
});
const expectedActions = [
{ type: categoryActions.REQUEST_CATEGORY },
{ type: categoryActions.RECEIVE_CATEGORY, category: {} },
{ type: errorActions.RECEIVE_API_ERROR, error: Error(errorMessage) },
];
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: { ...rules }, isFetching: false },
posts: { items: {}, isFetching: false },
selected: {
item: { ...category, type: actions.CATEGORY_TYPE },
next: false,
lastReached: false,
post: {},
},
error: {},
});
return store
.dispatch(actions.markRead({ ...category, type: constants.CATEGORY_TYPE }, 'TOKEN'))
.then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should handle exceptions when marking a rule as read', () => {
const rule = {
id: 1,
name: 'Ars Technica',
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
category: 1,
unread: 200,
};
const errorMessage = 'Page not found';
fetchMock.postOnce('/api/rules/1/read/', () => {
throw new Error(errorMessage);
});
const expectedActions = [
{ type: ruleActions.REQUEST_RULE },
{ type: ruleActions.RECEIVE_RULE, rule: {} },
{ type: errorActions.RECEIVE_API_ERROR, error: Error(errorMessage) },
];
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: { [rule.id]: { ...rule } }, isFetching: false },
posts: { items: {}, isFetching: false },
selected: {
item: { ...rule, type: constants.RULE_TYPE },
next: false,
lastReached: false,
post: {},
},
error: {},
});
return store
.dispatch(actions.markRead({ ...rule, type: constants.RULE_TYPE }, 'TOKEN'))
.then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});

View file

@ -0,0 +1,212 @@
import { categories as reducer } from '../../../pages/homepage/reducers/categories.js';
import { objectsFromArray } from '../../../utils.js';
import * as actions from '../../../pages/homepage/actions/categories.js';
import * as postActions from '../../../pages/homepage/actions/posts.js';
import * as selectedActions from '../../../pages/homepage/actions/selected.js';
import * as constants from '../../../pages/homepage/constants.js';
const defaultState = { items: {}, isFetching: false };
describe('category reducer', () => {
it('should return default state', () => {
expect(reducer(undefined, {})).toEqual(defaultState);
});
it('should return state after receiving category', () => {
const receivedCategory = { id: 9, name: 'Tech', unread: 291 };
const action = { type: actions.RECEIVE_CATEGORY, category: receivedCategory };
const expectedState = {
...defaultState,
items: { [receivedCategory.id]: receivedCategory },
};
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after receiving multiple categories', () => {
const receivedCategories = [
{ id: 9, name: 'Tech', unread: 291 },
{ id: 2, name: 'World news', unread: 444 },
];
const action = { type: actions.RECEIVE_CATEGORIES, categories: receivedCategories };
const expectedCategories = objectsFromArray(receivedCategories, 'id');
const expectedState = { ...defaultState, items: expectedCategories };
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after requesting a category', () => {
const action = { type: actions.REQUEST_CATEGORY };
const expectedState = { ...defaultState, isFetching: true };
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after requesting multiple categories', () => {
const action = { type: actions.REQUEST_CATEGORIES };
const expectedState = { ...defaultState, isFetching: true };
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after marking a post read with a category selected', () => {
const category = {
id: 9,
name: 'Tech',
unread: 291,
};
const post = {
id: 2091,
remoteIdentifier: 'https://www.bbc.co.uk/news/world-asia-china-51249208',
title: 'China coronavirus spread is accelerating, Xi Jinping warns',
body:
'China\'s president tells a high-level meeting that the country faces a "grave situation".',
author: null,
publicationDate: '2020-01-26T05:54:14Z',
url: 'https://www.bbc.co.uk/news/world-asia-china-51249208',
rule: 4,
read: false,
};
const action = {
type: postActions.MARK_POST_READ,
section: { ...category, type: constants.CATEGORY_TYPE },
post,
};
const state = {
...defaultState,
items: { [category.id]: { ...category } },
};
const expectedState = {
...defaultState,
items: {
[category.id]: { ...category, unread: 290 },
},
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after marking a post read with a rule selected', () => {
const category = {
id: 9,
name: 'Tech',
unread: 433,
};
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const post = {
id: 2182,
remoteIdentifier: 'https://arstechnica.com/?p=1648871',
title: 'Tesla needs to fix Autopilot safety flaws, demands Senator Markey',
body:
'It should be renamed and fitted with a real driver-monitoring system, he says.',
author: 'Jonathan M. Gitlin',
publicationDate: '2020-01-25T18:34:20Z',
url: 'https://arstechnica.com/?p=1648871',
rule: 1,
read: false,
};
const action = {
type: postActions.MARK_POST_READ,
section: { ...rule, type: constants.RULE_TYPE },
post,
};
const state = {
...defaultState,
items: { [category.id]: { ...category } },
};
const expectedState = {
...defaultState,
items: {
[category.id]: { ...category, unread: 432 },
},
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after marking a section read with a category', () => {
const category = {
id: 9,
name: 'Tech',
unread: 433,
};
const action = {
type: selectedActions.MARK_SECTION_READ,
section: { ...category, type: constants.CATEGORY_TYPE },
};
const state = {
...defaultState,
items: { [category.id]: { ...category } },
};
const expectedState = {
...defaultState,
items: {
[category.id]: { ...category, unread: 0 },
},
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after marking a section read with a rule', () => {
const category = {
id: 9,
name: 'Tech',
unread: 433,
};
const rule = {
id: 1,
name: 'Test rule',
unread: 211,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
type: selectedActions.MARK_SECTION_READ,
section: { ...rule, type: constants.RULE_TYPE },
};
const state = {
...defaultState,
items: { [category.id]: { ...category } },
};
const expectedState = {
...defaultState,
items: {
[category.id]: { ...category, unread: 222 },
},
};
expect(reducer(state, action)).toEqual(expectedState);
});
});

View file

@ -0,0 +1,307 @@
import { posts as reducer } from '../../../pages/homepage/reducers/posts.js';
import { objectsFromArray } from '../../../utils.js';
import * as actions from '../../../pages/homepage/actions/posts.js';
import * as selectedActions from '../../../pages/homepage/actions/selected.js';
import * as constants from '../../../pages/homepage/constants.js';
const defaultState = { items: {}, isFetching: false };
describe('post actions', () => {
it('should return state after requesting posts', () => {
const action = { type: actions.REQUEST_POSTS };
const expectedState = { ...defaultState, isFetching: true };
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after receiving a post', () => {
const post = {
id: 2067,
remoteIdentifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publicationDate: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
};
const action = {
type: actions.RECEIVE_POST,
post,
};
const expectedState = {
...defaultState,
isFetching: false,
items: { [post.id]: post },
};
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after receiving posts', () => {
const posts = [
{
id: 2067,
remoteIdentifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publicationDate: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
},
{
id: 2141,
remoteIdentifier: 'https://arstechnica.com/?p=1648757',
title: 'The most complete brain map ever is here: A flys “connectome”',
body:
'It took 12 years and at least $40 million to chart a region about 250µm across.',
author: 'WIRED',
publicationDate: '2020-01-25T11:06:46Z',
url: 'https://arstechnica.com/?p=1648757',
rule: 4,
read: false,
},
];
const action = {
type: actions.RECEIVE_POSTS,
next: 'https://durp.com/api/rules/4/posts/?page=2&read=false',
posts,
};
const expectedPosts = objectsFromArray(posts, 'id');
const expectedState = {
...defaultState,
isFetching: false,
items: expectedPosts,
};
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after marking a rule read', () => {
const posts = {
2067: {
id: 2067,
remoteIdentifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publicationDate: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 5,
read: false,
},
2141: {
id: 2141,
remoteIdentifier: 'https://arstechnica.com/?p=1648757',
title: 'The most complete brain map ever is here: A flys “connectome”',
body:
'It took 12 years and at least $40 million to chart a region about 250µm across.',
author: 'WIRED',
publicationDate: '2020-01-25T11:06:46Z',
url: 'https://arstechnica.com/?p=1648757',
rule: 5,
read: false,
},
4637: {
id: 4637,
remoteIdentifier: 'https://www.bbc.co.uk/news/world-asia-china-51299195',
title: "Coronavirus: Whole world 'must take action', warns WHO",
body:
'The World Health Organization will hold a further emergency meeting on the coronavirus on Thursday.',
author: null,
publicationDate: '2020-01-29T19:08:25Z',
url: 'https://www.bbc.co.uk/news/world-asia-china-51299195',
rule: 4,
read: false,
},
4638: {
id: 4638,
remoteIdentifier: 'https://www.bbc.co.uk/news/world-europe-51294305',
title: "Coronavirus: French Asians hit back at racism with 'I'm not a virus'",
body:
'The coronavirus outbreak in Wuhan prompts French Asians to complain of a backlash against them.',
author: null,
publicationDate: '2020-01-29T18:27:56Z',
url: 'https://www.bbc.co.uk/news/world-europe-51294305',
rule: 4,
read: false,
},
};
const rule = {
id: 5,
name: 'Ars Technica',
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
category: 9,
unread: 544,
};
const action = {
type: selectedActions.MARK_SECTION_READ,
section: { ...rule, type: constants.RULE_TYPE },
};
const state = { ...defaultState, items: { ...posts } };
const expectedState = {
...defaultState,
isFetching: false,
items: {
...posts,
2067: { ...posts[2067], read: true },
2141: { ...posts[2141], read: true },
},
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after marking a category read', () => {
const posts = {
2067: {
id: 2067,
remoteIdentifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publicationDate: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 5,
read: false,
},
2141: {
id: 2141,
remoteIdentifier: 'https://arstechnica.com/?p=1648757',
title: 'The most complete brain map ever is here: A flys “connectome”',
body:
'It took 12 years and at least $40 million to chart a region about 250µm across.',
author: 'WIRED',
publicationDate: '2020-01-25T11:06:46Z',
url: 'https://arstechnica.com/?p=1648757',
rule: 5,
read: false,
},
4637: {
id: 4637,
remoteIdentifier: 'https://www.bbc.co.uk/news/world-asia-china-51299195',
title: "Coronavirus: Whole world 'must take action', warns WHO",
body:
'The World Health Organization will hold a further emergency meeting on the coronavirus on Thursday.',
author: null,
publicationDate: '2020-01-29T19:08:25Z',
url: 'https://www.bbc.co.uk/news/world-asia-china-51299195',
rule: 4,
read: false,
},
4638: {
id: 4638,
remoteIdentifier: 'https://www.bbc.co.uk/news/world-europe-51294305',
title: "Coronavirus: French Asians hit back at racism with 'I'm not a virus'",
body:
'The coronavirus outbreak in Wuhan prompts French Asians to complain of a backlash against them.',
author: null,
publicationDate: '2020-01-29T18:27:56Z',
url: 'https://www.bbc.co.uk/news/world-europe-51294305',
rule: 4,
read: false,
},
4589: {
id: 4589,
remoteIdentifier: 'https://tweakers.net/nieuws/162878',
title: 'Analyse: Nintendo verdiende miljard dollar aan mobiele games',
body:
'Nintendo heeft tot nu toe een miljard dollar verdiend aan mobiele games, zo heeft SensorTower becijferd. Daarbij gaat het om inkomsten uit de App Store van Apple en Play Store van Google. De game die het meeste opbracht is Fire Emblem Heroes.<img alt="" src="http://feeds.feedburner.com/~r/tweakers/mixed/~4/Yn_we8WaUeA">',
author: 'Arnoud Wokke',
publicationDate: '2020-01-29T19:03:01Z',
url:
'https://tweakers.net/nieuws/162878/analyse-nintendo-verdiende-miljard-dollar-aan-mobiele-games.html',
rule: 7,
read: false,
},
4594: {
id: 4594,
remoteIdentifier: 'https://tweakers.net/nieuws/162870',
title: 'Samsung kondigt eerste tablet met 5g aan',
body:
'Samsung heef zijn eerste tablet met 5g aangekondigd. Het gaat om een variant op de al bestaande Galaxy Tab S6, maar dan voorzien van Qualcomm X50-modem. Er gingen al maanden geruchten over de release van de tablet.<img alt="" src="http://feeds.feedburner.com/~r/tweakers/mixed/~4/IfEYe00sm3U">',
author: 'Arnoud Wokke',
publicationDate: '2020-01-29T16:29:40Z',
url:
'https://tweakers.net/nieuws/162870/samsung-kondigt-eerste-tablet-met-5g-aan.html',
rule: 7,
read: false,
},
};
const rules = {
4: {
id: 4,
name: 'BBC',
url: 'http://feeds.bbci.co.uk/news/world/rss.xml',
favicon:
'https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png',
category: 8,
unread: 321,
},
5: {
id: 4,
name: 'BBC',
url: 'http://feeds.bbci.co.uk/news/world/rss.xml',
favicon:
'https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png',
category: 8,
unread: 632,
},
};
const category = {
id: 8,
name: 'News',
unread: 953,
};
const action = {
type: selectedActions.MARK_SECTION_READ,
section: {
...category,
type: constants.CATEGORY_TYPE,
rules,
},
};
const state = { ...defaultState, items: { ...posts } };
const expectedState = {
...defaultState,
isFetching: false,
items: {
...posts,
2067: { ...posts[2067], read: true },
2141: { ...posts[2141], read: true },
4637: { ...posts[4637], read: true },
4638: { ...posts[4638], read: true },
},
};
expect(reducer(state, action)).toEqual(expectedState);
});
});

View file

@ -0,0 +1,184 @@
import { rules as reducer } from '../../../pages/homepage/reducers/rules.js';
import { objectsFromArray } from '../../../utils.js';
import * as actions from '../../../pages/homepage/actions/rules.js';
import * as postActions from '../../../pages/homepage/actions/posts.js';
import * as selectedActions from '../../../pages/homepage/actions/selected.js';
import * as constants from '../../../pages/homepage/constants.js';
const defaultState = { items: {}, isFetching: false };
describe('category reducer', () => {
it('should return default state', () => {
expect(reducer(undefined, {})).toEqual(defaultState);
});
it('should return after requesting a rule', () => {
const action = { type: actions.REQUEST_RULE };
const expectedState = { ...defaultState, isFetching: true };
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return after requesting multiple rules', () => {
const action = { type: actions.REQUEST_RULES };
const expectedState = { ...defaultState, isFetching: true };
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after receiving a rule', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = { type: actions.RECEIVE_RULE, rule };
const expectedState = {
...defaultState,
items: {
[rule.id]: rule,
},
};
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after receiving multiple rules', () => {
const rules = [
{
id: 1,
name: 'Test rule',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
},
{
id: 2,
name: 'Another Test rule',
unread: 444,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
},
];
const action = { type: actions.RECEIVE_RULES, rules };
const mappedRules = objectsFromArray(rules, 'id');
const expectedState = { ...defaultState, items: { ...mappedRules } };
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after marking a post read', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const post = {
id: 2182,
remoteIdentifier: 'https://arstechnica.com/?p=1648871',
title: 'Tesla needs to fix Autopilot safety flaws, demands Senator Markey',
body:
'It should be renamed and fitted with a real driver-monitoring system, he says.',
author: 'Jonathan M. Gitlin',
publicationDate: '2020-01-25T18:34:20Z',
url: 'https://arstechnica.com/?p=1648871',
rule: 1,
read: false,
};
const action = {
type: postActions.MARK_POST_READ,
section: { ...rule, type: constants.RULE_TYPE },
post,
};
const state = {
...defaultState,
items: { [rule.id]: rule },
};
const expectedState = {
...defaultState,
items: { [rule.id]: { ...rule, unread: 99 } },
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after marking a category read', () => {
const category = {
id: 9,
name: 'Tech',
unread: 433,
};
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
type: selectedActions.MARK_SECTION_READ,
section: { ...category, type: constants.CATEGORY_TYPE },
};
const state = {
...defaultState,
items: { [rule.id]: rule },
};
const expectedState = {
...defaultState,
items: { [rule.id]: { ...rule, unread: 0 } },
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after marking a rule read', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
type: selectedActions.MARK_SECTION_READ,
section: { ...rule, type: constants.RULE_TYPE },
};
const state = {
...defaultState,
items: { [rule.id]: rule },
};
const expectedState = {
...defaultState,
items: { [rule.id]: { ...rule, unread: 0 } },
};
expect(reducer(state, action)).toEqual(expectedState);
});
});

View file

@ -0,0 +1,425 @@
import { selected as reducer } from '../../../pages/homepage/reducers/selected.js';
import * as actions from '../../../pages/homepage/actions/selected.js';
import * as categoryActions from '../../../pages/homepage/actions/categories.js';
import * as postActions from '../../../pages/homepage/actions/posts.js';
import * as ruleActions from '../../../pages/homepage/actions/rules.js';
import * as constants from '../../../pages/homepage/constants.js';
const defaultState = { item: {}, next: false, lastReached: false, post: {} };
describe('selected reducer', () => {
it('should return state', () => {
expect(reducer(undefined, {})).toEqual(defaultState);
});
it('should return state after selecting a category', () => {
const category = { id: 9, name: 'Tech', unread: 291 };
const action = {
type: categoryActions.SELECT_CATEGORY,
section: category,
};
const expectedState = {
...defaultState,
item: { ...category, clicks: 1 },
};
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after selecting a rule', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
type: ruleActions.SELECT_RULE,
section: rule,
};
const expectedState = {
...defaultState,
item: { ...rule, clicks: 1 },
};
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after selecting a category twice', () => {
const category = { id: 9, name: 'Tech', unread: 291 };
const action = {
type: categoryActions.SELECT_CATEGORY,
section: category,
};
const state = {
...defaultState,
item: { ...category, clicks: 1 },
};
const expectedState = {
...defaultState,
item: { ...category, clicks: 2 },
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after selecting a rule twice', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
type: ruleActions.SELECT_RULE,
section: rule,
};
const state = {
...defaultState,
item: { ...rule, clicks: 1 },
};
const expectedState = {
...defaultState,
item: { ...rule, clicks: 2 },
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after selecting a category the third time', () => {
const category = { id: 9, name: 'Tech', unread: 291 };
const action = {
type: categoryActions.SELECT_CATEGORY,
section: category,
};
const state = {
...defaultState,
item: { ...category, clicks: 2 },
};
const expectedState = {
...defaultState,
item: { ...category, clicks: 1 },
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after selecting a rule the third time', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
type: ruleActions.SELECT_RULE,
section: rule,
};
const state = {
...defaultState,
item: { ...rule, clicks: 2 },
};
const expectedState = {
...defaultState,
item: { ...rule, clicks: 1 },
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after selecting different (rule) section type', () => {
const category = { id: 9, name: 'Tech', unread: 291 };
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
type: ruleActions.SELECT_RULE,
section: rule,
};
const state = {
...defaultState,
item: { ...category, clicks: 1 },
};
const expectedState = {
...defaultState,
item: { ...rule, clicks: 1 },
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after selecting different (category) section type', () => {
const category = { id: 9, name: 'Tech', unread: 291 };
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
type: categoryActions.SELECT_CATEGORY,
section: category,
};
const state = {
...defaultState,
item: { ...rule, clicks: 1 },
};
const expectedState = {
...defaultState,
item: { ...category, clicks: 1 },
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after receiving posts', () => {
const posts = [
{
id: 2067,
remoteIdentifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publicationDate: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
},
{
id: 2141,
remoteIdentifier: 'https://arstechnica.com/?p=1648757',
title: 'The most complete brain map ever is here: A flys “connectome”',
body:
'It took 12 years and at least $40 million to chart a region about 250µm across.',
author: 'WIRED',
publicationDate: '2020-01-25T11:06:46Z',
url: 'https://arstechnica.com/?p=1648757',
rule: 4,
read: false,
},
];
const action = {
type: postActions.RECEIVE_POSTS,
next: 'https://durp.com/api/rules/4/posts/?page=2&read=false',
posts,
};
const expectedState = {
...defaultState,
next: 'https://durp.com/api/rules/4/posts/?page=2&read=false',
lastReached: false,
};
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after receiving a post which is selected', () => {
const post = {
id: 2067,
remoteIdentifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publicationDate: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
};
const action = {
type: postActions.RECEIVE_POST,
post: { ...post, rule: 6 },
};
const state = { ...defaultState, post };
const expectedState = { ...defaultState, post: { ...post, rule: 6 } };
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after receiving a post with none selected', () => {
const post = {
id: 2067,
remoteIdentifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publicationDate: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
};
const action = {
type: postActions.RECEIVE_POST,
post: { ...post, rule: 6 },
};
const state = { ...defaultState, post: {} };
const expectedState = { ...defaultState, post: {} };
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after selecting a post', () => {
const post = {
id: 2067,
remoteIdentifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publicationDate: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
};
const action = {
type: postActions.SELECT_POST,
post,
};
const expectedState = { ...defaultState, post };
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after unselecting a post', () => {
const post = {
id: 2067,
remoteIdentifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publicationDate: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
};
const action = {
type: postActions.UNSELECT_POST,
post,
};
const state = { ...defaultState, post };
const expectedState = { ...defaultState, post: {} };
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after marking a post read', () => {
const post = {
id: 2067,
remoteIdentifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publicationDate: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
};
const rule = {
id: 4,
name: 'Ars Technica',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
type: postActions.MARK_POST_READ,
section: rule,
post,
};
const state = {
...defaultState,
item: rule,
};
const expectedState = {
...defaultState,
item: { ...rule, unread: 99 },
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after marking a section read', () => {
const rule = {
id: 4,
name: 'Ars Technica',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
section: { ...rule },
type: actions.MARK_SECTION_READ,
};
const state = { ...defaultState, item: { ...rule, clicks: 2 } };
const expectedState = {
...defaultState,
item: { ...rule, unread: 0, clicks: 0 },
};
expect(reducer(state, action)).toEqual(expectedState);
});
});

View file

@ -0,0 +1,24 @@
export const formatDatetime = dateString => {
const locale = navigator.language ? navigator.language : 'en-US';
const dateOptions = {
year: 'numeric',
month: 'numeric',
day: 'numeric',
minute: 'numeric',
hour: 'numeric',
};
const date = new Date(dateString);
return date.toLocaleDateString(locale, dateOptions);
};
export const objectsFromArray = (array, key) => {
const arrayEntries = array
.filter(object => key in object)
.map(object => {
return [object[key], { ...object }];
});
return Object.fromEntries(arrayEntries);
};

View file

View file

@ -0,0 +1,18 @@
from django.contrib import admin
from newsreader.news.collection.models import CollectionRule
class CollectionRuleAdmin(admin.ModelAdmin):
fields = ("url", "name", "timezone", "category", "favicon", "user")
list_display = ("name", "category", "url", "last_suceeded", "succeeded")
list_filter = ("user",)
def save_model(self, request, obj, form, change):
if not change:
obj.user = request.user
obj.save()
admin.site.register(CollectionRule, CollectionRuleAdmin)

View file

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

View file

@ -0,0 +1,115 @@
from bs4 import BeautifulSoup
from newsreader.news.collection.exceptions import StreamParseException
from newsreader.news.collection.models import CollectionRule
from newsreader.news.collection.utils import fetch
class Stream:
def __init__(self, rule):
self.rule = rule
def read(self):
raise NotImplementedError
def parse(self, payload):
raise NotImplementedError
class Meta:
abstract = True
class Client:
stream = Stream
def __init__(self, rules=None):
self.rules = rules if rules else CollectionRule.objects.all()
def __enter__(self):
for rule in self.rules:
stream = self.stream(rule)
yield stream.read()
def __exit__(self, *args, **kwargs):
pass
class Meta:
abstract = True
class Builder:
instances = []
def __init__(self, stream):
self.stream = stream
def __enter__(self):
self.create_posts(self.stream)
return self
def __exit__(self, *args, **kwargs):
pass
def create_posts(self, stream):
pass
def save(self):
pass
class Meta:
abstract = True
class Collector:
client = None
builder = None
def __init__(self, client=None, builder=None):
self.client = client if client else self.client
self.builder = builder if builder else self.builder
def collect(self, rules=None):
with self.client(rules=rules) as client:
for data, stream in client:
with self.builder((data, stream)) as builder:
builder.save()
class Meta:
abstract = True
class WebsiteStream(Stream):
def __init__(self, url):
self.url = url
def read(self):
response = fetch(self.url)
return (self.parse(response.content), self)
def parse(self, payload):
try:
return BeautifulSoup(payload, "lxml")
except TypeError:
raise StreamParseException("Could not parse given HTML")
class URLBuilder(Builder):
def __enter__(self):
return self
def build(self):
data, stream = self.stream
rule = stream.rule
try:
url = data["feed"]["link"]
except (KeyError, TypeError):
url = None
if url:
rule.website_url = url
rule.save()
return rule, url

View file

@ -0,0 +1,28 @@
from bleach.sanitizer import ALLOWED_ATTRIBUTES as BLEACH_ATTRIBUTES
from bleach.sanitizer import ALLOWED_TAGS as BLEACH_TAGS
WHITELISTED_TAGS = (
*BLEACH_TAGS,
"h1",
"h2",
"h3",
"article",
"p",
"img",
"figure",
"small",
"picture",
"b",
"video",
"source",
"div",
"body",
)
WHITELISTED_ATTRIBUTES = {
**BLEACH_ATTRIBUTES,
"a": ["href", "rel"],
"img": ["alt", "src"],
"source": ["srcset", "media", "src", "type"],
}

View file

@ -0,0 +1,65 @@
from rest_framework import status
from rest_framework.generics import (
GenericAPIView,
ListAPIView,
RetrieveUpdateDestroyAPIView,
get_object_or_404,
)
from rest_framework.response import Response
from newsreader.core.pagination import LargeResultSetPagination, ResultSetPagination
from newsreader.news.collection.models import CollectionRule
from newsreader.news.collection.serializers import RuleSerializer
from newsreader.news.core.filters import ReadFilter
from newsreader.news.core.models import Post
from newsreader.news.core.serializers import PostSerializer
class ListRuleView(ListAPIView):
queryset = CollectionRule.objects.all()
serializer_class = RuleSerializer
pagination_class = ResultSetPagination
def get_queryset(self):
user = self.request.user
return self.queryset.filter(user=user).order_by("-created")
class DetailRuleView(RetrieveUpdateDestroyAPIView):
queryset = CollectionRule.objects.all()
serializer_class = RuleSerializer
pagination_class = ResultSetPagination
class NestedRuleView(ListAPIView):
queryset = CollectionRule.objects.prefetch_related("posts").all()
serializer_class = PostSerializer
pagination_class = LargeResultSetPagination
filter_backends = [ReadFilter]
def get_queryset(self):
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
# Default permission is IsOwner, therefore there shouldn't have to be
# filtered on the user.
filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
rule = get_object_or_404(self.queryset, **filter_kwargs)
self.check_object_permissions(self.request, rule)
return rule.posts.order_by("-publication_date")
class RuleReadView(GenericAPIView):
queryset = CollectionRule.objects.all()
serializer_class = RuleSerializer
def post(self, request, *args, **kwargs):
rule = self.get_object()
Post.objects.filter(rule=rule).update(read=True)
rule.refresh_from_db()
serializer_class = self.get_serializer_class()
serializer = serializer_class(rule)
return Response(serializer.data, status=status.HTTP_201_CREATED)

View file

@ -0,0 +1,32 @@
class StreamException(Exception):
message = "Stream exception"
def __init__(self, message=None):
self.message = message if message else self.message
def __str__(self):
return self.message
class StreamNotFoundException(StreamException):
message = "Stream not found"
class StreamDeniedException(StreamException):
message = "Stream does not have sufficient permissions"
class StreamTimeOutException(StreamException):
message = "Stream timed out"
class StreamForbiddenException(StreamException):
message = "Stream forbidden"
class StreamParseException(StreamException):
message = "Stream could not be parsed"
class StreamConnectionError(StreamException):
message = "A connection to the stream could not be made"

Some files were not shown because too many files have changed in this diff Show more