0.2.3 #99

Merged
sonny merged 112 commits from development into master 2020-05-23 16:58:42 +02:00
35 changed files with 766 additions and 14 deletions
Showing only changes of commit 38d9d74db4 - Show all commits

View file

@ -12,10 +12,18 @@ const SRC_DIR = path.join(PROJECT_DIR, 'js');
const STATIC_SUFFIX = 'dist/js/';
const CORE_DIR = path.join(PROJECT_DIR, 'news', 'core', 'static', 'core');
const COLLECTION_DIR = path.join(
PROJECT_DIR,
'news',
'collection',
'static',
'collection'
);
const taskMappings = [
{ name: 'homepage', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` },
{ name: 'categories', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` },
{ name: 'rules', destDir: `${COLLECTION_DIR}/${STATIC_SUFFIX}` },
];
const babelTask = done => {

View file

@ -10,6 +10,13 @@ const STATIC_SUFFIX = 'dist/css/';
export const ACCOUNTS_DIR = path.join(PROJECT_DIR, 'accounts', 'static', 'accounts');
export const CORE_DIR = path.join(PROJECT_DIR, 'news', 'core', 'static', 'core');
export const COLLECTION_DIR = path.join(
PROJECT_DIR,
'news',
'collection',
'static',
'collection'
);
const taskMappings = [
{ name: 'login', destDir: `${ACCOUNTS_DIR}/${STATIC_SUFFIX}` },
@ -19,6 +26,8 @@ const taskMappings = [
{ name: 'homepage', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` },
{ name: 'categories', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` },
{ name: 'category', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` },
{ name: 'rules', destDir: `${COLLECTION_DIR}/${STATIC_SUFFIX}` },
{ name: 'rule', destDir: `${COLLECTION_DIR}/${STATIC_SUFFIX}` },
];
export const sassTask = done => {

View file

@ -1,9 +1,11 @@
import React from 'react';
const Messages = props => {
const messages = props.messages.map(message => {
const messages = props.messages.map((index, message) => {
return (
<li className={`messages__item message__item--${message.type}`}>{message.text}</li>
<li key={index} className={`messages__item message__item--${message.type}`}>
{message.text}
</li>
);
});

View file

@ -19,9 +19,7 @@ const CategoryCard = props => {
const cardHeader = (
<>
<h2 className="h2">{category.name}</h2>
<span>
<small className="small">{category.created}</small>
</span>
<small className="small">{category.created}</small>
</>
);
const cardContent = <>{category.rules && <ul className="list">{categoryRules}</ul>}</>;

View file

@ -0,0 +1,100 @@
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>
<a className="link button button--confirm" href="/rules/create/">
Create rule
</a>
</>
);
return (
<>
{this.state.message && <Messages messages={[this.state.message]} />}
<Card header={pageHeader} />
{cards}
{selectedRule && (
<RuleModal
rule={selectedRule}
handleCancel={this.deselectRule}
handleDelete={this.deleteRule}
/>
)}
</>
);
}
}
export default App;

View file

@ -0,0 +1,62 @@
import React from 'react';
import Card from '../../../components/Card.js';
const RuleCard = props => {
const { rule } = props;
const faviconUrl = rule.favicon ? rule.favicon : '/static/picture.svg';
const stateIcon = rule.succeeded
? '/static/checkmark-circle.svg'
: '/static/warning.svg';
const cardHeader = (
<>
<div>
<img src={stateIcon} width="50" />
<h2 className="h2">{rule.name}</h2>
</div>
<img className="favicon" src={faviconUrl} />
</>
);
const cardContent = (
<>
<ul className="list rules">
<li className="list__item rules__item">{rule.category}</li>
<li className="list__item rules__item">
<a className="link" target="_blank" rel="noopener noreferrer" href={rule.url}>
{rule.url}
</a>
</li>
<li className="list__item rules__item">{rule.created}</li>
<li className="list__item rules__item">{rule.timezone}</li>
</ul>
{!rule.succeeded && (
<ul className="list errorlist">
<li className="list__item errorlist__item">{rule.error}</li>
</ul>
)}
</>
);
const cardFooter = (
<>
<a className="link button button--primary" href={`/rules/${rule.pk}/`}>
Edit
</a>
<button
id="rule-delete"
className="button button--error"
onClick={() => props.showDialog(rule.pk)}
data-id={`${rule.pk}`}
>
Delete
</button>
</>
);
return <Card header={cardHeader} content={cardContent} footer={cardFooter} />;
};
export default RuleCard;

View file

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

View file

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

View file

@ -0,0 +1,30 @@
from django import forms
from newsreader.news.collection.models import CollectionRule
from newsreader.news.core.models import Category
class CollectionRuleForm(forms.ModelForm):
category = forms.ModelChoiceField(required=False, queryset=Category.objects.all())
def __init__(self, *args, **kwargs) -> None:
self.user = kwargs.pop("user")
super().__init__(*args, **kwargs)
if self.user:
self.fields["category"].queryset = Category.objects.filter(user=self.user)
def save(self, commit=True) -> CollectionRule:
instance = super().save(commit=False)
instance.user = self.user
if commit:
instance.save()
self.save_m2m()
return instance
class Meta:
model = CollectionRule
fields = ("name", "url", "timezone", "favicon", "category")

View file

@ -0,0 +1,31 @@
{% extends "collection/rule.html" %}
{% block form-header %}
<h1 class="h1 form__title">Create a rule</h1>
{% endblock %}
{% block name-input %}
<input class="input rule-form__input" type="text" name="name" required />
{% endblock %}
{% block category-input %}
<option class="option rule-form__option" value="{{ category.pk }}">
{{ category.name }}
</option>
{% endblock %}
{% block url-input %}
<input class="input rule-form__input" type="url" name="url" required />
{% endblock %}
{% block favicon-input %}
<input class="input rule-form__input" type="url" name="favicon" />
{% endblock %}
{% block timezone-input %}
<option class="option rule-form__option">{{ timezone }}</option>
{% endblock %}
{% block confirm-button %}
<button class="button button--confirm">Create rule</button>
{% endblock %}

View file

@ -0,0 +1,34 @@
{% extends "collection/rule.html" %}
{% block form-header %}
<h1 class="h1 form__title">Update rule</h1>
{% endblock %}
{% block name-input %}
<input class="input rule-form__input" type="text" name="name"
value="{{ rule.name }}" required />
{% endblock %}
{% block category-input %}
<option class="option rule-form__option" value="{{ category.pk }}" {% if rule.category and rule.category.pk == category.pk %}selected{% endif %}>
{{ category.name }}
</option>
{% endblock %}
{% block url-input %}
<input class="input rule-form__input" type="url" name="url" value="{{ rule.url }}" required />
{% endblock %}
{% block favicon-input %}
<input class="input rule-form__input" type="url" value="{{ rule.favicon|default:"" }}" name="favicon" />
{% endblock %}
{% block timezone-input %}
<option class="option rule-form__option" {% if rule.timezone == timezone %}selected{% endif %}>
{{ timezone }}
</option>
{% endblock %}
{% block confirm-button %}
<button class="button button--confirm">Save rule</button>
{% endblock %}

View file

@ -0,0 +1,70 @@
{% extends "base.html" %}
{% load static %}
{% block head %}
<link href="{% static 'collection/dist/css/rule.css' %}" rel="stylesheet" />
{% endblock %}
{% block content %}
<div class="content">
<form class="form rule-form" method="post">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="form__header">
{% block form-header %}{% endblock %}
</div>
<section class="section form__section rule-form__section">
<fieldset class="form__fieldset rule-form__fieldset">
<label class="label rule-form__label" for="name">Name</label>
{% block name-input %}{% endblock %}
{{ form.name.errors }}
</fieldset>
<fieldset class="form__fieldset rule-form__fieldset">
<label class="label rule-form__label" for="name">Category</label>
<select class="select rule-form__select" name="category">
{% for category in categories %}
{% block category-input %}{% endblock %}
{% endfor %}
</select>
{{ form.category.errors }}
</fieldset>
<fieldset class="form__fieldset rule-form__fieldset">
<label class="label rule-form__label" for="name">Feed url</label>
{% block url-input %}{% endblock %}
{{ form.url.errors }}
</fieldset>
<fieldset class="form__fieldset rule-form__fieldset">
<label class="label rule-form__label" for="name">Favicon url</label>
{% block favicon-input %}{% endblock %}
{{ form.favicon.errors }}
</fieldset>
<fieldset class="form__fieldset rule-form__fieldset">
<label class="label rule-form__label" for="name">Timezone</label>
<small class="small helptext">The timezone which the feed uses</small>
<select class="select rule-form__select" size="{{ timezones|length }}" name="timezone">
{% for timezone in timezones %}
{% block timezone-input %}{% endblock %}
{% endfor %}
</select>
{{ form.timezone.errors }}
</fieldset>
</section>
<section class="section form__section rule-form__section">
<fieldset class="form__fieldset rule-form__fieldset">
<a class="link button button--cancel" href="{% url 'rules' %}">Cancel</a>
{% block confirm-button %}{% endblock %}
</fieldset>
</section>
</form>
</div>
{% endblock %}

View file

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

View file

@ -0,0 +1,148 @@
from django.test import Client, TestCase
from django.urls import reverse
import pytz
from newsreader.accounts.tests.factories import UserFactory
from newsreader.news.collection.models import CollectionRule
from newsreader.news.collection.tests.factories import CollectionRuleFactory
from newsreader.news.core.tests.factories import CategoryFactory
class CollectionRuleViewTestCase:
def setUp(self):
self.user = UserFactory(password="test")
self.client.login(email=self.user.email, password="test")
self.category = CategoryFactory(user=self.user)
self.form_data = {"name": "", "category": "", "url": "", "timezone": ""}
def test_simple(self):
response = self.client.get(self.url)
self.assertEquals(response.status_code, 200)
def test_no_category(self):
self.form_data.update(category="")
response = self.client.post(self.url, self.form_data)
self.assertEquals(response.status_code, 302)
rule = CollectionRule.objects.get()
self.assertEquals(rule.category, None)
def test_categories_only_from_user(self):
other_user = UserFactory()
other_categories = CategoryFactory.create_batch(size=4, user=other_user)
response = self.client.get(self.url)
for category in other_categories:
self.assertNotContains(response, category.name)
def test_category_of_other_user(self):
other_user = UserFactory()
other_rule = CollectionRuleFactory(name="other rule", user=other_user)
self.form_data.update(
name="new name",
category=other_rule.category,
url=other_rule.url,
timezone=other_rule.timezone,
)
other_url = reverse("rule-update", args=[other_rule.pk])
response = self.client.post(other_url, self.form_data)
self.assertEquals(response.status_code, 404)
other_rule.refresh_from_db()
self.assertEquals(other_rule.name, "other rule")
def test_with_other_user_rules(self):
other_user = UserFactory()
other_categories = CategoryFactory.create_batch(size=4, user=other_user)
self.form_data.update(category=other_categories[2].pk)
response = self.client.post(self.url, self.form_data)
self.assertContains(response, "not one of the available choices")
class CollectionRuleCreateViewTestCase(CollectionRuleViewTestCase, TestCase):
def setUp(self):
super().setUp()
self.url = reverse("rule-create")
self.form_data.update(
name="new rule",
url="https://www.rss.com/rss",
timezone=pytz.utc,
category=str(self.category.pk),
)
def test_creation(self):
response = self.client.post(self.url, self.form_data)
self.assertEquals(response.status_code, 302)
rule = CollectionRule.objects.get(name="new rule")
self.assertEquals(rule.url, "https://www.rss.com/rss")
self.assertEquals(rule.timezone, str(pytz.utc))
self.assertEquals(rule.favicon, None)
self.assertEquals(rule.category.pk, self.category.pk)
self.assertEquals(rule.user.pk, self.user.pk)
class CollectionRuleUpdateViewTestCase(CollectionRuleViewTestCase, TestCase):
def setUp(self):
super().setUp()
self.rule = CollectionRuleFactory(
name="collection rule", user=self.user, category=self.category
)
self.url = reverse("rule-update", args=[self.rule.pk])
self.form_data.update(
name=self.rule.name,
category=self.rule.category.pk,
url=self.rule.url,
timezone=self.rule.timezone,
)
def test_name_change(self):
self.form_data.update(name="new name")
response = self.client.post(self.url, self.form_data)
self.assertEquals(response.status_code, 302)
self.rule.refresh_from_db()
self.assertEquals(self.rule.name, "new name")
def test_category_change(self):
new_category = CategoryFactory(user=self.user)
self.form_data.update(category=new_category.pk)
response = self.client.post(self.url, self.form_data)
self.assertEquals(response.status_code, 302)
self.rule.refresh_from_db()
self.assertEquals(self.rule.category.pk, new_category.pk)
def test_category_removal(self):
self.form_data.update(category="")
response = self.client.post(self.url, self.form_data)
self.assertEquals(response.status_code, 302)
self.rule.refresh_from_db()
self.assertEquals(self.rule.category, None)

View file

@ -1,3 +1,4 @@
from django.contrib.auth.decorators import login_required
from django.urls import path
from newsreader.news.collection.endpoints import (
@ -6,11 +7,30 @@ from newsreader.news.collection.endpoints import (
NestedRuleView,
RuleReadView,
)
from newsreader.news.collection.views import (
CollectionRuleCreateView,
CollectionRuleListView,
CollectionRuleUpdateView,
)
endpoints = [
path("rules/<int:pk>", DetailRuleView.as_view(), name="rules-detail"),
path("rules/<int:pk>/", DetailRuleView.as_view(), name="rules-detail"),
path("rules/<int:pk>/posts/", NestedRuleView.as_view(), name="rules-nested-posts"),
path("rules/<int:pk>/read/", RuleReadView.as_view(), name="rules-read"),
path("rules/", ListRuleView.as_view(), name="rules-list"),
]
urlpatterns = [
path("rules/", login_required(CollectionRuleListView.as_view()), name="rules"),
path(
"rules/<int:pk>/",
login_required(CollectionRuleUpdateView.as_view()),
name="rule-update",
),
path(
"rules/create/",
login_required(CollectionRuleCreateView.as_view()),
name="rule-create",
),
]

View file

@ -0,0 +1,58 @@
from typing import Dict, Iterable
from django.urls import reverse_lazy
from django.views.generic.edit import CreateView, UpdateView
from django.views.generic.list import ListView
import pytz
from newsreader.news.collection.forms import CollectionRuleForm
from newsreader.news.collection.models import CollectionRule
from newsreader.news.core.models import Category
class CollectionRuleViewMixin:
queryset = CollectionRule.objects.order_by("name")
def get_queryset(self) -> Iterable:
user = self.request.user
return self.queryset.filter(user=user)
class CollectionRuleDetailMixin:
success_url = reverse_lazy("rules")
form_class = CollectionRuleForm
def get_context_data(self, **kwargs) -> Dict:
context_data = super().get_context_data(**kwargs)
rules = Category.objects.filter(user=self.request.user).order_by("name")
timezones = [timezone for timezone in pytz.all_timezones]
context_data["categories"] = rules
context_data["timezones"] = timezones
return context_data
def get_form_kwargs(self) -> Dict:
kwargs = super().get_form_kwargs()
kwargs["user"] = self.request.user
return kwargs
class CollectionRuleListView(CollectionRuleViewMixin, ListView):
template_name = "collection/rules.html"
context_object_name = "rules"
class CollectionRuleUpdateView(
CollectionRuleViewMixin, CollectionRuleDetailMixin, UpdateView
):
template_name = "collection/rule-update.html"
context_object_name = "rule"
class CollectionRuleCreateView(
CollectionRuleViewMixin, CollectionRuleDetailMixin, CreateView
):
template_name = "collection/rule-create.html"

View file

@ -6,8 +6,8 @@ from newsreader.news.core.models import Category
class CategoryForm(forms.ModelForm):
rules = forms.ModelMultipleChoiceField(
queryset=CollectionRule.objects.all(),
required=False,
queryset=CollectionRule.objects.all(),
widget=forms.widgets.CheckboxSelectMultiple,
)
@ -17,7 +17,9 @@ class CategoryForm(forms.ModelForm):
super().__init__(*args, **kwargs)
if self.user:
self.fields["rules"].queryset = CollectionRule.objects.filter(user=self.user)
self.fields["rules"].queryset = CollectionRule.objects.filter(
user=self.user
)
def save(self, commit=True) -> Category:
instance = super().save(commit=False)

View file

@ -13,6 +13,7 @@
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 0;

View file

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

View file

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

View file

@ -0,0 +1,27 @@
.rule-form {
margin: 20px 0;
&__section:last-child {
& .rule-form__fieldset {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
&__select[name=category] {
width: 50%;
padding: 0 10px;
}
&__select[name=timezone] {
max-height: 200px;
width: 50%;
margin: 0 15px;
padding: 0 10px;
}
}

View file

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

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,20 @@
.card {
&__header {
& div {
display: flex;
flex-direction: row;
& img {
padding: 0 10px;
}
}
}
&__content {
flex-direction: column;
}
&__footer > *:last-child {
margin: 0 0 0 10px;
}
}

View file

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

View file

@ -0,0 +1,3 @@
@import "card/index";
@import "rules/index";
@import "rule-modal/index";

View file

@ -0,0 +1,27 @@
.rule-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 "rule-modal";

View file

@ -0,0 +1,7 @@
.rules {
&__item {
& > * {
margin: 0;
}
}
}

View file

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

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

@ -11,7 +11,7 @@
{% if request.user.is_authenticated %}
<li class="nav__item"><a href="{% url 'index' %}">Home</a></li>
<li class="nav__item"><a href="{% url 'categories' %}">Categories</a></li>
<li class="nav__item"><a href="#">Feeds</a></li>
<li class="nav__item"><a href="{% url 'rules' %}">Feeds</a></li>
<li class="nav__item"><a href="#">Settings</a></li>
<li class="nav__item"><a href="{% url 'accounts:logout' %}">Logout</a></li>
{% else %}

View file

@ -6,8 +6,9 @@ from rest_framework_swagger.views import get_swagger_view
from newsreader.accounts.urls import urlpatterns as login_urls
from newsreader.news.collection.urls import endpoints as collection_endpoints
from newsreader.news.collection.urls import urlpatterns as collection_patterns
from newsreader.news.core.urls import endpoints as core_endpoints
from newsreader.news.core.urls import urlpatterns
from newsreader.news.core.urls import urlpatterns as core_patterns
schema_view = get_swagger_view(title="Newsreader API")
@ -18,7 +19,8 @@ endpoints = [
]
urlpatterns = [
path("", include(urlpatterns)),
path("", include(core_patterns)),
path("", include(collection_patterns)),
path("accounts/", include((login_urls, "accounts")), name="accounts"),
path("admin/", admin.site.urls, name="admin"),
path("api/", include((endpoints, "api")), name="api"),