0.2 release
This commit is contained in:
parent
747c6416d4
commit
18479a3f56
340 changed files with 27295 additions and 0 deletions
5
src/entrypoint.sh
Executable file
5
src/entrypoint.sh
Executable 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
21
src/manage.py
Executable 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()
|
||||
4
src/newsreader/__init__.py
Normal file
4
src/newsreader/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from .celery import app as celery_app
|
||||
|
||||
|
||||
__all__ = ["celery_app"]
|
||||
0
src/newsreader/accounts/__init__.py
Normal file
0
src/newsreader/accounts/__init__.py
Normal file
1
src/newsreader/accounts/admin.py
Normal file
1
src/newsreader/accounts/admin.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Register your models here.
|
||||
5
src/newsreader/accounts/apps.py
Normal file
5
src/newsreader/accounts/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
name = "accounts"
|
||||
151
src/newsreader/accounts/migrations/0001_initial.py
Normal file
151
src/newsreader/accounts/migrations/0001_initial.py
Normal 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())],
|
||||
)
|
||||
]
|
||||
|
|
@ -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")]
|
||||
|
|
@ -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())],
|
||||
)
|
||||
]
|
||||
|
|
@ -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",
|
||||
),
|
||||
)
|
||||
]
|
||||
|
|
@ -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")]
|
||||
|
|
@ -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",
|
||||
),
|
||||
)
|
||||
]
|
||||
|
|
@ -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",
|
||||
),
|
||||
)
|
||||
]
|
||||
0
src/newsreader/accounts/migrations/__init__.py
Normal file
0
src/newsreader/accounts/migrations/__init__.py
Normal file
80
src/newsreader/accounts/models.py
Normal file
80
src/newsreader/accounts/models.py
Normal 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)
|
||||
23
src/newsreader/accounts/permissions.py
Normal file
23
src/newsreader/accounts/permissions.py
Normal 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
|
||||
24
src/newsreader/accounts/templates/accounts/login.html
Normal file
24
src/newsreader/accounts/templates/accounts/login.html
Normal 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 %}
|
||||
0
src/newsreader/accounts/tests/__init__.py
Normal file
0
src/newsreader/accounts/tests/__init__.py
Normal file
39
src/newsreader/accounts/tests/factories.py
Normal file
39
src/newsreader/accounts/tests/factories.py
Normal 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
|
||||
99
src/newsreader/accounts/tests/test_activation.py
Normal file
99
src/newsreader/accounts/tests/test_activation.py
Normal 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)
|
||||
160
src/newsreader/accounts/tests/test_password_reset.py
Normal file
160
src/newsreader/accounts/tests/test_password_reset.py
Normal 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"))
|
||||
110
src/newsreader/accounts/tests/test_registration.py
Normal file
110
src/newsreader/accounts/tests/test_registration.py
Normal 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)
|
||||
77
src/newsreader/accounts/tests/test_resend_activation.py
Normal file
77
src/newsreader/accounts/tests/test_resend_activation.py
Normal 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)
|
||||
22
src/newsreader/accounts/tests/tests.py
Normal file
22
src/newsreader/accounts/tests/tests.py
Normal 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)
|
||||
56
src/newsreader/accounts/urls.py
Normal file
56
src/newsreader/accounts/urls.py
Normal 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
|
||||
]
|
||||
91
src/newsreader/accounts/views.py
Normal file
91
src/newsreader/accounts/views.py
Normal 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
14
src/newsreader/celery.py
Normal 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()
|
||||
0
src/newsreader/conf/__init__.py
Normal file
0
src/newsreader/conf/__init__.py
Normal file
160
src/newsreader/conf/base.py
Normal file
160
src/newsreader/conf/base.py
Normal 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
|
||||
35
src/newsreader/conf/dev.py
Normal file
35
src/newsreader/conf/dev.py
Normal 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
|
||||
19
src/newsreader/conf/docker.py
Normal file
19
src/newsreader/conf/docker.py
Normal 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",
|
||||
},
|
||||
}
|
||||
19
src/newsreader/conf/gitlab.py
Normal file
19
src/newsreader/conf/gitlab.py
Normal 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",
|
||||
},
|
||||
}
|
||||
45
src/newsreader/conf/production.py
Normal file
45
src/newsreader/conf/production.py
Normal 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
|
||||
0
src/newsreader/core/__init__.py
Normal file
0
src/newsreader/core/__init__.py
Normal file
1
src/newsreader/core/admin.py
Normal file
1
src/newsreader/core/admin.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Register your models here.
|
||||
5
src/newsreader/core/apps.py
Normal file
5
src/newsreader/core/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
name = "core"
|
||||
0
src/newsreader/core/migrations/__init__.py
Normal file
0
src/newsreader/core/migrations/__init__.py
Normal file
15
src/newsreader/core/models.py
Normal file
15
src/newsreader/core/models.py
Normal 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
|
||||
12
src/newsreader/core/pagination.py
Normal file
12
src/newsreader/core/pagination.py
Normal 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
|
||||
6
src/newsreader/core/permissions.py
Normal file
6
src/newsreader/core/permissions.py
Normal 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
|
||||
1
src/newsreader/core/tests.py
Normal file
1
src/newsreader/core/tests.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Create your tests here.
|
||||
1
src/newsreader/core/views.py
Normal file
1
src/newsreader/core/views.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Create your views here.
|
||||
298
src/newsreader/fixtures/default-fixture.json
Normal file
298
src/newsreader/fixtures/default-fixture.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
168
src/newsreader/fixtures/local/fixture.json
Normal file
168
src/newsreader/fixtures/local/fixture.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
13
src/newsreader/js/components/Card.js
Normal file
13
src/newsreader/js/components/Card.js
Normal 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;
|
||||
13
src/newsreader/js/components/LoadingIndicator.js
Normal file
13
src/newsreader/js/components/LoadingIndicator.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
|
||||
const LoadingIndicator = props => {
|
||||
return (
|
||||
<div className="loading-indicator">
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingIndicator;
|
||||
29
src/newsreader/js/components/Messages.js
Normal file
29
src/newsreader/js/components/Messages.js
Normal 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;
|
||||
11
src/newsreader/js/components/Modal.js
Normal file
11
src/newsreader/js/components/Modal.js
Normal 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;
|
||||
3
src/newsreader/js/index.js
Normal file
3
src/newsreader/js/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import './pages/homepage/index.js';
|
||||
import './pages/rules/index.js';
|
||||
import './pages/categories/index.js';
|
||||
106
src/newsreader/js/pages/categories/App.js
Normal file
106
src/newsreader/js/pages/categories/App.js
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
13
src/newsreader/js/pages/categories/index.js
Normal file
13
src/newsreader/js/pages/categories/index.js
Normal 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);
|
||||
}
|
||||
64
src/newsreader/js/pages/homepage/App.js
Normal file
64
src/newsreader/js/pages/homepage/App.js
Normal 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);
|
||||
87
src/newsreader/js/pages/homepage/actions/categories.js
Normal file
87
src/newsreader/js/pages/homepage/actions/categories.js
Normal 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));
|
||||
});
|
||||
};
|
||||
};
|
||||
6
src/newsreader/js/pages/homepage/actions/error.js
Normal file
6
src/newsreader/js/pages/homepage/actions/error.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export const RECEIVE_API_ERROR = 'RECEIVE_API_ERROR';
|
||||
|
||||
export const handleAPIError = error => ({
|
||||
type: RECEIVE_API_ERROR,
|
||||
error,
|
||||
});
|
||||
89
src/newsreader/js/pages/homepage/actions/posts.js
Normal file
89
src/newsreader/js/pages/homepage/actions/posts.js
Normal 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));
|
||||
});
|
||||
};
|
||||
};
|
||||
75
src/newsreader/js/pages/homepage/actions/rules.js
Normal file
75
src/newsreader/js/pages/homepage/actions/rules.js
Normal 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));
|
||||
});
|
||||
};
|
||||
};
|
||||
89
src/newsreader/js/pages/homepage/actions/selected.js
Normal file
89
src/newsreader/js/pages/homepage/actions/selected.js
Normal 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);
|
||||
}
|
||||
};
|
||||
91
src/newsreader/js/pages/homepage/components/PostModal.js
Normal file
91
src/newsreader/js/pages/homepage/components/PostModal.js
Normal 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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 [];
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export const filterCategories = (categories = {}) => {
|
||||
return Object.values({ ...categories });
|
||||
};
|
||||
|
||||
export const filterRules = (rules = {}) => {
|
||||
return Object.values({ ...rules });
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export const isSelected = (section, selected, type) => {
|
||||
if (!selected || selected.type != type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return section.id === selected.id;
|
||||
};
|
||||
18
src/newsreader/js/pages/homepage/configureStore.js
Normal file
18
src/newsreader/js/pages/homepage/configureStore.js
Normal 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;
|
||||
2
src/newsreader/js/pages/homepage/constants.js
Normal file
2
src/newsreader/js/pages/homepage/constants.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const RULE_TYPE = 'RULE';
|
||||
export const CATEGORY_TYPE = 'CATEGORY';
|
||||
20
src/newsreader/js/pages/homepage/index.js
Normal file
20
src/newsreader/js/pages/homepage/index.js
Normal 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
|
||||
);
|
||||
}
|
||||
93
src/newsreader/js/pages/homepage/reducers/categories.js
Normal file
93
src/newsreader/js/pages/homepage/reducers/categories.js
Normal 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;
|
||||
}
|
||||
};
|
||||
12
src/newsreader/js/pages/homepage/reducers/error.js
Normal file
12
src/newsreader/js/pages/homepage/reducers/error.js
Normal 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 {};
|
||||
}
|
||||
};
|
||||
11
src/newsreader/js/pages/homepage/reducers/index.js
Normal file
11
src/newsreader/js/pages/homepage/reducers/index.js
Normal 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;
|
||||
68
src/newsreader/js/pages/homepage/reducers/posts.js
Normal file
68
src/newsreader/js/pages/homepage/reducers/posts.js
Normal 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;
|
||||
}
|
||||
};
|
||||
82
src/newsreader/js/pages/homepage/reducers/rules.js
Normal file
82
src/newsreader/js/pages/homepage/reducers/rules.js
Normal 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;
|
||||
}
|
||||
};
|
||||
86
src/newsreader/js/pages/homepage/reducers/selected.js
Normal file
86
src/newsreader/js/pages/homepage/reducers/selected.js
Normal 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;
|
||||
}
|
||||
};
|
||||
106
src/newsreader/js/pages/rules/App.js
Normal file
106
src/newsreader/js/pages/rules/App.js
Normal 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;
|
||||
65
src/newsreader/js/pages/rules/components/RuleCard.js
Normal file
65
src/newsreader/js/pages/rules/components/RuleCard.js
Normal 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;
|
||||
35
src/newsreader/js/pages/rules/components/RuleModal.js
Normal file
35
src/newsreader/js/pages/rules/components/RuleModal.js
Normal 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;
|
||||
13
src/newsreader/js/pages/rules/index.js
Normal file
13
src/newsreader/js/pages/rules/index.js
Normal 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);
|
||||
}
|
||||
316
src/newsreader/js/tests/homepage/actions/category.test.js
Normal file
316
src/newsreader/js/tests/homepage/actions/category.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
408
src/newsreader/js/tests/homepage/actions/post.test.js
Normal file
408
src/newsreader/js/tests/homepage/actions/post.test.js
Normal 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 fly’s “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 fly’s “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);
|
||||
});
|
||||
});
|
||||
});
|
||||
341
src/newsreader/js/tests/homepage/actions/rule.test.js
Normal file
341
src/newsreader/js/tests/homepage/actions/rule.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
237
src/newsreader/js/tests/homepage/actions/selected.test.js
Normal file
237
src/newsreader/js/tests/homepage/actions/selected.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
212
src/newsreader/js/tests/homepage/reducers/category.test.js
Normal file
212
src/newsreader/js/tests/homepage/reducers/category.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
307
src/newsreader/js/tests/homepage/reducers/post.test.js
Normal file
307
src/newsreader/js/tests/homepage/reducers/post.test.js
Normal 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 fly’s “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 fly’s “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 fly’s “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);
|
||||
});
|
||||
});
|
||||
184
src/newsreader/js/tests/homepage/reducers/rule.test.js
Normal file
184
src/newsreader/js/tests/homepage/reducers/rule.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
425
src/newsreader/js/tests/homepage/reducers/selected.test.js
Normal file
425
src/newsreader/js/tests/homepage/reducers/selected.test.js
Normal 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 fly’s “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);
|
||||
});
|
||||
});
|
||||
24
src/newsreader/js/utils.js
Normal file
24
src/newsreader/js/utils.js
Normal 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);
|
||||
};
|
||||
0
src/newsreader/news/__init__.py
Normal file
0
src/newsreader/news/__init__.py
Normal file
0
src/newsreader/news/collection/__init__.py
Normal file
0
src/newsreader/news/collection/__init__.py
Normal file
18
src/newsreader/news/collection/admin.py
Normal file
18
src/newsreader/news/collection/admin.py
Normal 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)
|
||||
5
src/newsreader/news/collection/apps.py
Normal file
5
src/newsreader/news/collection/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CollectionConfig(AppConfig):
|
||||
name = "collection"
|
||||
115
src/newsreader/news/collection/base.py
Normal file
115
src/newsreader/news/collection/base.py
Normal 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
|
||||
28
src/newsreader/news/collection/constants.py
Normal file
28
src/newsreader/news/collection/constants.py
Normal 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"],
|
||||
}
|
||||
65
src/newsreader/news/collection/endpoints.py
Normal file
65
src/newsreader/news/collection/endpoints.py
Normal 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)
|
||||
32
src/newsreader/news/collection/exceptions.py
Normal file
32
src/newsreader/news/collection/exceptions.py
Normal 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
Loading…
Add table
Add a link
Reference in a new issue