Refactor endpoint tests

Replace force_login calls with login call from client class in setUp
This commit is contained in:
sonny 2019-10-28 21:35:19 +01:00
parent 61702e720a
commit 858f84aaad
132 changed files with 5158 additions and 661 deletions

View file

@ -1,3 +1,11 @@
{
"presets": ["@babel/preset-env"]
"presets": ["@babel/preset-env"],
"plugins": [
"@babel/plugin-transform-runtime",
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-transform-react-jsx",
"@babel/plugin-syntax-function-bind",
"@babel/plugin-proposal-function-bind",
["@babel/plugin-proposal-class-properties", {loose: true}],
]
}

1
.gitignore vendored
View file

@ -154,6 +154,7 @@ dmypy.json
# Translations
# Django stuff:
src/newsreader/fixtures/local
# Flask stuff:

View file

@ -1,13 +1,57 @@
services:
- postgres:9.6
stages:
- build
- test
- lint
variables:
POSTGRES_DB: newsreader
POSTGRES_USER: newsreader
javascript build:
image: node:12
stage: build
cache:
key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG"
paths:
- node_modules/
before_script:
- npm install --dev
script:
- npx gulp
python tests:
services:
- postgres:11
image: python:3.7.4-slim-stretch
stage: test
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
DJANGO_SETTINGS_MODULE: "newsreader.conf.gitlab"
POSTGRES_DB: newsreader
POSTGRES_USER: newsreader
cache:
key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG"
paths:
- .cache/pip
- env/
before_script:
- python3 -m venv env
- source env/bin/activate
- pip install -r requirements/gitlab.txt
script:
- python src/manage.py test newsreader
javascript linting:
image: node:12
stage: lint
cache:
key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG"
paths:
- node_modules/
before_script:
- npm install --dev
script:
- npm run lint
python linting:
image: python:3.7.4-slim-stretch
stage: lint
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
DJANGO_SETTINGS_MODULE: "newsreader.conf.gitlab"
@ -21,6 +65,6 @@ python tests:
- source env/bin/activate
- pip install -r requirements/gitlab.txt
script:
- python src/manage.py test newsreader
- isort -rc src/ --check-only
- black -l 90 --check src/
- autoflake -rc src/

10
.prettierrc.json Normal file
View file

@ -0,0 +1,10 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 90,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "avoid"
}

View file

@ -1,14 +1,18 @@
FROM python:3.7-buster
# Run project binaries from the user's local bin folder
ENV PATH=/home/newsreader/.local/bin:$PATH
# Set the default shell
RUN useradd -ms /bin/bash newsreader
RUN mkdir /app
WORKDIR /app
RUN chown newsreader:newsreader /app
USER newsreader
# Use a seperate layer for the project requirements
COPY ./requirements /app/requirements
RUN pip install -r requirements/dev.txt
COPY requirements /app/requirements
RUN pip install --user -r requirements/dev.txt
COPY . /app/
# Set the default shell & add a home dir
RUN useradd -ms /bin/bash newsreader
USER newsreader

View file

@ -4,11 +4,26 @@ services:
db:
# See https://hub.docker.com/_/postgres
image: postgres
container_name: postgres
environment:
- POSTGRES_USER=newsreader
- POSTGRES_DB=newsreader
rabbitmq:
image: rabbitmq:3.7
container_name: rabbitmq
celery:
build: .
container_name: celery
command: celery -A newsreader worker --beat --scheduler django --workdir=/app/src/
environment:
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker
volumes:
- .:/app
depends_on:
- rabbitmq
web:
build: .
container_name: web
command: src/entrypoint.sh
environment:
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker
@ -18,14 +33,3 @@ services:
- '8000:8000'
depends_on:
- db
rabbitmq:
image: rabbitmq:3.7
celery:
build: .
command: celery -A newsreader worker --beat --scheduler django --loglevel=info --workdir=/app/src/
environment:
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker
volumes:
- .:/app
depends_on:
- rabbitmq

28
gulp/babel.js Normal file
View file

@ -0,0 +1,28 @@
import path from 'path';
import { dest } from 'gulp';
import babelify from 'babelify';
import browserify from 'browserify';
import source from 'vinyl-source-stream';
import buffer from 'vinyl-buffer';
import concat from 'gulp-concat';
const PROJECT_DIR = path.join('src', 'newsreader');
const STATIC_DIR = path.join(PROJECT_DIR, 'js');
const CORE_DIR = path.join(PROJECT_DIR, 'news', 'core', 'static');
const babelTask = () => {
const config = browserify({
entries: `${STATIC_DIR}/homepage/index.js`,
debug: true,
}).transform(babelify);
return config
.bundle()
.pipe(source('index.js'))
.pipe(buffer())
.pipe(concat('homepage.js'))
.pipe(dest(`${CORE_DIR}/core/dist/js/`));
};
export default babelTask;

View file

