Collection rule pages
This commit is contained in:
parent
d345bc2595
commit
38d9d74db4
35 changed files with 766 additions and 14 deletions
|
|
@ -12,10 +12,18 @@ const SRC_DIR = path.join(PROJECT_DIR, 'js');
|
||||||
const STATIC_SUFFIX = 'dist/js/';
|
const STATIC_SUFFIX = 'dist/js/';
|
||||||
|
|
||||||
const CORE_DIR = path.join(PROJECT_DIR, 'news', 'core', 'static', 'core');
|
const CORE_DIR = path.join(PROJECT_DIR, 'news', 'core', 'static', 'core');
|
||||||
|
const COLLECTION_DIR = path.join(
|
||||||
|
PROJECT_DIR,
|
||||||
|
'news',
|
||||||
|
'collection',
|
||||||
|
'static',
|
||||||
|
'collection'
|
||||||
|
);
|
||||||
|
|
||||||
const taskMappings = [
|
const taskMappings = [
|
||||||
{ name: 'homepage', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` },
|
{ name: 'homepage', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` },
|
||||||
{ name: 'categories', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` },
|
{ name: 'categories', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` },
|
||||||
|
{ name: 'rules', destDir: `${COLLECTION_DIR}/${STATIC_SUFFIX}` },
|
||||||
];
|
];
|
||||||
|
|
||||||
const babelTask = done => {
|
const babelTask = done => {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,13 @@ const STATIC_SUFFIX = 'dist/css/';
|
||||||
|
|
||||||
export const ACCOUNTS_DIR = path.join(PROJECT_DIR, 'accounts', 'static', 'accounts');
|
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 CORE_DIR = path.join(PROJECT_DIR, 'news', 'core', 'static', 'core');
|
||||||
|
export const COLLECTION_DIR = path.join(
|
||||||
|
PROJECT_DIR,
|
||||||
|
'news',
|
||||||
|
'collection',
|
||||||
|
'static',
|
||||||
|
'collection'
|
||||||
|
);
|
||||||
|
|
||||||
const taskMappings = [
|
const taskMappings = [
|
||||||
{ name: 'login', destDir: `${ACCOUNTS_DIR}/${STATIC_SUFFIX}` },
|
{ name: 'login', destDir: `${ACCOUNTS_DIR}/${STATIC_SUFFIX}` },
|
||||||
|
|
@ -19,6 +26,8 @@ const taskMappings = [
|
||||||
{ name: 'homepage', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` },
|
{ name: 'homepage', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` },
|
||||||
{ name: 'categories', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` },
|
{ name: 'categories', destDir: `${CORE_DIR}/${STATIC_SUFFIX}` },
|
||||||
{ name: 'category', 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 => {
|
export const sassTask = done => {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
const Messages = props => {
|
const Messages = props => {
|
||||||
const messages = props.messages.map(message => {
|
const messages = props.messages.map((index, message) => {
|
||||||
return (
|
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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,7 @@ const CategoryCard = props => {
|
||||||
const cardHeader = (
|
const cardHeader = (
|
||||||
<>
|
<>
|
||||||
<h2 className="h2">{category.name}</h2>
|
<h2 className="h2">{category.name}</h2>
|
||||||
<span>
|
|
||||||
<small className="small">{category.created}</small>
|
<small className="small">{category.created}</small>
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
const cardContent = <>{category.rules && <ul className="list">{categoryRules}</ul>}</>;
|
const cardContent = <>{category.rules && <ul className="list">{categoryRules}</ul>}</>;
|
||||||
|
|
|
||||||
100
src/newsreader/js/pages/rules/App.js
Normal file
100
src/newsreader/js/pages/rules/App.js
Normal 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;
|
||||||
62
src/newsreader/js/pages/rules/components/RuleCard.js
Normal file
62
src/newsreader/js/pages/rules/components/RuleCard.js
Normal 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;
|
||||||
33
src/newsreader/js/pages/rules/components/RuleModal.js
Normal file
33
src/newsreader/js/pages/rules/components/RuleModal.js
Normal 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;
|
||||||
9
src/newsreader/js/pages/rules/index.js
Normal file
9
src/newsreader/js/pages/rules/index.js
Normal 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]);
|
||||||
30
src/newsreader/news/collection/forms.py
Normal file
30
src/newsreader/news/collection/forms.py
Normal 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")
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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 %}
|
||||||
148
src/newsreader/news/collection/tests/test_views.py
Normal file
148
src/newsreader/news/collection/tests/test_views.py
Normal 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)
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from newsreader.news.collection.endpoints import (
|
from newsreader.news.collection.endpoints import (
|
||||||
|
|
@ -6,11 +7,30 @@ from newsreader.news.collection.endpoints import (
|
||||||
NestedRuleView,
|
NestedRuleView,
|
||||||
RuleReadView,
|
RuleReadView,
|
||||||
)
|
)
|
||||||
|
from newsreader.news.collection.views import (
|
||||||
|
CollectionRuleCreateView,
|
||||||
|
CollectionRuleListView,
|
||||||
|
CollectionRuleUpdateView,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
endpoints = [
|
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>/posts/", NestedRuleView.as_view(), name="rules-nested-posts"),
|
||||||
path("rules/<int:pk>/read/", RuleReadView.as_view(), name="rules-read"),
|
path("rules/<int:pk>/read/", RuleReadView.as_view(), name="rules-read"),
|
||||||
path("rules/", ListRuleView.as_view(), name="rules-list"),
|
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",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -6,8 +6,8 @@ from newsreader.news.core.models import Category
|
||||||
|
|
||||||
class CategoryForm(forms.ModelForm):
|
class CategoryForm(forms.ModelForm):
|
||||||
rules = forms.ModelMultipleChoiceField(
|
rules = forms.ModelMultipleChoiceField(
|
||||||
queryset=CollectionRule.objects.all(),
|
|
||||||
required=False,
|
required=False,
|
||||||
|
queryset=CollectionRule.objects.all(),
|
||||||
widget=forms.widgets.CheckboxSelectMultiple,
|
widget=forms.widgets.CheckboxSelectMultiple,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -17,7 +17,9 @@ class CategoryForm(forms.ModelForm):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
if self.user:
|
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:
|
def save(self, commit=True) -> Category:
|
||||||
instance = super().save(commit=False)
|
instance = super().save(commit=False)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
&__header {
|
&__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
padding: 15px 0;
|
padding: 15px 0;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
.errorlist {
|
|
||||||
|
|
||||||
}
|
|
||||||
1
src/newsreader/scss/pages/rule/components/index.scss
Normal file
1
src/newsreader/scss/pages/rule/components/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "rule-form/index";
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
@import "rule-form";
|
||||||
0
src/newsreader/scss/pages/rule/elements/index.scss
Normal file
0
src/newsreader/scss/pages/rule/elements/index.scss
Normal file
8
src/newsreader/scss/pages/rule/index.scss
Normal file
8
src/newsreader/scss/pages/rule/index.scss
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
// General imports
|
||||||
|
@import "../../partials/variables";
|
||||||
|
@import "../../components/index";
|
||||||
|
@import "../../elements/index";
|
||||||
|
|
||||||
|
// Page specific
|
||||||
|
@import "./components/index";
|
||||||
|
@import "./elements/index";
|
||||||
20
src/newsreader/scss/pages/rules/components/card/_card.scss
Normal file
20
src/newsreader/scss/pages/rules/components/card/_card.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
@import "card";
|
||||||
3
src/newsreader/scss/pages/rules/components/index.scss
Normal file
3
src/newsreader/scss/pages/rules/components/index.scss
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
@import "card/index";
|
||||||
|
@import "rules/index";
|
||||||
|
@import "rule-modal/index";
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
@import "rule-modal";
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
.rules {
|
||||||
|
&__item {
|
||||||
|
& > * {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
@import "rules";
|
||||||
0
src/newsreader/scss/pages/rules/elements/index.scss
Normal file
0
src/newsreader/scss/pages/rules/elements/index.scss
Normal file
8
src/newsreader/scss/pages/rules/index.scss
Normal file
8
src/newsreader/scss/pages/rules/index.scss
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
// General imports
|
||||||
|
@import "../../partials/variables";
|
||||||
|
@import "../../components/index";
|
||||||
|
@import "../../elements/index";
|
||||||
|
|
||||||
|
// Page specific
|
||||||
|
@import "./components/index";
|
||||||
|
@import "./elements/index";
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
<li class="nav__item"><a href="{% url 'index' %}">Home</a></li>
|
<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="{% 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="#">Settings</a></li>
|
||||||
<li class="nav__item"><a href="{% url 'accounts:logout' %}">Logout</a></li>
|
<li class="nav__item"><a href="{% url 'accounts:logout' %}">Logout</a></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@ from rest_framework_swagger.views import get_swagger_view
|
||||||
|
|
||||||
from newsreader.accounts.urls import urlpatterns as login_urls
|
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 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 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")
|
schema_view = get_swagger_view(title="Newsreader API")
|
||||||
|
|
@ -18,7 +19,8 @@ endpoints = [
|
||||||
]
|
]
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", include(urlpatterns)),
|
path("", include(core_patterns)),
|
||||||
|
path("", include(collection_patterns)),
|
||||||
path("accounts/", include((login_urls, "accounts")), name="accounts"),
|
path("accounts/", include((login_urls, "accounts")), name="accounts"),
|
||||||
path("admin/", admin.site.urls, name="admin"),
|
path("admin/", admin.site.urls, name="admin"),
|
||||||
path("api/", include((endpoints, "api")), name="api"),
|
path("api/", include((endpoints, "api")), name="api"),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue