aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/web/controllers/user.py7
-rw-r--r--src/web/js/actions/RightPanelActions.js8
-rw-r--r--src/web/js/components/MainApp.react.js2
-rw-r--r--src/web/js/components/Menu.react.js8
-rw-r--r--src/web/js/components/MiddlePanel.react.js8
-rw-r--r--src/web/js/components/RightPanel.react.js30
-rw-r--r--src/web/js/stores/MenuStore.js6
-rw-r--r--src/web/templates/home.html2
-rw-r--r--src/web/templates/layout.html10
-rw-r--r--src/web/views/admin.py153
-rw-r--r--src/web/views/api/__init__.py28
-rw-r--r--src/web/views/api/article.py63
-rw-r--r--src/web/views/api/category.py20
-rw-r--r--src/web/views/api/common.py210
-rw-r--r--src/web/views/api/feed.py60
-rw-r--r--src/web/views/article.py10
-rw-r--r--src/web/views/category.py2
-rw-r--r--src/web/views/feed.py4
-rw-r--r--src/web/views/session_mgmt.py57
-rw-r--r--src/web/views/views.py28
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)
bgstack15