Refactor endpoint tests
Replace force_login calls with login call from client class in setUp
This commit is contained in:
parent
61702e720a
commit
858f84aaad
132 changed files with 5158 additions and 661 deletions
10
.babelrc
10
.babelrc
|
|
@ -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
1
.gitignore
vendored
|
|
@ -154,6 +154,7 @@ dmypy.json
|
||||||
# Translations
|
# Translations
|
||||||
|
|
||||||
# Django stuff:
|
# Django stuff:
|
||||||
|
src/newsreader/fixtures/local
|
||||||
|
|
||||||
# Flask stuff:
|
# Flask stuff:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,57 @@
|
||||||
services:
|
stages:
|
||||||
- postgres:9.6
|
- build
|
||||||
|
- test
|
||||||
|
- lint
|
||||||
|
|
||||||
variables:
|
javascript build:
|
||||||
POSTGRES_DB: newsreader
|
image: node:12
|
||||||
POSTGRES_USER: newsreader
|
stage: build
|
||||||
|
cache:
|
||||||
|
key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG"
|
||||||
|
paths:
|
||||||
|
- node_modules/
|
||||||
|
before_script:
|
||||||
|
- npm install --dev
|
||||||
|
script:
|
||||||
|
- npx gulp
|
||||||
|
|
||||||
python tests:
|
python tests:
|
||||||
|
services:
|
||||||
|
- postgres:11
|
||||||
image: python:3.7.4-slim-stretch
|
image: python:3.7.4-slim-stretch
|
||||||
stage: test
|
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:
|
variables:
|
||||||
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
|
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
|
||||||
DJANGO_SETTINGS_MODULE: "newsreader.conf.gitlab"
|
DJANGO_SETTINGS_MODULE: "newsreader.conf.gitlab"
|
||||||
|
|
@ -21,6 +65,6 @@ python tests:
|
||||||
- source env/bin/activate
|
- source env/bin/activate
|
||||||
- pip install -r requirements/gitlab.txt
|
- pip install -r requirements/gitlab.txt
|
||||||
script:
|
script:
|
||||||
- python src/manage.py test newsreader
|
|
||||||
- isort -rc src/ --check-only
|
- isort -rc src/ --check-only
|
||||||
- black -l 90 --check src/
|
- black -l 90 --check src/
|
||||||
|
- autoflake -rc src/
|
||||||
|
|
|
||||||
10
.prettierrc.json
Normal file
10
.prettierrc.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 90,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "avoid"
|
||||||
|
}
|
||||||
16
Dockerfile
16
Dockerfile
|
|
@ -1,14 +1,18 @@
|
||||||
FROM python:3.7-buster
|
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
|
RUN mkdir /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN chown newsreader:newsreader /app
|
||||||
|
USER newsreader
|
||||||
|
|
||||||
# Use a seperate layer for the project requirements
|
# Use a seperate layer for the project requirements
|
||||||
COPY ./requirements /app/requirements
|
COPY requirements /app/requirements
|
||||||
RUN pip install -r requirements/dev.txt
|
RUN pip install --user -r requirements/dev.txt
|
||||||
|
|
||||||
COPY . /app/
|
COPY . /app/
|
||||||
|
|
||||||
# Set the default shell & add a home dir
|
|
||||||
RUN useradd -ms /bin/bash newsreader
|
|
||||||
USER newsreader
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,26 @@ services:
|
||||||
db:
|
db:
|
||||||
# See https://hub.docker.com/_/postgres
|
# See https://hub.docker.com/_/postgres
|
||||||
image: postgres
|
image: postgres
|
||||||
|
container_name: postgres
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=newsreader
|
- POSTGRES_USER=newsreader
|
||||||
- POSTGRES_DB=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:
|
web:
|
||||||
build: .
|
build: .
|
||||||
|
container_name: web
|
||||||
command: src/entrypoint.sh
|
command: src/entrypoint.sh
|
||||||
environment:
|
environment:
|
||||||
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker
|
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker
|
||||||
|
|
@ -18,14 +33,3 @@ services:
|
||||||
- '8000:8000'
|
- '8000:8000'
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- 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
28
gulp/babel.js
Normal 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;
|
||||||
30
gulp/sass.js
30
gulp/sass.js
|
|
@ -1,17 +1,25 @@
|
||||||
import { src, dest } from "gulp";
|
import { src, dest } from 'gulp';
|
||||||
|
|
||||||
import concat from "gulp-concat";
|
import concat from 'gulp-concat';
|
||||||
import path from "path";
|
import path from 'path';
|
||||||
import sass from "gulp-sass";
|
import sass from 'gulp-sass';
|
||||||
|
|
||||||
const PROJECT_DIR = path.join("src", "newsreader");
|
const PROJECT_DIR = path.join('src', 'newsreader');
|
||||||
const STATIC_DIR = path.join(PROJECT_DIR, "static");
|
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(){
|
export const accountsTask = () => {
|
||||||
return src(`${STATIC_DIR}/src/scss/accounts/index.scss`)
|
return src(`${STATIC_DIR}/accounts/index.scss`)
|
||||||
.pipe(sass().on("error", sass.logError))
|
.pipe(sass().on('error', sass.logError))
|
||||||
.pipe(concat("accounts.css"))
|
.pipe(concat('accounts.css'))
|
||||||
.pipe(dest(`${ACCOUNTS_DIR}/accounts/dist/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`));
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,27 @@
|
||||||
import { series, watch as _watch } from 'gulp';
|
import { parallel, series, watch as _watch } from 'gulp';
|
||||||
|
|
||||||
import path from "path";
|
import path from 'path';
|
||||||
import del from "del";
|
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([
|
return del([
|
||||||
`${ACCOUNTS_DIR}/accounts/dist/css/*`,
|
`${ACCOUNTS_DIR}/accounts/dist/css/*`,
|
||||||
|
|
||||||
|
`${CORE_DIR}/core/dist/css/*`,
|
||||||
|
`${CORE_DIR}/core/dist/js/*`,
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function watch(){
|
export const watch = () => {
|
||||||
_watch(`${STATIC_DIR}/src/scss/**/*.scss`, (done) => {
|
return _watch([`${PROJECT_DIR}/scss/**/*.scss`, `${PROJECT_DIR}/js/**/*.js`], done => {
|
||||||
series(clean, buildSass)(done);
|
series(clean, ...sassTasks, babelTask)(done);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default series(clean, buildSass);
|
export default series(clean, ...sassTasks, babelTask);
|
||||||
|
|
|
||||||
1367
package-lock.json
generated
1367
package-lock.json
generated
File diff suppressed because it is too large
Load diff
36
package.json
36
package.json
|
|
@ -4,8 +4,8 @@
|
||||||
"description": "Application for viewing RSS feeds",
|
"description": "Application for viewing RSS feeds",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "prettier \"src/newsreader/**/*.js\" --check",
|
"lint": "prettier \"src/newsreader/js/**/*.js\" --check",
|
||||||
"format": "prettier \"src/newsreader/**/*.js\" --write"
|
"format": "prettier \"src/newsreader/js/**/*.js\" --write"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
@ -13,21 +13,27 @@
|
||||||
},
|
},
|
||||||
"author": "Sonny",
|
"author": "Sonny",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"prettier": {
|
"dependencies": {
|
||||||
"semi": true,
|
"js-cookie": "^2.2.1",
|
||||||
"trailingComma": "es5",
|
"lodash": "^4.17.15",
|
||||||
"singleQuote": false,
|
"react-redux": "^7.1.0",
|
||||||
"printWidth": 80,
|
"redux": "^4.0.4",
|
||||||
"tabWidth": 2,
|
"redux-logger": "^3.0.6",
|
||||||
"useTabs": false,
|
"redux-thunk": "^2.3.0"
|
||||||
"bracketSpacing": false,
|
|
||||||
"arrowParens": "always"
|
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.5.4",
|
"@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/preset-env": "^7.5.4",
|
||||||
"@babel/register": "^7.4.4",
|
"@babel/register": "^7.4.4",
|
||||||
|
"@babel/runtime": "^7.5.5",
|
||||||
|
"babelify": "^10.0.0",
|
||||||
|
"browserify": "^16.3.0",
|
||||||
"del": "^5.0.0",
|
"del": "^5.0.0",
|
||||||
"gulp": "^4.0.2",
|
"gulp": "^4.0.2",
|
||||||
"gulp-babel": "^8.0.0-beta.2",
|
"gulp-babel": "^8.0.0-beta.2",
|
||||||
|
|
@ -35,6 +41,10 @@
|
||||||
"gulp-concat": "^2.6.1",
|
"gulp-concat": "^2.6.1",
|
||||||
"gulp-sass": "^4.0.2",
|
"gulp-sass": "^4.0.2",
|
||||||
"node-sass": "^4.12.0",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ chardet==3.0.4
|
||||||
Django==2.2
|
Django==2.2
|
||||||
django-celery-beat==1.5.0
|
django-celery-beat==1.5.0
|
||||||
djangorestframework==3.9.4
|
djangorestframework==3.9.4
|
||||||
django-rest-swagger-2.2.0
|
django-rest-swagger==2.2.0
|
||||||
lxml==4.3.4
|
lxml==4.3.4
|
||||||
feedparser==5.2.1
|
feedparser==5.2.1
|
||||||
idna==2.8
|
idna==2.8
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
-r base.txt
|
-r testing.txt
|
||||||
|
|
||||||
factory-boy==2.12.0
|
|
||||||
freezegun==0.3.12
|
|
||||||
black==19.3b0
|
black==19.3b0
|
||||||
isort==4.3.20
|
isort==4.3.20
|
||||||
|
autoflake==1.3
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,11 @@ from django.contrib.auth.views import LogoutView as DjangoLogoutView
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
|
|
||||||
|
|
||||||
# TODO redirect to homepage when logged in
|
|
||||||
class LoginView(DjangoLoginView):
|
class LoginView(DjangoLoginView):
|
||||||
template_name = "accounts/login.html"
|
template_name = "accounts/login.html"
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
# TODO redirect to homepage
|
return reverse_lazy("index")
|
||||||
return reverse_lazy("admin:index")
|
|
||||||
|
|
||||||
|
|
||||||
class LogoutView(DjangoLogoutView):
|
class LogoutView(DjangoLogoutView):
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,8 @@ USE_TZ = True
|
||||||
# https://docs.djangoproject.com/en/2.2/howto/static-files/
|
# https://docs.djangoproject.com/en/2.2/howto/static-files/
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
|
|
||||||
|
STATICFILES_DIRS = ["src/newsreader/static/icons"]
|
||||||
|
|
||||||
# Third party settings
|
# Third party settings
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||||
|
|
@ -123,3 +125,9 @@ REST_FRAMEWORK = {
|
||||||
),
|
),
|
||||||
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
|
"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
13
src/newsreader/js/components/LoadingIndicator.js
Normal file
13
src/newsreader/js/components/LoadingIndicator.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const LoadingIndicator = props => {
|
||||||
|
return (
|
||||||
|
<div className="loading-indicator">
|
||||||
|
<div />
|
||||||
|
<div />
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingIndicator;
|
||||||
63
src/newsreader/js/homepage/App.js
Normal file
63
src/newsreader/js/homepage/App.js
Normal 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);
|
||||||
86
src/newsreader/js/homepage/actions/categories.js
Normal file
86
src/newsreader/js/homepage/actions/categories.js
Normal 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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
119
src/newsreader/js/homepage/actions/posts.js
Normal file
119
src/newsreader/js/homepage/actions/posts.js
Normal 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 }));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
68
src/newsreader/js/homepage/actions/rules.js
Normal file
68
src/newsreader/js/homepage/actions/rules.js
Normal 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));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
65
src/newsreader/js/homepage/actions/selected.js
Normal file
65
src/newsreader/js/homepage/actions/selected.js
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
84
src/newsreader/js/homepage/components/PostModal.js
Normal file
84
src/newsreader/js/homepage/components/PostModal.js
Normal 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);
|
||||||
102
src/newsreader/js/homepage/components/feedlist/FeedList.js
Normal file
102
src/newsreader/js/homepage/components/feedlist/FeedList.js
Normal 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);
|
||||||
50
src/newsreader/js/homepage/components/feedlist/PostItem.js
Normal file
50
src/newsreader/js/homepage/components/feedlist/PostItem.js
Normal 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);
|
||||||
25
src/newsreader/js/homepage/components/feedlist/RuleItem.js
Normal file
25
src/newsreader/js/homepage/components/feedlist/RuleItem.js
Normal 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;
|
||||||
44
src/newsreader/js/homepage/components/feedlist/filters.js
Normal file
44
src/newsreader/js/homepage/components/feedlist/filters.js
Normal 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 [];
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
36
src/newsreader/js/homepage/components/sidebar/ReadButton.js
Normal file
36
src/newsreader/js/homepage/components/sidebar/ReadButton.js
Normal 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);
|
||||||
56
src/newsreader/js/homepage/components/sidebar/RuleItem.js
Normal file
56
src/newsreader/js/homepage/components/sidebar/RuleItem.js
Normal 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);
|
||||||
52
src/newsreader/js/homepage/components/sidebar/Sidebar.js
Normal file
52
src/newsreader/js/homepage/components/sidebar/Sidebar.js
Normal 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);
|
||||||
7
src/newsreader/js/homepage/components/sidebar/filters.js
Normal file
7
src/newsreader/js/homepage/components/sidebar/filters.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export const filterCategories = (categories = {}) => {
|
||||||
|
return Object.values({ ...categories });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterRules = (rules = {}) => {
|
||||||
|
return Object.values({ ...rules });
|
||||||
|
};
|
||||||
18
src/newsreader/js/homepage/configureStore.js
Normal file
18
src/newsreader/js/homepage/configureStore.js
Normal 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;
|
||||||
16
src/newsreader/js/homepage/index.js
Normal file
16
src/newsreader/js/homepage/index.js
Normal 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]
|
||||||
|
);
|
||||||
116
src/newsreader/js/homepage/reducers/categories.js
Normal file
116
src/newsreader/js/homepage/reducers/categories.js
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
10
src/newsreader/js/homepage/reducers/index.js
Normal file
10
src/newsreader/js/homepage/reducers/index.js
Normal 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;
|
||||||
67
src/newsreader/js/homepage/reducers/posts.js
Normal file
67
src/newsreader/js/homepage/reducers/posts.js
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
65
src/newsreader/js/homepage/reducers/rules.js
Normal file
65
src/newsreader/js/homepage/reducers/rules.js
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
70
src/newsreader/js/homepage/reducers/selected.js
Normal file
70
src/newsreader/js/homepage/reducers/selected.js
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
14
src/newsreader/js/utils.js
Normal file
14
src/newsreader/js/utils.js
Normal 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);
|
||||||
|
};
|
||||||
|
|
@ -4,9 +4,10 @@ from newsreader.news.collection.models import CollectionRule
|
||||||
|
|
||||||
|
|
||||||
class CollectionRuleAdmin(admin.ModelAdmin):
|
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_display = ("name", "category", "url", "last_suceeded", "succeeded")
|
||||||
|
list_filter = ("user",)
|
||||||
|
|
||||||
def save_model(self, request, obj, form, change):
|
def save_model(self, request, obj, form, change):
|
||||||
if not change:
|
if not change:
|
||||||
|
|
|
||||||
69
src/newsreader/news/collection/endpoints.py
Normal file
69
src/newsreader/news/collection/endpoints.py
Normal 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)
|
||||||
|
|
@ -1,23 +1,15 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from newsreader.news import core
|
|
||||||
from newsreader.news.collection.models import CollectionRule
|
from newsreader.news.collection.models import CollectionRule
|
||||||
|
|
||||||
|
|
||||||
class CollectionRuleSerializer(serializers.HyperlinkedModelSerializer):
|
class RuleSerializer(serializers.ModelSerializer):
|
||||||
posts = serializers.SerializerMethodField()
|
|
||||||
user = serializers.HiddenField(default=serializers.CurrentUserDefault())
|
user = serializers.HiddenField(default=serializers.CurrentUserDefault())
|
||||||
|
unread = serializers.SerializerMethodField()
|
||||||
|
|
||||||
def get_posts(self, instance):
|
def get_unread(self, rule):
|
||||||
request = self.context.get("request")
|
return rule.posts.filter(read=False).count()
|
||||||
posts = instance.posts.order_by("-publication_date")
|
|
||||||
|
|
||||||
serializer = core.serializers.PostSerializer(
|
|
||||||
posts, context={"request": request}, many=True
|
|
||||||
)
|
|
||||||
return serializer.data
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CollectionRule
|
model = CollectionRule
|
||||||
fields = ("id", "name", "url", "favicon", "category", "posts", "user")
|
fields = ("id", "name", "url", "favicon", "category", "user", "unread")
|
||||||
extra_kwargs = {"category": {"view_name": "api:categories-detail"}}
|
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,22 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from newsreader.accounts.tests.factories import UserFactory
|
from newsreader.accounts.tests.factories import UserFactory
|
||||||
from newsreader.news.collection.tests.factories import CollectionRuleFactory
|
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):
|
class CollectionRuleDetailViewTestCase(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.maxDiff = None
|
self.user = UserFactory(password="test")
|
||||||
|
self.client.login(email=self.user.email, password="test")
|
||||||
self.user = UserFactory(is_staff=True, password="test")
|
|
||||||
self.client = Client()
|
|
||||||
|
|
||||||
def test_simple(self):
|
def test_simple(self):
|
||||||
rule = CollectionRuleFactory(user=self.user)
|
rule = CollectionRuleFactory(user=self.user)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:rules-detail", args=[rule.pk]))
|
response = self.client.get(reverse("api:rules-detail", args=[rule.pk]))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -31,10 +27,8 @@ class CollectionRuleDetailViewTestCase(TestCase):
|
||||||
self.assertTrue("url" in data)
|
self.assertTrue("url" in data)
|
||||||
self.assertTrue("favicon" in data)
|
self.assertTrue("favicon" in data)
|
||||||
self.assertTrue("category" in data)
|
self.assertTrue("category" in data)
|
||||||
self.assertTrue("posts" in data)
|
|
||||||
|
|
||||||
def test_not_known(self):
|
def test_not_known(self):
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:rules-detail", args=[100]))
|
response = self.client.get(reverse("api:rules-detail", args=[100]))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -44,7 +38,6 @@ class CollectionRuleDetailViewTestCase(TestCase):
|
||||||
def test_post(self):
|
def test_post(self):
|
||||||
rule = CollectionRuleFactory(user=self.user)
|
rule = CollectionRuleFactory(user=self.user)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.post(reverse("api:rules-detail", args=[rule.pk]))
|
response = self.client.post(reverse("api:rules-detail", args=[rule.pk]))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -54,7 +47,6 @@ class CollectionRuleDetailViewTestCase(TestCase):
|
||||||
def test_patch(self):
|
def test_patch(self):
|
||||||
rule = CollectionRuleFactory(name="BBC", user=self.user)
|
rule = CollectionRuleFactory(name="BBC", user=self.user)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
reverse("api:rules-detail", args=[rule.pk]),
|
reverse("api:rules-detail", args=[rule.pk]),
|
||||||
data=json.dumps({"name": "The guardian"}),
|
data=json.dumps({"name": "The guardian"}),
|
||||||
|
|
@ -65,18 +57,12 @@ class CollectionRuleDetailViewTestCase(TestCase):
|
||||||
self.assertEquals(response.status_code, 200)
|
self.assertEquals(response.status_code, 200)
|
||||||
self.assertEquals(data["name"], "The guardian")
|
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)
|
old_category = CategoryFactory(user=self.user)
|
||||||
new_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)
|
rule = CollectionRuleFactory(name="BBC", category=old_category, user=self.user)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
reverse("api:rules-detail", args=[rule.pk]),
|
reverse("api:rules-detail", args=[rule.pk]),
|
||||||
data=json.dumps({"category": absolute_url}),
|
data=json.dumps({"category": absolute_url}),
|
||||||
|
|
@ -85,34 +71,11 @@ class CollectionRuleDetailViewTestCase(TestCase):
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 200)
|
self.assertEquals(response.status_code, 200)
|
||||||
self.assertEquals(data["category"], absolute_url)
|
self.assertEquals(data["category"], new_category.pk)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
def test_identifier_cannot_be_changed(self):
|
def test_identifier_cannot_be_changed(self):
|
||||||
rule = CollectionRuleFactory(user=self.user)
|
rule = CollectionRuleFactory(user=self.user)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
reverse("api:rules-detail", args=[rule.pk]),
|
reverse("api:rules-detail", args=[rule.pk]),
|
||||||
data=json.dumps({"id": 44}),
|
data=json.dumps({"id": 44}),
|
||||||
|
|
@ -127,26 +90,20 @@ class CollectionRuleDetailViewTestCase(TestCase):
|
||||||
rule = CollectionRuleFactory(user=self.user)
|
rule = CollectionRuleFactory(user=self.user)
|
||||||
category = CategoryFactory(user=self.user)
|
category = CategoryFactory(user=self.user)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
reverse("api:rules-detail", args=[rule.pk]),
|
reverse("api:rules-detail", args=[rule.pk]),
|
||||||
data=json.dumps(
|
data=json.dumps({"category": category.pk}),
|
||||||
{"category": reverse("api:categories-detail", args=[category.pk])}
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
url = data["category"]
|
data["category"]
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 200)
|
self.assertEquals(response.status_code, 200)
|
||||||
self.assertTrue(
|
self.assertEquals(data["category"], category.pk)
|
||||||
url.endswith(reverse("api:categories-detail", args=[category.pk]))
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_put(self):
|
def test_put(self):
|
||||||
rule = CollectionRuleFactory(name="BBC", user=self.user)
|
rule = CollectionRuleFactory(name="BBC", user=self.user)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.put(
|
response = self.client.put(
|
||||||
reverse("api:rules-detail", args=[rule.pk]),
|
reverse("api:rules-detail", args=[rule.pk]),
|
||||||
data=json.dumps({"name": "BBC", "url": "https://www.bbc.co.uk"}),
|
data=json.dumps({"name": "BBC", "url": "https://www.bbc.co.uk"}),
|
||||||
|
|
@ -160,12 +117,13 @@ class CollectionRuleDetailViewTestCase(TestCase):
|
||||||
def test_delete(self):
|
def test_delete(self):
|
||||||
rule = CollectionRuleFactory(user=self.user)
|
rule = CollectionRuleFactory(user=self.user)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.delete(reverse("api:rules-detail", args=[rule.pk]))
|
response = self.client.delete(reverse("api:rules-detail", args=[rule.pk]))
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 204)
|
self.assertEquals(response.status_code, 204)
|
||||||
|
|
||||||
def test_rule_with_unauthenticated_user(self):
|
def test_rule_with_unauthenticated_user(self):
|
||||||
|
self.client.logout()
|
||||||
|
|
||||||
rule = CollectionRuleFactory(name="BBC", user=self.user)
|
rule = CollectionRuleFactory(name="BBC", user=self.user)
|
||||||
|
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
|
|
@ -180,7 +138,6 @@ class CollectionRuleDetailViewTestCase(TestCase):
|
||||||
other_user = UserFactory()
|
other_user = UserFactory()
|
||||||
rule = CollectionRuleFactory(name="BBC", user=other_user)
|
rule = CollectionRuleFactory(name="BBC", user=other_user)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
reverse("api:rules-detail", args=[rule.pk]),
|
reverse("api:rules-detail", args=[rule.pk]),
|
||||||
data=json.dumps({"name": "The guardian"}),
|
data=json.dumps({"name": "The guardian"}),
|
||||||
|
|
@ -188,3 +145,95 @@ class CollectionRuleDetailViewTestCase(TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 403)
|
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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from newsreader.news.collection.views import (
|
from newsreader.news.collection.endpoints import (
|
||||||
CollectionRuleAPIListView,
|
DetailRuleView,
|
||||||
CollectionRuleDetailView,
|
ListRuleView,
|
||||||
|
NestedRuleView,
|
||||||
|
RuleReadView,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
endpoints = [
|
endpoints = [
|
||||||
path("rules/<int:pk>", CollectionRuleDetailView.as_view(), name="rules-detail"),
|
path("rules/<int:pk>", DetailRuleView.as_view(), name="rules-detail"),
|
||||||
path("rules/", CollectionRuleAPIListView.as_view(), name="rules-list"),
|
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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
118
src/newsreader/news/core/endpoints.py
Normal file
118
src/newsreader/news/core/endpoints.py
Normal 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)
|
||||||
32
src/newsreader/news/core/filters.py
Normal file
32
src/newsreader/news/core/filters.py
Normal 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")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
14
src/newsreader/news/core/migrations/0003_post_read.py
Normal file
14
src/newsreader/news/core/migrations/0003_post_read.py
Normal 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)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
@ -12,6 +12,8 @@ class Post(TimeStampedModel):
|
||||||
publication_date = models.DateTimeField(blank=True, null=True)
|
publication_date = models.DateTimeField(blank=True, null=True)
|
||||||
url = models.URLField(max_length=1024, blank=True, null=True)
|
url = models.URLField(max_length=1024, blank=True, null=True)
|
||||||
|
|
||||||
|
read = models.BooleanField(default=False)
|
||||||
|
|
||||||
rule = models.ForeignKey(
|
rule = models.ForeignKey(
|
||||||
CollectionRule, on_delete=models.CASCADE, editable=False, related_name="posts"
|
CollectionRule, on_delete=models.CASCADE, editable=False, related_name="posts"
|
||||||
)
|
)
|
||||||
|
|
@ -24,7 +26,7 @@ class Post(TimeStampedModel):
|
||||||
|
|
||||||
|
|
||||||
class Category(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")
|
user = models.ForeignKey("accounts.User", _("Owner"), related_name="categories")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from newsreader.news import collection
|
||||||
from newsreader.news.core.models import Category, Post
|
from newsreader.news.core.models import Category, Post
|
||||||
|
|
||||||
|
|
||||||
class PostSerializer(serializers.HyperlinkedModelSerializer):
|
class PostSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Post
|
model = Post
|
||||||
fields = (
|
fields = (
|
||||||
|
|
@ -16,24 +16,19 @@ class PostSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
"publication_date",
|
"publication_date",
|
||||||
"url",
|
"url",
|
||||||
"rule",
|
"rule",
|
||||||
|
"read",
|
||||||
)
|
)
|
||||||
extra_kwargs = {"rule": {"view_name": "api:rules-detail"}}
|
|
||||||
|
|
||||||
|
|
||||||
class CategorySerializer(serializers.HyperlinkedModelSerializer):
|
class CategorySerializer(serializers.ModelSerializer):
|
||||||
rules = serializers.SerializerMethodField()
|
|
||||||
user = serializers.HiddenField(default=serializers.CurrentUserDefault())
|
user = serializers.HiddenField(default=serializers.CurrentUserDefault())
|
||||||
|
unread = serializers.SerializerMethodField()
|
||||||
|
|
||||||
def get_rules(self, instance):
|
def get_unread(self, category):
|
||||||
request = self.context.get("request")
|
return Post.objects.filter(
|
||||||
rules = instance.rules.order_by("-modified", "-created")
|
rule__in=category.rules.values_list("pk", flat=True), read=False
|
||||||
|
).count()
|
||||||
serializer = collection.serializers.CollectionRuleSerializer(
|
|
||||||
rules, context={"request": request}, many=True
|
|
||||||
)
|
|
||||||
return serializer.data
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Category
|
model = Category
|
||||||
fields = ("id", "name", "rules", "user")
|
fields = ("id", "name", "user", "unread")
|
||||||
extra_kwargs = {"rules": {"view_name": "api:rules-detail"}}
|
|
||||||
|
|
|
||||||
15
src/newsreader/news/core/templates/core/main.html
Normal file
15
src/newsreader/news/core/templates/core/main.html
Normal 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 %}
|
||||||
|
|
@ -10,25 +10,20 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory
|
||||||
|
|
||||||
class CategoryDetailViewTestCase(TestCase):
|
class CategoryDetailViewTestCase(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.maxDiff = None
|
self.user = UserFactory(password="test")
|
||||||
|
self.client.login(email=self.user.email, password="test")
|
||||||
self.client = Client()
|
|
||||||
self.user = UserFactory(is_staff=True, password="test")
|
|
||||||
|
|
||||||
def test_simple(self):
|
def test_simple(self):
|
||||||
category = CategoryFactory(user=self.user)
|
category = CategoryFactory(user=self.user)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:categories-detail", args=[category.pk]))
|
response = self.client.get(reverse("api:categories-detail", args=[category.pk]))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 200)
|
self.assertEquals(response.status_code, 200)
|
||||||
self.assertTrue("id" in data)
|
self.assertTrue("id" in data)
|
||||||
self.assertTrue("name" in data)
|
self.assertTrue("name" in data)
|
||||||
self.assertTrue("rules" in data)
|
|
||||||
|
|
||||||
def test_not_known(self):
|
def test_not_known(self):
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:categories-detail", args=[100]))
|
response = self.client.get(reverse("api:categories-detail", args=[100]))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -38,7 +33,6 @@ class CategoryDetailViewTestCase(TestCase):
|
||||||
def test_post(self):
|
def test_post(self):
|
||||||
category = CategoryFactory(user=self.user)
|
category = CategoryFactory(user=self.user)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.post(reverse("api:categories-detail", args=[category.pk]))
|
response = self.client.post(reverse("api:categories-detail", args=[category.pk]))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -48,7 +42,6 @@ class CategoryDetailViewTestCase(TestCase):
|
||||||
def test_patch(self):
|
def test_patch(self):
|
||||||
category = CategoryFactory(name="Clickbait", user=self.user)
|
category = CategoryFactory(name="Clickbait", user=self.user)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
reverse("api:categories-detail", args=[category.pk]),
|
reverse("api:categories-detail", args=[category.pk]),
|
||||||
data=json.dumps({"name": "Interesting posts"}),
|
data=json.dumps({"name": "Interesting posts"}),
|
||||||
|
|
@ -62,7 +55,6 @@ class CategoryDetailViewTestCase(TestCase):
|
||||||
def test_identifier_cannot_be_changed(self):
|
def test_identifier_cannot_be_changed(self):
|
||||||
category = CategoryFactory(user=self.user)
|
category = CategoryFactory(user=self.user)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
reverse("api:categories-detail", args=[category.pk]),
|
reverse("api:categories-detail", args=[category.pk]),
|
||||||
data=json.dumps({"id": 44}),
|
data=json.dumps({"id": 44}),
|
||||||
|
|
@ -76,7 +68,6 @@ class CategoryDetailViewTestCase(TestCase):
|
||||||
def test_put(self):
|
def test_put(self):
|
||||||
category = CategoryFactory(name="Clickbait", user=self.user)
|
category = CategoryFactory(name="Clickbait", user=self.user)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.put(
|
response = self.client.put(
|
||||||
reverse("api:categories-detail", args=[category.pk]),
|
reverse("api:categories-detail", args=[category.pk]),
|
||||||
data=json.dumps({"name": "Interesting posts"}),
|
data=json.dumps({"name": "Interesting posts"}),
|
||||||
|
|
@ -90,74 +81,15 @@ class CategoryDetailViewTestCase(TestCase):
|
||||||
def test_delete(self):
|
def test_delete(self):
|
||||||
category = CategoryFactory(user=self.user)
|
category = CategoryFactory(user=self.user)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.delete(
|
response = self.client.delete(
|
||||||
reverse("api:categories-detail", args=[category.pk])
|
reverse("api:categories-detail", args=[category.pk])
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 204)
|
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):
|
def test_category_with_unauthenticated_user(self):
|
||||||
|
self.client.logout()
|
||||||
|
|
||||||
category = CategoryFactory(user=self.user)
|
category = CategoryFactory(user=self.user)
|
||||||
|
|
||||||
response = self.client.get(reverse("api:categories-detail", args=[category.pk]))
|
response = self.client.get(reverse("api:categories-detail", args=[category.pk]))
|
||||||
|
|
@ -168,7 +100,112 @@ class CategoryDetailViewTestCase(TestCase):
|
||||||
other_user = UserFactory()
|
other_user = UserFactory()
|
||||||
category = CategoryFactory(user=other_user)
|
category = CategoryFactory(user=other_user)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:categories-detail", args=[category.pk]))
|
response = self.client.get(reverse("api:categories-detail", args=[category.pk]))
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 403)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -14,22 +14,17 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory
|
||||||
|
|
||||||
class CategoryListViewTestCase(TestCase):
|
class CategoryListViewTestCase(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.maxDiff = None
|
self.user = UserFactory(password="test")
|
||||||
|
self.client.login(email=self.user.email, password="test")
|
||||||
self.client = Client()
|
|
||||||
self.user = UserFactory(is_staff=True, password="test")
|
|
||||||
|
|
||||||
def test_simple(self):
|
def test_simple(self):
|
||||||
CategoryFactory.create_batch(size=3, user=self.user)
|
CategoryFactory.create_batch(size=3, user=self.user)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:categories-list"))
|
response = self.client.get(reverse("api:categories-list"))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 200)
|
self.assertEquals(response.status_code, 200)
|
||||||
self.assertTrue("results" in data)
|
self.assertEquals(len(data), 3)
|
||||||
self.assertTrue("count" in data)
|
|
||||||
self.assertEquals(data["count"], 3)
|
|
||||||
|
|
||||||
def test_ordering(self):
|
def test_ordering(self):
|
||||||
categories = [
|
categories = [
|
||||||
|
|
@ -53,35 +48,25 @@ class CategoryListViewTestCase(TestCase):
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:categories-list"))
|
response = self.client.get(reverse("api:categories-list"))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 200)
|
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[0]["id"], categories[1].pk)
|
||||||
self.assertEquals(data["results"][1]["id"], categories[2].pk)
|
self.assertEquals(data[1]["id"], categories[2].pk)
|
||||||
self.assertEquals(data["results"][2]["id"], categories[0].pk)
|
self.assertEquals(data[2]["id"], categories[0].pk)
|
||||||
|
|
||||||
def test_empty(self):
|
def test_empty(self):
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:categories-list"))
|
response = self.client.get(reverse("api:categories-list"))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 200)
|
self.assertEquals(response.status_code, 200)
|
||||||
self.assertTrue("results" in data)
|
self.assertEquals(len(data), 0)
|
||||||
self.assertTrue("count" in data)
|
|
||||||
|
|
||||||
self.assertEquals(data["count"], 0)
|
|
||||||
self.assertEquals(len(data["results"]), 0)
|
|
||||||
|
|
||||||
def test_post(self):
|
def test_post(self):
|
||||||
data = {"name": "Tech"}
|
data = {"name": "Tech"}
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("api:categories-list"),
|
reverse("api:categories-list"),
|
||||||
data=json.dumps(data),
|
data=json.dumps(data),
|
||||||
|
|
@ -93,7 +78,6 @@ class CategoryListViewTestCase(TestCase):
|
||||||
self.assertEquals(response_data["name"], "Tech")
|
self.assertEquals(response_data["name"], "Tech")
|
||||||
|
|
||||||
def test_patch(self):
|
def test_patch(self):
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.patch(reverse("api:categories-list"))
|
response = self.client.patch(reverse("api:categories-list"))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -101,7 +85,6 @@ class CategoryListViewTestCase(TestCase):
|
||||||
self.assertEquals(data["detail"], 'Method "PATCH" not allowed.')
|
self.assertEquals(data["detail"], 'Method "PATCH" not allowed.')
|
||||||
|
|
||||||
def test_put(self):
|
def test_put(self):
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.put(reverse("api:categories-list"))
|
response = self.client.put(reverse("api:categories-list"))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -109,66 +92,15 @@ class CategoryListViewTestCase(TestCase):
|
||||||
self.assertEquals(data["detail"], 'Method "PUT" not allowed.')
|
self.assertEquals(data["detail"], 'Method "PUT" not allowed.')
|
||||||
|
|
||||||
def test_delete(self):
|
def test_delete(self):
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.delete(reverse("api:categories-list"))
|
response = self.client.delete(reverse("api:categories-list"))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 405)
|
self.assertEquals(response.status_code, 405)
|
||||||
self.assertEquals(data["detail"], 'Method "DELETE" not allowed.')
|
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):
|
def test_categories_with_unauthenticated_user(self):
|
||||||
|
self.client.logout()
|
||||||
|
|
||||||
CategoryFactory.create_batch(size=3, user=self.user)
|
CategoryFactory.create_batch(size=3, user=self.user)
|
||||||
|
|
||||||
response = self.client.get(reverse("api:categories-list"))
|
response = self.client.get(reverse("api:categories-list"))
|
||||||
|
|
@ -179,10 +111,458 @@ class CategoryListViewTestCase(TestCase):
|
||||||
other_user = UserFactory()
|
other_user = UserFactory()
|
||||||
CategoryFactory.create_batch(size=3, user=other_user)
|
CategoryFactory.create_batch(size=3, user=other_user)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:categories-list"))
|
response = self.client.get(reverse("api:categories-list"))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 200)
|
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(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)
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,8 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory
|
||||||
|
|
||||||
class PostDetailViewTestCase(TestCase):
|
class PostDetailViewTestCase(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.maxDiff = None
|
self.user = UserFactory(password="test")
|
||||||
self.client = Client()
|
self.client.login(email=self.user.email, password="test")
|
||||||
|
|
||||||
self.client = Client()
|
|
||||||
self.user = UserFactory(is_staff=True, password="test")
|
|
||||||
|
|
||||||
def test_simple(self):
|
def test_simple(self):
|
||||||
rule = CollectionRuleFactory(
|
rule = CollectionRuleFactory(
|
||||||
|
|
@ -22,7 +19,6 @@ class PostDetailViewTestCase(TestCase):
|
||||||
)
|
)
|
||||||
post = PostFactory(rule=rule)
|
post = PostFactory(rule=rule)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:posts-detail", args=[post.pk]))
|
response = self.client.get(reverse("api:posts-detail", args=[post.pk]))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -38,7 +34,6 @@ class PostDetailViewTestCase(TestCase):
|
||||||
self.assertTrue("remote_identifier" in data)
|
self.assertTrue("remote_identifier" in data)
|
||||||
|
|
||||||
def test_not_known(self):
|
def test_not_known(self):
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:posts-detail", args=[100]))
|
response = self.client.get(reverse("api:posts-detail", args=[100]))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -51,7 +46,6 @@ class PostDetailViewTestCase(TestCase):
|
||||||
)
|
)
|
||||||
post = PostFactory(rule=rule)
|
post = PostFactory(rule=rule)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.post(reverse("api:posts-detail", args=[post.pk]))
|
response = self.client.post(reverse("api:posts-detail", args=[post.pk]))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -64,7 +58,6 @@ class PostDetailViewTestCase(TestCase):
|
||||||
)
|
)
|
||||||
post = PostFactory(title="This is clickbait for sure", rule=rule)
|
post = PostFactory(title="This is clickbait for sure", rule=rule)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
reverse("api:posts-detail", args=[post.pk]),
|
reverse("api:posts-detail", args=[post.pk]),
|
||||||
data=json.dumps({"title": "This title is very accurate"}),
|
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)
|
post = PostFactory(title="This is clickbait for sure", rule=rule)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
reverse("api:posts-detail", args=[post.pk]),
|
reverse("api:posts-detail", args=[post.pk]),
|
||||||
data=json.dumps({"id": 44}),
|
data=json.dumps({"id": 44}),
|
||||||
|
|
@ -101,18 +93,16 @@ class PostDetailViewTestCase(TestCase):
|
||||||
)
|
)
|
||||||
post = PostFactory(title="This is clickbait for sure", rule=rule)
|
post = PostFactory(title="This is clickbait for sure", rule=rule)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
reverse("api:posts-detail", args=[post.pk]),
|
reverse("api:posts-detail", args=[post.pk]),
|
||||||
data=json.dumps({"rule": reverse("api:rules-detail", args=[new_rule.pk])}),
|
data=json.dumps({"rule": reverse("api:rules-detail", args=[new_rule.pk])}),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
rule_url = data["rule"]
|
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 200)
|
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):
|
def test_put(self):
|
||||||
rule = CollectionRuleFactory(
|
rule = CollectionRuleFactory(
|
||||||
|
|
@ -120,7 +110,6 @@ class PostDetailViewTestCase(TestCase):
|
||||||
)
|
)
|
||||||
post = PostFactory(title="This is clickbait for sure", rule=rule)
|
post = PostFactory(title="This is clickbait for sure", rule=rule)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.put(
|
response = self.client.put(
|
||||||
reverse("api:posts-detail", args=[post.pk]),
|
reverse("api:posts-detail", args=[post.pk]),
|
||||||
data=json.dumps({"title": "This title is very accurate"}),
|
data=json.dumps({"title": "This title is very accurate"}),
|
||||||
|
|
@ -137,7 +126,6 @@ class PostDetailViewTestCase(TestCase):
|
||||||
)
|
)
|
||||||
post = PostFactory(rule=rule)
|
post = PostFactory(rule=rule)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.delete(reverse("api:posts-detail", args=[post.pk]))
|
response = self.client.delete(reverse("api:posts-detail", args=[post.pk]))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -145,6 +133,8 @@ class PostDetailViewTestCase(TestCase):
|
||||||
self.assertEquals(data["detail"], 'Method "DELETE" not allowed.')
|
self.assertEquals(data["detail"], 'Method "DELETE" not allowed.')
|
||||||
|
|
||||||
def test_post_with_unauthenticated_user_without_category(self):
|
def test_post_with_unauthenticated_user_without_category(self):
|
||||||
|
self.client.logout()
|
||||||
|
|
||||||
rule = CollectionRuleFactory(user=self.user, category=None)
|
rule = CollectionRuleFactory(user=self.user, category=None)
|
||||||
post = PostFactory(rule=rule)
|
post = PostFactory(rule=rule)
|
||||||
|
|
||||||
|
|
@ -153,6 +143,8 @@ class PostDetailViewTestCase(TestCase):
|
||||||
self.assertEquals(response.status_code, 403)
|
self.assertEquals(response.status_code, 403)
|
||||||
|
|
||||||
def test_post_with_unauthenticated_user_with_category(self):
|
def test_post_with_unauthenticated_user_with_category(self):
|
||||||
|
self.client.logout()
|
||||||
|
|
||||||
rule = CollectionRuleFactory(
|
rule = CollectionRuleFactory(
|
||||||
user=self.user, category=CategoryFactory(user=self.user)
|
user=self.user, category=CategoryFactory(user=self.user)
|
||||||
)
|
)
|
||||||
|
|
@ -167,7 +159,6 @@ class PostDetailViewTestCase(TestCase):
|
||||||
rule = CollectionRuleFactory(user=other_user, category=None)
|
rule = CollectionRuleFactory(user=other_user, category=None)
|
||||||
post = PostFactory(rule=rule)
|
post = PostFactory(rule=rule)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:posts-detail", args=[post.pk]))
|
response = self.client.get(reverse("api:posts-detail", args=[post.pk]))
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 403)
|
self.assertEquals(response.status_code, 403)
|
||||||
|
|
@ -179,7 +170,6 @@ class PostDetailViewTestCase(TestCase):
|
||||||
)
|
)
|
||||||
post = PostFactory(rule=rule)
|
post = PostFactory(rule=rule)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:posts-detail", args=[post.pk]))
|
response = self.client.get(reverse("api:posts-detail", args=[post.pk]))
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 403)
|
self.assertEquals(response.status_code, 403)
|
||||||
|
|
@ -191,7 +181,38 @@ class PostDetailViewTestCase(TestCase):
|
||||||
)
|
)
|
||||||
post = PostFactory(rule=rule)
|
post = PostFactory(rule=rule)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:posts-detail", args=[post.pk]))
|
response = self.client.get(reverse("api:posts-detail", args=[post.pk]))
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 403)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,8 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory
|
||||||
|
|
||||||
class PostListViewTestCase(TestCase):
|
class PostListViewTestCase(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.maxDiff = None
|
|
||||||
|
|
||||||
self.client = Client()
|
|
||||||
self.user = UserFactory(is_staff=True, password="test")
|
self.user = UserFactory(is_staff=True, password="test")
|
||||||
|
self.client.login(email=self.user.email, password="test")
|
||||||
|
|
||||||
def test_simple(self):
|
def test_simple(self):
|
||||||
rule = CollectionRuleFactory(
|
rule = CollectionRuleFactory(
|
||||||
|
|
@ -23,7 +21,6 @@ class PostListViewTestCase(TestCase):
|
||||||
)
|
)
|
||||||
PostFactory.create_batch(size=3, rule=rule)
|
PostFactory.create_batch(size=3, rule=rule)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:posts-list"))
|
response = self.client.get(reverse("api:posts-list"))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -61,7 +58,6 @@ class PostListViewTestCase(TestCase):
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:posts-list"))
|
response = self.client.get(reverse("api:posts-list"))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -81,7 +77,6 @@ class PostListViewTestCase(TestCase):
|
||||||
PostFactory.create_batch(size=80, rule=rule)
|
PostFactory.create_batch(size=80, rule=rule)
|
||||||
page_size = 50
|
page_size = 50
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:posts-list"), {"count": 50})
|
response = self.client.get(reverse("api:posts-list"), {"count": 50})
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -90,7 +85,6 @@ class PostListViewTestCase(TestCase):
|
||||||
self.assertEquals(len(data["results"]), page_size)
|
self.assertEquals(len(data["results"]), page_size)
|
||||||
|
|
||||||
def test_empty(self):
|
def test_empty(self):
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:posts-list"))
|
response = self.client.get(reverse("api:posts-list"))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -102,7 +96,6 @@ class PostListViewTestCase(TestCase):
|
||||||
self.assertEquals(len(data["results"]), 0)
|
self.assertEquals(len(data["results"]), 0)
|
||||||
|
|
||||||
def test_post(self):
|
def test_post(self):
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.post(reverse("api:posts-list"))
|
response = self.client.post(reverse("api:posts-list"))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -110,7 +103,6 @@ class PostListViewTestCase(TestCase):
|
||||||
self.assertEquals(data["detail"], 'Method "POST" not allowed.')
|
self.assertEquals(data["detail"], 'Method "POST" not allowed.')
|
||||||
|
|
||||||
def test_patch(self):
|
def test_patch(self):
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.patch(reverse("api:posts-list"))
|
response = self.client.patch(reverse("api:posts-list"))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -118,7 +110,6 @@ class PostListViewTestCase(TestCase):
|
||||||
self.assertEquals(data["detail"], 'Method "PATCH" not allowed.')
|
self.assertEquals(data["detail"], 'Method "PATCH" not allowed.')
|
||||||
|
|
||||||
def test_put(self):
|
def test_put(self):
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.put(reverse("api:posts-list"))
|
response = self.client.put(reverse("api:posts-list"))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -126,7 +117,6 @@ class PostListViewTestCase(TestCase):
|
||||||
self.assertEquals(data["detail"], 'Method "PUT" not allowed.')
|
self.assertEquals(data["detail"], 'Method "PUT" not allowed.')
|
||||||
|
|
||||||
def test_delete(self):
|
def test_delete(self):
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.delete(reverse("api:posts-list"))
|
response = self.client.delete(reverse("api:posts-list"))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -134,6 +124,8 @@ class PostListViewTestCase(TestCase):
|
||||||
self.assertEquals(data["detail"], 'Method "DELETE" not allowed.')
|
self.assertEquals(data["detail"], 'Method "DELETE" not allowed.')
|
||||||
|
|
||||||
def test_posts_with_unauthenticated_user_without_category(self):
|
def test_posts_with_unauthenticated_user_without_category(self):
|
||||||
|
self.client.logout()
|
||||||
|
|
||||||
PostFactory.create_batch(size=3, rule=CollectionRuleFactory(user=self.user))
|
PostFactory.create_batch(size=3, rule=CollectionRuleFactory(user=self.user))
|
||||||
|
|
||||||
response = self.client.get(reverse("api:posts-list"))
|
response = self.client.get(reverse("api:posts-list"))
|
||||||
|
|
@ -141,6 +133,8 @@ class PostListViewTestCase(TestCase):
|
||||||
self.assertEquals(response.status_code, 403)
|
self.assertEquals(response.status_code, 403)
|
||||||
|
|
||||||
def test_posts_with_unauthenticated_user_with_category(self):
|
def test_posts_with_unauthenticated_user_with_category(self):
|
||||||
|
self.client.logout()
|
||||||
|
|
||||||
category = CategoryFactory(user=self.user)
|
category = CategoryFactory(user=self.user)
|
||||||
|
|
||||||
PostFactory.create_batch(
|
PostFactory.create_batch(
|
||||||
|
|
@ -157,7 +151,6 @@ class PostListViewTestCase(TestCase):
|
||||||
rule = CollectionRuleFactory(user=other_user, category=None)
|
rule = CollectionRuleFactory(user=other_user, category=None)
|
||||||
PostFactory.create_batch(size=3, rule=rule)
|
PostFactory.create_batch(size=3, rule=rule)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:posts-list"))
|
response = self.client.get(reverse("api:posts-list"))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -173,7 +166,6 @@ class PostListViewTestCase(TestCase):
|
||||||
size=3, rule=CollectionRuleFactory(user=other_user, category=category)
|
size=3, rule=CollectionRuleFactory(user=other_user, category=category)
|
||||||
)
|
)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:posts-list"))
|
response = self.client.get(reverse("api:posts-list"))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -191,7 +183,6 @@ class PostListViewTestCase(TestCase):
|
||||||
)
|
)
|
||||||
PostFactory.create_batch(size=3, rule=rule)
|
PostFactory.create_batch(size=3, rule=rule)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:posts-list"))
|
response = self.client.get(reverse("api:posts-list"))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -201,12 +192,9 @@ class PostListViewTestCase(TestCase):
|
||||||
self.assertEquals(data["count"], 0)
|
self.assertEquals(data["count"], 0)
|
||||||
|
|
||||||
def test_posts_with_authorized_user_without_category(self):
|
def test_posts_with_authorized_user_without_category(self):
|
||||||
UserFactory()
|
|
||||||
|
|
||||||
rule = CollectionRuleFactory(user=self.user, category=None)
|
rule = CollectionRuleFactory(user=self.user, category=None)
|
||||||
PostFactory.create_batch(size=3, rule=rule)
|
PostFactory.create_batch(size=3, rule=rule)
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("api:posts-list"))
|
response = self.client.get(reverse("api:posts-list"))
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -214,3 +202,41 @@ class PostListViewTestCase(TestCase):
|
||||||
self.assertTrue("results" in data)
|
self.assertTrue("results" in data)
|
||||||
self.assertTrue("count" in data)
|
self.assertTrue("count" in data)
|
||||||
self.assertEquals(data["count"], 3)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -25,5 +25,7 @@ class PostFactory(factory.django.DjangoModelFactory):
|
||||||
"newsreader.news.collection.tests.factories.CollectionRuleFactory"
|
"newsreader.news.collection.tests.factories.CollectionRuleFactory"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
read = False
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Post
|
model = Post
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,34 @@
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from newsreader.news.core.views import (
|
from newsreader.news.core.endpoints import (
|
||||||
DetailCategoryAPIView,
|
CategoryReadView,
|
||||||
DetailPostAPIView,
|
DetailCategoryView,
|
||||||
ListCategoryAPIView,
|
DetailPostView,
|
||||||
ListPostAPIView,
|
ListCategoryView,
|
||||||
|
ListPostView,
|
||||||
|
NestedPostCategoryView,
|
||||||
|
NestedRuleCategoryView,
|
||||||
)
|
)
|
||||||
|
from newsreader.news.core.views import MainView
|
||||||
|
|
||||||
|
|
||||||
|
index_page = login_required(MainView.as_view())
|
||||||
|
|
||||||
endpoints = [
|
endpoints = [
|
||||||
path("posts/", ListPostAPIView.as_view(), name="posts-list"),
|
path("posts/", ListPostView.as_view(), name="posts-list"),
|
||||||
path("posts/<int:pk>/", DetailPostAPIView.as_view(), name="posts-detail"),
|
path("posts/<int:pk>/", DetailPostView.as_view(), name="posts-detail"),
|
||||||
path("categories/", ListCategoryAPIView.as_view(), name="categories-list"),
|
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(
|
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",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,25 @@
|
||||||
from django.db.models import Q
|
from typing import Dict
|
||||||
|
|
||||||
from rest_framework.generics import (
|
from django.views.generic.base import TemplateView
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class ListPostAPIView(ListAPIView):
|
class MainView(TemplateView):
|
||||||
queryset = Post.objects.all()
|
template_name = "core/main.html"
|
||||||
serializer_class = PostSerializer
|
|
||||||
pagination_class = LargeResultSetPagination
|
|
||||||
permission_classes = (IsAuthenticated, IsPostOwner)
|
|
||||||
|
|
||||||
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
|
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):
|
rules = {
|
||||||
queryset = Post.objects.all()
|
rule: rule.posts.order_by("-publication_date")[:30]
|
||||||
serializer_class = PostSerializer
|
for rule in user.rules.order_by("-created")
|
||||||
permission_classes = (IsAuthenticated, IsPostOwner)
|
}
|
||||||
|
|
||||||
|
context.update(categories=categories, rules=rules)
|
||||||
class ListCategoryAPIView(ListCreateAPIView):
|
return context
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
|
|
||||||
&__fieldset {
|
&__fieldset {
|
||||||
@extend .form__fieldset;
|
@extend .form__fieldset;
|
||||||
|
|
||||||
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__fieldset * {
|
&__fieldset * {
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
// General imports
|
||||||
@import "../partials/variables";
|
@import "../partials/variables";
|
||||||
@import "../components/index";
|
@import "../components/index";
|
||||||
|
|
||||||
|
// Page specific
|
||||||
@import "./components/index";
|
@import "./components/index";
|
||||||
|
|
@ -2,4 +2,9 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background-color: $gainsboro;
|
background-color: $gainsboro;
|
||||||
|
|
||||||
|
& * {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,8 +6,6 @@
|
||||||
|
|
||||||
padding: 10px 50px;
|
padding: 10px 50px;
|
||||||
|
|
||||||
width: 50px;
|
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
|
|
||||||
|
|
@ -4,3 +4,5 @@
|
||||||
@import "./main/index";
|
@import "./main/index";
|
||||||
@import "./navbar/index";
|
@import "./navbar/index";
|
||||||
@import "./error/index";
|
@import "./error/index";
|
||||||
|
@import "./loading-indicator/index";
|
||||||
|
@import "./modal/index";
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
@import "loading-indicator";
|
||||||
9
src/newsreader/scss/components/modal/_modal.scss
Normal file
9
src/newsreader/scss/components/modal/_modal.scss
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
|
||||||
|
background-color: $dark;
|
||||||
|
}
|
||||||
1
src/newsreader/scss/components/modal/index.scss
Normal file
1
src/newsreader/scss/components/modal/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "modal";
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
padding: 15px 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
background-color: $white;
|
background-color: $white;
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
@import "categories";
|
||||||
|
|
@ -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%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
@import "category";
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
margin: 2% 0 0 0;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
@import "content";
|
||||||
17
src/newsreader/scss/homepage/components/index.scss
Normal file
17
src/newsreader/scss/homepage/components/index.scss
Normal 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";
|
||||||
12
src/newsreader/scss/homepage/components/main/_main.scss
Normal file
12
src/newsreader/scss/homepage/components/main/_main.scss
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
margin: 0;
|
||||||
|
background-color: initial;
|
||||||
|
|
||||||
|
&--centered {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/newsreader/scss/homepage/components/main/index.scss
Normal file
1
src/newsreader/scss/homepage/components/main/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "main";
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
@import "post-block";
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue