0.2.3 #99
114 changed files with 1205 additions and 177 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
10
gulp/sass.js
10
gulp/sass.js
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1 @@
|
||||||
-r testing.txt
|
-r dev.txt
|
||||||
|
|
||||||
black==19.3b0
|
|
||||||
isort==4.3.20
|
|
||||||
autoflake==1.3
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Generated by Django 2.2.6 on 2019-11-16 11:28
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [("accounts", "0004_auto_20190714_1501")]
|
||||||
|
|
||||||
|
operations = [migrations.RemoveField(model_name="user", name="task_interval")]
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Generated by Django 2.2.6 on 2019-11-16 11:53
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [("accounts", "0005_remove_user_task_interval")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="task",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
blank=True,
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="django_celery_beat.PeriodicTask",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Generated by Django 2.2.6 on 2019-11-16 11:55
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [("accounts", "0006_auto_20191116_1253")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="task",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
blank=True,
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to="django_celery_beat.PeriodicTask",
|
||||||
|
verbose_name="collection task",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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": (
|
||||||
|
|
|
||||||
13
src/newsreader/js/components/Card.js
Normal file
13
src/newsreader/js/components/Card.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const Card = props => {
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card__header">{props.header}</div>
|
||||||
|
<div className="card__content">{props.content}</div>
|
||||||
|
<div className="card__footer">{props.footer}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Card;
|
||||||
13
src/newsreader/js/components/Messages.js
Normal file
13
src/newsreader/js/components/Messages.js
Normal 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;
|
||||||
7
src/newsreader/js/components/Modal.js
Normal file
7
src/newsreader/js/components/Modal.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const Modal = props => {
|
||||||
|
return <div className="modal">{props.content}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
106
src/newsreader/js/pages/categories/App.js
Normal file
106
src/newsreader/js/pages/categories/App.js
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import Cookies from 'js-cookie';
|
||||||
|
|
||||||
|
import Card from '../../components/Card.js';
|
||||||
|
import CategoryCard from './components/CategoryCard.js';
|
||||||
|
import CategoryModal from './components/CategoryModal.js';
|
||||||
|
import Messages from '../../components/Messages.js';
|
||||||
|
|
||||||
|
class App extends React.Component {
|
||||||
|
selectCategory = ::this.selectCategory;
|
||||||
|
deselectCategory = ::this.deselectCategory;
|
||||||
|
deleteCategory = ::this.deleteCategory;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.token = Cookies.get('csrftoken');
|
||||||
|
this.state = {
|
||||||
|
categories: props.categories,
|
||||||
|
selectedCategoryId: null,
|
||||||
|
message: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
selectCategory(categoryId) {
|
||||||
|
this.setState({ selectedCategoryId: categoryId });
|
||||||
|
}
|
||||||
|
|
||||||
|
deselectCategory() {
|
||||||
|
this.setState({ selectedCategoryId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCategory(categoryId) {
|
||||||
|
const url = `/api/categories/${categoryId}/`;
|
||||||
|
const options = {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': this.token,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch(url, options).then(response => {
|
||||||
|
if (response.ok) {
|
||||||
|
const categories = this.state.categories.filter(category => {
|
||||||
|
return category.pk != categoryId;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.setState({
|
||||||
|
categories: categories,
|
||||||
|
selectedCategoryId: null,
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
type: 'error',
|
||||||
|
text: 'Unable to remove category, try again later',
|
||||||
|
};
|
||||||
|
return this.setState({ selectedCategoryId: null, message: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { categories } = this.state;
|
||||||
|
const cards = categories.map(category => {
|
||||||
|
return (
|
||||||
|
<CategoryCard
|
||||||
|
key={category.pk}
|
||||||
|
category={category}
|
||||||
|
showDialog={this.selectCategory}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedCategory = categories.find(category => {
|
||||||
|
return category.pk === this.state.selectedCategoryId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageHeader = (
|
||||||
|
<>
|
||||||
|
<h1 className="h1">Categories</h1>
|
||||||
|
<a className="link button button--confirm" href="/categories/create/">
|
||||||
|
Create category
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{this.state.message && <Messages messages={[this.state.message]} />}
|
||||||
|
<Card header={pageHeader} />
|
||||||
|
{cards}
|
||||||
|
{selectedCategory && (
|
||||||
|
<CategoryModal
|
||||||
|
category={selectedCategory}
|
||||||
|
handleCancel={this.deselectCategory}
|
||||||
|
handleDelete={this.deleteCategory}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
@ -0,0 +1,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;
|
||||||
|
|
@ -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;
|
||||||
12
src/newsreader/js/pages/categories/index.js
Normal file
12
src/newsreader/js/pages/categories/index.js
Normal 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]
|
||||||
|
);
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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() {
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
37
src/newsreader/news/core/forms.py
Normal file
37
src/newsreader/news/core/forms.py
Normal 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")
|
||||||
|
|
@ -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")}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
38
src/newsreader/news/core/templates/core/categories.html
Normal file
38
src/newsreader/news/core/templates/core/categories.html
Normal 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 %}
|
||||||
18
src/newsreader/news/core/templates/core/category-create.html
Normal file
18
src/newsreader/news/core/templates/core/category-create.html
Normal 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 %}
|
||||||
20
src/newsreader/news/core/templates/core/category-update.html
Normal file
20
src/newsreader/news/core/templates/core/category-update.html
Normal 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 %}
|
||||||
54
src/newsreader/news/core/templates/core/category.html
Normal file
54
src/newsreader/news/core/templates/core/category.html
Normal 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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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])
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
36
src/newsreader/scss/components/card/_card.scss
Normal file
36
src/newsreader/scss/components/card/_card.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/newsreader/scss/components/card/index.scss
Normal file
1
src/newsreader/scss/components/card/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "card";
|
||||||
5
src/newsreader/scss/components/content/_content.scss
Normal file
5
src/newsreader/scss/components/content/_content.scss
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
1
src/newsreader/scss/components/content/index.scss
Normal file
1
src/newsreader/scss/components/content/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "content";
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
%error {
|
|
||||||
background-color: $error-red;
|
|
||||||
color: $white;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
.errorlist {
|
|
||||||
@extend %error;
|
|
||||||
}
|
|
||||||
3
src/newsreader/scss/components/errorlist/;
Normal file
3
src/newsreader/scss/components/errorlist/;
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
.errorlist {
|
||||||
|
|
||||||
|
}
|
||||||
20
src/newsreader/scss/components/errorlist/_errorlist.scss
Normal file
20
src/newsreader/scss/components/errorlist/_errorlist.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,2 +1 @@
|
||||||
@import "error";
|
|
||||||
@import "errorlist";
|
@import "errorlist";
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
16
src/newsreader/scss/components/list/_list.scss
Normal file
16
src/newsreader/scss/components/list/_list.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/newsreader/scss/components/list/index.scss
Normal file
1
src/newsreader/scss/components/list/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "list";
|
||||||
16
src/newsreader/scss/components/messages/_messages.scss
Normal file
16
src/newsreader/scss/components/messages/_messages.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/newsreader/scss/components/messages/index.scss
Normal file
1
src/newsreader/scss/components/messages/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "messages";
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
.modal {
|
.modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
||||||
|
|
@ -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%;
|
||||||
|
|
||||||
|
|
|
||||||
6
src/newsreader/scss/components/section/_section.scss
Normal file
6
src/newsreader/scss/components/section/_section.scss
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
1
src/newsreader/scss/components/section/index.scss
Normal file
1
src/newsreader/scss/components/section/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "section";
|
||||||
46
src/newsreader/scss/elements/button/_button.scss
Normal file
46
src/newsreader/scss/elements/button/_button.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/newsreader/scss/elements/h1/_h1.scss
Normal file
3
src/newsreader/scss/elements/h1/_h1.scss
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
.h1 {
|
||||||
|
font-family: $header-font;
|
||||||
|
}
|
||||||
1
src/newsreader/scss/elements/h1/index.scss
Normal file
1
src/newsreader/scss/elements/h1/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "h1";
|
||||||
3
src/newsreader/scss/elements/h2/_h2.scss
Normal file
3
src/newsreader/scss/elements/h2/_h2.scss
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
.h2 {
|
||||||
|
font-family: $header-font;
|
||||||
|
}
|
||||||
1
src/newsreader/scss/elements/h2/index.scss
Normal file
1
src/newsreader/scss/elements/h2/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "h2";
|
||||||
3
src/newsreader/scss/elements/h3/_h3.scss
Normal file
3
src/newsreader/scss/elements/h3/_h3.scss
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
.h3 {
|
||||||
|
font-family: $header-font;
|
||||||
|
}
|
||||||
1
src/newsreader/scss/elements/h3/index.scss
Normal file
1
src/newsreader/scss/elements/h3/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "h3";
|
||||||
5
src/newsreader/scss/elements/help-text/_help-text.scss
Normal file
5
src/newsreader/scss/elements/help-text/_help-text.scss
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.help-text {
|
||||||
|
@extends .small;
|
||||||
|
|
||||||
|
padding: 5px 15px;
|
||||||
|
}
|
||||||
1
src/newsreader/scss/elements/help-text/index.scss
Normal file
1
src/newsreader/scss/elements/help-text/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "help-text";
|
||||||
|
|
@ -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";
|
||||||
10
src/newsreader/scss/elements/input/_input.scss
Normal file
10
src/newsreader/scss/elements/input/_input.scss
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
.input {
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
border: 1px $border-gray solid;
|
||||||
|
border-radius: 2px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border: 1px $focus-blue solid;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/newsreader/scss/elements/input/index.scss
Normal file
1
src/newsreader/scss/elements/input/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "input";
|
||||||
3
src/newsreader/scss/elements/label/_label.scss
Normal file
3
src/newsreader/scss/elements/label/_label.scss
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
.label {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
1
src/newsreader/scss/elements/label/index.scss
Normal file
1
src/newsreader/scss/elements/label/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "label";
|
||||||
7
src/newsreader/scss/elements/link/_link.scss
Normal file
7
src/newsreader/scss/elements/link/_link.scss
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
.link {
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/newsreader/scss/elements/link/index.scss
Normal file
1
src/newsreader/scss/elements/link/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "link";
|
||||||
3
src/newsreader/scss/elements/small/_small.scss
Normal file
3
src/newsreader/scss/elements/small/_small.scss
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
.small {
|
||||||
|
color: $nickel;
|
||||||
|
}
|
||||||
1
src/newsreader/scss/elements/small/index.scss
Normal file
1
src/newsreader/scss/elements/small/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "small";
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
.card {
|
||||||
|
&__footer > *:last-child {
|
||||||
|
margin: 0 0 0 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
@import "card";
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
@import "category-modal";
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
@import "card/index";
|
||||||
|
@import "category-modal/index";
|
||||||
0
src/newsreader/scss/pages/categories/elements/index.scss
Normal file
0
src/newsreader/scss/pages/categories/elements/index.scss
Normal file
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue