From 83081ad7e488c44757e43ff40e83458a2e1451ed Mon Sep 17 00:00:00 2001 From: Cédric Bonhomme Date: Tue, 1 Mar 2016 22:47:53 +0100 Subject: begin integration of the new architecture --- src/web/views/__init__.py | 23 ++- src/web/views/admin.py | 32 +++- src/web/views/article.py | 16 +- src/web/views/category.py | 14 +- src/web/views/common.py | 53 ++++++ src/web/views/feed.py | 36 ++-- src/web/views/home.py | 155 +++++++++++++++++ src/web/views/session_mgmt.py | 123 ++++++++++++++ src/web/views/user.py | 22 +-- src/web/views/views.py | 386 +++--------------------------------------- 10 files changed, 435 insertions(+), 425 deletions(-) create mode 100644 src/web/views/common.py create mode 100644 src/web/views/home.py create mode 100644 src/web/views/session_mgmt.py (limited to 'src/web/views') 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..29f161d3 100644 --- a/src/web/views/admin.py +++ b/src/web/views/admin.py @@ -1,7 +1,35 @@ +#! /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 . + +__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 flask.ext.login import login_required +from flask.ext.login import login_required, current_user from flask.ext.principal import Permission, RoleNeed @@ -37,7 +65,7 @@ def dashboard(): users = UserController().read() return render_template('admin/dashboard.html', - users=users, current_user=g.user, form=form) + users=users, current_user=current_user, form=form) @admin_bp.route('/user/create', methods=['GET']) diff --git a/src/web/views/article.py b/src/web/views/article.py index 5b04fe7a..416bb96c 100644 --- a/src/web/views/article.py +++ b/src/web/views/article.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta 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 from web.lib.utils import clear_string, redirect_url from web.controllers import ArticleController @@ -17,7 +17,7 @@ article_bp = Blueprint('article', __name__, url_prefix='/article') @article_bp.route('/redirect/', 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}) @@ -31,7 +31,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] @@ -52,7 +52,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}) @@ -65,7 +65,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')) @@ -76,7 +76,7 @@ def delete(article_id=None): @articles_bp.route('/history//', methods=['GET']) @login_required def history(year=None, month=None): - counter, articles = ArticleController(g.user.id).get_history(year, month) + counter, articles = ArticleController(current_user.id).get_history(year, month) return render_template('history.html', articles_counter=counter, articles=articles, year=year, month=month) @@ -90,7 +90,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 @@ -116,7 +116,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}) diff --git a/src/web/views/category.py b/src/web/views/category.py index 20b90caa..3d8762e0 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.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/', 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..1b1b0b5e 100644 --- a/src/web/views/feed.py +++ b/src/web/views/feed.py @@ -9,7 +9,7 @@ from werkzeug.exceptions import BadRequest from flask import Blueprint, g, 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 +29,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 +41,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 +76,7 @@ def feed(feed_id=None): @feed_bp.route('/delete/', 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 +87,7 @@ def delete(feed_id=None): @feed_bp.route('/reset_errors/', 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 +98,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 +128,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 +146,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 +157,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 +181,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 +217,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 +230,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 +241,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..fd677b3c --- /dev/null +++ b/src/web/views/home.py @@ -0,0 +1,155 @@ +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/') +@current_app.route('/getart//') +@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) + acontr.update(filters, {'readed': True}) + return _articles_to_json(acontr.read(**filters)) + + +@current_app.route('/fetch', methods=['GET']) +@current_app.route('/fetch/', 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..f1b16927 --- /dev/null +++ b/src/web/views/session_mgmt.py @@ -0,0 +1,123 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +import json +import datetime +import logging + +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) +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 +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(id): + # Return an instance of the User model + return UserController().get(id=id) + +"""@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(): + 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(): + """ + 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: + 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') + 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..964a38ce 100644 --- a/src/web/views/views.py +++ b/src/web/views/views.py @@ -26,397 +26,49 @@ __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')) - - 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())}) - +@current_app.errorhandler(AssertionError) +def handle_sqlalchemy_assertion_error(error): + return error.args[0], 400 -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/') -@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/', 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 -- cgit