Category pages

This commit is contained in:
Sonny 2019-11-19 12:21:34 +01:00
parent a350cc280d
commit b952d70d92
114 changed files with 1205 additions and 177 deletions

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
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 UserManager as DjangoUserManager
from django.db import models
@ -9,7 +12,7 @@ from django_celery_beat.models import IntervalSchedule, PeriodicTask
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.
"""
@ -21,12 +24,14 @@ class UserManager(DjangoUserManager):
user.save(using=self._db)
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_superuser", False)
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_superuser", True)
@ -42,10 +47,12 @@ class User(AbstractUser):
email = models.EmailField(_("email address"), unique=True)
task = models.OneToOneField(
PeriodicTask, _("collection task"), null=True, blank=True, editable=False
)
task_interval = models.ForeignKey(
IntervalSchedule, _("collection schedule"), null=True, blank=True
PeriodicTask,
on_delete=models.SET_NULL,
null=True,
blank=True,
editable=False,
verbose_name="collection task",
)
username = None
@ -55,35 +62,24 @@ class User(AbstractUser):
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._original_interval = self.task_interval
def save(self, *args, **kwargs):
if self._original_interval != self.task_interval:
if self.task_interval and self.task:
self.task.interval = self.task_interval
self.task.enabled = True
self.task.save()
elif not self.task_interval and self.task:
self.task.enabled = False
self.task.save()
def save(self, *args, **kwargs) -> None:
super().save(*args, **kwargs)
if not self.task:
self.task_interval, _ = IntervalSchedule.objects.get_or_create(
task_interval, _ = IntervalSchedule.objects.get_or_create(
every=1, period=IntervalSchedule.HOURS
)
self.task, _ = PeriodicTask.objects.get_or_create(
enabled=True,
interval=self.task_interval,
interval=task_interval,
name=f"{self.email}-collection-task",
task="newsreader.news.collection.tasks",
task="newsreader.news.collection.tasks.collect",
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):
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")
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)
def test_task_is_updated(self):
user = User.objects.create(email="durp@burp.nl", task=None, task_interval=None)
def test_task_is_deleted(self):
user = User.objects.create(email="durp@burp.nl", task=None)
user.delete()
new_interval = IntervalSchedule.objects.create(
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)
self.assertEquals(PeriodicTask.objects.count(), 0)

View file

@ -114,6 +114,12 @@ STATIC_URL = "/static/"
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
REST_FRAMEWORK = {
"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 { unSelectPost, markPostRead } from '../actions/posts.js';
import { formatDatetime } from '../../utils.js';
import { formatDatetime } from '../../../utils.js';
class PostModal extends React.Component {
readTimer = null;

View file

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

View file

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

View file

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

View file

@ -10,7 +10,6 @@ def collect(user_pk):
try:
user = User.objects.get(pk=user_pk)
except ObjectDoesNotExist:
# TODO remove this task
return
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):
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")
@property
def rule_ids(self):
return self.rules.values_list("pk", flat=True)
class Meta:
verbose_name = _("Category")
verbose_name_plural = _("Categories")
unique_together = ("name", "user")
def __str__(self):
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 %}
{% block head %}
<link href="{% static 'core/dist/css/core.css' %}" rel="stylesheet" />
<link href="{% static 'core/dist/css/homepage.css' %}" rel="stylesheet" />
{% endblock %}
{% 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,
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 = [
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.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):
@ -23,3 +30,43 @@ class NewsView(TemplateView):
context.update(categories=categories, rules=rules)
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;
background-color: $gainsboro;
font-family: $default-font;
& * {
margin: 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

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

View file

@ -1,8 +1,12 @@
@import "./body/index";
@import "./button/index";
@import "./form/index";
@import "./main/index";
@import "./navbar/index";
@import "./error/index";
@import "./loading-indicator/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 {
display: flex;
flex-direction: column;
position: fixed;
width: 100%;

View file

@ -2,6 +2,7 @@
display: flex;
justify-content: center;
margin: 0 0 5px 0;
padding: 15px 0;
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";

View file

@ -0,0 +1,8 @@
// General imports
@import "../../partials/variables";
@import "../../components/index";
@import "../../elements/index";
// Page specific
@import "./components/index";
@import "./elements/index";

View file

@ -0,0 +1,12 @@
.category-form {
margin: 20px 0;
&__section:last-child {
& .category-form__fieldset {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
}

View file

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

View file

@ -0,0 +1 @@
@import "category-form/index";

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