@ -1,17 +1,25 @@
import { src, dest } from "gulp";
import { src, dest } from 'gulp';
import concat from "gulp-concat";
import path from "path";
import sass from "gulp-sass";
import concat from 'gulp-concat';
import path from 'path';
import sass from 'gulp-sass';
const PROJECT_DIR = path.join("src", "newsreader");
const STATIC_DIR = path.join(PROJECT_DIR, "static");
const PROJECT_DIR = path.join('src', 'newsreader');
const STATIC_DIR = path.join(PROJECT_DIR, 'scss');
export const ACCOUNTS_DIR = path.join(PROJECT_DIR, "accounts", "static");
export const ACCOUNTS_DIR = path.join(PROJECT_DIR, 'accounts', 'static');
export const CORE_DIR = path.join(PROJECT_DIR, 'news', 'core', 'static');
export default function accountsTask(){
return src(`${STATIC_DIR}/src/scss/accounts/index.scss`)
.pipe(sass().on("error", sass.logError))
.pipe(concat("accounts.css"))
export const accountsTask = () => {
return src(`${STATIC_DIR}/accounts/index.scss`)
.pipe(sass().on('error', sass.logError))
.pipe(concat('accounts.css'))
.pipe(dest(`${ACCOUNTS_DIR}/accounts/dist/css`));
};
export const coreTask = () => {
return src(`${STATIC_DIR}/homepage/index.scss`)
.pipe(sass().on('error', sass.logError))
.pipe(concat('core.css'))
.pipe(dest(`${CORE_DIR}/core/dist/css`));
};

View file

@ -1,22 +1,27 @@
import { series, watch as _watch } from 'gulp';
import { parallel, series, watch as _watch } from 'gulp';
import path from "path";
import del from "del";
import path from 'path';
import del from 'del';
import buildSass, { ACCOUNTS_DIR } from "./gulp/sass";
import { ACCOUNTS_DIR, CORE_DIR, accountsTask, coreTask } from './gulp/sass';
import babelTask from './gulp/babel';
const STATIC_DIR = path.join("src", "newsreader", "static");
const PROJECT_DIR = path.join('src', 'newsreader');
const sassTasks = [accountsTask, coreTask];
function clean(){
const clean = () => {
return del([
`${ACCOUNTS_DIR}/accounts/dist/css/*`,
`${CORE_DIR}/core/dist/css/*`,
`${CORE_DIR}/core/dist/js/*`,
]);
};
export function watch(){
_watch(`${STATIC_DIR}/src/scss/**/*.scss`, (done) => {
series(clean, buildSass)(done);
export const watch = () => {
return _watch([`${PROJECT_DIR}/scss/**/*.scss`, `${PROJECT_DIR}/js/**/*.js`], done => {
series(clean, ...sassTasks, babelTask)(done);
});
};
export default series(clean, buildSass);
export default series(clean, ...sassTasks, babelTask);

1367
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,8 +4,8 @@
"description": "Application for viewing RSS feeds",
"main": "index.js",
"scripts": {
"lint": "prettier \"src/newsreader/**/*.js\" --check",
"format": "prettier \"src/newsreader/**/*.js\" --write"
"lint": "prettier \"src/newsreader/js/**/*.js\" --check",
"format": "prettier \"src/newsreader/js/**/*.js\" --write"
},
"repository": {
"type": "git",
@ -13,21 +13,27 @@
},
"author": "Sonny",
"license": "GPL-3.0-or-later",
"prettier": {
"semi": true,
"trailingComma": "es5",
"singleQuote": false,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": false,
"arrowParens": "always"
"dependencies": {
"js-cookie": "^2.2.1",
"lodash": "^4.17.15",
"react-redux": "^7.1.0",
"redux": "^4.0.4",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0"
},
"dependencies": {},
"devDependencies": {
"@babel/core": "^7.5.4",
"@babel/plugin-proposal-class-properties": "^7.5.5",
"@babel/plugin-proposal-function-bind": "^7.2.0",
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
"@babel/plugin-syntax-function-bind": "^7.2.0",
"@babel/plugin-transform-react-jsx": "^7.3.0",
"@babel/plugin-transform-runtime": "^7.5.5",
"@babel/preset-env": "^7.5.4",
"@babel/register": "^7.4.4",
"@babel/runtime": "^7.5.5",
"babelify": "^10.0.0",
"browserify": "^16.3.0",
"del": "^5.0.0",
"gulp": "^4.0.2",
"gulp-babel": "^8.0.0-beta.2",
@ -35,6 +41,10 @@
"gulp-concat": "^2.6.1",
"gulp-sass": "^4.0.2",
"node-sass": "^4.12.0",
"prettier": "^1.18.2"
"prettier": "^1.18.2",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"vinyl-buffer": "^1.0.1",
"vinyl-source-stream": "^2.0.0"
}
}

View file

@ -6,7 +6,7 @@ chardet==3.0.4
Django==2.2
django-celery-beat==1.5.0
djangorestframework==3.9.4
django-rest-swagger-2.2.0
django-rest-swagger==2.2.0
lxml==4.3.4
feedparser==5.2.1
idna==2.8

View file

@ -1,6 +1,5 @@
-r base.txt
-r testing.txt
factory-boy==2.12.0
freezegun==0.3.12
black==19.3b0
isort==4.3.20
autoflake==1.3

View file

@ -3,13 +3,11 @@ from django.contrib.auth.views import LogoutView as DjangoLogoutView
from django.urls import reverse_lazy
# TODO redirect to homepage when logged in
class LoginView(DjangoLoginView):
template_name = "accounts/login.html"
def get_success_url(self):
# TODO redirect to homepage
return reverse_lazy("admin:index")
return reverse_lazy("index")
class LogoutView(DjangoLogoutView):

View file

@ -112,6 +112,8 @@ USE_TZ = True
# https://docs.djangoproject.com/en/2.2/howto/static-files/
STATIC_URL = "/static/"
STATICFILES_DIRS = ["src/newsreader/static/icons"]
# Third party settings
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
@ -123,3 +125,9 @@ REST_FRAMEWORK = {
),
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
}
SWAGGER_SETTINGS = {
"LOGIN_URL": "rest_framework:login",
"LOGOUT_URL": "rest_framework:logout",
"DOC_EXPANSION": "list",
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,13 @@
import React from 'react';
const LoadingIndicator = props => {
return (
<div className="loading-indicator">
<div />
<div />
<div />
</div>
);
};
export default LoadingIndicator;

View file

@ -0,0 +1,63 @@
import React from 'react';
import { connect } from 'react-redux';
import { isEqual } from 'lodash';
import { fetchCategories } from './actions/categories';
import Sidebar from './components/sidebar/Sidebar.js';
import FeedList from './components/feedlist/FeedList.js';
import PostModal from './components/PostModal.js';
class App extends React.Component {
componentDidMount() {
this.props.fetchCategories();
}
render() {
return (
<>
<main className="main">
<Sidebar />
<FeedList />
{!isEqual(this.props.post, {}) && (
<PostModal
post={this.props.post}
rule={this.props.rule}
category={this.props.category}
/>
)}
</main>
</>
);
}
}
const mapStateToProps = state => {
if (!isEqual(state.selected.post, {})) {
const ruleId = state.selected.post.rule;
const rule = state.rules.items[ruleId];
const category = state.categories.items[rule.category];
return {
post: state.selected.post,
rule,
category,
};
}
return {
post: state.selected.post,
};
};
const mapDispatchToProps = dispatch => ({
fetchCategories: () => dispatch(fetchCategories()),
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(App);

View file

@ -0,0 +1,86 @@
import { requestRules, receiveRules, fetchRulesByCategory } from './rules.js';
export const SELECT_CATEGORY = 'SELECT_CATEGORY';
export const RECEIVE_CATEGORY = 'RECEIVE_CATEGORY';
export const RECEIVE_CATEGORIES = 'RECEIVE_CATEGORIES';
export const REQUEST_CATEGORY = 'REQUEST_CATEGORY';
export const REQUEST_CATEGORIES = 'REQUEST_CATEGORIES';
export const selectCategory = category => ({
type: SELECT_CATEGORY,
item: category,
});
export const receiveCategory = category => ({
type: RECEIVE_CATEGORY,
category,
});
export const receiveCategories = json => ({
type: RECEIVE_CATEGORIES,
categories: json,
});
export const requestCategory = () => ({ type: REQUEST_CATEGORY });
export const requestCategories = () => ({ type: REQUEST_CATEGORIES });
export const fetchCategories = () => {
return dispatch => {
dispatch(requestCategories());
return fetch('/api/categories/')
.then(response => response.json())
.then(json => {
const categories = {};
json.forEach(category => {
categories[category.id] = { ...category };
});
dispatch(receiveCategories(categories));
return json;
})
.then(json => {
const promises = json.map(category => {
return fetch(`/api/categories/${category.id}/rules/`);
});
dispatch(requestRules());
return Promise.all(promises);
})
.then(responses => {
return Promise.all(responses.map(response => response.json()));
})
.then(responseData => {
let rules = {};
responseData.forEach(json => {
const data = Object.values(json);
data.forEach(item => {
rules = { ...rules, [item.id]: item };
});
});
setTimeout(dispatch, 500, receiveRules(rules));
});
};
};
export const fetchCategory = category => {
return dispatch => {
dispatch(requestCategory());
return fetch(`/api/categories/${category.id}`)
.then(response => response.json())
.then(json => {
dispatch(receiveCategory({ ...json }));
if (category.unread === 0) {
dispatch(fetchRulesByCategory(category));
}
});
};
};

View file

@ -0,0 +1,119 @@
export const SELECT_POST = 'SELECT_POST';
export const UNSELECT_POST = 'UNSELECT_POST';
export const RECEIVE_POSTS = 'RECEIVE_POSTS';
export const RECEIVE_POST = 'RECEIVE_POST';
export const REQUEST_POSTS = 'REQUEST_POSTS';
export const MARK_POST_READ = 'MARK_POST_READ';
export const selectPost = post => ({
type: SELECT_POST,
post,
});
export const unSelectPost = () => ({
type: UNSELECT_POST,
});
export const postRead = (post, rule, category) => ({
type: MARK_POST_READ,
category: category,
post: post,
rule: rule,
});
export const markPostRead = (post, token) => {
return (dispatch, getState) => {
const { rules } = getState();
const { categories } = getState();
const url = `/api/posts/${post.id}/`;
const options = {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': token,
},
body: JSON.stringify({ read: true }),
};
const rule = rules.items[post.rule];
const category = categories.items[rule.category];
return fetch(url, options)
.then(response => response.json())
.then(updatedPost => {
const updatedRule = { ...rule, unread: rule.unread - 1 };
const updatedCategory = { ...category, unread: category.unread - 1 };
dispatch(receivePost({ ...updatedPost }));
dispatch(postRead({ ...updatedPost }, updatedRule, updatedCategory));
});
};
};
export const receivePosts = json => ({
type: RECEIVE_POSTS,
posts: json.items,
next: json.next,
});
export const receivePost = post => ({
type: RECEIVE_POST,
post,
});
export const requestPosts = () => ({ type: REQUEST_POSTS });
export const fetchPostsByCategory = (category, page = false) => {
return dispatch => {
dispatch(requestPosts());
const url = page ? page : `/api/categories/${category.id}/posts/?read=false`;
return fetch(url)
.then(response => response.json())
.then(json => {
const posts = {};
json.results.forEach(post => {
posts[post.id] = post;
});
dispatch(receivePosts({ items: posts, next: json.next }));
})
.catch(error => {
if (error instanceof TypeError) {
console.log(`Unable to parse posts from request: ${error}`);
}
dispatch(receivePosts({ items: {}, next: null }));
});
};
};
export const fetchPostsByRule = (rule, page = false) => {
return dispatch => {
dispatch(requestPosts());
const url = page ? page : `/api/rules/${rule.id}/posts/?read=false`;
return fetch(url)
.then(response => response.json())
.then(json => {
const posts = {};
json.results.forEach(post => {
posts[post.id] = post;
});
dispatch(receivePosts({ items: posts, next: json.next }));
})
.catch(error => {
if (error instanceof TypeError) {
console.log(`Unable to parse posts from request: ${error}`);
}
dispatch(receivePosts({ items: {}, next: null }));
});
};
};

View file

@ -0,0 +1,68 @@
import { fetchCategory } from './categories.js';
export const SELECT_RULE = 'SELECT_RULE';
export const SELECT_RULES = 'SELECT_RULES';
export const RECEIVE_RULE = 'RECEIVE_RULE';
export const RECEIVE_RULES = 'RECEIVE_RULES';
export const REQUEST_RULE = 'REQUEST_RULE';
export const REQUEST_RULES = 'REQUEST_RULES';
export const selectRule = rule => ({
type: SELECT_RULE,
item: rule,
});
export const requestRule = () => ({ type: REQUEST_RULE });
export const requestRules = () => ({ type: REQUEST_RULES });
export const receiveRule = rule => ({
type: RECEIVE_RULE,
rule,
});
export const receiveRules = rules => ({
type: RECEIVE_RULES,
rules,
});
export const fetchRule = rule => {
return (dispatch, getState) => {
dispatch(requestRule());
const { categories } = getState();
const category = categories['items'][rule.category];
return fetch(`/api/rules/${rule.id}`)
.then(response => response.json())
.then(receivedRule => {
dispatch(receiveRule({ ...receivedRule }));
// fetch & update category info when the rule is read
if (rule.unread === 0) {
dispatch(fetchCategory({ ...category }));
}
});
};
};
export const fetchRulesByCategory = category => {
return (dispatch, getState) => {
dispatch(requestRules());
return fetch(`/api/categories/${category.id}/rules/`)
.then(response => response.json())
.then(responseData => {
dispatch(receiveRules());
const rules = {};
responseData.forEach(rule => {
rules[rule.id] = { ...rule };
});
setTimeout(dispatch, 500, receiveRules(rules));
});
};
};

View file

@ -0,0 +1,65 @@
import { receiveCategory, requestCategory } from './categories.js';
import { receiveRule, requestRule } from './rules.js';
export const MARK_SECTION_READ = 'MARK_SECTION_READ';
export const markSectionRead = (category, rule = {}) => ({
category: category,
rule: rule,
type: MARK_SECTION_READ,
});
const markCategoryRead = (category, token) => {
return dispatch => {
dispatch(requestCategory(category));
const url = `/api/categories/${category.id}/read/`;
const options = {
method: 'POST',
headers: {
'X-CSRFToken': token,
},
};
return fetch(url, options)
.then(response => response.json())
.then(updatedCategory => {
dispatch(receiveCategory({ ...updatedCategory }));
dispatch(markSectionRead({ ...category, ...updatedCategory }));
});
};
};
const markRuleRead = (rule, token) => {
return (dispatch, getState) => {
const { categories } = getState();
const category = categories.items[rule.category];
dispatch(requestRule(rule));
const url = `/api/rules/${rule.id}/read/`;
const options = {
method: 'POST',
headers: {
'X-CSRFToken': token,
},
};
return fetch(url, options)
.then(response => response.json())
.then(updatedRule => {
dispatch(receiveRule({ ...updatedRule }));
// Use the old rule to decrement category with old unread count
dispatch(markSectionRead({ ...category }, { ...rule }));
});
};
};
export const markRead = (selected, token) => {
if ('category' in selected) {
return markRuleRead(selected, token);
} else {
return markCategoryRead(selected, token);
}
};

View file

@ -0,0 +1,84 @@
import React from 'react';
import { connect } from 'react-redux';
import Cookies from 'js-cookie';
import { unSelectPost, markPostRead } from '../actions/posts.js';
import { formatDatetime } from '../../utils.js';
class PostModal extends React.Component {
readTimer = null;
componentDidMount() {
const post = { ...this.props.post };
const markPostRead = this.props.markPostRead;
const token = Cookies.get('csrftoken');
if (!post.read) {
this.readTimer = setTimeout(markPostRead, 30000, post, token);
}
}
componentWillUnmount() {
if (this.readTimer) {
clearTimeout(this.readTimer);
}
this.readTimer = null;
}
render() {
const post = this.props.post;
const publicationDate = formatDatetime(post.publication_date);
const titleClassName = post.read ? 'post__title post__title--read' : 'post__title';
return (
<div className="modal">
<div className="post">
<button
className="button post__close-button"
onClick={() => this.props.unSelectPost()}
>
Close{' '}
<span>
<img src="/static/times.svg" />
</span>
</button>
<div className="post__header">
<h1 className={titleClassName}>
{`${post.title} `}
<a
className="post__link"
href={post.url}
target="_blank"
rel="noopener noreferrer"
>
<img src="/static/link.svg" width="15" height="15" />
</a>
</h1>
<span className="post__date">{publicationDate}</span>
</div>
<aside className="post__meta-info">
{this.props.category && (
<h5 title={this.props.category.name}>{this.props.category.name}</h5>
)}
<h5 title={this.props.rule.name}>{this.props.rule.name}</h5>
</aside>
{/* HTML is sanitized by the collectors */}
<div className="post__body" dangerouslySetInnerHTML={{ __html: post.body }} />
</div>
</div>
);
}
}
const mapDispatchToProps = dispatch => ({
unSelectPost: () => dispatch(unSelectPost()),
markPostRead: (post, token) => dispatch(markPostRead(post, token)),
});
export default connect(
null,
mapDispatchToProps
)(PostModal);

View file

@ -0,0 +1,102 @@
import React from 'react';
import { connect } from 'react-redux';
import { isEqual } from 'lodash';
import { fetchPostsByRule, fetchPostsByCategory } from '../../actions/posts.js';
import { filterPosts } from './filters.js';
import LoadingIndicator from '../../../components/LoadingIndicator.js';
import RuleItem from './RuleItem.js';
class FeedList extends React.Component {
checkScrollHeight = ::this.checkScrollHeight;
componentDidMount() {
window.addEventListener('scroll', this.checkScrollHeight);
}
componentWillUnmount() {
window.removeEventListener('scroll', this.checkScrollHeight);
}
checkScrollHeight(e) {
const currentHeight = window.scrollY + window.innerHeight;
const totalHeight = document.body.offsetHeight;
const currentPercentage = (currentHeight / totalHeight) * 100;
if (this.props.next && !this.props.lastReached) {
if (currentPercentage > 60 && !this.props.isFetching) {
this.paginate();
}
}
}
paginate() {
if ('category' in this.props.selected) {
return this.props.fetchPostsByRule(this.props.selected, this.props.next);
} else {
return this.props.fetchPostsByCategory(this.props.selected, this.props.next);
}
}
render() {
const ruleItems = this.props.posts.map((item, index) => {
return <RuleItem key={index} posts={item.posts} rule={item.rule} />;
});
if (ruleItems.length > 0) {
return (
<div className="post-block">
{ruleItems}
{this.props.isFetching && <LoadingIndicator />}
</div>
);
} else if (isEqual(this.props.selected, {})) {
return (
<div className="post-message">
<div className="post-message__block">
<img src="/static/arrow-left.svg" height="28" width="28" />
<p className="post-message__text">
Select a category or rule to show its unread posts
</p>
</div>
</div>
);
} else if (ruleItems.length === 0) {
return (
<div className="post-message">
<div className="post-message__block">
<p className="post-message__text">
No unread posts from the selected section at this moment, try again later
</p>
</div>
</div>
);
} else {
return (
<div className="post-block">{this.props.isFetching && <LoadingIndicator />}</div>
);
}
}
}
const mapStateToProps = state => ({
isFetching: state.posts.isFetching,
posts: filterPosts(state),
next: state.selected.next,
lastReached: state.selected.lastReached,
selected: state.selected.item,
});
const mapDispatchToProps = dispatch => ({
fetchPostsByRule: (rule, page = false) => dispatch(fetchPostsByRule(rule, page)),
fetchPostsByCategory: (category, page = false) => {
dispatch(fetchPostsByCategory(category, page));
},
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(FeedList);

View file

@ -0,0 +1,50 @@
import React from 'react';
import { connect } from 'react-redux';
import { selectPost } from '../../actions/posts.js';
import { formatDatetime } from '../../../utils.js';
class PostItem extends React.Component {
render() {
const post = this.props.post;
const publicationDate = formatDatetime(post.publication_date);
const titleClassName = post.read
? 'posts-header__title posts-header__title--read'
: 'posts-header__title';
return (
<li
className="posts__item"
onClick={() => {
this.props.selectPost(post);
}}
>
<div className="posts-header">
<h5 className={titleClassName} title={post.title}>
{post.title}
</h5>
<a
className="posts-header__link"
href={post.url}
target="_blank"
rel="noopener noreferrer"
>
<img src="/static/link.svg" width="15" height="15" />
</a>
</div>
<span title={publicationDate}>{publicationDate}</span>
</li>
);
}
}
const mapDispatchToProps = dispatch => ({
selectPost: post => dispatch(selectPost(post)),
});
export default connect(
null,
mapDispatchToProps
)(PostItem);

View file

@ -0,0 +1,25 @@
import React from 'react';
import PostItem from './PostItem.js';
class RuleItem extends React.Component {
render() {
const posts = Object.values(this.props.posts).sort((firstEl, secondEl) => {
return new Date(secondEl.publication_date) - new Date(firstEl.publication_date);
});
const postItems = posts.map(post => {
return <PostItem key={post.id} post={post} />;
});
return (
<section className="posts-section">
<h3 className="posts-section__name">{this.props.rule.name}</h3>
{/* TODO: Add empty posts message */}
<ul className="posts">{postItems}</ul>
</section>
);
}
}
export default RuleItem;

View file

@ -0,0 +1,44 @@
const isEmpty = (object = {}) => {
return Object.keys(object).length === 0;
};
export const filterPostsByRule = (rule = {}, posts = []) => {
const filteredPosts = posts.filter(post => {
return post.rule === rule.id && !post.read;
});
return filteredPosts.length > 0 ? [{ rule, posts: filteredPosts }] : [];
};
export const filterPostsByCategory = (category = {}, rules = [], posts = []) => {
const filteredRules = rules.filter(rule => {
return rule.category === category.id;
});
const filteredData = filteredRules.map(rule => {
const filteredPosts = posts.filter(post => {
return post.rule === rule.id && !post.read;
});
return {
rule: { ...rule },
posts: filteredPosts,
};
});
return filteredData.filter(rule => rule.posts.length > 0);
};
export const filterPosts = state => {
const posts = Object.values({ ...state.posts.items });
if (!isEmpty(state.selected.item) && !('category' in state.selected.item)) {
const rules = Object.values({ ...state.rules.items });
return filterPostsByCategory({ ...state.selected.item }, rules, posts);
} else if ('category' in state.selected.item) {
return filterPostsByRule({ ...state.selected.item }, posts);
}
return [];
};

View file

@ -0,0 +1,68 @@
import React from 'react';
import { connect } from 'react-redux';
import { isEqual } from 'lodash';
import { selectCategory, fetchCategory } from '../../actions/categories.js';
import { fetchPostsByCategory } from '../../actions/posts.js';
import RuleItem from './RuleItem.js';
class CategoryItem extends React.Component {
state = { open: false };
toggleRules() {
this.setState({ open: !this.state.open });
}
handleSelect() {
const category = this.props.category;
this.props.selectCategory(category);
this.props.fetchPostsByCategory(category);
if (category.unread === 0) {
this.props.fetchCategory(category);
}
}
render() {
const imageSrc = this.state.open
? '/static/chevron-down.svg'
: '/static/chevron-right.svg';
const selected = isEqual(this.props.category, this.props.selected);
const className = selected ? 'category category--selected' : 'category';
const ruleItems = this.props.rules.map(rule => {
return <RuleItem key={rule.id} rule={rule} selected={this.props.selectedRule} />;
});
return (
<li className="categories__item">
<div className={className}>
<div className="category__menu" onClick={() => this.toggleRules()}>
<img src={imageSrc} width="20" heigth="20" />
</div>
<div className="category__info" onClick={() => this.handleSelect()}>
<h4>{this.props.category.name}</h4>
<span className="badge">{this.props.category.unread}</span>
</div>
</div>
{ruleItems.length > 0 && this.state.open && (
<ul className="rules">{ruleItems}</ul>
)}
</li>
);
}
}
const mapDispatchToProps = dispatch => ({
selectCategory: category => dispatch(selectCategory(category)),
fetchPostsByCategory: category => dispatch(fetchPostsByCategory(category)),
fetchCategory: category => dispatch(fetchCategory(category)),
});
export default connect(
null,
mapDispatchToProps
)(CategoryItem);

View file

@ -0,0 +1,36 @@
import React from 'react';
import { connect } from 'react-redux';
import Cookies from 'js-cookie';
import { markRead } from '../../actions/selected.js';
class ReadButton extends React.Component {
markSelectedRead = ::this.markSelectedRead;
markSelectedRead() {
const token = Cookies.get('csrftoken');
if (this.props.selected.unread > 0) {
this.props.markRead({ ...this.props.selected }, token);
}
}
render() {
return (
<button className="button read-button" onClick={this.markSelectedRead}>
Mark selected read
</button>
);
}
}
const mapDispatchToProps = dispatch => ({
markRead: (selected, token) => dispatch(markRead(selected, token)),
});
const mapStateToProps = state => ({ selected: state.selected.item });
export default connect(
mapStateToProps,
mapDispatchToProps
)(ReadButton);

View file

@ -0,0 +1,56 @@
import React from 'react';
import { connect } from 'react-redux';
import { isEqual } from 'lodash';
import { selectRule, fetchRule } from '../../actions/rules.js';
import { fetchPostsByRule } from '../../actions/posts.js';
class RuleItem extends React.Component {
handleSelect() {
const rule = { ...this.props.rule };
this.props.selectRule(rule);
this.props.fetchPostsByRule(rule);
if (rule.unread === 0) {
this.props.fetchRule(rule);
}
}
render() {
const selected = isEqual(this.props.selected, this.props.rule);
const className = `rules__item ${selected ? 'rules__item--selected' : ''}`;
return (
<li className={className} onClick={() => this.handleSelect()}>
<div className="rule">
{this.props.rule.favicon && (
<span>
<img
className="icon"
width="15"
height="15"
src={this.props.rule.favicon}
/>
</span>
)}
<h5 className="rule__title" title={this.props.rule.name}>
{this.props.rule.name}
</h5>
</div>
<span className="badge">{this.props.rule.unread}</span>
</li>
);
}
}
const mapDispatchToProps = dispatch => ({
selectRule: rule => dispatch(selectRule(rule)),
fetchPostsByRule: rule => dispatch(fetchPostsByRule(rule)),
fetchRule: rule => dispatch(fetchRule(rule)),
});
export default connect(
null,
mapDispatchToProps
)(RuleItem);

View file

@ -0,0 +1,52 @@
import React from 'react';
import { connect } from 'react-redux';
import { isEqual } from 'lodash';
import { filterCategories, filterRules } from './filters.js';
import LoadingIndicator from '../../../components/LoadingIndicator.js';
import CategoryItem from './CategoryItem.js';
import ReadButton from './ReadButton.js';
// TODO: show empty category message
class Sidebar extends React.Component {
render() {
const items = this.props.categories.items.map(category => {
const rules = this.props.rules.items.filter(rule => {
return rule.category === category.id;
});
return (
<CategoryItem
key={category.id}
category={category}
rules={rules}
selected={this.props.selected.item}
selectedRule={this.props.selected.item}
/>
);
});
return (
<div className="sidebar">
<nav className="categories">
{(this.props.categories.isFetching || this.props.rules.isFetching) && (
<LoadingIndicator />
)}
<ul>{items}</ul>
</nav>
{!isEqual(this.props.selected.item, {}) && <ReadButton />}
</div>
);
}
}
const mapStateToProps = state => ({
categories: { ...state.categories, items: filterCategories(state.categories.items) },
rules: { ...state.rules, items: filterRules(state.rules.items) },
selected: state.selected,
});
export default connect(mapStateToProps)(Sidebar);

View file

@ -0,0 +1,7 @@
export const filterCategories = (categories = {}) => {
return Object.values({ ...categories });
};
export const filterRules = (rules = {}) => {
return Object.values({ ...rules });
};

View file

@ -0,0 +1,18 @@
import { createStore, applyMiddleware } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { createLogger } from 'redux-logger';
import rootReducer from './reducers/index.js';
const loggerMiddleware = createLogger();
const configureStore = preloadedState => {
return createStore(
rootReducer,
preloadedState,
applyMiddleware(thunkMiddleware, loggerMiddleware)
);
};
export default configureStore;

View file

@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import configureStore from './configureStore.js';
import App from './App.js';
const store = configureStore();
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementsByClassName('content')[0]
);

View file

@ -0,0 +1,116 @@
import { isEqual } from 'lodash';
import {
RECEIVE_CATEGORY,
RECEIVE_CATEGORIES,
REQUEST_CATEGORY,
REQUEST_CATEGORIES,
} from '../actions/categories.js';
import { RECEIVE_RULE, RECEIVE_RULES } from '../actions/rules.js';
import { MARK_POST_READ } from '../actions/posts.js';
import { MARK_SECTION_READ } from '../actions/selected.js';
const defaultState = { items: {}, isFetching: false };
export const categories = (state = { ...defaultState }, action) => {
switch (action.type) {
case RECEIVE_CATEGORY:
return {
...state,
items: {
...state.items,
[action.category.id]: { ...action.category, rules: {} },
},
isFetching: false,
};
case RECEIVE_CATEGORIES:
const receivedCategories = {};
Object.values({ ...action.categories }).forEach(category => {
receivedCategories[category.id] = {
...category,
rules: {},
};
});
return {
...state,
items: { ...state.items, ...receivedCategories },
isFetching: false,
};
case RECEIVE_RULE:
const category = { ...state.items[action.rule.category] };
category['rules'][action.rule.id] = { ...action.rule };
return {
...state,
items: {
...state.items,
[category.id]: { ...category },
},
};
case RECEIVE_RULES:
const relevantCategories = {};
Object.values({ ...action.rules }).forEach(rule => {
if (!(rule.category in relevantCategories)) {
const category = { ...state.items[rule.category] };
relevantCategories[rule.category] = {
...category,
rules: {
...category.rules,
[rule.id]: { ...rule },
},
};
} else {
relevantCategories[rule.category]['rules'][rule.id] = { ...rule };
}
});
return {
...state,
items: {
...state.items,
...relevantCategories,
},
};
case REQUEST_CATEGORIES:
case REQUEST_CATEGORY:
return {
...state,
isFetching: true,
};
case MARK_POST_READ:
return {
...state,
items: { ...state.items, [action.category.id]: { ...action.category } },
};
case MARK_SECTION_READ:
if (!isEqual(action.rule, {})) {
return {
...state,
items: {
...state.items,
[action.category.id]: {
...action.category,
unread: action.category.unread - action.rule.unread,
},
},
};
}
return {
...state,
items: {
...state.items,
[action.category.id]: { ...action.category, unread: 0 },
},
};
default:
return state;
}
};

View file

@ -0,0 +1,10 @@
import { combineReducers } from 'redux';
import { categories } from './categories.js';
import { rules } from './rules.js';
import { posts } from './posts.js';
import { selected } from './selected.js';
const rootReducer = combineReducers({ categories, rules, posts, selected });
export default rootReducer;

View file

@ -0,0 +1,67 @@
import { isEqual } from 'lodash';
import {
SELECT_POST,
RECEIVE_POST,
RECEIVE_POSTS,
REQUEST_POSTS,
} from '../actions/posts.js';
import { SELECT_CATEGORY } from '../actions/categories.js';
import { SELECT_RULE } from '../actions/rules.js';
import { MARK_SECTION_READ } from '../actions/selected.js';
const defaultState = { items: {}, isFetching: false };
export const posts = (state = { ...defaultState }, action) => {
switch (action.type) {
case RECEIVE_POSTS:
return {
...state,
type: RECEIVE_POSTS,
isFetching: false,
items: { ...state.items, ...action.posts },
};
case REQUEST_POSTS:
return {
...state,
type: REQUEST_POSTS,
isFetching: true,
};
case RECEIVE_POST:
const items = { ...state.items, [action.post.id]: { ...action.post } };
return {
...state,
items: items,
type: RECEIVE_POST,
};
case MARK_SECTION_READ:
const updatedPosts = {};
let relatedPosts = [];
if (!isEqual(action.rule, {})) {
relatedPosts = Object.values({ ...state.items }).filter(post => {
return post.rule === action.rule.id;
});
} else {
relatedPosts = Object.values({ ...state.items }).filter(post => {
return post.rule in { ...action.category.rules };
});
}
relatedPosts.forEach(post => {
updatedPosts[post.id] = { ...post, read: true };
});
return {
...state,
items: {
...state.items,
...updatedPosts,
},
};
default:
return state;
}
};

View file

@ -0,0 +1,65 @@
import { isEqual } from 'lodash';
import {
REQUEST_RULES,
REQUEST_RULE,
RECEIVE_RULES,
RECEIVE_RULE,
} from '../actions/rules.js';
import { MARK_POST_READ } from '../actions/posts.js';
import { MARK_SECTION_READ } from '../actions/selected.js';
const defaultState = { items: {}, isFetching: false };
export const rules = (state = { ...defaultState }, action) => {
switch (action.type) {
case REQUEST_RULE:
case REQUEST_RULES:
return {
...state,
isFetching: true,
};
case RECEIVE_RULES:
return {
...state,
items: { ...state.items, ...action.rules },
isFetching: false,
};
case RECEIVE_RULE:
return {
...state,
items: { ...state.items, [action.rule.id]: { ...action.rule } },
isFetching: false,
};
case MARK_POST_READ:
case MARK_SECTION_READ:
if (!isEqual(action.rule, {})) {
return {
...state,
items: {
...state.items,
[action.rule.id]: {
...action.rule,
unread: 0,
},
},
};
}
const updatedRules = {};
Object.values({ ...state.items }).forEach(rule => {
if (rule.category === action.category.id) {
updatedRules[rule.id] = {
...rule,
unread: 0,
};
} else {
updatedRules[rule.id] = { ...rule };
}
});
return { ...state, items: { ...updatedRules } };
default:
return state;
}
};

View file

@ -0,0 +1,70 @@
import { isEqual } from 'lodash';
import { SELECT_CATEGORY } from '../actions/categories.js';
import { SELECT_RULE } from '../actions/rules.js';
import {
RECEIVE_POST,
RECEIVE_POSTS,
SELECT_POST,
UNSELECT_POST,
} from '../actions/posts.js';
import { MARK_SECTION_READ } from '../actions/selected.js';
const defaultState = { item: {}, next: false, lastReached: false, post: {} };
export const selected = (state = { ...defaultState }, action) => {
switch (action.type) {
case SELECT_CATEGORY:
case SELECT_RULE:
return {
...state,
item: action.item,
next: false,
lastReached: false,
};
case RECEIVE_POSTS:
return {
...state,
next: action.next,
lastReached: !action.next,
};
case RECEIVE_POST:
const isCurrentPost = !isEqual(state.post, {}) && state.post.id === action.post.id;
if (isCurrentPost) {
return {
...state,
post: { ...action.post },
};
}
return {
...state,
};
case SELECT_POST:
return {
...state,
post: action.post,
};
case UNSELECT_POST:
return {
...state,
post: {},
};
case MARK_SECTION_READ:
if (!isEqual(action.rule, {})) {
return {
...state,
item: { ...action.rule, unread: 0 },
};
}
return {
...state,
item: { ...action.category },
};
default:
return state;
}
};

View file

@ -0,0 +1,14 @@
export const formatDatetime = dateString => {
const locale = navigator.language ? navigator.language : 'en-US';
const dateOptions = {
hour: '2-digit',
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
};
const date = new Date(dateString);
return date.toLocaleDateString(locale, dateOptions);
};

View file

@ -4,9 +4,10 @@ from newsreader.news.collection.models import CollectionRule
class CollectionRuleAdmin(admin.ModelAdmin):
fields = ("url", "name", "timezone", "category", "favicon")
fields = ("url", "name", "timezone", "category", "favicon", "user")
list_display = ("name", "category", "url", "last_suceeded", "succeeded")
list_filter = ("user",)
def save_model(self, request, obj, form, change):
if not change:

View file

@ -0,0 +1,69 @@
from django.db.models.query import QuerySet
from rest_framework import status
from rest_framework.generics import (
GenericAPIView,
ListAPIView,
ListCreateAPIView,
RetrieveUpdateDestroyAPIView,
get_object_or_404,
)
from rest_framework.response import Response
from rest_framework.serializers import Serializer
from newsreader.core.pagination import LargeResultSetPagination, ResultSetPagination
from newsreader.news.collection.models import CollectionRule
from newsreader.news.collection.serializers import RuleSerializer
from newsreader.news.core.filters import ReadFilter
from newsreader.news.core.models import Post
from newsreader.news.core.serializers import PostSerializer
class ListRuleView(ListCreateAPIView):
queryset = CollectionRule.objects.all()
serializer_class = RuleSerializer
pagination_class = ResultSetPagination
def get_queryset(self) -> QuerySet:
user = self.request.user
return self.queryset.filter(user=user).order_by("-created")
class DetailRuleView(RetrieveUpdateDestroyAPIView):
queryset = CollectionRule.objects.all()
serializer_class = RuleSerializer
pagination_class = ResultSetPagination
class NestedRuleView(ListAPIView):
queryset = CollectionRule.objects.prefetch_related("posts").all()
serializer_class = PostSerializer
pagination_class = LargeResultSetPagination
filter_backends = [ReadFilter]
def get_queryset(self) -> QuerySet:
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
# Default permission is IsOwner, therefore there shouldn't have to be
# filtered on the user.
filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
rule = get_object_or_404(self.queryset, **filter_kwargs)
self.check_object_permissions(self.request, rule)
return rule.posts.order_by("-publication_date")
class RuleReadView(GenericAPIView):
queryset = CollectionRule.objects.all()
serializer_class = RuleSerializer
def post(self, request, *args, **kwargs):
rule = self.get_object()
Post.objects.filter(rule=rule).update(read=True)
rule.refresh_from_db()
serializer_class = self.get_serializer_class()
serializer = serializer_class(rule)
return Response(serializer.data, status=status.HTTP_201_CREATED)

View file

@ -1,23 +1,15 @@
from rest_framework import serializers
from newsreader.news import core
from newsreader.news.collection.models import CollectionRule
class CollectionRuleSerializer(serializers.HyperlinkedModelSerializer):
posts = serializers.SerializerMethodField()
class RuleSerializer(serializers.ModelSerializer):
user = serializers.HiddenField(default=serializers.CurrentUserDefault())
unread = serializers.SerializerMethodField()
def get_posts(self, instance):
request = self.context.get("request")
posts = instance.posts.order_by("-publication_date")
serializer = core.serializers.PostSerializer(
posts, context={"request": request}, many=True
)
return serializer.data
def get_unread(self, rule):
return rule.posts.filter(read=False).count()
class Meta:
model = CollectionRule
fields = ("id", "name", "url", "favicon", "category", "posts", "user")
extra_kwargs = {"category": {"view_name": "api:categories-detail"}}
fields = ("id", "name", "url", "favicon", "category", "user", "unread")

View file

@ -1,26 +1,22 @@
import json
from urllib.parse import urljoin
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
from newsreader.news.core.models import Post
from newsreader.news.core.tests.factories import CategoryFactory, PostFactory
class CollectionRuleDetailViewTestCase(TestCase):
def setUp(self):
self.maxDiff = None
self.user = UserFactory(is_staff=True, password="test")
self.client = Client()
self.user = UserFactory(password="test")
self.client.login(email=self.user.email, password="test")
def test_simple(self):
rule = CollectionRuleFactory(user=self.user)
self.client.force_login(self.user)
response = self.client.get(reverse("api:rules-detail", args=[rule.pk]))
data = response.json()
@ -31,10 +27,8 @@ class CollectionRuleDetailViewTestCase(TestCase):
self.assertTrue("url" in data)
self.assertTrue("favicon" in data)
self.assertTrue("category" in data)
self.assertTrue("posts" in data)
def test_not_known(self):
self.client.force_login(self.user)
response = self.client.get(reverse("api:rules-detail", args=[100]))
data = response.json()
@ -44,7 +38,6 @@ class CollectionRuleDetailViewTestCase(TestCase):
def test_post(self):
rule = CollectionRuleFactory(user=self.user)
self.client.force_login(self.user)
response = self.client.post(reverse("api:rules-detail", args=[rule.pk]))
data = response.json()
@ -54,7 +47,6 @@ class CollectionRuleDetailViewTestCase(TestCase):
def test_patch(self):
rule = CollectionRuleFactory(name="BBC", user=self.user)
self.client.force_login(self.user)
response = self.client.patch(
reverse("api:rules-detail", args=[rule.pk]),
data=json.dumps({"name": "The guardian"}),
@ -65,18 +57,12 @@ class CollectionRuleDetailViewTestCase(TestCase):
self.assertEquals(response.status_code, 200)
self.assertEquals(data["name"], "The guardian")
def test_category_change_with_absolute_url(self):
def test_category_change(self):
old_category = CategoryFactory(user=self.user)
new_category = CategoryFactory(user=self.user)
base_url = "http://testserver"
relative_url = reverse("api:categories-detail", args=[new_category.pk])
absolute_url = urljoin(base_url, relative_url)
rule = CollectionRuleFactory(name="BBC", category=old_category, user=self.user)
self.client.force_login(self.user)
response = self.client.patch(
reverse("api:rules-detail", args=[rule.pk]),
data=json.dumps({"category": absolute_url}),
@ -85,34 +71,11 @@ class CollectionRuleDetailViewTestCase(TestCase):
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertEquals(data["category"], absolute_url)
def test_category_change_with_relative_url(self):
old_category = CategoryFactory(user=self.user)
new_category = CategoryFactory(user=self.user)
base_url = "http://testserver"
relative_url = reverse("api:categories-detail", args=[new_category.pk])
absolute_url = urljoin(base_url, relative_url)
rule = CollectionRuleFactory(name="BBC", category=old_category, user=self.user)
self.client.force_login(self.user)
response = self.client.patch(
reverse("api:rules-detail", args=[rule.pk]),
data=json.dumps({"category": relative_url}),
content_type="application/json",
)
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertEquals(data["category"], absolute_url)
self.assertEquals(data["category"], new_category.pk)
def test_identifier_cannot_be_changed(self):
rule = CollectionRuleFactory(user=self.user)
self.client.force_login(self.user)
response = self.client.patch(
reverse("api:rules-detail", args=[rule.pk]),
data=json.dumps({"id": 44}),
@ -127,26 +90,20 @@ class CollectionRuleDetailViewTestCase(TestCase):
rule = CollectionRuleFactory(user=self.user)
category = CategoryFactory(user=self.user)
self.client.force_login(self.user)
response = self.client.patch(
reverse("api:rules-detail", args=[rule.pk]),
data=json.dumps(
{"category": reverse("api:categories-detail", args=[category.pk])}
),
data=json.dumps({"category": category.pk}),
content_type="application/json",
)
data = response.json()
url = data["category"]
data["category"]
self.assertEquals(response.status_code, 200)
self.assertTrue(
url.endswith(reverse("api:categories-detail", args=[category.pk]))
)
self.assertEquals(data["category"], category.pk)
def test_put(self):
rule = CollectionRuleFactory(name="BBC", user=self.user)
self.client.force_login(self.user)
response = self.client.put(
reverse("api:rules-detail", args=[rule.pk]),
data=json.dumps({"name": "BBC", "url": "https://www.bbc.co.uk"}),
@ -160,12 +117,13 @@ class CollectionRuleDetailViewTestCase(TestCase):
def test_delete(self):
rule = CollectionRuleFactory(user=self.user)
self.client.force_login(self.user)
response = self.client.delete(reverse("api:rules-detail", args=[rule.pk]))
self.assertEquals(response.status_code, 204)
def test_rule_with_unauthenticated_user(self):
self.client.logout()
rule = CollectionRuleFactory(name="BBC", user=self.user)
response = self.client.patch(
@ -180,7 +138,6 @@ class CollectionRuleDetailViewTestCase(TestCase):
other_user = UserFactory()
rule = CollectionRuleFactory(name="BBC", user=other_user)
self.client.force_login(self.user)
response = self.client.patch(
reverse("api:rules-detail", args=[rule.pk]),
data=json.dumps({"name": "The guardian"}),
@ -188,3 +145,95 @@ class CollectionRuleDetailViewTestCase(TestCase):
)
self.assertEquals(response.status_code, 403)
def test_read_count(self):
rule = CollectionRuleFactory(user=self.user)
PostFactory.create_batch(size=20, read=False, rule=rule)
PostFactory.create_batch(size=20, read=True, rule=rule)
response = self.client.get(reverse("api:rules-detail", args=[rule.pk]))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertEquals(data["unread"], 20)
class CollectionRuleReadTestCase(TestCase):
def setUp(self):
self.user = UserFactory(password="test")
self.client.login(email=self.user.email, password="test")
def test_rule_read(self):
rule = CollectionRuleFactory(user=self.user)
PostFactory.create_batch(size=20, read=False, rule=rule)
response = self.client.post(reverse("api:rules-read", args=[rule.pk]))
data = response.json()
self.assertEquals(response.status_code, 201)
self.assertEquals(data["unread"], 0)
def test_rule_unknown(self):
response = self.client.post(reverse("api:rules-read", args=[101]))
self.assertEquals(response.status_code, 404)
def test_unauthenticated_user(self):
self.client.logout()
rule = CollectionRuleFactory(user=self.user)
PostFactory.create_batch(size=20, read=False, rule=rule)
response = self.client.post(reverse("api:rules-read", args=[rule.pk]))
self.assertEquals(response.status_code, 403)
def test_unauthorized_user(self):
other_user = UserFactory()
rule = CollectionRuleFactory(user=other_user)
PostFactory.create_batch(size=20, read=False, rule=rule)
response = self.client.post(reverse("api:rules-read", args=[rule.pk]))
self.assertEquals(response.status_code, 403)
self.assertEquals(Post.objects.filter(read=False).count(), 20)
def test_get(self):
rule = CollectionRuleFactory(user=self.user)
response = self.client.get(reverse("api:rules-read", args=[rule.pk]))
self.assertEquals(response.status_code, 405)
def test_patch(self):
rule = CollectionRuleFactory(name="BBC", user=self.user)
response = self.client.patch(
reverse("api:rules-read", args=[rule.pk]),
data=json.dumps({"name": "Not possible"}),
content_type="application/json",
)
self.assertEquals(response.status_code, 405)
def test_put(self):
rule = CollectionRuleFactory(name="BBC", user=self.user)
response = self.client.put(
reverse("api:rules-read", args=[rule.pk]),
data=json.dumps({"name": "Not possible"}),
content_type="application/json",
)
self.assertEquals(response.status_code, 405)
def test_delete(self):
rule = CollectionRuleFactory(user=self.user)
response = self.client.delete(reverse("api:rules-read", args=[rule.pk]))
self.assertEquals(response.status_code, 405)

View file

@ -0,0 +1,371 @@
import json
from datetime import date, datetime, time
from django.test import Client, TestCase
from django.urls import reverse
import pytz
from newsreader.accounts.tests.factories import UserFactory
from newsreader.news.collection.tests.factories import CollectionRuleFactory
from newsreader.news.core.tests.factories import CategoryFactory, PostFactory
class RuleListViewTestCase(TestCase):
def setUp(self):
self.user = UserFactory(password="test")
self.client.login(email=self.user.email, password="test")
def test_simple(self):
CollectionRuleFactory.create_batch(size=3, user=self.user)
response = self.client.get(reverse("api:rules-list"))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertTrue("results" in data)
self.assertTrue("count" in data)
self.assertEquals(data["count"], 3)
def test_ordering(self):
rules = [
CollectionRuleFactory(
created=datetime.combine(
date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc
),
user=self.user,
),
CollectionRuleFactory(
created=datetime.combine(
date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc
),
user=self.user,
),
CollectionRuleFactory(
created=datetime.combine(
date(2019, 7, 20), time(hour=16, minute=7, second=37), pytz.utc
),
user=self.user,
),
]
response = self.client.get(reverse("api:rules-list"))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertTrue("results" in data)
self.assertTrue("count" in data)
self.assertEquals(data["count"], 3)
self.assertEquals(data["results"][0]["id"], rules[1].pk)
self.assertEquals(data["results"][1]["id"], rules[2].pk)
self.assertEquals(data["results"][2]["id"], rules[0].pk)
def test_pagination_count(self):
CollectionRuleFactory.create_batch(size=80, user=self.user)
response = self.client.get(reverse("api:rules-list"), {"count": 30})
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertEquals(data["count"], 80)
self.assertEquals(len(data["results"]), 30)
def test_empty(self):
response = self.client.get(reverse("api:rules-list"))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertTrue("results" in data)
self.assertTrue("count" in data)
self.assertEquals(data["count"], 0)
self.assertEquals(len(data["results"]), 0)
def test_post(self):
category = CategoryFactory(user=self.user)
data = {"name": "BBC", "url": "https://www.bbc.co.uk", "category": category.pk}
response = self.client.post(
reverse("api:rules-list"),
data=json.dumps(data),
content_type="application/json",
)
data = response.json()
data["category"]
self.assertEquals(response.status_code, 201)
self.assertEquals(data["name"], "BBC")
self.assertEquals(data["url"], "https://www.bbc.co.uk")
self.assertEquals(data["category"], category.pk)
def test_patch(self):
response = self.client.patch(reverse("api:rules-list"))
data = response.json()
self.assertEquals(response.status_code, 405)
self.assertEquals(data["detail"], 'Method "PATCH" not allowed.')
def test_put(self):
response = self.client.put(reverse("api:rules-list"))
data = response.json()
self.assertEquals(response.status_code, 405)
self.assertEquals(data["detail"], 'Method "PUT" not allowed.')
def test_delete(self):
response = self.client.delete(reverse("api:rules-list"))
data = response.json()
self.assertEquals(response.status_code, 405)
self.assertEquals(data["detail"], 'Method "DELETE" not allowed.')
def test_rules_with_unauthenticated_user(self):
self.client.logout()
CollectionRuleFactory.create_batch(size=3, user=self.user)
response = self.client.get(reverse("api:rules-list"))
self.assertEquals(response.status_code, 403)
def test_rules_with_unauthorized_user(self):
other_user = UserFactory()
CollectionRuleFactory.create_batch(size=3, user=other_user)
response = self.client.get(reverse("api:rules-list"))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertEquals(data["count"], 0)
self.assertEquals(len(data["results"]), 0)
class NestedRuleListViewTestCase(TestCase):
def setUp(self):
self.user = UserFactory(password="test")
self.client.login(email=self.user.email, password="test")
def test_simple(self):
rule = CollectionRuleFactory.create(user=self.user)
PostFactory.create_batch(size=5, rule=rule)
response = self.client.get(
reverse("api:rules-nested-posts", kwargs={"pk": rule.pk})
)
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertTrue("results" in data)
self.assertTrue("count" in data)
self.assertEquals(data["count"], 5)
def test_pagination(self):
rule = CollectionRuleFactory.create(user=self.user)
PostFactory.create_batch(size=80, rule=rule)
response = self.client.get(
reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), {"count": 30}
)
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertEquals(data["count"], 80)
self.assertEquals(len(data["results"]), 30)
def test_empty(self):
rule = CollectionRuleFactory.create(user=self.user)
response = self.client.get(
reverse("api:rules-nested-posts", kwargs={"pk": rule.pk})
)
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertEquals(data["count"], 0)
self.assertEquals(len(data["results"]), 0)
def test_not_known(self):
response = self.client.get(reverse("api:rules-nested-posts", kwargs={"pk": 0}))
self.assertEquals(response.status_code, 404)
def test_post(self):
rule = CollectionRuleFactory.create(user=self.user)
response = self.client.post(
reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}),
data=json.dumps({}),
content_type="application/json",
)
data = response.json()
self.assertEquals(response.status_code, 405)
self.assertEquals(data["detail"], 'Method "POST" not allowed.')
def test_patch(self):
rule = CollectionRuleFactory.create(user=self.user)
response = self.client.patch(
reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}),
data=json.dumps({}),
content_type="application/json",
)
data = response.json()
self.assertEquals(response.status_code, 405)
self.assertEquals(data["detail"], 'Method "PATCH" not allowed.')
def test_put(self):
rule = CollectionRuleFactory.create(user=self.user)
response = self.client.put(
reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}),
data=json.dumps({}),
content_type="application/json",
)
data = response.json()
self.assertEquals(response.status_code, 405)
self.assertEquals(data["detail"], 'Method "PUT" not allowed.')
def test_delete(self):
rule = CollectionRuleFactory.create(user=self.user)
response = self.client.delete(
reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}),
data=json.dumps({}),
content_type="application/json",
)
data = response.json()
self.assertEquals(response.status_code, 405)
self.assertEquals(data["detail"], 'Method "DELETE" not allowed.')
def test_rule_with_unauthenticated_user(self):
self.client.logout()
rule = CollectionRuleFactory(user=self.user)
response = self.client.get(
reverse("api:rules-nested-posts", kwargs={"pk": rule.pk})
)
self.assertEquals(response.status_code, 403)
def test_rule_with_unauthorized_user(self):
other_user = UserFactory()
rule = CollectionRuleFactory(user=other_user)
response = self.client.get(
reverse("api:rules-nested-posts", kwargs={"pk": rule.pk})
)
self.assertEquals(response.status_code, 403)
def test_posts_ordering(self):
rule = CollectionRuleFactory(
user=self.user, category=CategoryFactory(user=self.user)
)
posts = [
PostFactory(
title="I'm the first post",
rule=rule,
publication_date=datetime.combine(
date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc
),
),
PostFactory(
title="I'm the second post",
rule=rule,
publication_date=datetime.combine(
date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc
),
),
PostFactory(
title="I'm the third post",
rule=rule,
publication_date=datetime.combine(
date(2019, 7, 20), time(hour=16, minute=7, second=37), pytz.utc
),
),
]
response = self.client.get(
reverse("api:rules-nested-posts", kwargs={"pk": rule.pk})
)
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertTrue("results" in data)
self.assertTrue("count" in data)
self.assertEquals(data["count"], 3)
self.assertEquals(data["results"][0]["id"], posts[1].pk)
self.assertEquals(data["results"][1]["id"], posts[2].pk)
self.assertEquals(data["results"][2]["id"], posts[0].pk)
def test_only_posts_from_rule_are_returned(self):
rule = CollectionRuleFactory.create(user=self.user)
other_rule = CollectionRuleFactory.create(user=self.user)
PostFactory.create_batch(size=5, rule=rule)
PostFactory.create_batch(size=5, rule=other_rule)
response = self.client.get(
reverse("api:rules-nested-posts", kwargs={"pk": rule.pk})
)
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertTrue("results" in data)
self.assertTrue("count" in data)
self.assertEquals(data["count"], 5)
for post in data["results"]:
self.assertEquals(post["rule"], rule.pk)
def test_unread_posts(self):
rule = CollectionRuleFactory.create(user=self.user)
PostFactory.create_batch(size=10, rule=rule, read=False)
PostFactory.create_batch(size=10, rule=rule, read=True)
response = self.client.get(
reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), {"read": "false"}
)
data = response.json()
posts = data["results"]
self.assertEquals(response.status_code, 200)
self.assertEquals(data["count"], 10)
for post in posts:
self.assertEquals(post["read"], False)
def test_read_posts(self):
rule = CollectionRuleFactory.create(user=self.user)
PostFactory.create_batch(size=20, rule=rule, read=False)
PostFactory.create_batch(size=10, rule=rule, read=True)
response = self.client.get(
reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), {"read": "true"}
)
data = response.json()
posts = data["results"]
self.assertEquals(response.status_code, 200)
self.assertEquals(data["count"], 10)
for post in posts:
self.assertEquals(post["read"], True)

View file

@ -1,210 +0,0 @@
import json
from datetime import date, datetime, time
from django.test import Client, TestCase
from django.urls import reverse
import pytz
from newsreader.accounts.tests.factories import UserFactory
from newsreader.news.collection.tests.factories import CollectionRuleFactory
from newsreader.news.core.tests.factories import CategoryFactory, PostFactory
class CollectionRuleListViewTestCase(TestCase):
def setUp(self):
self.maxDiff = None
self.user = UserFactory(is_staff=True, password="test")
self.client = Client()
def test_simple(self):
CollectionRuleFactory.create_batch(size=3, user=self.user)
self.client.force_login(self.user)
response = self.client.get(reverse("api:rules-list"))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertTrue("results" in data)
self.assertTrue("count" in data)
self.assertEquals(data["count"], 3)
def test_ordering(self):
rules = [
CollectionRuleFactory(
created=datetime.combine(
date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc
),
user=self.user,
),
CollectionRuleFactory(
created=datetime.combine(
date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc
),
user=self.user,
),
CollectionRuleFactory(
created=datetime.combine(
date(2019, 7, 20), time(hour=16, minute=7, second=37), pytz.utc
),
user=self.user,
),
]
self.client.force_login(self.user)
response = self.client.get(reverse("api:rules-list"))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertTrue("results" in data)
self.assertTrue("count" in data)
self.assertEquals(data["count"], 3)
self.assertEquals(data["results"][0]["id"], rules[1].pk)
self.assertEquals(data["results"][1]["id"], rules[2].pk)
self.assertEquals(data["results"][2]["id"], rules[0].pk)
def test_pagination_count(self):
CollectionRuleFactory.create_batch(size=80, user=self.user)
self.client.force_login(self.user)
response = self.client.get(reverse("api:rules-list"), {"count": 30})
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertEquals(data["count"], 80)
self.assertEquals(len(data["results"]), 30)
def test_empty(self):
self.client.force_login(self.user)
response = self.client.get(reverse("api:rules-list"))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertTrue("results" in data)
self.assertTrue("count" in data)
self.assertEquals(data["count"], 0)
self.assertEquals(len(data["results"]), 0)
def test_post(self):
category = CategoryFactory(user=self.user)
data = {
"name": "BBC",
"url": "https://www.bbc.co.uk",
"category": reverse("api:categories-detail", args=[category.pk]),
}
self.client.force_login(self.user)
response = self.client.post(
reverse("api:rules-list"),
data=json.dumps(data),
content_type="application/json",
)
data = response.json()
category_url = data["category"]
self.assertEquals(response.status_code, 201)
self.assertEquals(data["name"], "BBC")
self.assertEquals(data["url"], "https://www.bbc.co.uk")
self.assertTrue(
category_url.endswith(reverse("api:categories-detail", args=[category.pk]))
)
def test_patch(self):
self.client.force_login(self.user)
response = self.client.patch(reverse("api:rules-list"))
data = response.json()
self.assertEquals(response.status_code, 405)
self.assertEquals(data["detail"], 'Method "PATCH" not allowed.')
def test_put(self):
self.client.force_login(self.user)
response = self.client.put(reverse("api:rules-list"))
data = response.json()
self.assertEquals(response.status_code, 405)
self.assertEquals(data["detail"], 'Method "PUT" not allowed.')
def test_delete(self):
self.client.force_login(self.user)
response = self.client.delete(reverse("api:rules-list"))
data = response.json()
self.assertEquals(response.status_code, 405)
self.assertEquals(data["detail"], 'Method "DELETE" not allowed.')
def test_rules_with_posts(self):
rules = {
rule: PostFactory.create_batch(size=5, rule=rule)
for rule in CollectionRuleFactory.create_batch(size=5, user=self.user)
}
self.client.force_login(self.user)
response = self.client.get(reverse("api:rules-list"))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertTrue("results" in data)
self.assertTrue("count" in data)
self.assertEquals(data["count"], 5)
self.assertEquals(len(data["results"]), 5)
self.assertEquals(len(data["results"][0]["posts"]), 5)
def test_rules_with_posts_ordered(self):
rules = {
rule: PostFactory.create_batch(size=5, rule=rule)
for rule in CollectionRuleFactory.create_batch(size=2, user=self.user)
}
self.client.force_login(self.user)
response = self.client.get(reverse("api:rules-list"))
data = response.json()
first_post_set = data["results"][0]["posts"]
second_post_set = data["results"][1]["posts"]
self.assertEquals(response.status_code, 200)
self.assertTrue("results" in data)
self.assertTrue("count" in data)
self.assertEquals(data["count"], 2)
self.assertEquals(len(data["results"]), 2)
for result_set in [first_post_set, second_post_set]:
for count, post in enumerate(result_set):
if count < 1:
continue
self.assertTrue(
post["publication_date"] < result_set[count - 1]["publication_date"]
)
def test_rule_with_unauthenticated_user(self):
CollectionRuleFactory.create_batch(size=3, user=self.user)
response = self.client.get(reverse("api:rules-list"))
response.json()
self.assertEquals(response.status_code, 403)
def test_rule_with_unauthorized_user(self):
other_user = UserFactory()
CollectionRuleFactory.create_batch(size=3, user=other_user)
self.client.force_login(self.user)
response = self.client.get(reverse("api:rules-list"))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertEquals(data["count"], 0)
self.assertEquals(len(data["results"]), 0)

View file

@ -1,12 +1,16 @@
from django.urls import path
from newsreader.news.collection.views import (
CollectionRuleAPIListView,
CollectionRuleDetailView,
from newsreader.news.collection.endpoints import (
DetailRuleView,
ListRuleView,
NestedRuleView,
RuleReadView,
)
endpoints = [
path("rules/<int:pk>", CollectionRuleDetailView.as_view(), name="rules-detail"),
path("rules/", CollectionRuleAPIListView.as_view(), name="rules-list"),
path("rules/<int:pk>", DetailRuleView.as_view(), name="rules-detail"),
path("rules/<int:pk>/posts/", NestedRuleView.as_view(), name="rules-nested-posts"),
path("rules/<int:pk>/read/", RuleReadView.as_view(), name="rules-read"),
path("rules/", ListRuleView.as_view(), name="rules-list"),
]

View file

@ -1,21 +0,0 @@
from rest_framework.generics import ListCreateAPIView, RetrieveUpdateDestroyAPIView
from newsreader.core.pagination import ResultSetPagination
from newsreader.news.collection.models import CollectionRule
from newsreader.news.collection.serializers import CollectionRuleSerializer
class CollectionRuleAPIListView(ListCreateAPIView):
queryset = CollectionRule.objects.all()
serializer_class = CollectionRuleSerializer
pagination_class = ResultSetPagination
def get_queryset(self):
user = self.request.user
return self.queryset.filter(user=user).order_by("-created")
class CollectionRuleDetailView(RetrieveUpdateDestroyAPIView):
queryset = CollectionRule.objects.all()
serializer_class = CollectionRuleSerializer
pagination_class = ResultSetPagination

View file

@ -0,0 +1,118 @@
from django.db.models import Q
from django.db.models.query import QuerySet
from rest_framework import status
from rest_framework.generics import (
GenericAPIView,
ListAPIView,
ListCreateAPIView,
RetrieveUpdateAPIView,
RetrieveUpdateDestroyAPIView,
get_object_or_404,
)
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from newsreader.accounts.permissions import IsPostOwner
from newsreader.core.pagination import LargeResultSetPagination, ResultSetPagination
from newsreader.news.collection.models import CollectionRule
from newsreader.news.collection.serializers import RuleSerializer
from newsreader.news.core.filters import ReadFilter
from newsreader.news.core.models import Category, Post
from newsreader.news.core.serializers import CategorySerializer, PostSerializer
class ListPostView(ListAPIView):
queryset = Post.objects.all()
serializer_class = PostSerializer
pagination_class = LargeResultSetPagination
filter_backends = [ReadFilter]
def get_queryset(self):
user = self.request.user
queryset = (
self.queryset.filter(rule__user=user)
.filter(Q(rule__category=None) | Q(rule__category__user=user))
.order_by("rule", "-publication_date", "-created")
)
return queryset
class DetailPostView(RetrieveUpdateAPIView):
queryset = Post.objects.all()
serializer_class = PostSerializer
permission_classes = (IsAuthenticated, IsPostOwner)
class ListCategoryView(ListCreateAPIView):
queryset = Category.objects.all()
serializer_class = CategorySerializer
def get_queryset(self):
user = self.request.user
return self.queryset.filter(user=user).order_by("-created", "-modified")
class DetailCategoryView(RetrieveUpdateDestroyAPIView):
queryset = Category.objects.all()
serializer_class = CategorySerializer
class NestedRuleCategoryView(ListAPIView):
queryset = Category.objects.prefetch_related("rules").all()
serializer_class = RuleSerializer
def get_queryset(self) -> QuerySet:
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
# Default permission is IsOwner, therefore there shouldn't have to be
# filtered on the user.
filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
category = get_object_or_404(self.queryset, **filter_kwargs)
self.check_object_permissions(self.request, category)
return category.rules.order_by("name")
class NestedPostCategoryView(ListAPIView):
queryset = Category.objects.prefetch_related("rules", "rules__posts").all()
serializer_class = PostSerializer
pagination_class = LargeResultSetPagination
filter_backends = [ReadFilter]
def get_queryset(self):
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
# Default permission is IsOwner, therefore there shouldn't have to be
# filtered on the user.
filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
category = get_object_or_404(self.queryset, **filter_kwargs)
self.check_object_permissions(self.request, category)
queryset = Post.objects.filter(
rule__in=category.rules.values_list("id", flat=True)
).order_by("rule__name", "-publication_date")
return queryset
class CategoryReadView(GenericAPIView):
queryset = Category.objects.all()
serializer_class = CategorySerializer
def post(self, request, *args, **kwargs):
category = self.get_object()
(
Post.objects.filter(
rule__in=category.rules.values_list("pk", flat=True)
).update(read=True)
)
category.refresh_from_db()
serializer_class = self.get_serializer_class()
serializer = serializer_class(category)
return Response(serializer.data, status=status.HTTP_201_CREATED)

View file

@ -0,0 +1,32 @@
from django.utils.encoding import force_text
from django.utils.translation import ugettext_lazy as _
from rest_framework import filters
from rest_framework.compat import coreapi, coreschema
class ReadFilter(filters.BaseFilterBackend):
query_param = "read"
def filter_queryset(self, request, queryset, view):
key = request.query_params.get(self.query_param, None)
available_values = {"True": True, "true": True, "False": False, "false": False}
if not key or key not in available_values.keys():
return queryset
value = available_values[key]
return queryset.filter(read=value)
def get_schema_fields(self, view):
return [
coreapi.Field(
name=self.query_param,
required=False,
location="query",
schema=coreschema.String(
title=force_text(self.query_param),
description=force_text(_("Wether posts should be read or not")),
),
)
]

View file

@ -0,0 +1,14 @@
# Generated by Django 2.2 on 2019-09-09 19:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0002_auto_20190714_1425")]
operations = [
migrations.AddField(
model_name="post", name="read", field=models.BooleanField(default=False)
)
]

View file

@ -12,6 +12,8 @@ class Post(TimeStampedModel):
publication_date = models.DateTimeField(blank=True, null=True)
url = models.URLField(max_length=1024, blank=True, null=True)
read = models.BooleanField(default=False)
rule = models.ForeignKey(
CollectionRule, on_delete=models.CASCADE, editable=False, related_name="posts"
)
@ -24,7 +26,7 @@ class Post(TimeStampedModel):
class Category(TimeStampedModel):
name = models.CharField(max_length=50, unique=True)
name = models.CharField(max_length=50, unique=True) # TODO remove unique value
user = models.ForeignKey("accounts.User", _("Owner"), related_name="categories")
class Meta:

View file

@ -4,7 +4,7 @@ from newsreader.news import collection
from newsreader.news.core.models import Category, Post
class PostSerializer(serializers.HyperlinkedModelSerializer):
class PostSerializer(serializers.ModelSerializer):
class Meta:
model = Post
fields = (
@ -16,24 +16,19 @@ class PostSerializer(serializers.HyperlinkedModelSerializer):
"publication_date",
"url",
"rule",
"read",
)
extra_kwargs = {"rule": {"view_name": "api:rules-detail"}}
class CategorySerializer(serializers.HyperlinkedModelSerializer):
rules = serializers.SerializerMethodField()
class CategorySerializer(serializers.ModelSerializer):
user = serializers.HiddenField(default=serializers.CurrentUserDefault())
unread = serializers.SerializerMethodField()
def get_rules(self, instance):
request = self.context.get("request")
rules = instance.rules.order_by("-modified", "-created")
serializer = collection.serializers.CollectionRuleSerializer(
rules, context={"request": request}, many=True
)
return serializer.data
def get_unread(self, category):
return Post.objects.filter(
rule__in=category.rules.values_list("pk", flat=True), read=False
).count()
class Meta:
model = Category
fields = ("id", "name", "rules", "user")
extra_kwargs = {"rules": {"view_name": "api:rules-detail"}}
fields = ("id", "name", "user", "unread")

View file

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% load static %}
{% block head %}
<link href="{% static 'core/dist/css/core.css' %}" rel="stylesheet" />
{% endblock %}
{% block content %}
<div class="content"></div>
{% endblock %}
{% block scripts %}
<script src="{% static 'core/dist/js/homepage.js' %}"></script>
{% endblock %}

View file

@ -10,25 +10,20 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory
class CategoryDetailViewTestCase(TestCase):
def setUp(self):
self.maxDiff = None
self.client = Client()
self.user = UserFactory(is_staff=True, password="test")
self.user = UserFactory(password="test")
self.client.login(email=self.user.email, password="test")
def test_simple(self):
category = CategoryFactory(user=self.user)
self.client.force_login(self.user)
response = self.client.get(reverse("api:categories-detail", args=[category.pk]))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertTrue("id" in data)
self.assertTrue("name" in data)
self.assertTrue("rules" in data)
def test_not_known(self):
self.client.force_login(self.user)
response = self.client.get(reverse("api:categories-detail", args=[100]))
data = response.json()
@ -38,7 +33,6 @@ class CategoryDetailViewTestCase(TestCase):
def test_post(self):
category = CategoryFactory(user=self.user)
self.client.force_login(self.user)
response = self.client.post(reverse("api:categories-detail", args=[category.pk]))
data = response.json()
@ -48,7 +42,6 @@ class CategoryDetailViewTestCase(TestCase):
def test_patch(self):
category = CategoryFactory(name="Clickbait", user=self.user)
self.client.force_login(self.user)
response = self.client.patch(
reverse("api:categories-detail", args=[category.pk]),
data=json.dumps({"name": "Interesting posts"}),
@ -62,7 +55,6 @@ class CategoryDetailViewTestCase(TestCase):
def test_identifier_cannot_be_changed(self):
category = CategoryFactory(user=self.user)
self.client.force_login(self.user)
response = self.client.patch(
reverse("api:categories-detail", args=[category.pk]),
data=json.dumps({"id": 44}),
@ -76,7 +68,6 @@ class CategoryDetailViewTestCase(TestCase):
def test_put(self):
category = CategoryFactory(name="Clickbait", user=self.user)
self.client.force_login(self.user)
response = self.client.put(
reverse("api:categories-detail", args=[category.pk]),
data=json.dumps({"name": "Interesting posts"}),
@ -90,74 +81,15 @@ class CategoryDetailViewTestCase(TestCase):
def test_delete(self):
category = CategoryFactory(user=self.user)
self.client.force_login(self.user)
response = self.client.delete(
reverse("api:categories-detail", args=[category.pk])
)
self.assertEquals(response.status_code, 204)
def test_rules(self):
category = CategoryFactory(user=self.user)
rules = CollectionRuleFactory.create_batch(
size=5, category=category, user=self.user
)
self.client.force_login(self.user)
response = self.client.get(reverse("api:categories-detail", args=[category.pk]))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertTrue("id" in data["rules"][0])
self.assertTrue("name" in data["rules"][0])
self.assertTrue("url" in data["rules"][0])
def test_rules_with_posts(self):
category = CategoryFactory(user=self.user)
rules = {
rule.pk: PostFactory.create_batch(size=5, rule=rule)
for rule in CollectionRuleFactory.create_batch(
size=5, category=category, user=self.user
)
}
self.client.force_login(self.user)
response = self.client.get(reverse("api:categories-detail", args=[category.pk]))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertEquals(len(data["rules"][0]["posts"]), 5)
def test_rules_with_posts_ordered(self):
category = CategoryFactory(user=self.user)
rules = {
rule.pk: PostFactory.create_batch(size=5, rule=rule)
for rule in CollectionRuleFactory.create_batch(
size=5, category=category, user=self.user
)
}
self.client.force_login(self.user)
response = self.client.get(reverse("api:categories-detail", args=[category.pk]))
data = response.json()
self.assertEquals(response.status_code, 200)
posts = data["rules"][0]["posts"]
for count, post in enumerate(posts):
if count < 1:
continue
self.assertTrue(
post["publication_date"] < posts[count - 1]["publication_date"]
)
def test_category_with_unauthenticated_user(self):
self.client.logout()
category = CategoryFactory(user=self.user)
response = self.client.get(reverse("api:categories-detail", args=[category.pk]))
@ -168,7 +100,112 @@ class CategoryDetailViewTestCase(TestCase):
other_user = UserFactory()
category = CategoryFactory(user=other_user)
self.client.force_login(self.user)
response = self.client.get(reverse("api:categories-detail", args=[category.pk]))
self.assertEquals(response.status_code, 403)
def test_read_count(self):
category = CategoryFactory(user=self.user)
unread_rule = CollectionRuleFactory(category=category)
read_rule = CollectionRuleFactory(category=category)
PostFactory.create_batch(size=20, read=False, rule=unread_rule)
PostFactory.create_batch(size=20, read=True, rule=read_rule)
response = self.client.get(reverse("api:categories-detail", args=[category.pk]))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertEquals(data["unread"], 20)
class CategoryReadTestCase(TestCase):
def setUp(self):
self.user = UserFactory(password="test")
self.client.login(email=self.user.email, password="test")
def test_category_read(self):
category = CategoryFactory(user=self.user)
rules = [
PostFactory.create_batch(size=5, read=False, rule=rule)
for rule in CollectionRuleFactory.create_batch(size=5, category=category)
]
response = self.client.post(reverse("api:categories-read", args=[category.pk]))
data = response.json()
self.assertEquals(response.status_code, 201)
self.assertEquals(data["unread"], 0)
self.assertEquals(data["id"], category.pk)
def test_category_unknown(self):
response = self.client.post(reverse("api:categories-read", args=[101]))
self.assertEquals(response.status_code, 404)
def test_unauthenticated_user(self):
self.client.logout()
category = CategoryFactory(user=self.user)
rules = [
PostFactory.create_batch(size=5, read=False, rule=rule)
for rule in CollectionRuleFactory.create_batch(
size=5, category=category, user=self.user
)
]
response = self.client.post(reverse("api:categories-read", args=[category.pk]))
self.assertEquals(response.status_code, 403)
def test_unauthorized_user(self):
other_user = UserFactory()
category = CategoryFactory(user=other_user)
rules = [
PostFactory.create_batch(size=5, read=False, rule=rule)
for rule in CollectionRuleFactory.create_batch(
size=5, category=category, user=other_user
)
]
response = self.client.post(reverse("api:categories-read", args=[category.pk]))
self.assertEquals(response.status_code, 403)
def test_get(self):
category = CategoryFactory(name="Clickbait", user=self.user)
response = self.client.get(reverse("api:categories-read", args=[category.pk]))
self.assertEquals(response.status_code, 405)
def test_patch(self):
category = CategoryFactory(name="Clickbait", user=self.user)
response = self.client.patch(
reverse("api:categories-read", args=[category.pk]),
data=json.dumps({"name": "Not possible"}),
content_type="application/json",
)
self.assertEquals(response.status_code, 405)
def test_put(self):
category = CategoryFactory(name="Clickbait", user=self.user)
response = self.client.put(
reverse("api:categories-read", args=[category.pk]),
data=json.dumps({"name": "Not possible"}),
content_type="application/json",
)
self.assertEquals(response.status_code, 405)
def test_delete(self):
category = CategoryFactory(name="Clickbait", user=self.user)
response = self.client.delete(reverse("api:categories-read", args=[category.pk]))
self.assertEquals(response.status_code, 405)

View file

@ -14,22 +14,17 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory
class CategoryListViewTestCase(TestCase):
def setUp(self):
self.maxDiff = None
self.client = Client()
self.user = UserFactory(is_staff=True, password="test")
self.user = UserFactory(password="test")
self.client.login(email=self.user.email, password="test")
def test_simple(self):
CategoryFactory.create_batch(size=3, user=self.user)
self.client.force_login(self.user)
response = self.client.get(reverse("api:categories-list"))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertTrue("results" in data)
self.assertTrue("count" in data)
self.assertEquals(data["count"], 3)
self.assertEquals(len(data), 3)
def test_ordering(self):
categories = [
@ -53,35 +48,25 @@ class CategoryListViewTestCase(TestCase):
),
]
self.client.force_login(self.user)
response = self.client.get(reverse("api:categories-list"))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertTrue("results" in data)
self.assertTrue("count" in data)
self.assertEquals(data["count"], 3)
self.assertEquals(data["results"][0]["id"], categories[1].pk)
self.assertEquals(data["results"][1]["id"], categories[2].pk)
self.assertEquals(data["results"][2]["id"], categories[0].pk)
self.assertEquals(data[0]["id"], categories[1].pk)
self.assertEquals(data[1]["id"], categories[2].pk)
self.assertEquals(data[2]["id"], categories[0].pk)
def test_empty(self):
self.client.force_login(self.user)
response = self.client.get(reverse("api:categories-list"))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertTrue("results" in data)
self.assertTrue("count" in data)
self.assertEquals(data["count"], 0)
self.assertEquals(len(data["results"]), 0)
self.assertEquals(len(data), 0)
def test_post(self):
data = {"name": "Tech"}
self.client.force_login(self.user)
response = self.client.post(
reverse("api:categories-list"),
data=json.dumps(data),
@ -93,7 +78,6 @@ class CategoryListViewTestCase(TestCase):
self.assertEquals(response_data["name"], "Tech")
def test_patch(self):
self.client.force_login(self.user)
response = self.client.patch(reverse("api:categories-list"))
data = response.json()
@ -101,7 +85,6 @@ class CategoryListViewTestCase(TestCase):
self.assertEquals(data["detail"], 'Method "PATCH" not allowed.')
def test_put(self):
self.client.force_login(self.user)
response = self.client.put(reverse("api:categories-list"))
data = response.json()
@ -109,66 +92,15 @@ class CategoryListViewTestCase(TestCase):
self.assertEquals(data["detail"], 'Method "PUT" not allowed.')
def test_delete(self):
self.client.force_login(self.user)
response = self.client.delete(reverse("api:categories-list"))
data = response.json()
self.assertEquals(response.status_code, 405)
self.assertEquals(data["detail"], 'Method "DELETE" not allowed.')
def test_rules(self):
categories = {
category.pk: CollectionRuleFactory.create_batch(
size=5, category=category, user=self.user
)
for category in CategoryFactory.create_batch(size=5, user=self.user)
}
self.client.force_login(self.user)
response = self.client.get(reverse("api:categories-list"))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertTrue("results" in data)
self.assertTrue("count" in data)
self.assertEquals(data["count"], 5)
self.assertEquals(len(data["results"]), 5)
self.assertEquals(len(data["results"][0]["rules"]), 5)
self.assertTrue("id" in data["results"][0]["rules"][0])
self.assertTrue("name" in data["results"][0]["rules"][0])
self.assertTrue("url" in data["results"][0]["rules"][0])
self.assertTrue("posts" in data["results"][0]["rules"][0])
def test_rules_with_posts(self):
categories = {
category.pk: CollectionRuleFactory.create_batch(
size=5, category=category, user=self.user
)
for category in CategoryFactory.create_batch(size=5, user=self.user)
}
for category in categories:
for rule in categories[category]:
PostFactory.create_batch(size=5, rule=rule)
self.client.force_login(self.user)
response = self.client.get(reverse("api:categories-list"))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertTrue("results" in data)
self.assertTrue("count" in data)
self.assertEquals(data["count"], 5)
self.assertEquals(len(data["results"]), 5)
self.assertEquals(len(data["results"][0]["rules"]), 5)
self.assertEquals(len(data["results"][0]["rules"][0]["posts"]), 5)
def test_categories_with_unauthenticated_user(self):
self.client.logout()
CategoryFactory.create_batch(size=3, user=self.user)
response = self.client.get(reverse("api:categories-list"))
@ -179,10 +111,458 @@ class CategoryListViewTestCase(TestCase):
other_user = UserFactory()
CategoryFactory.create_batch(size=3, user=other_user)
self.client.force_login(self.user)
response = self.client.get(reverse("api:categories-list"))
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertEquals(len(data["results"]), 0)
self.assertEquals(len(data), 0)
class NestedCategoryListViewTestCase(TestCase):
def setUp(self):
self.user = UserFactory(password="test")
self.client.login(email=self.user.email, password="test")
def test_simple(self):
category = CategoryFactory.create(user=self.user)
rules = CollectionRuleFactory.create_batch(size=5, category=category)
response = self.client.get(
reverse("api:categories-nested-rules", kwargs={"pk": category.pk})
)
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertEquals(len(data), 5)
self.assertTrue("id" in data[0])
self.assertTrue("name" in data[0])
self.assertTrue("category" in data[0])
self.assertTrue("url" in data[0])
self.assertTrue("favicon" in data[0])
def test_empty(self):
category = CategoryFactory.create(user=self.user)
response = self.client.get(
reverse("api:categories-nested-rules", kwargs={"pk": category.pk})
)
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertEquals(len(data), 0)
self.assertEquals(data, [])
def test_not_known(self):
response = self.client.get(
reverse("api:categories-nested-rules", kwargs={"pk": 100})
)
self.assertEquals(response.status_code, 404)
def test_post(self):
response = self.client.post(
reverse("api:categories-nested-rules", kwargs={"pk": 100}),
data=json.dumps({}),
content_type="application/json",
)
data = response.json()
self.assertEquals(response.status_code, 405)
self.assertEquals(data["detail"], 'Method "POST" not allowed.')
def test_patch(self):
category = CategoryFactory.create(user=self.user)
response = self.client.patch(
reverse("api:categories-nested-rules", kwargs={"pk": category.pk}),
data=json.dumps({"name": "test"}),
content_type="application/json",
)
data = response.json()
self.assertEquals(response.status_code, 405)
self.assertEquals(data["detail"], 'Method "PATCH" not allowed.')
def test_put(self):
category = CategoryFactory.create(user=self.user)
response = self.client.put(
reverse("api:categories-nested-rules", kwargs={"pk": category.pk}),
data=json.dumps({"name": "test"}),
content_type="application/json",
)
data = response.json()
self.assertEquals(response.status_code, 405)
self.assertEquals(data["detail"], 'Method "PUT" not allowed.')
def test_delete(self):
category = CategoryFactory.create(user=self.user)
response = self.client.delete(
reverse("api:categories-nested-rules", kwargs={"pk": category.pk}),
content_type="application/json",
)
data = response.json()
self.assertEquals(response.status_code, 405)
self.assertEquals(data["detail"], 'Method "DELETE" not allowed.')
def test_with_unauthenticated_user(self):
self.client.logout()
category = CategoryFactory.create(user=self.user)
rules = CollectionRuleFactory.create_batch(size=5, category=category)
response = self.client.get(
reverse("api:categories-nested-rules", kwargs={"pk": category.pk})
)
self.assertEquals(response.status_code, 403)
def test_with_unauthorized_user(self):
other_user = UserFactory.create()
category = CategoryFactory.create(user=other_user)
rules = CollectionRuleFactory.create_batch(size=5, category=category)
response = self.client.get(
reverse("api:categories-nested-rules", kwargs={"pk": category.pk})
)
self.assertEquals(response.status_code, 403)
def test_ordering(self):
category = CategoryFactory.create(user=self.user)
rules = [
CollectionRuleFactory.create(category=category, name="Durp"),
CollectionRuleFactory.create(category=category, name="Slurp"),
CollectionRuleFactory.create(category=category, name="Burp"),
]
response = self.client.get(
reverse("api:categories-nested-rules", kwargs={"pk": category.pk})
)
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertEquals(len(data), 3)
self.assertEquals(data[0]["id"], rules[2].pk)
self.assertEquals(data[1]["id"], rules[0].pk)
self.assertEquals(data[2]["id"], rules[1].pk)
def test_only_rules_from_category_are_returned(self):
other_category = CategoryFactory(user=self.user)
CollectionRuleFactory.create_batch(size=5, category=other_category)
category = CategoryFactory.create(user=self.user)
rules = [
CollectionRuleFactory.create(category=category, name="Durp"),
CollectionRuleFactory.create(category=category, name="Slurp"),
CollectionRuleFactory.create(category=category, name="Burp"),
]
response = self.client.get(
reverse("api:categories-nested-rules", kwargs={"pk": category.pk})
)
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertEquals(len(data), 3)
self.assertEquals(data[0]["id"], rules[2].pk)
self.assertEquals(data[1]["id"], rules[0].pk)
self.assertEquals(data[2]["id"], rules[1].pk)
class NestedCategoryPostView(TestCase):
def setUp(self):
self.user = UserFactory(password="test")
self.client.login(email=self.user.email, password="test")
def test_simple(self):
category = CategoryFactory.create(user=self.user)
rules = {
rule.pk: PostFactory.create_batch(size=5, rule=rule)
for rule in CollectionRuleFactory.create_batch(
size=5, category=category, user=self.user
)
}
response = self.client.get(
reverse("api:categories-nested-posts", kwargs={"pk": category.pk})
)
data = response.json()
posts = data["results"]
self.assertEquals(response.status_code, 200)
self.assertEquals(data["count"], 25)
self.assertTrue("id" in posts[0])
self.assertTrue("title" in posts[0])
self.assertTrue("body" in posts[0])
self.assertTrue("rule" in posts[0])
self.assertTrue("url" in posts[0])
def test_no_rules(self):
category = CategoryFactory.create(user=self.user)
response = self.client.get(
reverse("api:categories-nested-posts", kwargs={"pk": category.pk})
)
data = response.json()
posts = data["results"]
self.assertEquals(response.status_code, 200)
self.assertEquals(data["count"], 0)
self.assertEquals(posts, [])
def test_no_posts(self):
category = CategoryFactory.create(user=self.user)
rules = CollectionRuleFactory.create_batch(
size=5, user=self.user, category=category
)
response = self.client.get(
reverse("api:categories-nested-posts", kwargs={"pk": category.pk})
)
data = response.json()
posts = data["results"]
self.assertEquals(response.status_code, 200)
self.assertEquals(data["count"], 0)
self.assertEquals(posts, [])
def test_not_known(self):
response = self.client.get(
reverse("api:categories-nested-posts", kwargs={"pk": 100})
)
self.assertEquals(response.status_code, 404)
def test_post(self):
response = self.client.post(
reverse("api:categories-nested-posts", kwargs={"pk": 100}),
data=json.dumps({}),
content_type="application/json",
)
data = response.json()
self.assertEquals(response.status_code, 405)
self.assertEquals(data["detail"], 'Method "POST" not allowed.')
def test_patch(self):
category = CategoryFactory.create(user=self.user)
response = self.client.patch(
reverse("api:categories-nested-posts", kwargs={"pk": category.pk}),
data=json.dumps({}),
content_type="application/json",
)
data = response.json()
self.assertEquals(response.status_code, 405)
self.assertEquals(data["detail"], 'Method "PATCH" not allowed.')
def test_put(self):
category = CategoryFactory.create(user=self.user)
response = self.client.put(
reverse("api:categories-nested-posts", kwargs={"pk": category.pk}),
data=json.dumps({}),
content_type="application/json",
)
data = response.json()
self.assertEquals(response.status_code, 405)
self.assertEquals(data["detail"], 'Method "PUT" not allowed.')
def test_delete(self):
category = CategoryFactory.create(user=self.user)
response = self.client.delete(
reverse("api:categories-nested-posts", kwargs={"pk": category.pk}),
content_type="application/json",
)
data = response.json()
self.assertEquals(response.status_code, 405)
self.assertEquals(data["detail"], 'Method "DELETE" not allowed.')
def test_with_unauthenticated_user(self):
self.client.logout()
category = CategoryFactory.create(user=self.user)
response = self.client.get(
reverse("api:categories-nested-posts", kwargs={"pk": category.pk})
)
self.assertEquals(response.status_code, 403)
def test_with_unauthorized_user(self):
other_user = UserFactory.create()
category = CategoryFactory.create(user=other_user)
response = self.client.get(
reverse("api:categories-nested-posts", kwargs={"pk": category.pk})
)
self.assertEquals(response.status_code, 403)
def test_ordering(self):
category = CategoryFactory.create(user=self.user)
bbc_rule = CollectionRuleFactory.create(
name="BBC", category=category, user=self.user
)
guardian_rule = CollectionRuleFactory.create(
name="The Guardian", category=category, user=self.user
)
reuters_rule = CollectionRuleFactory.create(
name="Reuters", category=category, user=self.user
)
reuters_rule = [
PostFactory.create(
title="Second Reuters post",
rule=reuters_rule,
publication_date=datetime.combine(
date(2019, 5, 21), time(hour=16, minute=7, second=37), pytz.utc
),
),
PostFactory.create(
title="First Reuters post",
rule=reuters_rule,
publication_date=datetime.combine(
date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc
),
),
]
guardian_posts = [
PostFactory.create(
title="Second Guardian post",
rule=guardian_rule,
publication_date=datetime.combine(
date(2019, 5, 21), time(hour=16, minute=7, second=37), pytz.utc
),
),
PostFactory.create(
title="First Guardian post",
rule=guardian_rule,
publication_date=datetime.combine(
date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc
),
),
]
bbc_posts = [
PostFactory.create(
title="Second BBC post",
rule=bbc_rule,
publication_date=datetime.combine(
date(2019, 5, 21), time(hour=16, minute=7, second=37), pytz.utc
),
),
PostFactory.create(
title="First BBC post",
rule=bbc_rule,
publication_date=datetime.combine(
date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc
),
),
]
response = self.client.get(
reverse("api:categories-nested-posts", kwargs={"pk": category.pk})
)
data = response.json()
posts = data["results"]
self.assertEquals(response.status_code, 200)
self.assertEquals(data["count"], 6)
self.assertEquals(posts[0]["title"], "Second BBC post")
self.assertEquals(posts[1]["title"], "First BBC post")
self.assertEquals(posts[2]["title"], "Second Reuters post")
self.assertEquals(posts[3]["title"], "First Reuters post")
self.assertEquals(posts[4]["title"], "Second Guardian post")
self.assertEquals(posts[5]["title"], "First Guardian post")
def test_only_posts_from_category_are_returned(self):
category = CategoryFactory.create(user=self.user)
other_category = CategoryFactory.create(user=self.user)
guardian_rule = CollectionRuleFactory.create(
name="BBC", category=category, user=self.user
)
other_rule = CollectionRuleFactory.create(name="The Guardian", user=self.user)
guardian_posts = [
PostFactory.create(rule=guardian_rule),
PostFactory.create(rule=guardian_rule),
]
other_posts = [
PostFactory.create(rule=other_rule),
PostFactory.create(rule=other_rule),
]
response = self.client.get(
reverse("api:categories-nested-posts", kwargs={"pk": category.pk})
)
data = response.json()
posts = data["results"]
self.assertEquals(response.status_code, 200)
self.assertEquals(data["count"], 2)
self.assertEquals(posts[0]["rule"], guardian_rule.pk)
self.assertEquals(posts[1]["rule"], guardian_rule.pk)
def test_unread_posts(self):
category = CategoryFactory.create(user=self.user)
rule = CollectionRuleFactory(category=category)
PostFactory.create_batch(size=10, rule=rule, read=False)
PostFactory.create_batch(size=10, rule=rule, read=True)
response = self.client.get(
reverse("api:categories-nested-posts", kwargs={"pk": category.pk}),
{"read": "false"},
)
data = response.json()
posts = data["results"]
self.assertEquals(response.status_code, 200)
self.assertEquals(data["count"], 10)
for post in posts:
self.assertEquals(post["read"], False)
def test_read_posts(self):
category = CategoryFactory.create(user=self.user)
rule = CollectionRuleFactory(category=category)
PostFactory.create_batch(size=20, rule=rule, read=False)
PostFactory.create_batch(size=10, rule=rule, read=True)
response = self.client.get(
reverse("api:categories-nested-posts", kwargs={"pk": category.pk}),
{"read": "true"},
)
data = response.json()
posts = data["results"]
self.assertEquals(response.status_code, 200)
self.assertEquals(data["count"], 10)
for post in posts:
self.assertEquals(post["read"], True)

View file

@ -10,11 +10,8 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory
class PostDetailViewTestCase(TestCase):
def setUp(self):
self.maxDiff = None
self.client = Client()
self.client = Client()
self.user = UserFactory(is_staff=True, password="test")
self.user = UserFactory(password="test")
self.client.login(email=self.user.email, password="test")
def test_simple(self):
rule = CollectionRuleFactory(
@ -22,7 +19,6 @@ class PostDetailViewTestCase(TestCase):
)
post = PostFactory(rule=rule)
self.client.force_login(self.user)
response = self.client.get(reverse("api:posts-detail", args=[post.pk]))
data = response.json()
@ -38,7 +34,6 @@ class PostDetailViewTestCase(TestCase):
self.assertTrue("remote_identifier" in data)
def test_not_known(self):
self.client.force_login(self.user)
response = self.client.get(reverse("api:posts-detail", args=[100]))
data = response.json()
@ -51,7 +46,6 @@ class PostDetailViewTestCase(TestCase):
)
post = PostFactory(rule=rule)
self.client.force_login(self.user)
response = self.client.post(reverse("api:posts-detail", args=[post.pk]))
data = response.json()
@ -64,7 +58,6 @@ class PostDetailViewTestCase(TestCase):
)
post = PostFactory(title="This is clickbait for sure", rule=rule)
self.client.force_login(self.user)
response = self.client.patch(
reverse("api:posts-detail", args=[post.pk]),
data=json.dumps({"title": "This title is very accurate"}),
@ -81,7 +74,6 @@ class PostDetailViewTestCase(TestCase):
)
post = PostFactory(title="This is clickbait for sure", rule=rule)
self.client.force_login(self.user)
response = self.client.patch(
reverse("api:posts-detail", args=[post.pk]),
data=json.dumps({"id": 44}),
@ -101,18 +93,16 @@ class PostDetailViewTestCase(TestCase):
)
post = PostFactory(title="This is clickbait for sure", rule=rule)
self.client.force_login(self.user)
response = self.client.patch(
reverse("api:posts-detail", args=[post.pk]),
data=json.dumps({"rule": reverse("api:rules-detail", args=[new_rule.pk])}),
content_type="application/json",
)
data = response.json()
rule_url = data["rule"]
self.assertEquals(response.status_code, 200)
self.assertTrue(rule_url.endswith(reverse("api:rules-detail", args=[rule.pk])))
self.assertTrue(data["rule"], rule.pk)
def test_put(self):
rule = CollectionRuleFactory(
@ -120,7 +110,6 @@ class PostDetailViewTestCase(TestCase):
)
post = PostFactory(title="This is clickbait for sure", rule=rule)
self.client.force_login(self.user)
response = self.client.put(
reverse("api:posts-detail", args=[post.pk]),
data=json.dumps({"title": "This title is very accurate"}),
@ -137,7 +126,6 @@ class PostDetailViewTestCase(TestCase):
)
post = PostFactory(rule=rule)
self.client.force_login(self.user)
response = self.client.delete(reverse("api:posts-detail", args=[post.pk]))
data = response.json()
@ -145,6 +133,8 @@ class PostDetailViewTestCase(TestCase):
self.assertEquals(data["detail"], 'Method "DELETE" not allowed.')
def test_post_with_unauthenticated_user_without_category(self):
self.client.logout()
rule = CollectionRuleFactory(user=self.user, category=None)
post = PostFactory(rule=rule)
@ -153,6 +143,8 @@ class PostDetailViewTestCase(TestCase):
self.assertEquals(response.status_code, 403)
def test_post_with_unauthenticated_user_with_category(self):
self.client.logout()
rule = CollectionRuleFactory(
user=self.user, category=CategoryFactory(user=self.user)
)
@ -167,7 +159,6 @@ class PostDetailViewTestCase(TestCase):
rule = CollectionRuleFactory(user=other_user, category=None)
post = PostFactory(rule=rule)
self.client.force_login(self.user)
response = self.client.get(reverse("api:posts-detail", args=[post.pk]))
self.assertEquals(response.status_code, 403)
@ -179,7 +170,6 @@ class PostDetailViewTestCase(TestCase):
)
post = PostFactory(rule=rule)
self.client.force_login(self.user)
response = self.client.get(reverse("api:posts-detail", args=[post.pk]))
self.assertEquals(response.status_code, 403)
@ -191,7 +181,38 @@ class PostDetailViewTestCase(TestCase):
)
post = PostFactory(rule=rule)
self.client.force_login(self.user)
response = self.client.get(reverse("api:posts-detail", args=[post.pk]))
self.assertEquals(response.status_code, 403)
def test_mark_read(self):
rule = CollectionRuleFactory(
user=self.user, category=CategoryFactory(user=self.user)
)
post = PostFactory(rule=rule, read=False)
response = self.client.patch(
reverse("api:posts-detail", args=[post.pk]),
data=json.dumps({"read": True}),
content_type="application/json",
)
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertEquals(data["read"], True)
def test_mark_unread(self):
rule = CollectionRuleFactory(
user=self.user, category=CategoryFactory(user=self.user)
)
post = PostFactory(rule=rule, read=True)
response = self.client.patch(
reverse("api:posts-detail", args=[post.pk]),
data=json.dumps({"read": False}),
content_type="application/json",
)
data = response.json()
self.assertEquals(response.status_code, 200)
self.assertEquals(data["read"], False)

