diff options
author | Cédric Bonhomme <cedric@cedricbonhomme.org> | 2016-04-07 00:00:37 +0200 |
---|---|---|
committer | Cédric Bonhomme <cedric@cedricbonhomme.org> | 2016-04-07 00:00:37 +0200 |
commit | 181ee8dced7cccc687136c6f35faf2bff1d22d23 (patch) | |
tree | 7a2aea7553433957be0455694a7b39e91668dd42 | |
parent | commit the session after deleting old articles. (diff) | |
parent | Fixed merge conflicts. (diff) | |
download | newspipe-181ee8dced7cccc687136c6f35faf2bff1d22d23.tar.gz newspipe-181ee8dced7cccc687136c6f35faf2bff1d22d23.tar.bz2 newspipe-181ee8dced7cccc687136c6f35faf2bff1d22d23.zip |
Fixed merge conflicts.
47 files changed, 970 insertions, 982 deletions
diff --git a/migrations/versions/2472eddbf44b_update_of_the_user_model.py b/migrations/versions/2472eddbf44b_update_of_the_user_model.py new file mode 100644 index 00000000..a0194090 --- /dev/null +++ b/migrations/versions/2472eddbf44b_update_of_the_user_model.py @@ -0,0 +1,38 @@ +"""update of the user model + +Revision ID: 2472eddbf44b +Revises: ac35c979311a +Create Date: 2016-03-01 22:35:03.659694 + +""" + +# revision identifiers, used by Alembic. +revision = '2472eddbf44b' +down_revision = 'ac35c979311a' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.drop_column('user', 'enabled') + op.add_column('user', sa.Column('is_active', sa.Boolean(), default=False)) + op.add_column('user', sa.Column('is_admin', sa.Boolean(), default=False)) + op.add_column('user', sa.Column('is_api', sa.Boolean(), default=False)) + op.drop_table('role') + + + +def downgrade(): + op.drop_column('user', 'is_active') + op.drop_column('user', 'is_admin') + op.drop_column('user', 'is_api') + op.create_table('role', + sa.Column('id', sa.INTEGER(), nullable=False), + sa.Column('name', sa.VARCHAR(), nullable=True), + sa.Column('user_id', sa.INTEGER(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name')) diff --git a/src/conf.py b/src/conf.py index 0fcb330e..f30b5701 100644 --- a/src/conf.py +++ b/src/conf.py @@ -9,6 +9,7 @@ import logging BASE_DIR = os.path.abspath(os.path.dirname(__file__)) PATH = os.path.abspath(".") +API_ROOT = '/api/v2.0' # available languages LANGUAGES = { @@ -23,6 +24,7 @@ TIME_ZONE = { ON_HEROKU = int(os.environ.get('HEROKU', 0)) == 1 DEFAULTS = {"platform_url": "https://jarr.herokuapp.com/", + "self_registration": "false", "cdn_address": "", "admin_email": "root@jarr.localhost", "postmark_api_key": "", @@ -45,7 +47,7 @@ DEFAULTS = {"platform_url": "https://jarr.herokuapp.com/", "host": "0.0.0.0", "port": "5000", "crawling_method": "classic", - "webzine_root": "/tmp", + "webzine_root": "~/tmp", } if not ON_HEROKU: @@ -76,6 +78,7 @@ else: PLATFORM_URL = config.get('misc', 'platform_url') ADMIN_EMAIL = config.get('misc', 'admin_email') +SELF_REGISTRATION = config.getboolean('misc', 'self_registration') RECAPTCHA_PUBLIC_KEY = config.get('misc', 'recaptcha_public_key') RECAPTCHA_PRIVATE_KEY = config.get('misc', 'recaptcha_private_key') diff --git a/src/manager.py b/src/manager.py index e64263f2..14139ec2 100755 --- a/src/manager.py +++ b/src/manager.py @@ -64,7 +64,7 @@ def fetch_asyncio(user_id, feed_id): loop = asyncio.get_event_loop() for user in users: - if user.enabled: + if user.is_active: print("Fetching articles for " + user.nickname) g.user = user classic_crawler.retrieve_feed(loop, g.user, feed_id) diff --git a/src/web/controllers/abstract.py b/src/web/controllers/abstract.py index 828e6a29..2a2e6f9f 100644 --- a/src/web/controllers/abstract.py +++ b/src/web/controllers/abstract.py @@ -1,30 +1,29 @@ import logging -from flask import g +import dateutil.parser from bootstrap import db +from datetime import datetime +from collections import defaultdict from sqlalchemy import or_, func from werkzeug.exceptions import Forbidden, NotFound logger = logging.getLogger(__name__) -class AbstractController(object): +class AbstractController: _db_cls = None # reference to the database class _user_id_key = 'user_id' - def __init__(self, user_id=None): + def __init__(self, user_id=None, ignore_context=False): """User id is a right management mechanism that should be used to filter objects in database on their denormalized "user_id" field (or "id" field for users). Should no user_id be provided, the Controller won't apply any filter allowing for a kind of "super user" mode. """ - self.user_id = user_id try: - if self.user_id is not None \ - and self.user_id != g.user.id and not g.user.is_admin(): - self.user_id = g.user.id - except RuntimeError: # passing on out of context errors - pass + self.user_id = int(user_id) + except TypeError: + self.user_id = user_id def _to_filters(self, **filters): """ @@ -83,6 +82,7 @@ class AbstractController(object): return obj def create(self, **attrs): + assert attrs, "attributes to update must not be empty" if self._user_id_key is not None and self._user_id_key not in attrs: attrs[self._user_id_key] = self.user_id assert self._user_id_key is None or self._user_id_key in attrs \ @@ -98,6 +98,7 @@ class AbstractController(object): return self._get(**filters) def update(self, filters, attrs): + assert attrs, "attributes to update must not be empty" result = self._get(**filters).update(attrs, synchronize_session=False) db.session.commit() return result @@ -121,3 +122,30 @@ class AbstractController(object): return dict(db.session.query(elem_to_group_by, func.count('id')) .filter(*self._to_filters(**filters)) .group_by(elem_to_group_by).all()) + + @classmethod + def _get_attrs_desc(cls, role, right=None): + result = defaultdict(dict) + if role == 'admin': + columns = cls._db_cls.__table__.columns.keys() + else: + assert role in {'base', 'api'}, 'unknown role %r' % role + assert right in {'read', 'write'}, \ + "right must be 'read' or 'write' with role %r" % role + columns = getattr(cls._db_cls, 'fields_%s_%s' % (role, right))() + for column in columns: + result[column] = {} + db_col = getattr(cls._db_cls, column).property.columns[0] + try: + result[column]['type'] = db_col.type.python_type + except NotImplementedError: + if db_col.default: + result[column]['type'] = db_col.default.arg.__class__ + if column not in result: + continue + if issubclass(result[column]['type'], datetime): + result[column]['default'] = datetime.utcnow() + result[column]['type'] = lambda x: dateutil.parser.parse(x) + elif db_col.default: + result[column]['default'] = db_col.default.arg + return result diff --git a/src/web/controllers/article.py b/src/web/controllers/article.py index bc9ef36e..37a35023 100644 --- a/src/web/controllers/article.py +++ b/src/web/controllers/article.py @@ -6,7 +6,7 @@ from collections import Counter from bootstrap import db from .abstract import AbstractController -from web.controllers import FeedController +from web.controllers import CategoryController, FeedController from web.models import Article logger = logging.getLogger(__name__) @@ -35,11 +35,12 @@ class ArticleController(AbstractController): def create(self, **attrs): # handling special denorm for article rights - assert 'feed_id' in attrs + assert 'feed_id' in attrs, "must provide feed_id when creating article" feed = FeedController( attrs.get('user_id', self.user_id)).get(id=attrs['feed_id']) if 'user_id' in attrs: - assert feed.user_id == attrs['user_id'] or self.user_id is None + assert feed.user_id == attrs['user_id'] or self.user_id is None, \ + "no right on feed %r" % feed.id attrs['user_id'], attrs['category_id'] = feed.user_id, feed.category_id # handling feed's filters @@ -66,6 +67,17 @@ class ArticleController(AbstractController): return super().create(**attrs) + def update(self, filters, attrs): + user_id = attrs.get('user_id', self.user_id) + if 'feed_id' in attrs: + feed = FeedController().get(id=attrs['feed_id']) + assert feed.user_id == user_id, "no right on feed %r" % feed.id + attrs['category_id'] = feed.category_id + if attrs.get('category_id'): + cat = CategoryController().get(id=attrs['category_id']) + assert cat.user_id == user_id, "no right on cat %r" % cat.id + return super().update(filters, attrs) + def get_history(self, year=None, month=None): """ Sort articles by year and month. @@ -84,3 +96,8 @@ class ArticleController(AbstractController): else: articles_counter[article.date.year] += 1 return articles_counter, articles + + def read_light(self, **filters): + return super().read(**filters).with_entities(Article.id, Article.title, + Article.readed, Article.like, Article.feed_id, Article.date, + Article.category_id).order_by(Article.date.desc()) diff --git a/src/web/controllers/feed.py b/src/web/controllers/feed.py index 78caf2e1..a3f5cae7 100644 --- a/src/web/controllers/feed.py +++ b/src/web/controllers/feed.py @@ -1,24 +1,3 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- - -# jarr - A Web based news aggregator. -# Copyright (C) 2010-2016 Cédric Bonhomme - https://www.cedricbonhomme.org -# -# For more information : https://github.com/JARR-aggregator/JARR/ -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - import logging import itertools from datetime import datetime, timedelta @@ -26,7 +5,7 @@ from datetime import datetime, timedelta import conf from .abstract import AbstractController from .icon import IconController -from web.models import Feed +from web.models import User, Feed from web.lib.utils import clear_string logger = logging.getLogger(__name__) @@ -43,11 +22,12 @@ class FeedController(AbstractController): return [feed for feed in self.read( error_count__lt=max_error, enabled=True, last_retrieved__lt=max_last) + .join(User).filter(User.is_active == True) .order_by('last_retrieved') .limit(limit)] - def list_fetchable(self, max_error=DEFAULT_MAX_ERROR, limit=DEFAULT_LIMIT, - refresh_rate=DEFAULT_REFRESH_RATE): + def list_fetchable(self, max_error=DEFAULT_MAX_ERROR, + limit=DEFAULT_LIMIT, refresh_rate=DEFAULT_REFRESH_RATE): now = datetime.now() max_last = now - timedelta(minutes=refresh_rate) feeds = self.list_late(max_last, max_error, limit) @@ -104,7 +84,9 @@ class FeedController(AbstractController): def update(self, filters, attrs): from .article import ArticleController self._ensure_icon(attrs) - if 'category_id' in attrs: + if 'category_id' in attrs and attrs['category_id'] == 0: + del attrs['category_id'] + elif 'category_id' in attrs: art_contr = ArticleController(self.user_id) for feed in self.read(**filters): art_contr.update({'feed_id': feed.id}, diff --git a/src/web/controllers/user.py b/src/web/controllers/user.py index ae169b05..1b5c123e 100644 --- a/src/web/controllers/user.py +++ b/src/web/controllers/user.py @@ -1,9 +1,10 @@ -import random -import hashlib -from werkzeug import generate_password_hash +import logging +from werkzeug import generate_password_hash, check_password_hash from .abstract import AbstractController from web.models import User +logger = logging.getLogger(__name__) + class UserController(AbstractController): _db_cls = User @@ -11,10 +12,13 @@ class UserController(AbstractController): def _handle_password(self, attrs): if attrs.get('password'): - attrs['pwdhash'] = generate_password_hash(attrs.pop('password')) + attrs['password'] = generate_password_hash(attrs.pop('password')) elif 'password' in attrs: del attrs['password'] + def check_password(self, user, password): + return check_password_hash(user.pwdhash, password) + def create(self, **attrs): self._handle_password(attrs) return super().create(**attrs) diff --git a/src/web/export.py b/src/web/export.py index 220f8a42..41ee839c 100644 --- a/src/web/export.py +++ b/src/web/export.py @@ -139,7 +139,7 @@ def export_html(user): """ Export all articles of 'user' in Web pages. """ - webzine_root = conf.WEBZINE_ROOT + "webzine/" + webzine_root = conf.WEBZINE_ROOT + "/webzine/" nb_articles = format(len(models.Article.query.filter(models.Article.user_id == user.id).all()), ",d") index = HTML_HEADER("News archive") index += "<h1>List of feeds</h1>\n" diff --git a/src/web/forms.py b/src/web/forms.py index b17d2f7a..bf321ae3 100644 --- a/src/web/forms.py +++ b/src/web/forms.py @@ -30,12 +30,14 @@ __license__ = "GPLv3" from flask import flash, url_for, redirect from flask.ext.wtf import Form from flask.ext.babel import lazy_gettext +from werkzeug.exceptions import NotFound from wtforms import TextField, TextAreaField, PasswordField, BooleanField, \ SubmitField, IntegerField, SelectField, validators, HiddenField from flask.ext.wtf.html5 import EmailField from flask_wtf import RecaptchaField from web import utils +from web.controllers import UserController from web.models import User @@ -52,15 +54,16 @@ class SignupForm(Form): password = PasswordField(lazy_gettext("Password"), [validators.Required(lazy_gettext("Please enter a password.")), validators.Length(min=6, max=100)]) - recaptcha = RecaptchaField() submit = SubmitField(lazy_gettext("Sign up")) def validate(self): - validated = super(SignupForm, self).validate() - if self.nickname.data != User.make_valid_nickname(self.nickname.data): - self.nickname.errors.append(lazy_gettext( - 'This nickname has invalid characters. ' - 'Please use letters, numbers, dots and underscores only.')) + ucontr = UserController() + validated = super().validate() + if ucontr.read(login=self.login.data).count(): + self.login.errors.append('Login already taken') + validated = False + if self.password.data != self.password_conf.data: + self.password_conf.errors.append("Passwords don't match") validated = False return validated @@ -94,19 +97,27 @@ class SigninForm(RedirectForm): validators.Length(min=6, max=100)]) submit = SubmitField(lazy_gettext("Log In")) - def validate(self): - if not super(SigninForm, self).validate(): - return False + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.user = None - user = User.query.filter(User.email == self.email.data).first() - if user and user.check_password(self.password.data) and user.enabled: - return True - elif user and not user.enabled: - flash(lazy_gettext('Account not confirmed'), 'danger') - return False + def validate(self): + validated = super().validate() + ucontr = UserController() + try: + user = ucontr.get(email=self.email.data) + except NotFound: + self.email.errors.append('Wrong login') + validated = False else: - flash(lazy_gettext('Invalid email or password'), 'danger') - return False + if not user.is_active: + self.email.errors.append('User is desactivated') + validated = False + if not ucontr.check_password(user, self.password.data): + self.password.errors.append('Wrong password') + validated = False + self.user = user + return validated class UserForm(Form): diff --git a/src/web/js/actions/MenuActions.js b/src/web/js/actions/MenuActions.js index b9154581..824610d8 100644 --- a/src/web/js/actions/MenuActions.js +++ b/src/web/js/actions/MenuActions.js @@ -5,7 +5,7 @@ var jquery = require('jquery'); var MenuActions = { // PARENT FILTERS - reload: function() { + reload: function(setFilterFunc, id) { jquery.getJSON('/menu', function(payload) { JarrDispatcher.dispatch({ type: ActionTypes.RELOAD_MENU, @@ -18,6 +18,9 @@ var MenuActions = { crawling_method: payload.crawling_method, all_unread_count: payload.all_unread_count, }); + if(setFilterFunc && id) { + setFilterFunc(id); + } }); }, setFilter: function(filter) { diff --git a/src/web/js/actions/MiddlePanelActions.js b/src/web/js/actions/MiddlePanelActions.js index f805b7b1..3704e7ec 100644 --- a/src/web/js/actions/MiddlePanelActions.js +++ b/src/web/js/actions/MiddlePanelActions.js @@ -140,11 +140,9 @@ var MiddlePanelActions = { data: JSON.stringify(filters), url: "/mark_all_as_read", success: function (payload) { + console.log(payload); JarrDispatcher.dispatch({ - type: ActionTypes.CHANGE_ATTR, - attribute: 'read', - value_num: -1, - value_bool: true, + type: ActionTypes.MARK_ALL_AS_READ, articles: payload.articles, }); }, diff --git a/src/web/js/actions/RightPanelActions.js b/src/web/js/actions/RightPanelActions.js index 47adad79..5d78e001 100644 --- a/src/web/js/actions/RightPanelActions.js +++ b/src/web/js/actions/RightPanelActions.js @@ -4,8 +4,12 @@ var ActionTypes = require('../constants/JarrConstants'); var MenuActions = require('../actions/MenuActions'); var RightPanelActions = { - loadArticle: function(article_id, was_read_before) { - jquery.getJSON('/getart/' + article_id, + loadArticle: function(article_id, was_read_before, to_parse) { + var suffix = ''; + if(to_parse) { + suffix = '/parse'; + } + jquery.getJSON('/getart/' + article_id + suffix, function(payload) { JarrDispatcher.dispatch({ type: ActionTypes.LOAD_ARTICLE, diff --git a/src/web/js/components/MainApp.react.js b/src/web/js/components/MainApp.react.js index cbdc5833..ffb14589 100644 --- a/src/web/js/components/MainApp.react.js +++ b/src/web/js/components/MainApp.react.js @@ -15,7 +15,7 @@ var MainApp = React.createClass({ <Grid fluid id="jarr-container"> <Menu /> <Col id="middle-panel" mdOffset={3} lgOffset={2} - xs={4} sm={4} md={4} lg={4}> + xs={12} sm={4} md={4} lg={4}> <MiddlePanel.MiddlePanelFilter /> <MiddlePanel.MiddlePanel /> </Col> diff --git a/src/web/js/components/Menu.react.js b/src/web/js/components/Menu.react.js index 60578f8a..4537ee81 100644 --- a/src/web/js/components/Menu.react.js +++ b/src/web/js/components/Menu.react.js @@ -84,13 +84,15 @@ var CategoryGroup = React.createClass({ name: React.PropTypes.string.isRequired, feeds: React.PropTypes.array.isRequired, unread: React.PropTypes.number.isRequired, - folded: React.PropTypes.bool.isRequired, + folded: React.PropTypes.bool, }, getInitialState: function() { - return {folded: this.props.folded}; + return {folded: false}; }, componentWillReceiveProps: function(nextProps) { - this.setState({folded: nextProps.folded}); + if(nextProps.folded != null) { + this.setState({folded: nextProps.folded}); + } }, render: function() { // hidden the no category if empty @@ -265,7 +267,22 @@ var Menu = React.createClass({ ); }, componentDidMount: function() { - MenuActions.reload(); + var setFilterFunc = null; + var id = null; + if(window.location.search.substring(1)) { + var args = window.location.search.substring(1).split('&'); + args.map(function(arg) { + if (arg.split('=')[0] == 'at' && arg.split('=')[1] == 'c') { + setFilterFunc = MiddlePanelActions.setCategoryFilter; + } else if (arg.split('=')[0] == 'at' && arg.split('=')[1] == 'f') { + setFilterFunc = MiddlePanelActions.setFeedFilter; + + } else if (arg.split('=')[0] == 'ai') { + id = parseInt(arg.split('=')[1]); + } + }); + } + MenuActions.reload(setFilterFunc, id); MenuStore.addChangeListener(this._onChange); }, componentWillUnmount: function() { diff --git a/src/web/js/components/MiddlePanel.react.js b/src/web/js/components/MiddlePanel.react.js index dad33acc..f6e44777 100644 --- a/src/web/js/components/MiddlePanel.react.js +++ b/src/web/js/components/MiddlePanel.react.js @@ -35,7 +35,8 @@ var TableLine = React.createClass({ icon = <Glyphicon glyph="ban-circle" />; } var title = (<a href={'/article/redirect/' + this.props.article_id} - onClick={this.openRedirectLink}> + onClick={this.openRedirectLink} target="_blank" + title={this.props.feed_title}> {icon} {this.props.feed_title} </a>); var read = (<Glyphicon glyph={this.state.read?"check":"unchecked"} @@ -43,21 +44,17 @@ var TableLine = React.createClass({ var liked = (<Glyphicon glyph={this.state.liked?"star":"star-empty"} onClick={this.toogleLike} />); icon = <Glyphicon glyph={"new-window"} />; - var newTab = (<a href={'/article/redirect/' + this.props.article_id} - onClick={this.openRedirectLink} target="_blank"> - {icon} - </a>); var clsses = "list-group-item"; if(this.props.selected) { clsses += " active"; } // FIXME https://github.com/yahoo/react-intl/issues/189 // use FormattedRelative when fixed, will have to upgrade to ReactIntlv2 - return (<div className={clsses} onClick={this.loadArticle}> + return (<div className={clsses} onClick={this.loadArticle} title={this.props.title}> <h5><strong>{title}</strong></h5> <JarrTime text={this.props.date} stamp={this.props.timestamp} /> - <div>{read} {liked} {newTab} {this.props.title}</div> + <div>{read} {liked} {this.props.title}</div> </div> ); }, @@ -81,7 +78,7 @@ var TableLine = React.createClass({ evnt.stopPropagation(); }, loadArticle: function() { - this.setState({active: true, read: true}, function() { + this.setState({selected: true, read: true}, function() { RightPanelActions.loadArticle( this.props.article_id, this.props.read); }.bind(this)); @@ -232,7 +229,11 @@ var MiddlePanel = React.createClass({ return (<Row className="show-grid"> <div className="list-group"> {this.state.articles.map(function(article){ - return (<TableLine key={"a" + article.article_id} + var key = "a" + article.article_id; + if(article.read) {key+="r";} + if(article.liked) {key+="l";} + if(article.selected) {key+="s";} + return (<TableLine key={key} title={article.title} icon_url={article.icon_url} read={article.read} diff --git a/src/web/js/components/Navbar.react.js b/src/web/js/components/Navbar.react.js index 08716977..67e9ed56 100644 --- a/src/web/js/components/Navbar.react.js +++ b/src/web/js/components/Navbar.react.js @@ -38,13 +38,13 @@ JarrNavBar = React.createClass({ if(this.state.modalType == 'addFeed') { heading = 'Add a new feed'; action = '/feed/bookmarklet'; - placeholder = "Site or feed url, we'll sort it out later ;)"; + placeholder = "Site or feed url"; body = <Input name="url" type="text" placeholder={placeholder} />; } else { heading = 'Add a new category'; action = '/category/create'; body = <Input name="name" type="text" - placeholder="Name, there isn't much more to it" />; + placeholder="Name" />; } return (<Modal show={this.state.showModal} onHide={this.close}> <form action={action} method="POST"> @@ -70,7 +70,7 @@ JarrNavBar = React.createClass({ this.setState({showModal: true, modalType: 'addCategory'}); }, render: function() { - return (<Navbar fixedTop inverse id="jarrnav"> + return (<Navbar fixedTop inverse id="jarrnav" fluid staticTop={true}> {this.getModal()} <Navbar.Header> <Navbar.Brand> @@ -78,6 +78,7 @@ JarrNavBar = React.createClass({ </Navbar.Brand> <Navbar.Toggle /> </Navbar.Header> + <Navbar.Collapse> <Nav pullRight> {this.buttonFetch()} <NavItem className="jarrnavitem" @@ -116,6 +117,7 @@ JarrNavBar = React.createClass({ </MenuItem> </NavDropdown> </Nav> + </Navbar.Collapse> </Navbar> ); }, diff --git a/src/web/js/components/RightPanel.react.js b/src/web/js/components/RightPanel.react.js index 39b06f38..97a7c461 100644 --- a/src/web/js/components/RightPanel.react.js +++ b/src/web/js/components/RightPanel.react.js @@ -172,10 +172,10 @@ var Article = React.createClass({ getBody: function() { return (<div className="panel-body"> {this.getCore()} - <div dangerouslySetInnerHTML={ + <div id="article-content" dangerouslySetInnerHTML={ {__html: this.props.obj.content}} /> </div>); - }, + } }); var Feed = React.createClass({ @@ -317,8 +317,6 @@ var Feed = React.createClass({ <dd><JarrTime stamp={this.props.obj.last_stamp} text={this.props.obj.last_retrieved} /> </dd> - <dt>Number of articles</dt> - <dd>{this.props.obj.nb_articles}</dd> </dl> {this.getErrorFields()} {this.getCategorySelect()} @@ -423,9 +421,9 @@ var RightPanel = React.createClass({ key={this.state.category.id} />); } - return (<Col id="right-panel" xsOffset={4} smOffset={4} - mdOffset={7} lgOffset={6} - xs={8} sm={8} md={5} lg={6}> + return (<Col id="right-panel" xsHidden + smOffset={4} mdOffset={7} lgOffset={6} + sm={8} md={5} lg={6}> {breadcrum} {cntnt} </Col> diff --git a/src/web/js/constants/JarrConstants.js b/src/web/js/constants/JarrConstants.js index 0ea42aad..78e8bf04 100644 --- a/src/web/js/constants/JarrConstants.js +++ b/src/web/js/constants/JarrConstants.js @@ -1,23 +1,13 @@ -/* - * Copyright (c) 2014-2015, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - * - * TodoConstants - */ - var keyMirror = require('keymirror'); module.exports = keyMirror({ TOGGLE_MENU_FOLD: null, RELOAD_MENU: null, - PARENT_FILTER: null, - MENU_FILTER: null, - CHANGE_ATTR: null, + PARENT_FILTER: null, // set a feed or a category as filter in menu + MENU_FILTER: null, // change displayed feed in the menu + CHANGE_ATTR: null, // edit an attr on an article (like / read) RELOAD_MIDDLE_PANEL: null, - MIDDLE_PANEL_FILTER: null, - LOAD_ARTICLE: null, + MIDDLE_PANEL_FILTER: null, // set a filter (read/like/all) + LOAD_ARTICLE: null, // load a single article in right panel + MARK_ALL_AS_READ: null, }); diff --git a/src/web/js/stores/MenuStore.js b/src/web/js/stores/MenuStore.js index 49c61bc1..1cbbbda7 100644 --- a/src/web/js/stores/MenuStore.js +++ b/src/web/js/stores/MenuStore.js @@ -17,6 +17,7 @@ var MenuStore = assign({}, EventEmitter.prototype, { setFilter: function(value) { if(this._datas.filter != value) { this._datas.filter = value; + this._datas.all_folded = null; this.emitChange(); } }, @@ -24,12 +25,10 @@ var MenuStore = assign({}, EventEmitter.prototype, { if(this._datas.active_id != value || this._datas.active_type != type) { this._datas.active_type = type; this._datas.active_id = value; + this._datas.all_folded = null; this.emitChange(); } }, - readFeedArticle: function(feed_id) { - // TODO - }, emitChange: function() { this.emit(CHANGE_EVENT); }, @@ -53,6 +52,7 @@ MenuStore.dispatchToken = JarrDispatcher.register(function(action) { MenuStore._datas['error_threshold'] = action.error_threshold; MenuStore._datas['crawling_method'] = action.crawling_method; MenuStore._datas['all_unread_count'] = action.all_unread_count; + MenuStore._datas.all_folded = null; MenuStore.emitChange(); break; case ActionTypes.PARENT_FILTER: @@ -81,10 +81,10 @@ MenuStore.dispatchToken = JarrDispatcher.register(function(action) { MenuStore._datas.categories[cat_id].unread += new_unread[feed_id]; } if(changed) { + MenuStore._datas.all_folded = null; MenuStore.emitChange(); } } - break; case ActionTypes.MENU_FILTER: MenuStore.setFilter(action.filter); @@ -98,18 +98,35 @@ MenuStore.dispatchToken = JarrDispatcher.register(function(action) { MenuStore._datas.categories[article.category_id].unread += val; MenuStore._datas.feeds[article.feed_id].unread += val; }); + MenuStore._datas.all_folded = null; MenuStore.emitChange(); break; case ActionTypes.LOAD_ARTICLE: if(!action.was_read_before) { MenuStore._datas.categories[action.article.category_id].unread -= 1; MenuStore._datas.feeds[action.article.feed_id].unread -= 1; + MenuStore._datas.all_folded = null; MenuStore.emitChange(); } break; case ActionTypes.TOGGLE_MENU_FOLD: MenuStore._datas.all_folded = action.all_folded; MenuStore.emitChange(); + break; + case ActionTypes.MARK_ALL_AS_READ: + action.articles.map(function(art) { + if(!art.read) { + MenuStore._datas.feeds[art.feed_id].unread -= 1; + if(art.category_id) { + MenuStore._datas.categories[art.category_id].unread -= 1; + + } + } + }); + + MenuStore._datas.all_folded = null; + MenuStore.emitChange(); + break; default: // do nothing } diff --git a/src/web/js/stores/MiddlePanelStore.js b/src/web/js/stores/MiddlePanelStore.js index 4a5efd00..c554f929 100644 --- a/src/web/js/stores/MiddlePanelStore.js +++ b/src/web/js/stores/MiddlePanelStore.js @@ -82,20 +82,18 @@ var MiddlePanelStore = assign({}, EventEmitter.prototype, { MiddlePanelStore.dispatchToken = JarrDispatcher.register(function(action) { var changed = false; - switch(action.type) { - case ActionTypes.RELOAD_MIDDLE_PANEL: - changed = MiddlePanelStore.registerFilter(action); - changed = MiddlePanelStore.setArticles(action.articles) || changed; - break; - case ActionTypes.PARENT_FILTER: - changed = MiddlePanelStore.registerFilter(action); - changed = MiddlePanelStore.setArticles(action.articles) || changed; - break; - case ActionTypes.MIDDLE_PANEL_FILTER: - changed = MiddlePanelStore.registerFilter(action); - changed = MiddlePanelStore.setArticles(action.articles) || changed; - break; - case ActionTypes.CHANGE_ATTR: + if (action.type == ActionTypes.RELOAD_MIDDLE_PANEL + || action.type == ActionTypes.PARENT_FILTER + || action.type == ActionTypes.MIDDLE_PANEL_FILTER) { + changed = MiddlePanelStore.registerFilter(action); + changed = MiddlePanelStore.setArticles(action.articles) || changed; + } else if (action.type == ActionTypes.MARK_ALL_AS_READ) { + changed = MiddlePanelStore.registerFilter(action); + for(var i in action.articles) { + action.articles[i].read = true; + } + changed = MiddlePanelStore.setArticles(action.articles) || changed; + } else if (action.type == ActionTypes.CHANGE_ATTR) { var attr = action.attribute; var val = action.value_bool; action.articles.map(function(article) { @@ -112,19 +110,15 @@ MiddlePanelStore.dispatchToken = JarrDispatcher.register(function(action) { } } }); - break; - case ActionTypes.LOAD_ARTICLE: - changed = true; - MiddlePanelStore._datas.selected_article = action.article.id; - for (var i in MiddlePanelStore._datas.articles) { - if(MiddlePanelStore._datas.articles[i].article_id == action.article.id) { - MiddlePanelStore._datas.articles[i].read = true; - break; - } + } else if (action.type == ActionTypes.LOAD_ARTICLE) { + changed = true; + MiddlePanelStore._datas.selected_article = action.article.id; + for (var i in MiddlePanelStore._datas.articles) { + if(MiddlePanelStore._datas.articles[i].article_id == action.article.id) { + MiddlePanelStore._datas.articles[i].read = true; + break; } - break; - default: - // pass + } } if(changed) {MiddlePanelStore.emitChange();} }); diff --git a/src/web/lib/feed_utils.py b/src/web/lib/feed_utils.py index 80800bec..9925613f 100644 --- a/src/web/lib/feed_utils.py +++ b/src/web/lib/feed_utils.py @@ -1,3 +1,4 @@ +import html import urllib import logging import requests @@ -17,6 +18,19 @@ def is_parsing_ok(parsed_feed): return parsed_feed['entries'] or not parsed_feed['bozo'] +def escape_keys(*keys): + def wrapper(func): + def metawrapper(*args, **kwargs): + result = func(*args, **kwargs) + for key in keys: + if key in result: + result[key] = html.unescape(result[key]) + return result + return metawrapper + return wrapper + + +@escape_keys('title', 'description') def construct_feed_from(url=None, fp_parsed=None, feed=None, query_site=True): requests_kwargs = {'headers': {'User-Agent': USER_AGENT}, 'verify': False} if url is None and fp_parsed is not None: diff --git a/src/web/lib/utils.py b/src/web/lib/utils.py index 88d24ba5..f2bed3ff 100644 --- a/src/web/lib/utils.py +++ b/src/web/lib/utils.py @@ -9,12 +9,12 @@ from flask import request, url_for logger = logging.getLogger(__name__) -def default_handler(obj): +def default_handler(obj, role='admin'): """JSON handler for default query formatting""" if hasattr(obj, 'isoformat'): return obj.isoformat() if hasattr(obj, 'dump'): - return obj.dump() + return obj.dump(role=role) if isinstance(obj, (set, frozenset, types.GeneratorType)): return list(obj) if isinstance(obj, BaseException): diff --git a/src/web/models/__init__.py b/src/web/models/__init__.py index d9489dbb..7c50cab3 100644 --- a/src/web/models/__init__.py +++ b/src/web/models/__init__.py @@ -88,17 +88,13 @@ def db_create(db): "Will create the database from conf parameters." db.create_all() - role_admin = Role(name="admin") - role_user = Role(name="user") - user1 = User(nickname="admin", email=os.environ.get("ADMIN_EMAIL", "root@jarr.localhost"), pwdhash=generate_password_hash( os.environ.get("ADMIN_PASSWORD", "password")), - enabled=True) - user1.roles.extend([role_admin, role_user]) + is_admin=True) db.session.add(user1) db.session.commit() - return role_admin, role_user + return user1 diff --git a/src/web/models/article.py b/src/web/models/article.py index 1fee7096..d3c0bed2 100644 --- a/src/web/models/article.py +++ b/src/web/models/article.py @@ -29,9 +29,10 @@ __license__ = "GPLv3" from bootstrap import db from datetime import datetime from sqlalchemy import asc, desc +from web.models.right_mixin import RightMixin -class Article(db.Model): +class Article(db.Model, RightMixin): "Represent an article from a feed." id = db.Column(db.Integer(), primary_key=True) entry_id = db.Column(db.String()) @@ -68,18 +69,3 @@ class Article(db.Model): return "<Article(id=%d, entry_id=%s, title=%r, " \ "date=%r, retrieved_date=%r)>" % (self.id, self.entry_id, self.title, self.date, self.retrieved_date) - - def dump(self): - return {"id": self.id, - "user_id": self.user_id, - "entry_id": self.entry_id, - "title": self.title, - "link": self.link, - "content": self.content, - "readed": self.readed, - "like": self.like, - "date": self.date, - "updated_date": self.updated_date, - "retrieved_date": self.retrieved_date, - "feed_id": self.feed_id, - "category_id": self.category_id} diff --git a/src/web/models/category.py b/src/web/models/category.py index 78054809..c35db52e 100644 --- a/src/web/models/category.py +++ b/src/web/models/category.py @@ -1,11 +1,21 @@ from bootstrap import db +from sqlalchemy import Index +from web.models.right_mixin import RightMixin -class Category(db.Model): +class Category(db.Model, RightMixin): id = db.Column(db.Integer(), primary_key=True) name = db.Column(db.String()) user_id = db.Column(db.Integer, db.ForeignKey('user.id')) - def dump(self): - return {key: getattr(self, key) for key in ('id', 'name', 'user_id')} + idx_category_uid = Index('user_id') + + # api whitelists + @staticmethod + def _fields_base_read(): + return {'id', 'user_id'} + + @staticmethod + def _fields_base_write(): + return {'name'} diff --git a/src/web/models/feed.py b/src/web/models/feed.py index cb78f7ad..ba9255e9 100644 --- a/src/web/models/feed.py +++ b/src/web/models/feed.py @@ -29,9 +29,10 @@ __license__ = "GPLv3" from bootstrap import db from datetime import datetime from sqlalchemy import desc +from web.models.right_mixin import RightMixin -class Feed(db.Model): +class Feed(db.Model, RightMixin): """ Represent a feed. """ @@ -63,22 +64,3 @@ class Feed(db.Model): def __repr__(self): return '<Feed %r>' % (self.title) - - def dump(self): - return {"id": self.id, - "user_id": self.user_id, - "category_id": self.category_id, - "title": self.title, - "description": self.description, - "link": self.link, - "site_link": self.site_link, - "etag": self.etag, - "enabled": self.enabled, - "filters": self.filters, - "icon_url": self.icon_url, - "error_count": self.error_count, - "last_error": self.last_error, - "created_date": self.created_date, - "last_modified": self.last_modified, - "last_retrieved": self.last_retrieved, - "nb_articles": self.articles.count()} diff --git a/src/web/models/right_mixin.py b/src/web/models/right_mixin.py new file mode 100644 index 00000000..c4d92008 --- /dev/null +++ b/src/web/models/right_mixin.py @@ -0,0 +1,54 @@ +class RightMixin: + + @staticmethod + def _fields_base_write(): + return {} + + @staticmethod + def _fields_base_read(): + return {'id'} + + @staticmethod + def _fields_api_write(): + return {} + + @staticmethod + def _fields_api_read(): + return {'id'} + + @classmethod + def fields_base_write(cls): + return cls._fields_base_write() + + @classmethod + def fields_base_read(cls): + return cls._fields_base_write().union(cls._fields_base_read()) + + @classmethod + def fields_api_write(cls): + return cls.fields_base_write().union(cls._fields_api_write()) + + @classmethod + def fields_api_read(cls): + return cls.fields_base_read().union(cls._fields_api_read()) + + def __getitem__(self, key): + if not hasattr(self, '__dump__'): + self.__dump__ = {} + return self.__dump__.get(key) + + def __setitem__(self, key, value): + if not hasattr(self, '__dump__'): + self.__dump__ = {} + self.__dump__[key] = value + + def dump(self, role='admin'): + if role == 'admin': + dico = {k: getattr(self, k) for k in self.__table__.columns.keys()} + elif role == 'api': + dico = {k: getattr(self, k) for k in self.fields_api_read()} + else: + dico = {k: getattr(self, k) for k in self.fields_base_read()} + if hasattr(self, '__dump__'): + dico.update(self.__dump__) + return dico diff --git a/src/web/models/user.py b/src/web/models/user.py index 1a276f7e..133901a5 100644 --- a/src/web/models/user.py +++ b/src/web/models/user.py @@ -34,9 +34,10 @@ from werkzeug import check_password_hash from flask.ext.login import UserMixin from bootstrap import db +from web.models.right_mixin import RightMixin -class User(db.Model, UserMixin): +class User(db.Model, UserMixin, RightMixin): """ Represent a user. """ @@ -44,14 +45,17 @@ class User(db.Model, UserMixin): nickname = db.Column(db.String(), unique=True) email = db.Column(db.String(254), index=True, unique=True) pwdhash = db.Column(db.String()) - roles = db.relationship('Role', backref='user', lazy='dynamic') - enabled = db.Column(db.Boolean(), default=False) date_created = db.Column(db.DateTime(), default=datetime.now) last_seen = db.Column(db.DateTime(), default=datetime.now) feeds = db.relationship('Feed', backref='subscriber', lazy='dynamic', cascade='all,delete-orphan') refresh_rate = db.Column(db.Integer, default=60) # in minutes + # user rights + is_active = db.Column(db.Boolean(), default=True) + is_admin = db.Column(db.Boolean(), default=False) + is_api = db.Column(db.Boolean(), default=False) + @staticmethod def make_valid_nickname(nickname): return re.sub('[^a-zA-Z0-9_\.]', '', nickname) @@ -68,12 +72,6 @@ class User(db.Model, UserMixin): """ return check_password_hash(self.pwdhash, password) - def is_admin(self): - """ - Return True if the user has administrator rights. - """ - return "admin" in [role.name for role in self.roles] - def __eq__(self, other): return self.id == other.id diff --git a/src/web/templates/admin/dashboard.html b/src/web/templates/admin/dashboard.html index 57b20bb5..d6e53526 100644 --- a/src/web/templates/admin/dashboard.html +++ b/src/web/templates/admin/dashboard.html @@ -17,7 +17,7 @@ </tr> </thead> <tbody> - {% for user in users|sort(attribute="last_seen")|reverse %} + {% for user in users %} <tr {% if not user.enabled %}class="warning"{% endif %}> <td>{{ loop.index }}</td> <td>{{ user.nickname }}{% if user.id == current_user.id %} (It's you!){% endif %}</td> @@ -25,7 +25,7 @@ <td class="date">{{ user.date_created | datetime }}</td> <td class="date">{{ user.last_seen | datetime }}</td> <td> - <a href="{{ url_for("admin.user_form", user_id=user.id) }}"><i class="glyphicon glyphicon-edit" title="{{ _('Edit this user') }}"></i></a> + <a href="{{ url_for("admin.user", user_id=user.id) }}"><i class="glyphicon glyphicon-edit" title="{{ _('Edit this user') }}"></i></a> {% if user.id != current_user.id %} <a href="{{ url_for("admin.toggle_user", user_id=user.id) }}"> {% if user.enabled %} @@ -34,7 +34,7 @@ <i class="glyphicon glyphicon-ok-circle" title="{{ _("Enable this account") }}"></i> {% endif %} </a> - <a href="{{ url_for("admin.delete_user", user_id=user.id) }}"><i class="glyphicon glyphicon-remove" title="{{ _('Delete this user') }}" onclick="return confirm('{{ _('You are going to delete this account.') }}');"></i></a> + <a href="{{ url_for("admin.toggle_user", user_id=user.id) }}"><i class="glyphicon glyphicon-remove" title="{{ _('Delete this user') }}" onclick="return confirm('{{ _('You are going to delete this account.') }}');"></i></a> {% endif %} </td> </tr> diff --git a/src/web/templates/home.html b/src/web/templates/home.html index fcb2a042..155742c5 100644 --- a/src/web/templates/home.html +++ b/src/web/templates/home.html @@ -7,7 +7,7 @@ <meta name="description" content="JARR is a web-based news aggregator." /> <meta name="author" content="" /> <title>JARR{% if head_titles %} - {{ ' - '.join(head_titles) }}{% endif %}</title> - <link rel="shortcut icon" href="{{ url_for("static", filename="img/favicon.png") }}" /> + <link rel="shortcut icon" href="{{ url_for("static", filename="img/favicon.ico") }}" /> <!-- Add custom CSS here --> <link href="{{ url_for("static", filename="css/customized-bootstrap.css") }}" rel="stylesheet" media="screen" /> <link href="{{ url_for("static", filename="css/one-page-app.css") }}" rel="stylesheet" media="screen" /> diff --git a/src/web/templates/layout.html b/src/web/templates/layout.html index feb370e3..50f96e8f 100644 --- a/src/web/templates/layout.html +++ b/src/web/templates/layout.html @@ -7,12 +7,12 @@ <meta name="description" content="JARR is a web-based news aggregator." /> <meta name="author" content="" /> <title>JARR{% if head_titles %} - {{ ' - '.join(head_titles) }}{% endif %}</title> - <link rel="shortcut icon" href="{{ url_for("static", filename="img/favicon.png") }}" /> + <link rel="shortcut icon" href="{{ url_for("static", filename="img/favicon.ico") }}" /> <!-- Bootstrap core CSS --> <link href="{{ url_for("static", filename="css/bootstrap.min.css") }}" rel="stylesheet" media="screen" /> <!-- Add custom CSS here --> <link href="{{ url_for("static", filename="css/customized-bootstrap.css") }}" rel="stylesheet" media="screen" /> - {% endblock %} + {% endblock %} </head> <body> <nav id="jarrnav" class="navbar navbar-inverse navbar-fixed-top" role="navigation"> @@ -35,7 +35,7 @@ <!-- Collect the nav links, forms, and other content for toggling --> <div class="collapse navbar-collapse navbar-ex1-collapse"> <ul class="nav navbar-nav navbar-right"> - {% if g.user.is_authenticated %} + {% if current_user.is_authenticated %} <!-- <li><a href="{{ url_for("feed.form") }}"><span class="glyphicon glyphicon-plus-sign"></span> {{ _('Add a feed') }}</a></li> --> <li class="dropdown"> <a href="#" class="dropdown-toggle" data-toggle="dropdown"> @@ -54,17 +54,12 @@ </li> </ul> </li> - {% if conf.CRAWLING_METHOD == "classic" and (not conf.ON_HEROKU or g.user.is_admin()) %} + {% if conf.CRAWLING_METHOD == "classic" and (not conf.ON_HEROKU or current_user.is_admin) %} <li><a href="/fetch"><span class="glyphicon glyphicon-import"></span> {{ _('Fetch') }}</a></li> {% endif %} <li class="dropdown"> <a href="#" class="dropdown-toggle" data-toggle="dropdown">{{ _('Feed') }} <b class="caret"></b></a> <ul class="dropdown-menu"> - <li><a href="{{ url_for("feeds.update", action="read") }}">{{ _('Mark all as read') }}</a></li> - <li><a href="{{ url_for("feeds.update", action="read", nb_days="1") }}">{{ _('Mark all as read older than yesterday') }}</a></li> - <li><a href="{{ url_for("feeds.update", action="read", nb_days="5") }}">{{ gettext('Mark all as read older than %(days)s days', days=5) }}</a></li> - <li><a href="{{ url_for("feeds.update", action="read", nb_days="10") }}">{{ gettext('Mark all as read older than %(days)s days', days=10) }}</a></li> - <li role="presentation" class="divider"></li> <li><a href="{{ url_for("feeds.inactives") }}">{{ _('Inactive') }}</a></li> <li><a href="{{ url_for("articles.history") }}">{{ _('History') }}</a></li> <li><a href="{{ url_for("feeds.feeds") }}">{{ _('All') }}</a></li> @@ -76,9 +71,8 @@ </a> <ul class="dropdown-menu"> <li><a href="{{ url_for("user.profile") }}"><span class="glyphicon glyphicon-user"></span> {{ _('Profile') }}</a></li> - <li><a href="{{ url_for("user.management") }}"><span class="glyphicon glyphicon-cog"></span> {{ _('Your data') }}</a></li> <li><a href="{{ url_for("about") }}"><span class="glyphicon glyphicon-question-sign"></span> {{ _('About') }}</a></li> - {% if g.user.is_admin() %} + {% if current_user.is_admin %} <li role="presentation" class="divider"></li> <li><a href="{{ url_for("admin.dashboard") }}"><span class="glyphicon glyphicon-dashboard"></span> {{ _('Dashboard') }}</a></li> <li role="presentation" class="divider"></li> diff --git a/src/web/templates/management.html b/src/web/templates/management.html index 01179b5e..dc95a052 100644 --- a/src/web/templates/management.html +++ b/src/web/templates/management.html @@ -14,18 +14,18 @@ <button class="btn btn-default" type="submit">OK</button> </form> <br /> - <a href="/export_opml" class="btn btn-default">{{ _('Export feeds to OPML') }}</a> + <a href="{{ url_for('articles.export', format='OPML') }}" class="btn btn-default">{{ _('Export feeds to OPML') }}</a> <h1>{{ _('Data liberation') }}</h1> <form action="" method="post" id="formImportJSON" enctype="multipart/form-data"> <span class="btn btn-default btn-file">{{ _('Import account') }} (<span class="text-info">*.json</span>)<input type="file" name="jsonfile" /></span> <button class="btn btn-default" type="submit">OK</button> </form> <br /> - <a href="/export?format=JSON" class="btn btn-default">{{ _('Export account to JSON') }}</a> + <a href="{{ url_for('articles.export', format='JSON') }}" class="btn btn-default">{{ _('Export account to JSON') }}</a> </div> <div class="well"> <h1>{{ _('Export articles') }}</h1> - <a href="/export?format=HTML" class="btn btn-default">HTML</a> + <a href="{{ url_for('articles.export', format='HTML') }}" class="btn btn-default">HTML</a> </div> </div><!-- /.container --> {% endblock %} diff --git a/src/web/views/__init__.py b/src/web/views/__init__.py index 27370fc3..c5903d9b 100644 --- a/src/web/views/__init__.py +++ b/src/web/views/__init__.py @@ -1,13 +1,12 @@ -from .views import * -from .api import * +from web.views import views, home, session_mgmt, api +from web.views.article import article_bp, articles_bp +from web.views.feed import feed_bp, feeds_bp +from web.views.category import category_bp, categories_bp +from web.views.icon import icon_bp +from web.views.admin import admin_bp +from web.views.user import user_bp, users_bp -from .article import article_bp, articles_bp -from .feed import feed_bp, feeds_bp -from .category import category_bp, categories_bp -from .icon import icon_bp -from .admin import admin_bp -from .user import user_bp, users_bp - - -__all__ = ['article_bp', 'articles_bp', 'feed_bp', 'feeds_bp', 'category_bp', - 'categories_bp', 'icon_bp', 'admin_bp', 'user_bp', 'users_bp'] +__all__ = ['views', 'home', 'session_mgmt', 'api', + 'article_bp', 'articles_bp', 'feed_bp', 'feeds_bp', + 'category_bp', 'categories_bp', 'icon_bp', + 'admin_bp', 'user_bp', 'users_bp'] diff --git a/src/web/views/admin.py b/src/web/views/admin.py index b5b0fd54..1dc676de 100644 --- a/src/web/views/admin.py +++ b/src/web/views/admin.py @@ -1,43 +1,48 @@ -from flask import (Blueprint, g, render_template, redirect, - flash, url_for, request) -from flask.ext.babel import gettext -from flask.ext.login import login_required - -from flask.ext.principal import Permission, RoleNeed +from datetime import datetime +from flask import (Blueprint, render_template, redirect, flash, url_for) +from flask.ext.babel import gettext, format_timedelta +from flask.ext.login import login_required, current_user +from werkzeug import generate_password_hash +from web.views.common import admin_permission from web.lib.utils import redirect_url -from web.models import Role from web.controllers import UserController, ArticleController - from web.forms import InformationMessageForm, UserForm -from web import notifications admin_bp = Blueprint('admin', __name__, url_prefix='/admin') -admin_permission = Permission(RoleNeed('admin')) @admin_bp.route('/dashboard', methods=['GET', 'POST']) @login_required @admin_permission.require(http_exception=403) def dashboard(): - """ - Adminstrator's dashboard. - """ + last_cons, now = {}, datetime.utcnow() + users = list(UserController().read().order_by('id')) form = InformationMessageForm() + for user in users: + last_cons[user.id] = format_timedelta(now - user.last_seen) + return render_template('admin/dashboard.html', now=datetime.utcnow(), + last_cons=last_cons, users=users, current_user=current_user, + form=form) + - if request.method == 'POST': - if form.validate(): - try: - notifications.information_message(form.subject.data, - form.message.data) - except Exception as error: - flash(gettext( - 'Problem while sending email: %(error)s', error=error), - 'danger') +@admin_bp.route('/user/<int:user_id>', methods=['GET']) +@login_required +@admin_permission.require(http_exception=403) +def user(user_id=None): + """ + See information about a user (stations, etc.). + """ + user = UserController().get(id=user_id) + if user is not None: + article_contr = ArticleController(user_id) + return render_template('/admin/user.html', user=user, feeds=user.feeds, + article_count=article_contr.count_by_feed(), + unread_article_count=article_contr.count_by_feed(readed=False)) - users = UserController().read() - return render_template('admin/dashboard.html', - users=users, current_user=g.user, form=form) + else: + flash(gettext('This user does not exist.'), 'danger') + return redirect(redirect_url()) @admin_bp.route('/user/create', methods=['GET']) @@ -71,7 +76,6 @@ def process_user_form(user_id=None): return render_template('/admin/create_user.html', form=form, message=gettext('Some errors were found')) - role_user = Role.query.filter(Role.name == "user").first() if user_id is not None: # Edit a user user_contr.update({'id': user_id}, @@ -86,50 +90,14 @@ def process_user_form(user_id=None): # Create a new user (by the admin) user = user_contr.create(nickname=form.nickname.data, email=form.email.data, - password=form.password.data, - roles=[role_user], - refresh_rate=form.refresh_rate.data, - enabled=True) + pwdhash=generate_password_hash(form.password.data), + is_admin=False, + refresh_rate=form.refresh_rate.data) flash(gettext('User %(nick)s successfully created', nick=user.nickname), 'success') return redirect(url_for('admin.user_form', user_id=user.id)) -@admin_bp.route('/user/<int:user_id>', methods=['GET']) -@login_required -@admin_permission.require(http_exception=403) -def user(user_id=None): - """ - See information about a user (stations, etc.). - """ - user = UserController().get(id=user_id) - if user is not None: - article_contr = ArticleController(user_id) - return render_template('/admin/user.html', user=user, feeds=user.feeds, - article_count=article_contr.count_by_feed(), - unread_article_count=article_contr.count_by_feed(readed=False)) - - else: - flash(gettext('This user does not exist.'), 'danger') - return redirect(redirect_url()) - - -@admin_bp.route('/delete_user/<int:user_id>', methods=['GET']) -@login_required -@admin_permission.require(http_exception=403) -def delete_user(user_id=None): - """ - Delete a user (with all its data). - """ - try: - user = UserController().delete(user_id) - flash(gettext('User %(nick)s successfully deleted', - nick=user.nickname), 'success') - except Exception as error: - flash(gettext('An error occured while trying to delete a user: ' - '%(error)', error=error), 'danger') - return redirect(redirect_url()) - @admin_bp.route('/toggle_user/<int:user_id>', methods=['GET']) @login_required @admin_permission.require() @@ -137,14 +105,18 @@ def toggle_user(user_id=None): """ Enable or disable the account of a user. """ - user_contr = UserController() - user = user_contr.get(id=user_id) + ucontr = UserController() + user = ucontr.get(id=user_id) + user_changed = ucontr.update({'id': user_id}, + {'is_active': not user.is_active}) - if user is None: + if not user_changed: flash(gettext('This user does not exist.'), 'danger') return redirect(url_for('admin.dashboard')) - user_contr.update({'id': user.id}, {'enabled': not user.enabled}) - flash(gettext('Account of the user %(nick)s successfully ' - 'updated.', nick=user.nickname), 'success') + else: + act_txt = 'activated' if user.is_active else 'desactivated' + message = gettext('User %(login)s successfully %(is_active)s', + login=user.login, is_active=act_txt) + flash(message, 'success') return redirect(url_for('admin.dashboard')) diff --git a/src/web/views/api/__init__.py b/src/web/views/api/__init__.py index 90e1ab0f..458e031b 100644 --- a/src/web/views/api/__init__.py +++ b/src/web/views/api/__init__.py @@ -1,31 +1,3 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- - -# jarr - A Web based news aggregator. -# Copyright (C) 2010-2016 Cédric Bonhomme - http://JARR-aggregator.org/ -# -# For more information : https://github.com/JARR-aggregator/JARR/ -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -__author__ = "Cedric Bonhomme" -__version__ = "$Revision: 0.2 $" -__date__ = "$Date: 2014/06/18 $" -__revision__ = "$Date: 2014/07/05 $" -__copyright__ = "Copyright (c) Cedric Bonhomme" -__license__ = "AGPLv3" - from web.views.api import article, feed, category __all__ = ['article', 'feed', 'category'] diff --git a/src/web/views/api/article.py b/src/web/views/api/article.py index 23c5c495..5971f47d 100644 --- a/src/web/views/api/article.py +++ b/src/web/views/api/article.py @@ -1,66 +1,53 @@ -#! /usr/bin/env python -# -*- coding: utf-8 - - -from flask import g +from conf import API_ROOT import dateutil.parser +from datetime import datetime +from flask import current_app +from flask.ext.restful import Api +from web.views.common import api_permission from web.controllers import ArticleController -from web.views.api.common import PyAggAbstractResource,\ - PyAggResourceNew, \ - PyAggResourceExisting, \ - PyAggResourceMulti - - -ARTICLE_ATTRS = {'user_id': {'type': int}, - 'feed_id': {'type': int}, - 'category_id': {'type': int}, - 'entry_id': {'type': str}, - 'link': {'type': str}, - 'title': {'type': str}, - 'readed': {'type': bool}, - 'like': {'type': bool}, - 'content': {'type': str}, - 'date': {'type': str}, - 'retrieved_date': {'type': str}} +from web.views.api.common import (PyAggAbstractResource, + PyAggResourceNew, PyAggResourceExisting, PyAggResourceMulti) class ArticleNewAPI(PyAggResourceNew): controller_cls = ArticleController - attrs = ARTICLE_ATTRS - to_date = ['date', 'retrieved_date'] class ArticleAPI(PyAggResourceExisting): controller_cls = ArticleController - attrs = ARTICLE_ATTRS - to_date = ['date', 'retrieved_date'] class ArticlesAPI(PyAggResourceMulti): controller_cls = ArticleController - attrs = ARTICLE_ATTRS - to_date = ['date', 'retrieved_date'] class ArticlesChallenge(PyAggAbstractResource): controller_cls = ArticleController attrs = {'ids': {'type': list, 'default': []}} - to_date = ['date', 'retrieved_date'] + @api_permission.require(http_exception=403) def get(self): - parsed_args = self.reqparse_args() + parsed_args = self.reqparse_args(right='read') + # collecting all attrs for casting purpose + attrs = self.controller_cls._get_attrs_desc('admin') for id_dict in parsed_args['ids']: - for key in self.to_date: - if key in id_dict: + keys_to_ignore = [] + for key in id_dict: + if key not in attrs: + keys_to_ignore.append(key) + if issubclass(attrs[key]['type'], datetime): id_dict[key] = dateutil.parser.parse(id_dict[key]) + for key in keys_to_ignore: + del id_dict[key] - result = list(self.wider_controller.challenge(parsed_args['ids'])) + result = list(self.controller.challenge(parsed_args['ids'])) return result or None, 200 if result else 204 +api = Api(current_app, prefix=API_ROOT) -g.api.add_resource(ArticleNewAPI, '/article', endpoint='article_new.json') -g.api.add_resource(ArticleAPI, '/article/<int:obj_id>', - endpoint='article.json') -g.api.add_resource(ArticlesAPI, '/articles', endpoint='articles.json') -g.api.add_resource(ArticlesChallenge, '/articles/challenge', - endpoint='articles_challenge.json') +api.add_resource(ArticleNewAPI, '/article', endpoint='article_new.json') +api.add_resource(ArticleAPI, '/article/<int:obj_id>', endpoint='article.json') +api.add_resource(ArticlesAPI, '/articles', endpoint='articles.json') +api.add_resource(ArticlesChallenge, '/articles/challenge', + endpoint='articles_challenge.json') diff --git a/src/web/views/api/category.py b/src/web/views/api/category.py index 7923279a..eecfa785 100644 --- a/src/web/views/api/category.py +++ b/src/web/views/api/category.py @@ -1,4 +1,6 @@ -from flask import g +from conf import API_ROOT +from flask import current_app +from flask.ext.restful import Api from web.controllers.category import CategoryController from web.views.api.common import (PyAggResourceNew, @@ -6,26 +8,20 @@ from web.views.api.common import (PyAggResourceNew, PyAggResourceMulti) -CAT_ATTRS = {'name': {'type': str}, - 'user_id': {'type': int}} - - class CategoryNewAPI(PyAggResourceNew): controller_cls = CategoryController - attrs = CAT_ATTRS class CategoryAPI(PyAggResourceExisting): controller_cls = CategoryController - attrs = CAT_ATTRS class CategoriesAPI(PyAggResourceMulti): controller_cls = CategoryController - attrs = CAT_ATTRS -g.api.add_resource(CategoryNewAPI, '/category', endpoint='category_new.json') -g.api.add_resource(CategoryAPI, '/category/<int:obj_id>', - endpoint='category.json') -g.api.add_resource(CategoriesAPI, '/categories', endpoint='categories.json') +api = Api(current_app, prefix=API_ROOT) +api.add_resource(CategoryNewAPI, '/category', endpoint='category_new.json') +api.add_resource(CategoryAPI, '/category/<int:obj_id>', + endpoint='category.json') +api.add_resource(CategoriesAPI, '/categories', endpoint='categories.json') diff --git a/src/web/views/api/common.py b/src/web/views/api/common.py index c155a254..ace6ba3a 100644 --- a/src/web/views/api/common.py +++ b/src/web/views/api/common.py @@ -1,6 +1,3 @@ -#! /usr/bin/env python -# -*- coding: utf-8 - - """For a given resources, classes in the module intend to create the following routes : GET resource/<id> @@ -21,82 +18,53 @@ routes : DELETE resources -> to delete several """ -import ast -import json import logging -import dateutil.parser from functools import wraps -from werkzeug.exceptions import Unauthorized, BadRequest -from flask import request, g, session, Response +from werkzeug.exceptions import Unauthorized, BadRequest, Forbidden, NotFound +from flask import request from flask.ext.restful import Resource, reqparse +from flask.ext.login import current_user -from web.lib.utils import default_handler -from web.models import User +from web.views.common import admin_permission, api_permission, \ + login_user_bundle, jsonify +from web.controllers import UserController logger = logging.getLogger(__name__) def authenticate(func): - """ - Decorator for the authentication to the web services. - """ @wraps(func) def wrapper(*args, **kwargs): - logged_in = False - if not getattr(func, 'authenticated', True): - logged_in = True - # authentication based on the session (already logged on the site) - elif 'email' in session or g.user.is_authenticated: - logged_in = True - else: - # authentication via HTTP only - auth = request.authorization - if auth is not None: - user = User.query.filter( - User.nickname == auth.username).first() - if user and user.check_password(auth.password) and user.enabled: - g.user = user - logged_in = True - if logged_in: + if request.authorization: + ucontr = UserController() + try: + user = ucontr.get(login=request.authorization.username) + except NotFound: + raise Forbidden("Couldn't authenticate your user") + if not ucontr.check_password(user, request.authorization.password): + raise Forbidden("Couldn't authenticate your user") + if not user.is_active: + raise Forbidden("User is desactivated") + login_user_bundle(user) + if current_user.is_authenticated: return func(*args, **kwargs) - raise Unauthorized({'WWWAuthenticate': 'Basic realm="Login Required"'}) - return wrapper - - -def to_response(func): - """Will cast results of func as a result, and try to extract - a status_code for the Response object""" - def wrapper(*args, **kwargs): - status_code = 200 - result = func(*args, **kwargs) - if isinstance(result, Response): - return result - elif isinstance(result, tuple): - result, status_code = result - return Response(json.dumps(result, default=default_handler), - status=status_code) + raise Unauthorized() return wrapper class PyAggAbstractResource(Resource): - method_decorators = [authenticate, to_response] - attrs = {} - to_date = [] # list of fields to cast to datetime - - def __init__(self, *args, **kwargs): - super(PyAggAbstractResource, self).__init__(*args, **kwargs) + method_decorators = [authenticate, jsonify] + controller_cls = None + attrs = None @property def controller(self): - return self.controller_cls(getattr(g.user, 'id', None)) - - @property - def wider_controller(self): - if g.user.is_admin(): + if admin_permission.can(): return self.controller_cls() - return self.controller_cls(getattr(g.user, 'id', None)) + return self.controller_cls(current_user.id) - def reqparse_args(self, req=None, strict=False, default=True, args=None): + def reqparse_args(self, right, req=None, strict=False, default=True, + allow_empty=False): """ strict: bool if True will throw 400 error if args are defined and not in request @@ -105,31 +73,39 @@ class PyAggAbstractResource(Resource): args: dict the args to parse, if None, self.attrs will be used """ + try: + in_values = req.json if req else (request.json or {}) + if not in_values and allow_empty: + return {} + except BadRequest: + if allow_empty: + return {} + raise parser = reqparse.RequestParser() - for attr_name, attrs in (args or self.attrs).items(): - if attrs.pop('force_default', False): - parser.add_argument(attr_name, location='json', **attrs) - elif not default and (not request.json - or request.json and attr_name not in request.json): + if self.attrs is not None: + attrs = self.attrs + elif admin_permission.can(): + attrs = self.controller_cls._get_attrs_desc('admin') + elif api_permission.can(): + attrs = self.controller_cls._get_attrs_desc('api', right) + else: + attrs = self.controller_cls._get_attrs_desc('base', right) + assert attrs, "No defined attrs for %s" % self.__class__.__name__ + + for attr_name, attr in attrs.items(): + if not default and attr_name not in in_values: continue else: - parser.add_argument(attr_name, location='json', **attrs) - parsed = parser.parse_args(strict=strict) if req is None \ - else parser.parse_args(req, strict=strict) - for field in self.to_date: - if parsed.get(field): - try: - parsed[field] = dateutil.parser.parse(parsed[field]) - except Exception: - logger.exception('failed to parse %r', parsed[field]) - return parsed + parser.add_argument(attr_name, location='json', **attr) + return parser.parse_args(req=req, strict=strict) class PyAggResourceNew(PyAggAbstractResource): + @api_permission.require(http_exception=403) def post(self): """Create a single new object""" - return self.controller.create(**self.reqparse_args()), 201 + return self.controller.create(**self.reqparse_args(right='write')), 201 class PyAggResourceExisting(PyAggAbstractResource): @@ -140,14 +116,10 @@ class PyAggResourceExisting(PyAggAbstractResource): def put(self, obj_id=None): """update an object, new attrs should be passed in the payload""" - args = self.reqparse_args(default=False) - new_values = {key: args[key] for key in - set(args).intersection(self.attrs)} - if 'user_id' in new_values and g.user.is_admin(): - controller = self.wider_controller - else: - controller = self.controller - return controller.update({'id': obj_id}, new_values), 200 + args = self.reqparse_args(right='write', default=False) + if not args: + raise BadRequest() + return self.controller.update({'id': obj_id}, args), 200 def delete(self, obj_id=None): """delete a object""" @@ -164,73 +136,75 @@ class PyAggResourceMulti(PyAggAbstractResource): try: limit = request.json.pop('limit', 10) order_by = request.json.pop('order_by', None) - query = self.controller.read(**request.json) - except: - args = {} - for k, v in request.args.items(): - if k in self.attrs.keys(): - if self.attrs[k]['type'] in [bool, int]: - args[k] = ast.literal_eval(v) - else: - args[k] = v - limit = request.args.get('limit', 10) - order_by = request.args.get('order_by', None) - query = self.controller.read(**args) + args = self.reqparse_args(right='read', default=False) + except BadRequest: + limit, order_by, args = 10, None, {} + query = self.controller.read(**args) if order_by: query = query.order_by(order_by) if limit: query = query.limit(limit) return [res for res in query] + @api_permission.require(http_exception=403) def post(self): - """creating several objects. payload should be a list of dict. + """creating several objects. payload should be: + >>> payload + [{attr1: val1, attr2: val2}, {attr1: val1, attr2: val2}] """ - if 'application/json' not in request.headers.get('Content-Type'): - raise BadRequest("Content-Type must be application/json") - status = 201 - results = [] + assert 'application/json' in request.headers.get('Content-Type') + status, fail_count, results = 200, 0, [] + + class Proxy: + pass for attrs in request.json: try: - results.append(self.controller.create(**attrs).id) + Proxy.json = attrs + args = self.reqparse_args('write', req=Proxy, default=False) + obj = self.controller.create(**args) + results.append(obj) except Exception as error: - status = 206 + fail_count += 1 results.append(str(error)) - # if no operation succeded, it's not partial anymore, returning err 500 - if status == 206 and results.count('ok') == 0: + if fail_count == len(results): # all failed => 500 status = 500 + elif fail_count: # some failed => 206 + status = 206 return results, status def put(self): - """creating several objects. payload should be: + """updating several objects. payload should be: >>> payload [[obj_id1, {attr1: val1, attr2: val2}] [obj_id2, {attr1: val1, attr2: val2}]] """ - if 'application/json' not in request.headers.get('Content-Type'): - raise BadRequest("Content-Type must be application/json") - status = 200 - results = [] + assert 'application/json' in request.headers.get('Content-Type') + status, results = 200, [] + + class Proxy: + pass for obj_id, attrs in request.json: try: - new_values = {key: attrs[key] for key in - set(attrs).intersection(self.attrs)} - self.controller.update({'id': obj_id}, new_values) - results.append('ok') + Proxy.json = attrs + args = self.reqparse_args('write', req=Proxy, default=False) + result = self.controller.update({'id': obj_id}, args) + if result: + results.append('ok') + else: + results.append('nok') except Exception as error: - status = 206 results.append(str(error)) - # if no operation succeded, it's not partial anymore, returning err 500 - if status == 206 and results.count('ok') == 0: + if results.count('ok') == 0: # all failed => 500 status = 500 + elif results.count('ok') != len(results): # some failed => 206 + status = 206 return results, status def delete(self): """will delete several objects, a list of their ids should be in the payload""" - if 'application/json' not in request.headers.get('Content-Type'): - raise BadRequest("Content-Type must be application/json") - status = 204 - results = [] + assert 'application/json' in request.headers.get('Content-Type') + status, results = 204, [] for obj_id in request.json: try: self.controller.delete(obj_id) diff --git a/src/web/views/api/feed.py b/src/web/views/api/feed.py index 604620b4..774bff5f 100644 --- a/src/web/views/api/feed.py +++ b/src/web/views/api/feed.py @@ -1,8 +1,8 @@ -#! /usr/bin/env python -# -*- coding: utf-8 - - -from flask import g +from conf import API_ROOT +from flask import current_app +from flask.ext.restful import Api +from web.views.common import api_permission from web.controllers.feed import (FeedController, DEFAULT_MAX_ERROR, DEFAULT_LIMIT, @@ -13,59 +13,37 @@ from web.views.api.common import PyAggAbstractResource, \ PyAggResourceExisting, \ PyAggResourceMulti -FEED_ATTRS = {'title': {'type': str}, - 'description': {'type': str}, - 'link': {'type': str}, - 'user_id': {'type': int}, - 'category_id': {'type': int}, - 'site_link': {'type': str}, - 'enabled': {'type': bool, 'default': True}, - 'etag': {'type': str, 'default': ''}, - 'icon_url': {'type': str, 'default': ''}, - 'filters': {'type': list}, - 'last_modified': {'type': str}, - 'last_retrieved': {'type': str}, - 'last_error': {'type': str}, - 'error_count': {'type': int, 'default': 0}} class FeedNewAPI(PyAggResourceNew): controller_cls = FeedController - attrs = FEED_ATTRS - to_date = ['date', 'last_retrieved'] + class FeedAPI(PyAggResourceExisting): controller_cls = FeedController - attrs = FEED_ATTRS - to_date = ['date', 'last_retrieved'] + class FeedsAPI(PyAggResourceMulti): controller_cls = FeedController - attrs = FEED_ATTRS - to_date = ['date', 'last_retrieved'] + class FetchableFeedAPI(PyAggAbstractResource): controller_cls = FeedController - to_date = ['date', 'last_retrieved'] attrs = {'max_error': {'type': int, 'default': DEFAULT_MAX_ERROR}, 'limit': {'type': int, 'default': DEFAULT_LIMIT}, - 'refresh_rate': {'type': int, 'default': DEFAULT_REFRESH_RATE}, - 'retreive_all': {'type': bool, 'default': False}} + 'refresh_rate': {'type': int, 'default': DEFAULT_REFRESH_RATE}} + @api_permission.require(http_exception=403) def get(self): - args = self.reqparse_args() - if g.user.refresh_rate: - args['refresh_rate'] = g.user.refresh_rate - - if args.pop('retreive_all', False): - contr = self.wider_controller - else: - contr = self.controller - result = [feed for feed in contr.list_fetchable(**args)] + args = self.reqparse_args(right='read', allow_empty=True) + result = [feed for feed + in self.controller.list_fetchable(**args)] return result or None, 200 if result else 204 -g.api.add_resource(FeedNewAPI, '/feed', endpoint='feed_new.json') -g.api.add_resource(FeedAPI, '/feed/<int:obj_id>', endpoint='feed.json') -g.api.add_resource(FeedsAPI, '/feeds', endpoint='feeds.json') -g.api.add_resource(FetchableFeedAPI, '/feeds/fetchable', - endpoint='fetchable_feed.json') +api = Api(current_app, prefix=API_ROOT) + +api.add_resource(FeedNewAPI, '/feed', endpoint='feed_new.json') +api.add_resource(FeedAPI, '/feed/<int:obj_id>', endpoint='feed.json') +api.add_resource(FeedsAPI, '/feeds', endpoint='feeds.json') +api.add_resource(FetchableFeedAPI, '/feeds/fetchable', + endpoint='fetchable_feed.json') diff --git a/src/web/views/article.py b/src/web/views/article.py index 7996e894..407345c3 100644 --- a/src/web/views/article.py +++ b/src/web/views/article.py @@ -1,14 +1,16 @@ -#! /usr/bin/env python -# -*- coding: utf-8 - from datetime import datetime, timedelta from flask import (Blueprint, g, render_template, redirect, - flash, url_for, request) + flash, url_for, make_response, request) + from flask.ext.babel import gettext -from flask.ext.login import login_required +from flask.ext.login import login_required, current_user + from bootstrap import db +from web.export import export_json, export_html from web.lib.utils import clear_string, redirect_url -from web.controllers import ArticleController +from web.controllers import (ArticleController, UserController, + CategoryController) from web.lib.view_utils import etag_match articles_bp = Blueprint('articles', __name__, url_prefix='/articles') @@ -18,7 +20,7 @@ article_bp = Blueprint('article', __name__, url_prefix='/article') @article_bp.route('/redirect/<int:article_id>', methods=['GET']) @login_required def redirect_to_article(article_id): - contr = ArticleController(g.user.id) + contr = ArticleController(current_user.id) article = contr.get(id=article_id) if not article.readed: contr.update({'id': article.id}, {'readed': True}) @@ -32,7 +34,7 @@ def article(article_id=None): """ Presents the content of an article. """ - article = ArticleController(g.user.id).get(id=article_id) + article = ArticleController(current_user.id).get(id=article_id) previous_article = article.previous_article() if previous_article is None: previous_article = article.source.articles[0] @@ -53,7 +55,7 @@ def like(article_id=None): """ Mark or unmark an article as favorites. """ - art_contr = ArticleController(g.user.id) + art_contr = ArticleController(current_user.id) article = art_contr.get(id=article_id) art_contr = art_contr.update({'id': article_id}, {'like': not article.like}) @@ -66,7 +68,7 @@ def delete(article_id=None): """ Delete an article from the database. """ - article = ArticleController(g.user.id).delete(article_id) + article = ArticleController(current_user.id).delete(article_id) flash(gettext('Article %(article_title)s deleted', article_title=article.title), 'success') return redirect(url_for('home')) @@ -77,9 +79,9 @@ def delete(article_id=None): @articles_bp.route('/history/<int:year>/<int:month>', methods=['GET']) @login_required def history(year=None, month=None): - counter, articles = ArticleController(g.user.id).get_history(year, month) - return render_template('history.html', articles_counter=counter, - articles=articles, year=year, month=month) + cntr, artcles = ArticleController(current_user.id).get_history(year, month) + return render_template('history.html', articles_counter=cntr, + articles=artcles, year=year, month=month) @article_bp.route('/mark_as/<string:new_value>', methods=['GET']) @@ -91,7 +93,7 @@ def mark_as(new_value='read', feed_id=None, article_id=None): Mark all unreaded articles as read. """ readed = new_value == 'read' - art_contr = ArticleController(g.user.id) + art_contr = ArticleController(current_user.id) filters = {'readed': not readed} if feed_id is not None: filters['feed_id'] = feed_id @@ -117,7 +119,7 @@ def expire(): """ current_time = datetime.utcnow() weeks_ago = current_time - timedelta(int(request.args.get('weeks', 10))) - art_contr = ArticleController(g.user.id) + art_contr = ArticleController(current_user.id) query = art_contr.read(__or__={'date__lt': weeks_ago, 'retrieved_date__lt': weeks_ago}) @@ -126,3 +128,47 @@ def expire(): db.session.commit() flash(gettext('%(count)d articles deleted', count=count), 'info') return redirect(redirect_url()) + + +@articles_bp.route('/export', methods=['GET']) +@login_required +def export(): + """ + Export all articles to HTML or JSON. + """ + user = UserController(current_user.id).get(id=current_user.id) + if request.args.get('format') == "HTML": + # Export to HTML + try: + archive_file, archive_file_name = export_html(user) + except Exception as e: + print(e) + flash(gettext("Error when exporting articles."), 'danger') + return redirect(redirect_url()) + response = make_response(archive_file) + response.headers['Content-Type'] = 'application/x-compressed' + response.headers['Content-Disposition'] = 'attachment; filename=%s' \ + % archive_file_name + elif request.args.get('format') == "JSON": + # Export to JSON + try: + json_result = export_json(user) + except Exception as e: + flash(gettext("Error when exporting articles."), 'danger') + return redirect(redirect_url()) + response = make_response(json_result) + response.mimetype = 'application/json' + response.headers["Content-Disposition"] \ + = 'attachment; filename=account.json' + elif request.args.get('format') == "OPML": + categories = {cat.id: cat.dump() + for cat in CategoryController(user.id).read()} + response = make_response(render_template('opml.xml', user=user, + categories=categories, + now=datetime.now())) + response.headers['Content-Type'] = 'application/xml' + response.headers['Content-Disposition'] = 'attachment; filename=feeds.opml' + else: + flash(gettext('Export format not supported.'), 'warning') + return redirect(redirect_url()) + return response diff --git a/src/web/views/category.py b/src/web/views/category.py index 20b90caa..a7447775 100644 --- a/src/web/views/category.py +++ b/src/web/views/category.py @@ -1,6 +1,6 @@ -from flask import g, Blueprint, render_template, flash, redirect, url_for +from flask import Blueprint, render_template, flash, redirect, url_for from flask.ext.babel import gettext -from flask.ext.login import login_required +from flask.ext.login import login_required, current_user from web.forms import CategoryForm from web.lib.utils import redirect_url @@ -17,10 +17,10 @@ category_bp = Blueprint('category', __name__, url_prefix='/category') @etag_match def list_(): "Lists the subscribed feeds in a table." - art_contr = ArticleController(g.user.id) + art_contr = ArticleController(current_user.id) return render_template('categories.html', - categories=list(CategoryController(g.user.id).read()), - feeds_count=FeedController(g.user.id).count_by_category(), + categories=list(CategoryController(current_user.id).read()), + feeds_count=FeedController(current_user.id).count_by_category(), unread_article_count=art_contr.count_by_category(readed=False), article_count=art_contr.count_by_category()) @@ -35,7 +35,7 @@ def form(category_id=None): if category_id is None: return render_template('edit_category.html', action=action, head_titles=head_titles, form=CategoryForm()) - category = CategoryController(g.user.id).get(id=category_id) + category = CategoryController(current_user.id).get(id=category_id) action = gettext('Edit category') head_titles = [action] if category.name: @@ -48,7 +48,7 @@ def form(category_id=None): @category_bp.route('/delete/<int:category_id>', methods=['GET']) @login_required def delete(category_id=None): - category = CategoryController(g.user.id).delete(category_id) + category = CategoryController(current_user.id).delete(category_id) flash(gettext("Category %(category_name)s successfully deleted.", category_name=category.name), 'success') return redirect(redirect_url()) @@ -59,7 +59,7 @@ def delete(category_id=None): @login_required def process_form(category_id=None): form = CategoryForm() - cat_contr = CategoryController(g.user.id) + cat_contr = CategoryController(current_user.id) if not form.validate(): return render_template('edit_category.html', form=form) diff --git a/src/web/views/common.py b/src/web/views/common.py new file mode 100644 index 00000000..690c4d1c --- /dev/null +++ b/src/web/views/common.py @@ -0,0 +1,53 @@ +import json +from functools import wraps +from datetime import datetime +from flask import current_app, Response +from flask.ext.login import login_user +from flask.ext.principal import (Identity, Permission, RoleNeed, + session_identity_loader, identity_changed) +from web.controllers import UserController +from web.lib.utils import default_handler + +admin_role = RoleNeed('admin') +api_role = RoleNeed('api') + +admin_permission = Permission(admin_role) +api_permission = Permission(api_role) + + +def scoped_default_handler(): + if admin_permission.can(): + role = 'admin' + elif api_permission.can(): + role = 'api' + else: + role = 'user' + + @wraps(default_handler) + def wrapper(obj): + return default_handler(obj, role=role) + return wrapper + + +def jsonify(func): + """Will cast results of func as a result, and try to extract + a status_code for the Response object""" + @wraps(func) + def wrapper(*args, **kwargs): + status_code = 200 + result = func(*args, **kwargs) + if isinstance(result, Response): + return result + elif isinstance(result, tuple): + result, status_code = result + return Response(json.dumps(result, default=scoped_default_handler()), + mimetype='application/json', status=status_code) + return wrapper + + +def login_user_bundle(user): + login_user(user) + identity_changed.send(current_app, identity=Identity(user.id)) + session_identity_loader() + UserController(user.id).update( + {'id': user.id}, {'last_seen': datetime.utcnow()}) diff --git a/src/web/views/feed.py b/src/web/views/feed.py index 4a07ac52..69e093b2 100644 --- a/src/web/views/feed.py +++ b/src/web/views/feed.py @@ -1,15 +1,13 @@ -#! /usr/bin/env python -# -*- coding: utf-8 - import logging import requests.exceptions from datetime import datetime, timedelta from sqlalchemy import desc from werkzeug.exceptions import BadRequest -from flask import Blueprint, g, render_template, flash, \ +from flask import Blueprint, render_template, flash, \ redirect, request, url_for from flask.ext.babel import gettext -from flask.ext.login import login_required +from flask.ext.login import login_required, current_user import conf from web import utils @@ -29,9 +27,9 @@ feed_bp = Blueprint('feed', __name__, url_prefix='/feed') @etag_match def feeds(): "Lists the subscribed feeds in a table." - art_contr = ArticleController(g.user.id) + art_contr = ArticleController(current_user.id) return render_template('feeds.html', - feeds=FeedController(g.user.id).read(), + feeds=FeedController(current_user.id).read(), unread_article_count=art_contr.count_by_feed(readed=False), article_count=art_contr.count_by_feed()) @@ -41,12 +39,12 @@ def feeds(): @etag_match def feed(feed_id=None): "Presents detailed information about a feed." - feed = FeedController(g.user.id).get(id=feed_id) + feed = FeedController(current_user.id).get(id=feed_id) word_size = 6 category = None if feed.category_id: - category = CategoryController(g.user.id).get(id=feed.category_id) - articles = ArticleController(g.user.id) \ + category = CategoryController(current_user.id).get(id=feed.category_id) + articles = ArticleController(current_user.id) \ .read(feed_id=feed_id) \ .order_by(desc("date")).all() top_words = utils.top_words(articles, n=50, size=int(word_size)) @@ -76,7 +74,7 @@ def feed(feed_id=None): @feed_bp.route('/delete/<feed_id>', methods=['GET']) @login_required def delete(feed_id=None): - feed_contr = FeedController(g.user.id) + feed_contr = FeedController(current_user.id) feed = feed_contr.get(id=feed_id) feed_contr.delete(feed_id) flash(gettext("Feed %(feed_title)s successfully deleted.", @@ -87,7 +85,7 @@ def delete(feed_id=None): @feed_bp.route('/reset_errors/<int:feed_id>', methods=['GET', 'POST']) @login_required def reset_errors(feed_id): - feed_contr = FeedController(g.user.id) + feed_contr = FeedController(current_user.id) feed = feed_contr.get(id=feed_id) feed_contr.update({'id': feed_id}, {'error_count': 0, 'last_error': ''}) flash(gettext('Feed %(feed_title)r successfully updated.', @@ -98,7 +96,7 @@ def reset_errors(feed_id): @feed_bp.route('/bookmarklet', methods=['GET', 'POST']) @login_required def bookmarklet(): - feed_contr = FeedController(g.user.id) + feed_contr = FeedController(current_user.id) url = (request.args if request.method == 'GET' else request.form)\ .get('url', None) if not url: @@ -128,7 +126,7 @@ def bookmarklet(): feed = feed_contr.create(**feed) flash(gettext('Feed was successfully created.'), 'success') if feed.enabled and conf.CRAWLING_METHOD == "classic": - utils.fetch(g.user.id, feed.id) + utils.fetch(current_user.id, feed.id) flash(gettext("Downloading articles for the new feed..."), 'info') return redirect(url_for('feed.form', feed_id=feed.id)) @@ -146,7 +144,7 @@ def update(action, feed_id=None): if feed_id: filters['feed_id'] = feed_id - ArticleController(g.user.id).update(filters, {'readed': readed}) + ArticleController(current_user.id).update(filters, {'readed': readed}) flash(gettext('Feed successfully updated.'), 'success') return redirect(request.referrer or url_for('home')) @@ -157,14 +155,14 @@ def update(action, feed_id=None): @etag_match def form(feed_id=None): action = gettext("Add a feed") - categories = CategoryController(g.user.id).read() + categories = CategoryController(current_user.id).read() head_titles = [action] if feed_id is None: form = AddFeedForm() form.set_category_choices(categories) return render_template('edit_feed.html', action=action, head_titles=head_titles, form=form) - feed = FeedController(g.user.id).get(id=feed_id) + feed = FeedController(current_user.id).get(id=feed_id) form = AddFeedForm(obj=feed) form.set_category_choices(categories) action = gettext('Edit feed') @@ -181,8 +179,8 @@ def form(feed_id=None): @login_required def process_form(feed_id=None): form = AddFeedForm() - feed_contr = FeedController(g.user.id) - form.set_category_choices(CategoryController(g.user.id).read()) + feed_contr = FeedController(current_user.id) + form.set_category_choices(CategoryController(current_user.id).read()) if not form.validate(): return render_template('edit_feed.html', form=form) @@ -217,7 +215,7 @@ def process_form(feed_id=None): feed_title=new_feed.title), 'success') if conf.CRAWLING_METHOD == "classic": - utils.fetch(g.user.id, new_feed.id) + utils.fetch(current_user.id, new_feed.id) flash(gettext("Downloading articles for the new feed..."), 'info') return redirect(url_for('feed.form', feed_id=new_feed.id)) @@ -230,7 +228,7 @@ def inactives(): List of inactive feeds. """ nb_days = int(request.args.get('nb_days', 365)) - inactives = FeedController(g.user.id).get_inactives(nb_days) + inactives = FeedController(current_user.id).get_inactives(nb_days) return render_template('inactives.html', inactives=inactives, nb_days=nb_days) @@ -241,7 +239,7 @@ def duplicates(feed_id): """ Return duplicates article for a feed. """ - feed, duplicates = FeedController(g.user.id).get_duplicates(feed_id) + feed, duplicates = FeedController(current_user.id).get_duplicates(feed_id) if len(duplicates) == 0: flash(gettext('No duplicates in the feed "{}".').format(feed.title), 'info') diff --git a/src/web/views/home.py b/src/web/views/home.py new file mode 100644 index 00000000..12a06024 --- /dev/null +++ b/src/web/views/home.py @@ -0,0 +1,156 @@ +import logging +from calendar import timegm + +from flask import current_app, render_template, \ + request, flash, url_for, redirect +from flask.ext.login import login_required, current_user +from flask.ext.babel import gettext + +import conf +from web.lib.utils import redirect_url +from web import utils +from web.lib.view_utils import etag_match +from web.models import Article +from web.views.common import jsonify + +from web.controllers import FeedController, \ + ArticleController, CategoryController + +logger = logging.getLogger(__name__) + + +@current_app.route('/') +@login_required +@etag_match +def home(): + return render_template('home.html', cdn=conf.CDN_ADDRESS) + + +@current_app.route('/menu') +@login_required +@etag_match +@jsonify +def get_menu(): + categories_order = [0] + categories = {0: {'name': 'No category', 'id': 0}} + for cat in CategoryController(current_user.id).read().order_by('name'): + categories_order.append(cat.id) + categories[cat.id] = cat + unread = ArticleController(current_user.id).count_by_feed(readed=False) + for cat_id in categories: + categories[cat_id]['unread'] = 0 + categories[cat_id]['feeds'] = [] + feeds = {feed.id: feed for feed in FeedController(current_user.id).read()} + for feed_id, feed in feeds.items(): + feed['created_stamp'] = timegm(feed.created_date.timetuple()) * 1000 + feed['last_stamp'] = timegm(feed.last_retrieved.timetuple()) * 1000 + feed['category_id'] = feed.category_id or 0 + feed['unread'] = unread.get(feed.id, 0) + if not feed.filters: + feed['filters'] = [] + if feed.icon_url: + feed['icon_url'] = url_for('icon.icon', url=feed.icon_url) + categories[feed['category_id']]['unread'] += feed['unread'] + categories[feed['category_id']]['feeds'].append(feed_id) + return {'feeds': feeds, 'categories': categories, + 'categories_order': categories_order, + 'crawling_method': conf.CRAWLING_METHOD, + 'max_error': conf.DEFAULT_MAX_ERROR, + 'error_threshold': conf.ERROR_THRESHOLD, + 'is_admin': current_user.is_admin, + 'all_unread_count': sum(unread.values())} + + +def _get_filters(in_dict): + filters = {} + query = in_dict.get('query') + if query: + search_title = in_dict.get('search_title') == 'true' + search_content = in_dict.get('search_content') == 'true' + if search_title: + filters['title__ilike'] = "%%%s%%" % query + if search_content: + filters['content__ilike'] = "%%%s%%" % query + if len(filters) == 0: + filters['title__ilike'] = "%%%s%%" % query + if len(filters) > 1: + filters = {"__or__": filters} + if in_dict.get('filter') == 'unread': + filters['readed'] = False + elif in_dict.get('filter') == 'liked': + filters['like'] = True + filter_type = in_dict.get('filter_type') + if filter_type in {'feed_id', 'category_id'} and in_dict.get('filter_id'): + filters[filter_type] = int(in_dict['filter_id']) or None + return filters + + +@jsonify +def _articles_to_json(articles, fd_hash=None): + return {'articles': [{'title': art.title, 'liked': art.like, + 'read': art.readed, 'article_id': art.id, 'selected': False, + 'feed_id': art.feed_id, 'category_id': art.category_id or 0, + 'feed_title': fd_hash[art.feed_id]['title'] if fd_hash else None, + 'icon_url': fd_hash[art.feed_id]['icon_url'] if fd_hash else None, + 'date': art.date, 'timestamp': timegm(art.date.timetuple()) * 1000} + for art in articles.limit(1000)]} + + +@current_app.route('/middle_panel') +@login_required +@etag_match +def get_middle_panel(): + filters = _get_filters(request.args) + art_contr = ArticleController(current_user.id) + fd_hash = {feed.id: {'title': feed.title, + 'icon_url': url_for('icon.icon', url=feed.icon_url) + if feed.icon_url else None} + for feed in FeedController(current_user.id).read()} + articles = art_contr.read(**filters).order_by(Article.date.desc()) + return _articles_to_json(articles, fd_hash) + + +@current_app.route('/getart/<int:article_id>') +@current_app.route('/getart/<int:article_id>/<parse>') +@login_required +@etag_match +@jsonify +def get_article(article_id, parse=False): + contr = ArticleController(current_user.id) + article = contr.get(id=article_id) + if not article.readed: + article['readed'] = True + contr.update({'id': article_id}, {'readed': True}) + article['category_id'] = article.category_id or 0 + feed = FeedController(current_user.id).get(id=article.feed_id) + article['icon_url'] = url_for('icon.icon', url=feed.icon_url) \ + if feed.icon_url else None + return article + + +@current_app.route('/mark_all_as_read', methods=['PUT']) +@login_required +def mark_all_as_read(): + filters = _get_filters(request.json) + acontr = ArticleController(current_user.id) + processed_articles = _articles_to_json(acontr.read_light(**filters)) + acontr.update(filters, {'readed': True}) + return processed_articles + + +@current_app.route('/fetch', methods=['GET']) +@current_app.route('/fetch/<int:feed_id>', methods=['GET']) +@login_required +def fetch(feed_id=None): + """ + Triggers the download of news. + News are downloaded in a separated process, mandatory for Heroku. + """ + if conf.CRAWLING_METHOD == "classic" \ + and (not conf.ON_HEROKU or current_user.is_admin): + utils.fetch(current_user.id, feed_id) + flash(gettext("Downloading articles..."), "info") + else: + flash(gettext("The manual retrieving of news is only available " + + "for administrator, on the Heroku platform."), "info") + return redirect(redirect_url()) diff --git a/src/web/views/session_mgmt.py b/src/web/views/session_mgmt.py new file mode 100644 index 00000000..65be856c --- /dev/null +++ b/src/web/views/session_mgmt.py @@ -0,0 +1,92 @@ +import json +import logging + +from werkzeug.exceptions import NotFound +from flask import (render_template, flash, session, request, + url_for, redirect, current_app) +from flask.ext.babel import gettext +from flask.ext.login import LoginManager, logout_user, \ + login_required, current_user +from flask.ext.principal import (Principal, AnonymousIdentity, UserNeed, + identity_changed, identity_loaded, + session_identity_loader) + +import conf +from web.views.common import admin_role, api_role, login_user_bundle +from web.controllers import UserController +from web.forms import SignupForm, SigninForm + +Principal(current_app) +# Create a permission with a single Need, in this case a RoleNeed. + +login_manager = LoginManager() +login_manager.init_app(current_app) +login_manager.login_view = 'login' + +logger = logging.getLogger(__name__) + + +@identity_loaded.connect_via(current_app._get_current_object()) +def on_identity_loaded(sender, identity): + # Set the identity user object + identity.user = current_user + + # Add the UserNeed to the identity + if current_user.is_authenticated: + identity.provides.add(UserNeed(current_user.id)) + if current_user.is_admin: + identity.provides.add(admin_role) + if current_user.is_api: + identity.provides.add(api_role) + + +@login_manager.user_loader +def load_user(user_id): + return UserController(user_id, ignore_context=True).get( + id=user_id, is_active=True) + + +@current_app.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('home')) + form = SigninForm() + if form.validate_on_submit(): + login_user_bundle(form.user) + return form.redirect('home') + return render_template('login.html', form=form) + + +@current_app.route('/logout') +@login_required +def logout(): + # Remove the user information from the session + logout_user() + + # Remove session keys set by Flask-Principal + for key in ('identity.name', 'identity.auth_type'): + session.pop(key, None) + + # Tell Flask-Principal the user is anonymous + identity_changed.send(current_app, identity=AnonymousIdentity()) + session_identity_loader() + + return redirect(url_for('login')) + + +@current_app.route('/signup', methods=['GET', 'POST']) +def signup(): + if not conf.SELF_REGISTRATION: + flash(gettext("Self-registration is disabled."), 'warning') + return redirect(url_for('home')) + if current_user.is_authenticated: + return redirect(url_for('home')) + + form = SignupForm() + if form.validate_on_submit(): + user = UserController().create(login=form.login.data, + email=form.email.data, password=form.password.data) + login_user_bundle(user) + return redirect(url_for('home')) + + return render_template('signup.html', form=form) diff --git a/src/web/views/user.py b/src/web/views/user.py index 06dc2089..24f9bedb 100644 --- a/src/web/views/user.py +++ b/src/web/views/user.py @@ -3,7 +3,7 @@ import random from flask import (Blueprint, g, render_template, redirect, flash, url_for, request) from flask.ext.babel import gettext -from flask.ext.login import login_required +from flask.ext.login import login_required, current_user import conf from web import utils, notifications @@ -30,9 +30,9 @@ def management(): flash(gettext('File not allowed.'), 'danger') else: try: - nb = utils.import_opml(g.user.email, data.read()) + nb = utils.import_opml(current_user.email, data.read()) if conf.CRAWLING_METHOD == "classic": - utils.fetch(g.user.email, None) + utils.fetch(current_user.email, None) flash(str(nb) + ' ' + gettext('feeds imported.'), "success") flash(gettext("Downloading articles..."), 'info') @@ -46,7 +46,7 @@ def management(): flash(gettext('File not allowed.'), 'danger') else: try: - nb = utils.import_json(g.user.email, data.read()) + nb = utils.import_json(current_user.email, data.read()) flash(gettext('Account imported.'), "success") except: flash(gettext("Impossible to import the account."), @@ -54,11 +54,11 @@ def management(): else: flash(gettext('File not allowed.'), 'danger') - nb_feeds = FeedController(g.user.id).read().count() - art_contr = ArticleController(g.user.id) + nb_feeds = FeedController(current_user.id).read().count() + art_contr = ArticleController(current_user.id) nb_articles = art_contr.read().count() nb_unread_articles = art_contr.read(readed=False).count() - return render_template('management.html', user=g.user, + return render_template('management.html', user=current_user, nb_feeds=nb_feeds, nb_articles=nb_articles, nb_unread_articles=nb_unread_articles) @@ -69,13 +69,13 @@ def profile(): """ Edit the profile of the currently logged user. """ - user_contr = UserController(g.user.id) - user = user_contr.get(id=g.user.id) + user_contr = UserController(current_user.id) + user = user_contr.get(id=current_user.id) form = ProfileForm() if request.method == 'POST': if form.validate(): - user_contr.update({'id': g.user.id}, + user_contr.update({'id': current_user.id}, {'nickname': form.nickname.data, 'email': form.email.data, 'password': form.password.data, @@ -98,7 +98,7 @@ def delete_account(): """ Delete the account of the user (with all its data). """ - UserController(g.user.id).delete(g.user.id) + UserController(current_user.id).delete(current_user.id) flash(gettext('Your account has been deleted.'), 'success') return redirect(url_for('login')) diff --git a/src/web/views/views.py b/src/web/views/views.py index b31322eb..b23a41a1 100644 --- a/src/web/views/views.py +++ b/src/web/views/views.py @@ -1,422 +1,46 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- - -# jarr - A Web based news aggregator. -# Copyright (C) 2010-2016 Cédric Bonhomme - https://www.cedricbonhomme.org -# -# For more information : https://github.com/JARR-aggregator/JARR -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -__author__ = "Cedric Bonhomme" -__version__ = "$Revision: 5.3 $" -__date__ = "$Date: 2010/01/29 $" -__revision__ = "$Date: 2014/08/27 $" -__copyright__ = "Copyright (c) Cedric Bonhomme" -__license__ = "AGPLv3" - -import os import logging -import datetime - -from bootstrap import application as app, db -from flask import render_template, request, flash, session, \ - url_for, redirect, g, current_app, make_response -from flask.ext.login import LoginManager, login_user, logout_user, \ - login_required, current_user, AnonymousUserMixin -from flask.ext.principal import Principal, Identity, AnonymousIdentity, \ - identity_changed, identity_loaded, Permission,\ - RoleNeed, UserNeed +from flask import (request, render_template, flash, + url_for, redirect, current_app) from flask.ext.babel import gettext -from sqlalchemy.exc import IntegrityError -from werkzeug import generate_password_hash -import conf -from web.lib.utils import redirect_url -from web import utils, notifications, export +from conf import API_ROOT from web.lib.view_utils import etag_match -from web.models import User, Article, Role -from web.forms import SignupForm, SigninForm - -from web.controllers import UserController, FeedController, \ - ArticleController, CategoryController - - -Principal(app) -# Create a permission with a single Need, in this case a RoleNeed. -admin_permission = Permission(RoleNeed('admin')) - -login_manager = LoginManager() -login_manager.init_app(app) -login_manager.login_message = gettext('Authentication required.') -login_manager.login_message_category = "info" -login_manager.login_view = 'login' logger = logging.getLogger(__name__) -# -# Management of the user's session. -# -@identity_loaded.connect_via(app) -def on_identity_loaded(sender, identity): - # Set the identity user object - identity.user = current_user - - # Add the UserNeed to the identity - if hasattr(current_user, 'id'): - identity.provides.add(UserNeed(current_user.id)) - - # Assuming the User model has a list of roles, update the - # identity with the roles that the user provides - if hasattr(current_user, 'roles'): - for role in current_user.roles: - identity.provides.add(RoleNeed(role.name)) - - -@app.before_request -def before_request(): - g.user = current_user - if g.user.is_authenticated: - g.user.last_seen = datetime.datetime.utcnow() - db.session.add(g.user) - db.session.commit() - - -@login_manager.user_loader -def load_user(id): - # Return an instance of the User model - return UserController().get(id=id) - - -# -# Custom error pages. -# -@app.errorhandler(401) -def authentication_required(e): +@current_app.errorhandler(401) +def authentication_required(error): + if API_ROOT in request.url: + return error flash(gettext('Authentication required.'), 'info') return redirect(url_for('login')) -@app.errorhandler(403) -def authentication_failed(e): +@current_app.errorhandler(403) +def authentication_failed(error): + if API_ROOT in request.url: + return error flash(gettext('Forbidden.'), 'danger') return redirect(url_for('login')) -@app.errorhandler(404) -def page_not_found(e): +@current_app.errorhandler(404) +def page_not_found(error): return render_template('errors/404.html'), 404 -@app.errorhandler(500) -def internal_server_error(e): +@current_app.errorhandler(500) +def internal_server_error(error): return render_template('errors/500.html'), 500 -@g.babel.localeselector -def get_locale(): - """ - Called before each request to give us a chance to choose - the language to use when producing its response. - """ - return request.accept_languages.best_match(conf.LANGUAGES.keys()) - - -@g.babel.timezoneselector -def get_timezone(): - try: - return conf.TIME_ZONE[get_locale()] - except: - return conf.TIME_ZONE["en"] - - -# -# Views. -# -@app.route('/login', methods=['GET', 'POST']) -def login(): - """ - Log in view. - """ - if g.user is not None and g.user.is_authenticated: - return redirect(url_for('home')) - g.user = AnonymousUserMixin() - form = SigninForm() - if form.validate_on_submit(): - user = UserController().get(email=form.email.data) - login_user(user) - g.user = user - session['email'] = form.email.data - identity_changed.send(current_app._get_current_object(), - identity=Identity(user.id)) - return form.redirect('home') - return render_template('login.html', form=form) - - -@app.route('/logout') -@login_required -def logout(): - """ - Log out view. Removes the user information from the session. - """ - session.pop('email', None) - - # Remove the user information from the session - logout_user() - - # Remove session keys set by Flask-Principal - for key in ('identity.name', 'identity.auth_type'): - session.pop(key, None) - - # Tell Flask-Principal the user is anonymous - identity_changed.send(current_app._get_current_object(), - identity=AnonymousIdentity()) - - flash(gettext("Logged out successfully."), 'success') - return redirect(url_for('login')) - - -@app.route('/signup', methods=['GET', 'POST']) -def signup(): - """ - Signup page. - """ - if int(os.environ.get("SELF_REGISTRATION", 0)) != 1: - flash(gettext("Self-registration is disabled."), 'warning') - return redirect(url_for('home')) - if g.user is not None and g.user.is_authenticated: - return redirect(url_for('home')) +@current_app.errorhandler(AssertionError) +def handle_sqlalchemy_assertion_error(error): + return error.args[0], 400 - form = SignupForm() - if form.validate_on_submit(): - role_user = Role.query.filter(Role.name == "user").first() - user = User(nickname=form.nickname.data, - email=form.email.data, - pwdhash=generate_password_hash(form.password.data)) - user.roles = [role_user] - db.session.add(user) - try: - db.session.commit() - except IntegrityError: - flash(gettext('Email already used.'), 'warning') - return render_template('signup.html', form=form) - - # Send the confirmation email - try: - notifications.new_account_notification(user) - except Exception as error: - flash(gettext('Problem while sending activation email: %(error)s', - error=error), 'danger') - return redirect(url_for('home')) - - flash(gettext('Your account has been created. ' - 'Check your mail to confirm it.'), 'success') - return redirect(url_for('home')) - - return render_template('signup.html', form=form) - - -from calendar import timegm -from flask import jsonify - - -@app.route('/') -@login_required -@etag_match -def home(): - return render_template('home.html', cdn=conf.CDN_ADDRESS) - - -@app.route('/menu') -@login_required -def get_menu(): - categories_order = [0] - categories = {0: {'name': 'No category', 'id': 0}} - for cat in CategoryController(g.user.id).read().order_by('name'): - categories_order.append(cat.id) - categories[cat.id] = cat.dump() - unread = ArticleController(g.user.id).count_by_feed(readed=False) - for cat_id in categories: - categories[cat_id]['unread'] = 0 - categories[cat_id]['feeds'] = [] - feeds = {feed.id: feed.dump() for feed in FeedController(g.user.id).read()} - for feed_id, feed in feeds.items(): - feed['created_stamp'] = timegm(feed['created_date'].timetuple()) * 1000 - feed['last_stamp'] = timegm(feed['last_retrieved'].timetuple()) * 1000 - feed['category_id'] = feed['category_id'] or 0 - feed['unread'] = unread.get(feed['id'], 0) - if not feed['filters']: - feed['filters'] = [] - if feed.get('icon_url'): - feed['icon_url'] = url_for('icon.icon', url=feed['icon_url']) - categories[feed['category_id']]['unread'] += feed['unread'] - categories[feed['category_id']]['feeds'].append(feed_id) - return jsonify(**{'feeds': feeds, 'categories': categories, - 'categories_order': categories_order, - 'crawling_method': conf.CRAWLING_METHOD, - 'max_error': conf.DEFAULT_MAX_ERROR, - 'error_threshold': conf.ERROR_THRESHOLD, - 'is_admin': g.user.is_admin(), - 'all_unread_count': sum(unread.values())}) - - -def _get_filters(in_dict): - filters = {} - query = in_dict.get('query') - if query: - search_title = in_dict.get('search_title') == 'true' - search_content = in_dict.get('search_content') == 'true' - if search_title: - filters['title__ilike'] = "%%%s%%" % query - if search_content: - filters['content__ilike'] = "%%%s%%" % query - if len(filters) == 0: - filters['title__ilike'] = "%%%s%%" % query - if len(filters) > 1: - filters = {"__or__": filters} - if in_dict.get('filter') == 'unread': - filters['readed'] = False - elif in_dict.get('filter') == 'liked': - filters['like'] = True - filter_type = in_dict.get('filter_type') - if filter_type in {'feed_id', 'category_id'} and in_dict.get('filter_id'): - filters[filter_type] = int(in_dict['filter_id']) or None - return filters - - -def _articles_to_json(articles, fd_hash=None): - return jsonify(**{'articles': [{'title': art.title, 'liked': art.like, - 'read': art.readed, 'article_id': art.id, 'selected': False, - 'feed_id': art.feed_id, 'category_id': art.category_id or 0, - 'feed_title': fd_hash[art.feed_id]['title'] if fd_hash else None, - 'icon_url': fd_hash[art.feed_id]['icon_url'] if fd_hash else None, - 'date': art.date, 'timestamp': timegm(art.date.timetuple()) * 1000} - for art in articles.limit(1000)]}) - - -@app.route('/middle_panel') -@login_required -def get_middle_panel(): - filters = _get_filters(request.args) - art_contr = ArticleController(g.user.id) - fd_hash = {feed.id: {'title': feed.title, - 'icon_url': url_for('icon.icon', url=feed.icon_url) - if feed.icon_url else None} - for feed in FeedController(g.user.id).read()} - articles = art_contr.read(**filters).order_by(Article.date.desc()) - return _articles_to_json(articles, fd_hash) - - -@app.route('/getart/<int:article_id>') -@login_required -def get_article(article_id): - contr = ArticleController(g.user.id) - article = contr.get(id=article_id).dump() - if not article['readed']: - contr.update({'id': article_id}, {'readed': True}) - article['category_id'] = article['category_id'] or 0 - feed = FeedController(g.user.id).get(id=article['feed_id']) - article['icon_url'] = url_for('icon.icon', url=feed.icon_url) \ - if feed.icon_url else None - return jsonify(**article) - - -@app.route('/mark_all_as_read', methods=['PUT']) -@login_required -def mark_all_as_read(): - filters, acontr = _get_filters(request.json), ArticleController(g.user.id) - articles = _articles_to_json(acontr.read(**filters)) - acontr.update(filters, {'readed': True}) - return articles - - -@app.route('/fetch', methods=['GET']) -@app.route('/fetch/<int:feed_id>', methods=['GET']) -@login_required -def fetch(feed_id=None): - """ - Triggers the download of news. - News are downloaded in a separated process, mandatory for Heroku. - """ - if conf.CRAWLING_METHOD == "classic" \ - and (not conf.ON_HEROKU or g.user.is_admin()): - utils.fetch(g.user.id, feed_id) - flash(gettext("Downloading articles..."), "info") - else: - flash(gettext("The manual retrieving of news is only available " + - "for administrator, on the Heroku platform."), "info") - return redirect(redirect_url()) - - -@app.route('/about', methods=['GET']) +@current_app.route('/about', methods=['GET']) @etag_match def about(): - """ - 'About' page. - """ return render_template('about.html') - - -@app.route('/export', methods=['GET']) -@login_required -def export_articles(): - """ - Export all articles to HTML or JSON. - """ - user = UserController(g.user.id).get(id=g.user.id) - if request.args.get('format') == "HTML": - # Export to HTML - try: - archive_file, archive_file_name = export.export_html(user) - except: - flash(gettext("Error when exporting articles."), 'danger') - return redirect(redirect_url()) - response = make_response(archive_file) - response.headers['Content-Type'] = 'application/x-compressed' - response.headers['Content-Disposition'] = 'attachment; filename=%s' \ - % archive_file_name - elif request.args.get('format') == "JSON": - # Export to JSON - try: - json_result = export.export_json(user) - except: - flash(gettext("Error when exporting articles."), 'danger') - return redirect(redirect_url()) - response = make_response(json_result) - response.mimetype = 'application/json' - response.headers["Content-Disposition"] \ - = 'attachment; filename=account.json' - else: - flash(gettext('Export format not supported.'), 'warning') - return redirect(redirect_url()) - return response - - -@app.route('/export_opml', methods=['GET']) -@login_required -def export_opml(): - """ - Export all feeds to OPML. - """ - user = UserController(g.user.id).get(id=g.user.id) - categories = {cat.id: cat.dump() - for cat in CategoryController(g.user.id).read()} - response = make_response(render_template('opml.xml', user=user, - categories=categories, - now=datetime.datetime.now())) - response.headers['Content-Type'] = 'application/xml' - response.headers['Content-Disposition'] = 'attachment; filename=feeds.opml' - return response |