diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/web/controllers/user.py | 7 | ||||
-rw-r--r-- | src/web/js/actions/RightPanelActions.js | 8 | ||||
-rw-r--r-- | src/web/js/components/MainApp.react.js | 2 | ||||
-rw-r--r-- | src/web/js/components/Menu.react.js | 8 | ||||
-rw-r--r-- | src/web/js/components/MiddlePanel.react.js | 8 | ||||
-rw-r--r-- | src/web/js/components/RightPanel.react.js | 30 | ||||
-rw-r--r-- | src/web/js/stores/MenuStore.js | 6 | ||||
-rw-r--r-- | src/web/templates/home.html | 2 | ||||
-rw-r--r-- | src/web/templates/layout.html | 10 | ||||
-rw-r--r-- | src/web/views/admin.py | 153 | ||||
-rw-r--r-- | src/web/views/api/__init__.py | 28 | ||||
-rw-r--r-- | src/web/views/api/article.py | 63 | ||||
-rw-r--r-- | src/web/views/api/category.py | 20 | ||||
-rw-r--r-- | src/web/views/api/common.py | 210 | ||||
-rw-r--r-- | src/web/views/api/feed.py | 60 | ||||
-rw-r--r-- | src/web/views/article.py | 10 | ||||
-rw-r--r-- | src/web/views/category.py | 2 | ||||
-rw-r--r-- | src/web/views/feed.py | 4 | ||||
-rw-r--r-- | src/web/views/session_mgmt.py | 57 | ||||
-rw-r--r-- | src/web/views/views.py | 28 |
20 files changed, 234 insertions, 482 deletions
diff --git a/src/web/controllers/user.py b/src/web/controllers/user.py index ee2eb4c2..1b5c123e 100644 --- a/src/web/controllers/user.py +++ b/src/web/controllers/user.py @@ -1,9 +1,10 @@ -import random -import hashlib +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,7 +12,7 @@ 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'] 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..f4ad76d0 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 diff --git a/src/web/js/components/MiddlePanel.react.js b/src/web/js/components/MiddlePanel.react.js index dad33acc..cb5be92d 100644 --- a/src/web/js/components/MiddlePanel.react.js +++ b/src/web/js/components/MiddlePanel.react.js @@ -35,7 +35,7 @@ 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"> {icon} {this.props.feed_title} </a>); var read = (<Glyphicon glyph={this.state.read?"check":"unchecked"} @@ -43,10 +43,6 @@ 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"; @@ -57,7 +53,7 @@ var TableLine = React.createClass({ <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> ); }, diff --git a/src/web/js/components/RightPanel.react.js b/src/web/js/components/RightPanel.react.js index 39b06f38..6005e954 100644 --- a/src/web/js/components/RightPanel.react.js +++ b/src/web/js/components/RightPanel.react.js @@ -35,6 +35,7 @@ var PanelMixin = { </Button>); } btn_grp = (<ButtonGroup bsSize="small"> + {this.getExtraButton()} {edit_button} {rem_button} </ButtonGroup>); @@ -169,13 +170,28 @@ var Article = React.createClass({ ], obj_type: 'article', getTitle: function() {return this.props.obj.title;}, + getExtraButton: function() { + if(!this.props.obj.readability_available) { + return null; + } + return (<Button id="readability-reload" onClick={this.reloadParsed} + active={this.props.obj.readability_parsed}> + <img src="/static/img/readability.png" /> + </Button>); + }, getBody: function() { return (<div className="panel-body"> {this.getCore()} - <div dangerouslySetInnerHTML={ + <div id="article-content" dangerouslySetInnerHTML={ {__html: this.props.obj.content}} /> </div>); }, + reloadParsed: function() { + if(this.props.obj.readability_available + && !this.props.obj.readability_parsed) { + RightPanelActions.loadArticle(this.props.obj.id, true, true); + } + }, }); var Feed = React.createClass({ @@ -188,10 +204,13 @@ var Feed = React.createClass({ {'title': 'Feed link', 'type': 'link', 'key': 'link'}, {'title': 'Site link', 'type': 'link', 'key': 'site_link'}, {'title': 'Enabled', 'type': 'bool', 'key': 'enabled'}, + {'title': 'Auto Readability', + 'type': 'bool', 'key': 'readability_auto_parse'}, {'title': 'Filters', 'type': 'ignore', 'key': 'filters'}, {'title': 'Category', 'type': 'ignore', 'key': 'category_id'}, ], getTitle: function() {return this.props.obj.title;}, + getExtraButton: function() {return null;}, getFilterRow: function(i, filter) { return (<dd key={'d' + i + '-' + this.props.obj.id} className="input-group filter-row"> @@ -317,8 +336,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()} @@ -353,6 +370,7 @@ var Category = React.createClass({ if(this.props.obj.id != 0) {return true;} else {return false;} }, + getExtraButton: function () {return null;}, isRemovable: function() {return this.isEditable();}, obj_type: 'category', fields: [{'title': 'Category name', 'type': 'string', 'key': 'name'}], @@ -423,9 +441,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/stores/MenuStore.js b/src/web/js/stores/MenuStore.js index 49c61bc1..9686ff4a 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,6 +25,7 @@ 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(); } }, @@ -53,6 +55,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,6 +84,7 @@ 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(); } } @@ -98,12 +102,14 @@ 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; 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 d382a6dd..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"> @@ -60,11 +60,6 @@ <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,7 +71,6 @@ </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 current_user.is_admin %} <li role="presentation" class="divider"></li> diff --git a/src/web/views/admin.py b/src/web/views/admin.py index 29f161d3..78344983 100644 --- a/src/web/views/admin.py +++ b/src/web/views/admin.py @@ -1,126 +1,25 @@ -#! /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: 0.1 $" -__date__ = "$Date: 2010/02/28 $" -__revision__ = "$Date: 2014/02/28 $" -__copyright__ = "Copyright (c) Cedric Bonhomme" -__license__ = "AGPLv3" - -from flask import (Blueprint, g, render_template, redirect, - flash, url_for, request) -from flask.ext.babel import gettext +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 flask.ext.principal import Permission, RoleNeed - +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. - """ - form = InformationMessageForm() - - 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') - - users = UserController().read() - return render_template('admin/dashboard.html', - users=users, current_user=current_user, form=form) - - -@admin_bp.route('/user/create', methods=['GET']) -@admin_bp.route('/user/edit/<int:user_id>', methods=['GET']) -@login_required -@admin_permission.require(http_exception=403) -def user_form(user_id=None): - if user_id is not None: - user = UserController().get(id=user_id) - form = UserForm(obj=user) - message = gettext('Edit the user <i>%(nick)s</i>', nick=user.nickname) - else: - form = UserForm() - message = gettext('Add a new user') - return render_template('/admin/create_user.html', - form=form, message=message) - - -@admin_bp.route('/user/create', methods=['POST']) -@admin_bp.route('/user/edit/<int:user_id>', methods=['POST']) -@login_required -@admin_permission.require(http_exception=403) -def process_user_form(user_id=None): - """ - Create or edit a user. - """ - form = UserForm() - user_contr = UserController() - - if not form.validate(): - 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}, - {'nickname': form.nickname.data, - 'email': form.email.data, - 'password': form.password.data, - 'refresh_rate': form.refresh_rate.data}) - user = user_contr.get(id=user_id) - flash(gettext('User %(nick)s successfully updated', - nick=user.nickname), 'success') - else: - # 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) - flash(gettext('User %(nick)s successfully created', - nick=user.nickname), 'success') - return redirect(url_for('admin.user_form', user_id=user.id)) + last_cons, now = {}, datetime.utcnow() + users = list(UserController().read().order_by('id')) + for user in users: + last_cons[user.id] = format_timedelta(now - user.last_connection) + return render_template('admin/dashboard.html', now=datetime.utcnow(), + last_cons=last_cons, users=users, current_user=current_user) @admin_bp.route('/user/<int:user_id>', methods=['GET']) @@ -142,22 +41,6 @@ def user(user_id=None): 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() @@ -165,14 +48,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 91a9bfff..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 current_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.is_active: - 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(current_user, 'id', None)) - - @property - def wider_controller(self): - if current_user.is_admin: + if admin_permission.can(): return self.controller_cls() - return self.controller_cls(getattr(current_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 current_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 416bb96c..46e8b786 100644 --- a/src/web/views/article.py +++ b/src/web/views/article.py @@ -1,7 +1,5 @@ -#! /usr/bin/env python -# -*- coding: utf-8 - from datetime import datetime, timedelta -from flask import (Blueprint, g, render_template, redirect, +from flask import (Blueprint, render_template, redirect, flash, url_for, request) from flask.ext.babel import gettext from flask.ext.login import login_required, current_user @@ -76,9 +74,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(current_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']) diff --git a/src/web/views/category.py b/src/web/views/category.py index 3d8762e0..a7447775 100644 --- a/src/web/views/category.py +++ b/src/web/views/category.py @@ -1,4 +1,4 @@ -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, current_user diff --git a/src/web/views/feed.py b/src/web/views/feed.py index 1b1b0b5e..69e093b2 100644 --- a/src/web/views/feed.py +++ b/src/web/views/feed.py @@ -1,12 +1,10 @@ -#! /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, current_user diff --git a/src/web/views/session_mgmt.py b/src/web/views/session_mgmt.py index f1b16927..65be856c 100644 --- a/src/web/views/session_mgmt.py +++ b/src/web/views/session_mgmt.py @@ -1,10 +1,7 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- - import json -import datetime 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 @@ -13,8 +10,6 @@ from flask.ext.login import LoginManager, logout_user, \ from flask.ext.principal import (Principal, AnonymousIdentity, UserNeed, identity_changed, identity_loaded, session_identity_loader) -from werkzeug import generate_password_hash -from sqlalchemy.exc import IntegrityError import conf from web.views.common import admin_role, api_role, login_user_bundle @@ -41,20 +36,15 @@ def on_identity_loaded(sender, identity): 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) + if current_user.is_api: + identity.provides.add(api_role) + @login_manager.user_loader -def load_user(id): - # Return an instance of the User model - return UserController().get(id=id) +def load_user(user_id): + return UserController(user_id, ignore_context=True).get( + id=user_id, is_active=True) -"""@current_app.before_request -def before_request(): - if current_user.is_authenticated: - current_user.last_seen = datetime.datetime.utcnow() - db.session.add(current_user) - db.session.commit()""" @current_app.route('/login', methods=['GET', 'POST']) def login(): @@ -66,6 +56,7 @@ def login(): return form.redirect('home') return render_template('login.html', form=form) + @current_app.route('/logout') @login_required def logout(): @@ -82,42 +73,20 @@ def logout(): return redirect(url_for('login')) + @current_app.route('/signup', methods=['GET', 'POST']) def signup(): - """ - Signup page. - """ if not conf.SELF_REGISTRATION: flash(gettext("Self-registration is disabled."), 'warning') return redirect(url_for('home')) - if current_user is not None and current_user.is_authenticated: + if current_user.is_authenticated: return redirect(url_for('home')) 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') + 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/views.py b/src/web/views/views.py index 964a38ce..b23a41a1 100644 --- a/src/web/views/views.py +++ b/src/web/views/views.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 - 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 logging from flask import (request, render_template, flash, url_for, redirect, current_app) |