View file

@ -12,10 +12,8 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory
class PostListViewTestCase(TestCase):
def setUp(self):
self.maxDiff = None
self.client = Client()
self.user = UserFactory(is_staff=True, password="test")
self.client.login(email=self.user.email, password="test")
def test_simple(self):
rule = CollectionRuleFactory(
@ -23,7 +21,6 @@ class PostListViewTestCase(TestCase):
)
PostFactory.create_batch(size=3, rule=rule)
self.client.force_login(self.user)
response = self.client.get(reverse("api:posts-list"))
data = response.json()
@ -61,7 +58,6 @@ class PostListViewTestCase(TestCase):
),
]
self.client.force_login(self.user)
response = self.client.get(reverse("api:posts-list"))
data = response.json()
@ -81,7 +77,6 @@ class PostListViewTestCase(TestCase):
PostFactory.create_batch(size=80, rule=rule)
page_size = 50
self.client.force_login(self.user)
response = self.client.get(reverse("api:posts-list"), {"count": 50})
data = response.json()
@ -90,7 +85,6 @@ class PostListViewTestCase(TestCase):
self.assertEquals(len(data["results"]), page_size)
def test_empty(self):
self.client.force_login(self.user)
response = self.client.get(reverse("api:posts-list"))
data = response.json()
@ -102,7 +96,6 @@ class PostListViewTestCase(TestCase):
self.assertEquals(len(data["results"]), 0)
def test_post(self):
self.client.force_login(self.user)
response = self.client.post(reverse("api:posts-list"))
data = response.json()
@ -110,7 +103,6 @@ class PostListViewTestCase(TestCase):
self.assertEquals(data["detail"], 'Method "POST" not allowed.')
def test_patch(self):
self.client.force_login(self.user)
response = self.client.patch(reverse("api:posts-list"))
data = response.json()
@ -118,7 +110,6 @@ class PostListViewTestCase(TestCase):
self.assertEquals(data["detail"], 'Method "PATCH" not allowed.')
def test_put(self):
self.client.force_login(self.user)
response = self.client.put(reverse("api:posts-list"))
data = response.json()
@ -126,7 +117,6 @@ class PostListViewTestCase(TestCase):
self.assertEquals(data["detail"], 'Method "PUT" not allowed.')
def test_delete(self):
self.client.force_login(self.user)
response = self.client.delete(reverse("api:posts-list"))
data = response.json()
@ -134,6 +124,8 @@ class PostListViewTestCase(TestCase):
self.assertEquals(data["detail"], 'Method "DELETE" not allowed.')
def test_posts_with_unauthenticated_user_without_category(self):
self.client.logout()
PostFactory.create_batch(size=3, rule=CollectionRuleFactory(user=self.user))
response = self.client.get(reverse("api:posts-list"))
@ -141,6 +133,8 @@ class PostListViewTestCase(TestCase):
self.assertEquals(response.status_code, 403)
def test_posts_with_unauthenticated_user_with_category(self):
self.client.logout()
category = CategoryFactory(user=self.user)
PostFactory.create_batch(
@ -157,7 +151,6 @@ class PostListViewTestCase(TestCase):
rule = CollectionRuleFactory(user=other_user, category=None)
PostFactory.create_batch(size=3, rule=rule)
self.client.force_login(self.user)
response = self.client.get(reverse("api:posts-list"))
data = response.json()
@ -173,7 +166,6 @@ class PostListViewTestCase(TestCase):
size=3, rule=CollectionRuleFactory(user=other_user, category=category)
)
self.client.force_login(self.user)
response = self.client.get(reverse("api:posts-list"))
data = response.json()
@ -191,7 +183,6 @@ class PostListViewTestCase(TestCase):
)
PostFactory.create_batch(size=3, rule=rule)
self.client.force_login(self.user)
response = self.client.get(reverse("api:posts-list"))
data = response.json()
@ -201,12 +192,9 @@ class PostListViewTestCase(TestCase):
self.assertEquals(data["count"], 0)
def test_posts_with_authorized_user_without_category(self):
UserFactory()
rule = CollectionRuleFactory(user=self.user, category=None)
PostFactory.create_batch(size=3, rule=rule)
self.client.force_login(self.user)
response = self.client.get(reverse("api:posts-list"))
data = response.json()
@ -214,3 +202,41 @@ class PostListViewTestCase(TestCase):
self.assertTrue("results" in data)
self.assertTrue("count" in data)
self.assertEquals(data["count"], 3)
def test_unread_posts(self):
rule = CollectionRuleFactory(
user=self.user, category=CategoryFactory(user=self.user)
)
PostFactory.create_batch(size=10, rule=rule, read=False)
PostFactory.create_batch(size=10, rule=rule, read=True)
response = self.client.get(reverse("api:posts-list"), {"read": "false"})
data = response.json()
posts = data["results"]
self.assertEquals(response.status_code, 200)
self.assertEquals(data["count"], 10)
for post in posts:
self.assertEquals(post["read"], False)
def test_read_posts(self):
rule = CollectionRuleFactory(
user=self.user, category=CategoryFactory(user=self.user)
)
PostFactory.create_batch(size=20, rule=rule, read=False)
PostFactory.create_batch(size=10, rule=rule, read=True)
response = self.client.get(reverse("api:posts-list"), {"read": "true"})
data = response.json()
posts = data["results"]
self.assertEquals(response.status_code, 200)
self.assertEquals(data["count"], 10)
for post in posts:
self.assertEquals(post["read"], True)

