0.2.3 #99

Merged
sonny merged 112 commits from development into master 2020-05-23 16:58:42 +02:00
114 changed files with 1205 additions and 177 deletions
Showing only changes of commit b952d70d92 - Show all commits

View file

@ -1,6 +1,6 @@
import path from 'path'; import path from 'path';
import { dest } from 'gulp'; import { dest, series } from 'gulp';
import babelify from 'babelify'; import babelify from 'babelify';
import browserify from 'browserify'; import browserify from 'browserify';
import source from 'vinyl-source-stream'; import source from 'vinyl-source-stream';
@ -8,21 +8,37 @@ import buffer from 'vinyl-buffer';
import concat from 'gulp-concat'; import concat from 'gulp-concat';
const PROJECT_DIR = path.join('src', 'newsreader'); const PROJECT_DIR = path.join('src', 'newsreader');
const STATIC_DIR = path.join(PROJECT_DIR, 'js'); const SRC_DIR = path.join(PROJECT_DIR, 'js');
const CORE_DIR = path.join(PROJECT_DIR, 'news', 'core', 'static'); const STATIC_SUFFIX = 'dist/js/';
const babelTask = () => { const CORE_DIR = path.join(PROJECT_DIR, 'news', 'core', 'static', 'core');
const config = browserify({
entries: `${STATIC_DIR}/homepage/index.js`, const taskMappings = [
{ name: 'homepage', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` },
{ name: 'categories', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` },
];
const babelTask = done => {
const tasks = taskMappings.map(taskMapping => {
const { name, destDir } = taskMapping;
const bundle = browserify({
entries: `${SRC_DIR}/pages/${name}/index.js`,
debug: true, debug: true,
}).transform(babelify); });
return config const transpiledBundle = bundle.transform(babelify);
return () =>
transpiledBundle
.bundle() .bundle()
.pipe(source('index.js')) .pipe(source('index.js'))
.pipe(buffer()) .pipe(buffer())
.pipe(concat('homepage.js')) .pipe(concat(`${name}.js`))
.pipe(dest(`${CORE_DIR}/core/dist/js/`)); .pipe(dest(destDir));
});
return series(...tasks)(done);
}; };
export default babelTask; export default babelTask;

View file