View file

@ -25,5 +25,7 @@ class PostFactory(factory.django.DjangoModelFactory):
"newsreader.news.collection.tests.factories.CollectionRuleFactory"
)
read = False
class Meta:
model = Post

View file

@ -1,18 +1,34 @@
from django.contrib.auth.decorators import login_required
from django.urls import path
from newsreader.news.core.views import (
DetailCategoryAPIView,
DetailPostAPIView,
ListCategoryAPIView,
ListPostAPIView,
from newsreader.news.core.endpoints import (
CategoryReadView,
DetailCategoryView,
DetailPostView,
ListCategoryView,
ListPostView,
NestedPostCategoryView,
NestedRuleCategoryView,
)
from newsreader.news.core.views import MainView
index_page = login_required(MainView.as_view())
endpoints = [
path("posts/", ListPostAPIView.as_view(), name="posts-list"),
path("posts/<int:pk>/", DetailPostAPIView.as_view(), name="posts-detail"),
path("categories/", ListCategoryAPIView.as_view(), name="categories-list"),
path("posts/", ListPostView.as_view(), name="posts-list"),
path("posts/<int:pk>/", DetailPostView.as_view(), name="posts-detail"),
path("categories/", ListCategoryView.as_view(), name="categories-list"),
path("categories/<int:pk>/", DetailCategoryView.as_view(), name="categories-detail"),
path("categories/<int:pk>/read/", CategoryReadView.as_view(), name="categories-read"),
path(
"categories/<int:pk>/", DetailCategoryAPIView.as_view(), name="categories-detail"
"categories/<int:pk>/rules/",
NestedRuleCategoryView.as_view(),
name="categories-nested-rules",
),
path(
"categories/<int:pk>/posts/",
NestedPostCategoryView.as_view(),
name="categories-nested-posts",
),
]

View file

@ -1,49 +1,25 @@
from django.db.models import Q
from typing import Dict
from rest_framework.generics import (
ListAPIView,
ListCreateAPIView,
RetrieveUpdateAPIView,
RetrieveUpdateDestroyAPIView,
)
from rest_framework.permissions import IsAuthenticated
from newsreader.accounts.permissions import IsPostOwner
from newsreader.core.pagination import LargeResultSetPagination, ResultSetPagination
from newsreader.news.core.models import Category, Post
from newsreader.news.core.serializers import CategorySerializer, PostSerializer
from django.views.generic.base import TemplateView
class ListPostAPIView(ListAPIView):
queryset = Post.objects.all()
serializer_class = PostSerializer
pagination_class = LargeResultSetPagination
permission_classes = (IsAuthenticated, IsPostOwner)
class MainView(TemplateView):
template_name = "core/main.html"
def get_queryset(self):
# TODO serialize objects to show filled main page
def get_context_data(self, **kwargs) -> Dict:
context = super().get_context_data(**kwargs)
user = self.request.user
initial_queryset = self.queryset.filter(rule__user=user)
return initial_queryset.filter(
Q(rule__category=None) | Q(rule__category__user=user)
).order_by("rule", "-publication_date", "-created")
categories = {
category: category.rules.order_by("-created")
for category in user.categories.order_by("name")
}
class DetailPostAPIView(RetrieveUpdateAPIView):
queryset = Post.objects.all()
serializer_class = PostSerializer
permission_classes = (IsAuthenticated, IsPostOwner)
rules = {
rule: rule.posts.order_by("-publication_date")[:30]
for rule in user.rules.order_by("-created")
}
class ListCategoryAPIView(ListCreateAPIView):
queryset = Category.objects.all()
serializer_class = CategorySerializer
pagination_class = ResultSetPagination
def get_queryset(self):
user = self.request.user
return self.queryset.filter(user=user).order_by("-created", "-modified")
class DetailCategoryAPIView(RetrieveUpdateDestroyAPIView):
queryset = Category.objects.all()
serializer_class = CategorySerializer
context.update(categories=categories, rules=rules)
return context

View file

@ -8,6 +8,8 @@
&__fieldset {
@extend .form__fieldset;
padding: 10px;
}
&__fieldset * {

View file

@ -1,4 +1,6 @@
// General imports
@import "../partials/variables";
@import "../components/index";
// Page specific
@import "./components/index";

View file

@ -2,4 +2,9 @@
margin: 0;
padding: 0;
background-color: $gainsboro;
& * {
margin: 0;
padding: 0;
}
}

View file

@ -6,8 +6,6 @@
padding: 10px 50px;
width: 50px;
border: none;
border-radius: 2px;

View file

@ -4,3 +4,5 @@
@import "./main/index";
@import "./navbar/index";
@import "./error/index";
@import "./loading-indicator/index";
@import "./modal/index";

View file

@ -0,0 +1,41 @@
.loading-indicator {
display: inline-block;
position: relative;
width: 64px;
height: 64px;
& div {
display: inline-block;
position: absolute;
left: 6px;
width: 13px;
background-color: $lavendal-pink;
animation: loading-indicator 1.2s cubic-bezier(0, 0.5, 0.5, 1) infinite;
&:nth-child(1){
left: 6px;
animation-delay: -0.24s;
}
&:nth-child(2){
left: 26px;
animation-delay: -0.12s;
}
&:nth-child(3){
left: 45px;
animation-delay: 0;
}
}
}
@keyframes loading-indicator {
0% {
top: 6px;
height: 51px;
}
50%, 100% {
top: 19px;
height: 26px;
}
}

View file

@ -0,0 +1 @@
@import "loading-indicator";

View file

@ -0,0 +1,9 @@
.modal {
position: fixed;
width: 100%;
height: 100%;
top: 0;
background-color: $dark;
}

View file

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

View file

@ -2,6 +2,7 @@
display: flex;
justify-content: center;
padding: 15px 0;
width: 100%;
background-color: $white;

View file

@ -0,0 +1,24 @@
.categories {
display: flex;
flex-direction: column;
align-items: center;
width: 90%;
font-family: $sidebar-font;
border-radius: 2px;
& ul {
margin: 0;
padding: 0;
width: 100%;
list-style: none;
border-radius: 5px;
}
&__item {
padding: 2px 10px 5px 10px;
}
}

View file

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

View file

@ -0,0 +1,46 @@
.category {
display: flex;
align-items: center;
padding: 5px;
border-radius: 5px;
&__info {
display: flex;
justify-content: space-between;
width: 100%;
padding: 0 0 0 20px;
overflow: hidden;
white-space: nowrap;
& h4 {
overflow: hidden;
text-overflow: ellipsis;
}
&:hover {
cursor: pointer;
}
}
&__menu {
display: flex;
align-items: center;
&:hover {
cursor: pointer;
}
}
&:hover {
background-color: darken($gainsboro, 10%);
}
&--selected {
color: $white;
background-color: darken($gainsboro, 10%);
}
}

View file

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

View file

@ -0,0 +1,7 @@
.content {
display: flex;
flex-direction: column;
align-items: center;
margin: 2% 0 0 0;
}

View file

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

View file

@ -0,0 +1,17 @@
@import "content/index";
@import "main/index";
@import "sidebar/index";
@import "categories/index";
@import "category/index";
@import "rules/index";
@import "rule/index";
@import "post-block/index";
@import "posts-section/index";
@import "posts/index";
@import "posts-header/index";
@import "post/index";
@import "post-message/index";
@import "read-button/index";

View file

@ -0,0 +1,12 @@
.main {
display: flex;
flex-direction: row;
width: 100%;
margin: 0;
background-color: initial;
&--centered {
justify-content: center;
}
}

View file

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

View file

@ -0,0 +1,12 @@
.post-block {
display: flex;
flex-direction: column;
width: 60%;
margin: 0 0 2% 0;
font-family: $article-font;
border-radius: 2px;
background-color: $white;
}

View file

@ -0,0 +1 @@
@import "post-block";

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