@ -14,19 +14,19 @@ export const CORE_DIR = path.join(PROJECT_DIR, 'news', 'core', 'static', 'core')
const taskMappings = [ const taskMappings = [
{ name: 'login', destDir: `${ACCOUNTS_DIR}/${STATIC_SUFFIX}` }, { name: 'login', destDir: `${ACCOUNTS_DIR}/${STATIC_SUFFIX}` },
{ name: 'homepage', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` }, { name: 'homepage', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` },
{ name: 'categories', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` },
{ name: 'category', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` },
]; ];
export const sassTask = done => { export const sassTask = done => {
const tasks = taskMappings.map(taskMapping => { const tasks = taskMappings.map(taskMapping => {
const name = taskMapping.name; const { name, destDir } = taskMapping;
const destDir = taskMapping.destDir;
return () => { return () =>
return src(`${SRC_DIR}/pages/${name}/index.scss`) src(`${SRC_DIR}/pages/${name}/index.scss`)
.pipe(sass().on('error', sass.logError)) .pipe(sass().on('error', sass.logError))
.pipe(concat(`${name}.css`)) .pipe(concat(`${name}.css`))
.pipe(dest(destDir)); .pipe(dest(destDir));
};
}); });
series(...tasks)(done); series(...tasks)(done);

View file

@ -4,3 +4,7 @@ factory-boy==2.12.0
freezegun==0.3.12 freezegun==0.3.12
django-debug-toolbar==2.0 django-debug-toolbar==2.0
django-extensions==2.1.9 django-extensions==2.1.9
black==19.3b0
isort==4.3.20
autoflake==1.3

View file

@ -1,5 +1 @@
-r testing.txt -r dev.txt
black==19.3b0
isort==4.3.20
autoflake==1.3

View file

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

View file

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

View file

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

View file

@ -1,5 +1,8 @@
import json import json
from typing import Iterable
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import UserManager as DjangoUserManager from django.contrib.auth.models import UserManager as DjangoUserManager
from django.db import models from django.db import models
@ -9,7 +12,7 @@ from django_celery_beat.models import IntervalSchedule, PeriodicTask
class UserManager(DjangoUserManager): class UserManager(DjangoUserManager):
def _create_user(self, email, password, **extra_fields): def _create_user(self, email, password, **extra_fields) -> get_user_model:
""" """
Create and save a user with the given username, email, and password. Create and save a user with the given username, email, and password.
""" """
@ -21,12 +24,14 @@ class UserManager(DjangoUserManager):
user.save(using=self._db) user.save(using=self._db)
return user return user
def create_user(self, email, password=None, **extra_fields): def create_user(self, email: str, password=None, **extra_fields) -> get_user_model:
extra_fields.setdefault("is_staff", False) extra_fields.setdefault("is_staff", False)
extra_fields.setdefault("is_superuser", False) extra_fields.setdefault("is_superuser", False)
return self._create_user(email, password, **extra_fields) return self._create_user(email, password, **extra_fields)
def create_superuser(self, email, password, **extra_fields): def create_superuser(
self, email: str, password: str, **extra_fields
) -> get_user_model:
extra_fields.setdefault("is_staff", True) extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True) extra_fields.setdefault("is_superuser", True)
@ -42,10 +47,12 @@ class User(AbstractUser):
email = models.EmailField(_("email address"), unique=True) email = models.EmailField(_("email address"), unique=True)
task = models.OneToOneField( task = models.OneToOneField(
PeriodicTask, _("collection task"), null=True, blank=True, editable=False PeriodicTask,
) on_delete=models.SET_NULL,
task_interval = models.ForeignKey( null=True,
IntervalSchedule, _("collection schedule"), null=True, blank=True blank=True,
editable=False,
verbose_name="collection task",
) )
username = None username = None
@ -55,35 +62,24 @@ class User(AbstractUser):
USERNAME_FIELD = "email" USERNAME_FIELD = "email"
REQUIRED_FIELDS = [] REQUIRED_FIELDS = []
def __init__(self, *args, **kwargs): def save(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs) super().save(*args, **kwargs)
self._original_interval = self.task_interval
def save(self, *args, **kwargs):
if self._original_interval != self.task_interval:
if self.task_interval and self.task:
self.task.interval = self.task_interval
self.task.enabled = True
self.task.save()
elif not self.task_interval and self.task:
self.task.enabled = False
self.task.save()
if not self.task: if not self.task:
self.task_interval, _ = IntervalSchedule.objects.get_or_create( task_interval, _ = IntervalSchedule.objects.get_or_create(
every=1, period=IntervalSchedule.HOURS every=1, period=IntervalSchedule.HOURS
) )
self.task, _ = PeriodicTask.objects.get_or_create( self.task, _ = PeriodicTask.objects.get_or_create(
enabled=True, enabled=True,
interval=self.task_interval, interval=task_interval,
name=f"{self.email}-collection-task", name=f"{self.email}-collection-task",
task="newsreader.news.collection.tasks", task="newsreader.news.collection.tasks.collect",
args=json.dumps([self.pk]), args=json.dumps([self.pk]),
kwargs={},
) )
self._original_interval = self.task_interval self.save()
super().save(*args, **kwargs) def delete(self, *args, **kwargs) -> Iterable:
self.task.delete()
return super().delete(*args, **kwargs)

View file

@ -7,35 +7,16 @@ from newsreader.accounts.models import User
class UserTestCase(TestCase): class UserTestCase(TestCase):
def test_task_is_created(self): def test_task_is_created(self):
user = User.objects.create(email="durp@burp.nl", task=None, task_interval=None) user = User.objects.create(email="durp@burp.nl", task=None)
task = PeriodicTask.objects.get(name=f"{user.email}-collection-task") task = PeriodicTask.objects.get(name=f"{user.email}-collection-task")
expected_interval = IntervalSchedule.objects.get(
every=1, period=IntervalSchedule.HOURS
)
self.assertEquals(task.interval, expected_interval) user.refresh_from_db()
self.assertEquals(task, user.task)
self.assertEquals(PeriodicTask.objects.count(), 1) self.assertEquals(PeriodicTask.objects.count(), 1)
def test_task_is_updated(self): def test_task_is_deleted(self):
user = User.objects.create(email="durp@burp.nl", task=None, task_interval=None) user = User.objects.create(email="durp@burp.nl", task=None)
user.delete()
new_interval = IntervalSchedule.objects.create( self.assertEquals(PeriodicTask.objects.count(), 0)
every=2, period=IntervalSchedule.HOURS
)
user.task_interval = new_interval
user.save()
task = PeriodicTask.objects.get(name=f"{user.email}-collection-task")
self.assertEquals(task.interval, new_interval)
def test_task_is_disabled(self):
user = User.objects.create(email="durp@burp.nl", task=None, task_interval=None)
user.task_interval = None
user.save()
task = PeriodicTask.objects.get(name=f"{user.email}-collection-task")
self.assertEquals(task.enabled, False)

View file

@ -114,6 +114,12 @@ STATIC_URL = "/static/"
STATICFILES_DIRS = ["src/newsreader/static/icons"] STATICFILES_DIRS = ["src/newsreader/static/icons"]
# 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",
]
# Third party settings # Third party settings
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": ( "DEFAULT_AUTHENTICATION_CLASSES": (

View file

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

View file

@ -0,0 +1,13 @@
import React from 'react';
const Messages = props => {
const messages = props.messages.map(message => {
return (
<li className={`messages__item message__item--${message.type}`}>{message.text}</li>
);
});
return <ul className="list messages">{messages}</ul>;
};
export default Messages;

View file

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

View file

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

View file

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

View file

@ -0,0 +1,37 @@
import React from 'react';
import Modal from '../../../components/Modal.js';
const CategoryModal = props => {
const content = (
<div className="category-modal">
<div className="category-modal__header">
<h1 className="h1 category-modal__title">Delete category</h1>
</div>
<div className="category-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="category-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>
</div>
);
return <Modal content={content} />;
};
export default CategoryModal;

View file

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

View file

@ -3,7 +3,7 @@ import { connect } from 'react-redux';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { unSelectPost, markPostRead } from '../actions/posts.js'; import { unSelectPost, markPostRead } from '../actions/posts.js';
import { formatDatetime } from '../../utils.js'; import { formatDatetime } from '../../../utils.js';
class PostModal extends React.Component { class PostModal extends React.Component {
readTimer = null; readTimer = null;

View file

@ -5,7 +5,7 @@ import { isEqual } from 'lodash';
import { fetchPostsByRule, fetchPostsByCategory } from '../../actions/posts.js'; import { fetchPostsByRule, fetchPostsByCategory } from '../../actions/posts.js';
import { filterPosts } from './filters.js'; import { filterPosts } from './filters.js';
import LoadingIndicator from '../../../components/LoadingIndicator.js'; import LoadingIndicator from '../../../../components/LoadingIndicator.js';
import RuleItem from './RuleItem.js'; import RuleItem from './RuleItem.js';
class FeedList extends React.Component { class FeedList extends React.Component {

View file

@ -3,7 +3,7 @@ import { connect } from 'react-redux';
import { selectPost } from '../../actions/posts.js'; import { selectPost } from '../../actions/posts.js';
import { formatDatetime } from '../../../utils.js'; import { formatDatetime } from '../../../../utils.js';
class PostItem extends React.Component { class PostItem extends React.Component {
render() { render() {

View file

@ -4,7 +4,7 @@ import { isEqual } from 'lodash';
import { filterCategories, filterRules } from './filters.js'; import { filterCategories, filterRules } from './filters.js';
import LoadingIndicator from '../../../components/LoadingIndicator.js'; import LoadingIndicator from '../../../../components/LoadingIndicator.js';
import CategoryItem from './CategoryItem.js'; import CategoryItem from './CategoryItem.js';
import ReadButton from './ReadButton.js'; import ReadButton from './ReadButton.js';

View file

@ -10,7 +10,6 @@ def collect(user_pk):
try: try:
user = User.objects.get(pk=user_pk) user = User.objects.get(pk=user_pk)
except ObjectDoesNotExist: except ObjectDoesNotExist:
# TODO remove this task
return return
rules = user.rules.all() rules = user.rules.all()

View file

@ -0,0 +1,37 @@
from django import forms
from newsreader.news.collection.models import CollectionRule
from newsreader.news.core.models import Category
class CategoryForm(forms.ModelForm):
rules = forms.ModelMultipleChoiceField(
queryset=CollectionRule.objects.all(),
required=False,
widget=forms.widgets.CheckboxSelectMultiple,
)
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
super().__init__(*args, **kwargs)
if self.user:
self.fields["rules"].queryset = CollectionRule.objects.filter(user=self.user)
def save(self, commit=True) -> Category:
instance = super().save(commit=False)
instance.user = self.user
if commit:
instance.save()
self.save_m2m()
rule_ids = self.cleaned_data.get("rules", [])
instance.rules.set(rule_ids)
return instance
class Meta:
model = Category
fields = ("name", "rules")

View file

@ -0,0 +1,21 @@
# Generated by Django 2.2.6 on 2019-11-16 12:15
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("core", "0003_post_read"),
]
operations = [
migrations.AlterField(
model_name="category", name="name", field=models.CharField(max_length=50)
),
migrations.AlterUniqueTogether(
name="category", unique_together={("name", "user")}
),
]

View file

@ -26,12 +26,17 @@ class Post(TimeStampedModel):
class Category(TimeStampedModel): class Category(TimeStampedModel):
name = models.CharField(max_length=50, unique=True) # TODO remove unique value name = models.CharField(max_length=50)
user = models.ForeignKey("accounts.User", _("Owner"), related_name="categories") user = models.ForeignKey("accounts.User", _("Owner"), related_name="categories")
@property
def rule_ids(self):
return self.rules.values_list("pk", flat=True)
class Meta: class Meta:
verbose_name = _("Category") verbose_name = _("Category")
verbose_name_plural = _("Categories") verbose_name_plural = _("Categories")
unique_together = ("name", "user")
def __str__(self): def __str__(self):
return self.name return self.name

View file

@ -0,0 +1,38 @@
{% extends "base.html" %}
{% load static %}
{% block head %}
<link href="{% static 'core/dist/css/categories.css' %}" rel="stylesheet" />
{% endblock %}
{% block content %}
<div class="content"></div>
{% endblock %}
{% block scripts %}
<script id="categories-data">
[
{% for category in categories %}
{% if not forloop.first %}, {% endif %}
{
"pk": {{ category.pk }},
"name": "{{ category.name }}",
"created": "{{ category.created }}",
"rules" : [
{% for rule in category.rules.all %}
{% if not forloop.first %}, {% endif %}
{
"pk": {{ rule.pk }},
"name": "{{ rule.name }}",
"favicon": {% if rule.favicon %}"{{ rule.favicon }}"{% else %}null{% endif %},
"created": "{{ rule.created }}"
}
{% endfor %}
]
}
{% endfor %}
]
</script>
<script src="{% static 'core/dist/js/categories.js' %}"></script>
{% endblock %}

View file

@ -0,0 +1,18 @@
{% extends "core/category.html" %}
{% block form-header %}
<h1 class="h1">Create a category</h1>
{% endblock %}
{% block name-input %}
<input class="input category-form__input" type="text" name="name" required />
{% endblock %}
{% block rule-input %}
<input class="input category-form__input" type="checkbox" name="rules"
value="{{ rule.pk }}" />
{% endblock %}
{% block confirm-button %}
<button class="button button--confirm">Create category</button>
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "core/category.html" %}
{% block form-header %}
<h1 class="h1">Update category</h1>
{% endblock %}
{% block name-input %}
<input class="input category-form__input" type="text" name="name"
value="{{ category.name }}" required />
{% endblock %}
{% block rule-input %}
<input class="input category-form__input" type="checkbox" name="rules"
{% if rule.pk in category.rule_ids %}checked{% endif %}
value="{{ rule.pk }}" />
{% endblock %}
{% block confirm-button %}
<button class="button button--confirm">Save category</button>
{% endblock %}

View file

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% load static %}
{% block head %}
<link href="{% static 'core/dist/css/category.css' %}" rel="stylesheet" />
{% endblock %}
{% block content %}
<div class="content">
<form class="form category-form" method="post">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="form__header">
{% block form-header %}{% endblock %}
</div>
<section class="section form__section category-form__section">
<fieldset class="form__fieldset category-form__fieldset">
<label class="label category-form__label" for="name">Name</label>
{% block name-input %}{% endblock %}
{{ form.name.errors }}
</fieldset>
</section>
<section class="section form__section category-form__section">
<fieldset class="form__fieldset category-form__fieldset">
<label class="label category-form__label" for="rules">Collection rules</label>
<small class="small help-text">
Note that existing assigned rules will be reassigned to this category
</small>
<ul class="list checkbox-list">
{% for rule in rules %}
<li class="list__item checkbox-list__item">
{% block rule-input %}{% endblock %}
<img class="favicon"
src="{% if rule.favicon %}{{ rule.favicon }}{% else %}/static/favicon-placeholder.svg{% endif %}" />
<span>{{ rule.name }}</span>
</li>
{% endfor %}
</ul>
{{ form.rules.errors }}
</fieldset>
</section>
<section class="section form__section category-form__section">
<fieldset class="form__fieldset category-form__fieldset">
<a class="link button button--cancel" href="{% url 'categories' %}">Cancel</a>
{% block confirm-button %}{% endblock %}
</fieldset>
</section>
</form>
</div>
{% endblock %}

View file

@ -3,7 +3,7 @@
{% load static %} {% load static %}
{% block head %} {% block head %}
<link href="{% static 'core/dist/css/core.css' %}" rel="stylesheet" /> <link href="{% static 'core/dist/css/homepage.css' %}" rel="stylesheet" />
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View file

@ -0,0 +1,58 @@
from django.test import Client, TestCase
from django.urls import reverse
from newsreader.accounts.tests.factories import UserFactory
from newsreader.news.collection.tests.factories import CollectionRuleFactory
from newsreader.news.core.models import Category
from newsreader.news.core.tests.factories import CategoryFactory
class CategoryCreateViewTestCase(TestCase):
def setUp(self):
self.user = UserFactory(password="test")
self.client.login(email=self.user.email, password="test")
self.url = reverse("category-create")
def test_simple(self):
response = self.client.get(self.url)
self.assertEquals(response.status_code, 200)
def test_creation(self):
rules = CollectionRuleFactory.create_batch(size=4, user=self.user)
data = {"name": "new-category", "rules": [rule.pk for rule in rules]}
response = self.client.post(self.url, data)
self.assertEquals(response.status_code, 302)
category = Category.objects.get(name="new-category")
self.assertCountEqual(category.rule_ids, [rule.pk for rule in rules])
def test_collection_rules_only_from_user(self):
other_user = UserFactory()
other_rules = CollectionRuleFactory.create_batch(size=4, user=other_user)
response = self.client.get(self.url)
for rule in other_rules:
self.assertNotContains(response, rule.name)
def test_creation_with_other_user_rules(self):
other_user = UserFactory()
other_rules = CollectionRuleFactory.create_batch(
size=4, user=other_user, category=None
)
user_rules = CollectionRuleFactory.create_batch(
size=3, user=self.user, category=None
)
data = {"name": "new-category", "rules": [rule.pk for rule in other_rules]}
response = self.client.post(self.url, data)
self.assertContains(response, "not one of the available choices")
self.assertEquals(Category.objects.count(), 0)

View file

@ -0,0 +1,123 @@
from django.test import Client, TestCase
from django.urls import reverse
from newsreader.accounts.tests.factories import UserFactory
from newsreader.news.collection.tests.factories import CollectionRuleFactory
from newsreader.news.core.tests.factories import CategoryFactory
class CategoryUpdateViewTestCase(TestCase):
def setUp(self):
self.user = UserFactory(password="test")
self.client.login(email=self.user.email, password="test")
self.category = CategoryFactory(name="category", user=self.user)
self.url = reverse("category-update", args=[self.category.pk])
def test_simple(self):
response = self.client.get(self.url)
self.assertEquals(response.status_code, 200)
def test_name_change(self):
data = {"name": "durp"}
self.client.post(self.url, data)
self.category.refresh_from_db()
self.assertEquals(self.category.name, "durp")
def test_add_collection_rules(self):
rules = CollectionRuleFactory.create_batch(size=4, user=self.user)
data = {"name": self.category.name, "rules": [rule.pk for rule in rules]}
self.client.post(self.url, data)
self.category.refresh_from_db()
# this actually checks for sequence contents too
# see https://docs.python.org/3/library/unittest.html#unittest.TestCase.assertCountEqual
self.assertCountEqual(self.category.rule_ids, [rule.pk for rule in rules])
def test_collection_rule_change(self):
other_rules = CollectionRuleFactory.create_batch(size=4, user=self.user)
other_category = CategoryFactory(user=self.user)
other_category.rules.set([*other_rules])
current_rules = CollectionRuleFactory.create_batch(size=3, user=self.user)
self.category.rules.set([*current_rules])
self.assertCountEqual(self.category.rule_ids, [rule.pk for rule in current_rules])
data = {"name": self.category.name, "rules": [rule.pk for rule in other_rules]}
self.client.post(self.url, data)
self.category.refresh_from_db()
self.assertCountEqual(self.category.rule_ids, [rule.pk for rule in other_rules])
other_category.refresh_from_db()
self.assertCountEqual(other_category.rule_ids, [])
def test_collection_rule_removal(self):
current_rules = CollectionRuleFactory.create_batch(size=3, user=self.user)
self.category.rules.set([*current_rules])
self.assertCountEqual(self.category.rule_ids, [rule.pk for rule in current_rules])
data = {"name": "durp"}
self.client.post(self.url, data)
self.category.refresh_from_db()
self.assertEquals(self.category.rules.count(), 0)
def test_collection_rules_only_from_user(self):
other_user = UserFactory()
other_rules = CollectionRuleFactory.create_batch(size=4, user=other_user)
response = self.client.get(self.url)
for rule in other_rules:
self.assertNotContains(response, rule.name)
def test_update_category_of_other_user(self):
other_user = UserFactory()
other_rules = CollectionRuleFactory.create_batch(size=4, user=other_user)
other_category = CategoryFactory(name="other category", user=other_user)
other_category.rules.set([*other_rules])
data = {"name": "durp"}
other_url = reverse("category-update", args=[other_category.pk])
response = self.client.post(other_url, data)
self.assertEquals(response.status_code, 404)
other_category.refresh_from_db()
self.assertEquals(other_category.name, "other category")
self.assertEquals(other_category.rules.count(), 4)
def test_category_update_with_other_user_rules(self):
other_user = UserFactory()
other_rules = CollectionRuleFactory.create_batch(size=4, user=other_user)
other_category = CategoryFactory(user=other_user)
other_category.rules.set([*other_rules])
current_rules = CollectionRuleFactory.create_batch(size=3, user=self.user)
self.category.rules.set([*current_rules])
self.assertCountEqual(self.category.rule_ids, [rule.pk for rule in current_rules])
data = {"name": self.category.name, "rules": [rule.pk for rule in other_rules]}
response = self.client.post(self.url, data)
self.assertContains(response, "not one of the available choices")
self.category.refresh_from_db()
self.assertCountEqual(self.category.rule_ids, [rule.pk for rule in current_rules])
other_category.refresh_from_db()
self.assertCountEqual(other_category.rule_ids, [rule.pk for rule in other_rules])

View file

@ -10,10 +10,28 @@ from newsreader.news.core.endpoints import (
NestedPostCategoryView, NestedPostCategoryView,
NestedRuleCategoryView, NestedRuleCategoryView,
) )
from newsreader.news.core.views import NewsView from newsreader.news.core.views import (
CategoryCreateView,
CategoryListView,
CategoryUpdateView,
NewsView,
)
index_page = login_required(NewsView.as_view()) urlpatterns = [
path("", login_required(NewsView.as_view()), name="index"),
path("categories/", login_required(CategoryListView.as_view()), name="categories"),
path(
"categories/<int:pk>/",
login_required(CategoryUpdateView.as_view()),
name="category-update",
),
path(
"categories/create/",
login_required(CategoryCreateView.as_view()),
name="category-create",
),
]
endpoints = [ endpoints = [
path("posts/", ListPostView.as_view(), name="posts-list"), path("posts/", ListPostView.as_view(), name="posts-list"),

View file

@ -1,6 +1,13 @@
from typing import Dict from typing import Dict, Iterable
from django.urls import reverse_lazy
from django.views.generic.base import TemplateView from django.views.generic.base import TemplateView
from django.views.generic.edit import CreateView, UpdateView
from django.views.generic.list import ListView
from newsreader.news.collection.models import CollectionRule
from newsreader.news.core.forms import CategoryForm
from newsreader.news.core.models import Category
class NewsView(TemplateView): class NewsView(TemplateView):
@ -23,3 +30,43 @@ class NewsView(TemplateView):
context.update(categories=categories, rules=rules) context.update(categories=categories, rules=rules)
return context return context
class CategoryViewMixin:
queryset = Category.objects.prefetch_related("rules").order_by("name")
def get_queryset(self) -> Iterable:
user = self.request.user
return self.queryset.filter(user=user)
class CategoryDetailMixin:
success_url = reverse_lazy("categories")
form_class = CategoryForm
def get_context_data(self, **kwargs) -> Dict:
context_data = super().get_context_data(**kwargs)
rules = CollectionRule.objects.filter(user=self.request.user).order_by("name")
context_data["rules"] = rules
return context_data
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["user"] = self.request.user
return kwargs
class CategoryListView(CategoryViewMixin, ListView):
template_name = "core/categories.html"
context_object_name = "categories"
class CategoryUpdateView(CategoryViewMixin, CategoryDetailMixin, UpdateView):
template_name = "core/category-update.html"
context_object_name = "category"
class CategoryCreateView(CategoryViewMixin, CategoryDetailMixin, CreateView):
template_name = "core/category-create.html"

View file

@ -3,6 +3,8 @@
padding: 0; padding: 0;
background-color: $gainsboro; background-color: $gainsboro;
font-family: $default-font;
& * { & * {
margin: 0; margin: 0;
padding: 0; padding: 0;

View file

@ -1,26 +0,0 @@
.button {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 50px;
border: none;
border-radius: 2px;
font-family: $button-font;
&:hover {
cursor: pointer;
}
&--confirm {
color: $white;
background-color: $confirm-green;
&:hover {
background-color: lighten($confirm-green, +5%);
}
}
}

View file

@ -0,0 +1,36 @@
.card {
display: flex;
flex-direction: column;
margin: 20px 0;
padding: 15px;
width: 50%;
border-radius: 2px;
background-color: $white;
&__header {
display: flex;
justify-content: space-between;
padding: 15px 0;
border-bottom: 2px $border-gray solid;
}
&__content {
display: flex;
padding: 10px;
}
&__footer {
display: flex;
padding: 10px;
}
& .favicon {
height: 30px;
margin: 0 20px 0 0;
}
}

View file

@ -0,0 +1 @@
@import "card";

View file

@ -0,0 +1,5 @@
.content {
display: flex;
flex-direction: column;
align-items: center;
}

View file

@ -0,0 +1 @@
@import "content";

View file

@ -1,5 +0,0 @@
%error {
background-color: $error-red;
color: $white;
list-style: none;
}

View file

@ -1,3 +0,0 @@
.errorlist {
@extend %error;
}

View file

@ -0,0 +1,3 @@
.errorlist {
}

View file

@ -0,0 +1,20 @@
.errorlist {
@extend .list;
padding: 10px;
margin: 5px 0;
background-color: $error-red;
color: $white;
list-style: disc;
list-style-position: inside;
& li {
margin: 15px 0 0 0;
&:first-child {
margin: 0;
}
}
}

View file

@ -1,2 +1 @@
@import "error";
@import "errorlist"; @import "errorlist";

View file

@ -2,12 +2,27 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 70%;
font-family: $form-font; font-family: $form-font;
background-color: $white;
&__fieldset { &__fieldset {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 15px;
border: none; border: none;
} }
&__header {
display: flex;
flex-direction: row;
padding: 15px;
}
& .favicon {
height: 30px;
}
} }

View file

@ -1,8 +1,12 @@
@import "./body/index"; @import "./body/index";
@import "./button/index";
@import "./form/index"; @import "./form/index";
@import "./main/index"; @import "./main/index";
@import "./navbar/index"; @import "./navbar/index";
@import "./error/index";
@import "./loading-indicator/index"; @import "./loading-indicator/index";
@import "./modal/index"; @import "./modal/index";
@import "./card/index";
@import "./list/index";
@import "./content/index";
@import "./messages/index";
@import "./section/index";
@import "./errorlist/index";

View file

@ -0,0 +1,16 @@
.list {
padding: 0 10px;
margin: 0;
list-style: none;
&__item {
display: flex;
align-items: center;
padding: 10px 0;
& > * {
margin: 0 15px;
}
}
}

View file

@ -0,0 +1 @@
@import "list";

View file

@ -0,0 +1,16 @@
.messages {
display: flex;
flex-direction: column;
width: 100%;
margin: 5px 0 20px 0;
padding: 15px 0;
color: $white;
background-color: $error-red;
&__item {
padding: 0 30px;
}
}

View file

@ -0,0 +1 @@
@import "messages";

View file

@ -1,4 +1,7 @@
.modal { .modal {
display: flex;
flex-direction: column;
position: fixed; position: fixed;
width: 100%; width: 100%;

View file

@ -2,6 +2,7 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
margin: 0 0 5px 0;
padding: 15px 0; padding: 15px 0;
width: 100%; width: 100%;

View file

@ -0,0 +1,6 @@
.section {
display: flex;
flex-direction: column;
border: none;
}

View file

@ -0,0 +1 @@
@import "section";

View file

@ -0,0 +1,46 @@
.button {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 50px;
border: none;
border-radius: 2px;
font-family: $button-font;
font-size: 16px;
&:hover {
cursor: pointer;
}
&--success, &--confirm {
color: $white;
background-color: $confirm-green;
&:hover {
background-color: lighten($confirm-green, +5%);
}
}
&--error, &--cancel {
color: $white;
background-color: $error-red;
&:hover {
background-color: lighten($error-red, +5%);
}
}
&--primary {
color: $white;
background-color: darken($azureish-white, +20%);
&:hover {
background-color: $azureish-white;
}
}
}

View file

@ -0,0 +1,3 @@
.h1 {
font-family: $header-font;
}

View file

@ -0,0 +1 @@
@import "h1";

View file

@ -0,0 +1,3 @@
.h2 {
font-family: $header-font;
}

View file

@ -0,0 +1 @@
@import "h2";

View file

@ -0,0 +1,3 @@
.h3 {
font-family: $header-font;
}

View file

@ -0,0 +1 @@
@import "h3";

View file

@ -0,0 +1,5 @@
.help-text {
@extends .small;
padding: 5px 15px;
}

View file

@ -0,0 +1 @@
@import "help-text";

View file

@ -0,0 +1,9 @@
@import "./button/index";
@import "link/index";
@import "h1/index";
@import "h2/index";
@import "h3/index";
@import "small/index";
@import "input/index";
@import "label/index";
@import "help-text/index";

View file

@ -0,0 +1,10 @@
.input {
padding: 10px;
border: 1px $border-gray solid;
border-radius: 2px;
&:focus {
border: 1px $focus-blue solid;
}
}

View file

@ -0,0 +1 @@
@import "input";

View file

@ -0,0 +1,3 @@
.label {
padding: 10px;
}

View file

@ -0,0 +1 @@
@import "label";

View file

@ -0,0 +1,7 @@
.link {
text-decoration: none;
&:hover {
cursor: pointer;
}
}

View file

@ -0,0 +1 @@
@import "link";

View file

@ -0,0 +1,3 @@
.small {
color: $nickel;
}

View file

@ -0,0 +1 @@
@import "small";

View file

@ -0,0 +1,5 @@
.card {
&__footer > *:last-child {
margin: 0 0 0 10px;
}
}

View file

@ -0,0 +1 @@
@import "card";

View file

@ -0,0 +1,27 @@
.category-modal {
display: flex;
flex-direction: column;
align-self: center;
margin: 20px 0;
width: 50%;
border-radius: 2px;
background-color: $white;
&__header {
padding: 5px 20px;
}
&__content {
padding: 10px 30px;
}
&__footer {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 10px;
}
}

View file

@ -0,0 +1 @@
@import "category-modal";

View file

@ -0,0 +1,2 @@
@import "card/index";
@import "category-modal/index";

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