diff options
-rw-r--r-- | src/web/controllers/article.py | 21 | ||||
-rw-r--r-- | src/web/controllers/feed.py | 33 | ||||
-rw-r--r-- | src/web/lib/utils.py | 16 | ||||
-rw-r--r-- | src/web/templates/article.html | 2 | ||||
-rw-r--r-- | src/web/templates/duplicates.html | 4 | ||||
-rw-r--r-- | src/web/templates/feed_list.html | 2 | ||||
-rw-r--r-- | src/web/templates/history.html | 8 | ||||
-rw-r--r-- | src/web/templates/inactives.html | 2 | ||||
-rw-r--r-- | src/web/templates/layout.html | 4 | ||||
-rwxr-xr-x | src/web/utils.py | 1 | ||||
-rw-r--r-- | src/web/views/article.py | 81 | ||||
-rw-r--r-- | src/web/views/feed.py | 26 | ||||
-rw-r--r-- | src/web/views/views.py | 117 |
13 files changed, 192 insertions, 125 deletions
diff --git a/src/web/controllers/article.py b/src/web/controllers/article.py index 3d8d5c01..72288a09 100644 --- a/src/web/controllers/article.py +++ b/src/web/controllers/article.py @@ -1,6 +1,8 @@ import re import logging +import sqlalchemy from sqlalchemy import func +from collections import Counter from bootstrap import db from .abstract import AbstractController @@ -70,3 +72,22 @@ class ArticleController(AbstractController): attrs['link']) return super().create(**attrs) + + def get_history(self, year=None, month=None): + """ + Sort articles by year and month. + """ + articles_counter = Counter() + articles = self.read() + if year is not None: + articles = articles.filter( + sqlalchemy.extract('year', Article.date) == year) + if month is not None: + articles = articles.filter( + sqlalchemy.extract('month', Article.date) == month) + for article in articles.all(): + if year is not None: + articles_counter[article.date.month] += 1 + else: + articles_counter[article.date.year] += 1 + return articles_counter, articles diff --git a/src/web/controllers/feed.py b/src/web/controllers/feed.py index 3b05b294..b76c4e42 100644 --- a/src/web/controllers/feed.py +++ b/src/web/controllers/feed.py @@ -20,12 +20,14 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import logging +import itertools from datetime import datetime, timedelta import conf from .abstract import AbstractController from .icon import IconController from web.models import Feed +from web.lib.utils import clear_string logger = logging.getLogger(__name__) DEFAULT_LIMIT = 5 @@ -54,6 +56,37 @@ class FeedController(AbstractController): {'last_retrieved': now}) return feeds + def get_duplicates(self, feed_id): + """ + Compare a list of documents by pair. + Pairs of duplicates are sorted by "retrieved date". + """ + feed = self.get(id=feed_id) + duplicates = [] + for pair in itertools.combinations(feed.articles, 2): + date1, date2 = pair[0].date, pair[1].date + if clear_string(pair[0].title) == clear_string(pair[1].title) \ + and (date1 - date2) < timedelta(days=1): + if pair[0].retrieved_date < pair[1].retrieved_date: + duplicates.append((pair[0], pair[1])) + else: + duplicates.append((pair[1], pair[0])) + return feed, duplicates + + def get_inactives(self, nb_days): + today = datetime.now() + inactives = [] + for feed in self.read(): + try: + last_post = feed.articles[0].date + except IndexError: + continue + elapsed = today - last_post + if elapsed > timedelta(days=nb_days): + inactives.append((feed, elapsed)) + inactives.sort(key=lambda tup: tup[1], reverse=True) + return inactives + def _ensure_icon(self, attrs): if not attrs.get('icon_url'): return diff --git a/src/web/lib/utils.py b/src/web/lib/utils.py index aa552a12..88d24ba5 100644 --- a/src/web/lib/utils.py +++ b/src/web/lib/utils.py @@ -1,8 +1,10 @@ +import re import types import urllib import logging import requests from hashlib import md5 +from flask import request, url_for logger = logging.getLogger(__name__) @@ -55,3 +57,17 @@ def try_get_icon_url(url, *splits): def to_hash(text): return md5(text.encode('utf8') if hasattr(text, 'encode') else text)\ .hexdigest() + + +def clear_string(data): + """ + Clear a string by removing HTML tags, HTML special caracters + and consecutive white spaces (more that one). + """ + p = re.compile('<[^>]+>') # HTML tags + q = re.compile('\s') # consecutive white spaces + return p.sub('', q.sub(' ', data)) + + +def redirect_url(default='home'): + return request.args.get('next') or request.referrer or url_for(default) diff --git a/src/web/templates/article.html b/src/web/templates/article.html index b1f950b4..6ab9d0bb 100644 --- a/src/web/templates/article.html +++ b/src/web/templates/article.html @@ -4,7 +4,7 @@ <div class="well"> <h2><a href="{{ article.link }}" target="_blank">{{ article.title|safe }}</a></h2> <h3>{{ _('from') }} <a href="/feed/{{ article.source.id }}">{{ article.source.title }}</a></h3> - <a href="/delete/{{ article.id }}"><i class="glyphicon glyphicon-remove" title="{{ _('Delete this article') }}"></i></a> + <a href="{{ url_for("article.delete", article_id=article.id) }}"><i class="glyphicon glyphicon-remove" title="{{ _('Delete this article') }}"></i></a> {% if article.like %} <a href="#"><i class="glyphicon glyphicon-star like" title="{{ _('One of your favorites') }}"></i></a> {% else %} diff --git a/src/web/templates/duplicates.html b/src/web/templates/duplicates.html index 32fbdf9a..d944a1a5 100644 --- a/src/web/templates/duplicates.html +++ b/src/web/templates/duplicates.html @@ -19,8 +19,8 @@ {% for pair in duplicates %} <tr> <td>{{ loop.index }}</td> - <td id="{{ pair[0].id }}"><a href="/delete/{{ pair[0].id }}"><i class="glyphicon glyphicon-remove" title="{{ _('Delete this article') }}"></i></a> <a href="/article/{{ pair[0].id }}">{{ pair[0].title }}</a> ({{ pair[0].retrieved_date }})</td> - <td id="{{ pair[1].id }}"><a href="/delete/{{ pair[1].id }}"><i class="glyphicon glyphicon-remove" title="{{ _('Delete this article') }}"></i></a> <a href="/article/{{ pair[1].id }}">{{ pair[1].title }}</a> ({{ pair[1].retrieved_date }})</td> + <td id="{{ pair[0].id }}"><a href="{{ url_for("article.delete", article_id=pair[0].id) }}"><i class="glyphicon glyphicon-remove" title="{{ _('Delete this article') }}"></i></a> <a href="/article/{{ pair[0].id }}">{{ pair[0].title }}</a> ({{ pair[0].retrieved_date }})</td> + <td id="{{ pair[1].id }}"><a href="{{ url_for("article.delete", article_id=pair[1].id) }}"><i class="glyphicon glyphicon-remove" title="{{ _('Delete this article') }}"></i></a> <a href="/article/{{ pair[1].id }}">{{ pair[1].title }}</a> ({{ pair[1].retrieved_date }})</td> </tr> {% endfor %} </tobdy> diff --git a/src/web/templates/feed_list.html b/src/web/templates/feed_list.html index c5cadab0..27815250 100644 --- a/src/web/templates/feed_list.html +++ b/src/web/templates/feed_list.html @@ -36,7 +36,7 @@ <td> <a href="{{ url_for("home", feed_id=feed.id, filter_="all") }}"><i class="glyphicon glyphicon-th-list" title="{{ _('Articles') }}"></i></a> <a href="{{ url_for("feed.form", feed_id=feed.id) }}"><i class="glyphicon glyphicon-edit" title="{{ _('Edit this feed') }}"></i></a> - <a href="/duplicates/{{ feed.id }}"><i class="glyphicon glyphicon-book" title="{{ _('Duplicate articles') }}"></i></a> + <a href="{{ url_for("feed.duplicates", feed_id=feed.id) }}"><i class="glyphicon glyphicon-book" title="{{ _('Duplicate articles') }}"></i></a> <a href="{{ url_for("feed.delete", feed_id=feed.id) }}"><i class="glyphicon glyphicon-remove" title="{{ _('Delete this feed') }}" onclick="return confirm('{{ _('You are going to delete this feed.') }}');"></i></a> </td> </tr> diff --git a/src/web/templates/history.html b/src/web/templates/history.html index 499fe50b..caa0b057 100644 --- a/src/web/templates/history.html +++ b/src/web/templates/history.html @@ -3,18 +3,18 @@ <div class="container"> <h1>{{ _('History') }}</h1> {% if month != None %} - <h2><a href="/history/{{ year }}"><span class="glyphicon glyphicon-chevron-left"></span> {{ year }}</a></h2> + <h2><a href="{{ url_for("articles.history", year=year) }}"><span class="glyphicon glyphicon-chevron-left"></span> {{ year }}</a></h2> <h3>{{ month | month_name }}</h3> {% elif year != None %} - <h2><a href="/history"><span class="glyphicon glyphicon-chevron-left"></span> {{ _('all years') }}</a></h2> + <h2><a href="{{ url_for("articles.history") }}"><span class="glyphicon glyphicon-chevron-left"></span> {{ _('all years') }}</a></h2> <h3>{{ year }}</h3> {% endif %} <ul class="list-group"> {% for article in articles_counter | sort(reverse = True) %} {% if year == None %} - <li class="list-group-item"><a href="/history/{{ article }}">{{ article }}</a> : {{ articles_counter[article] }} articles</li> + <li class="list-group-item"><a href="{{ url_for("articles.history", year=article) }}">{{ article }}</a> : {{ articles_counter[article] }} articles</li> {% elif month == None %} - <li class="list-group-item"><a href="/history/{{ year }}/{{ article }}">{{ article | month_name }}</a> : {{ articles_counter[article] }} articles</li> + <li class="list-group-item"><a href="{{ url_for("articles.history", year=year, month=article) }}">{{ article | month_name }}</a> : {{ articles_counter[article] }} articles</li> {% else %} {% for article in articles | sort(attribute="date", reverse = True) %} <li class="list-group-item">{{ article.date | datetime }} - <a href="/article/{{ article.id }}">{{ article.title | safe }}</a></li> diff --git a/src/web/templates/inactives.html b/src/web/templates/inactives.html index eb546eca..e89a5fe1 100644 --- a/src/web/templates/inactives.html +++ b/src/web/templates/inactives.html @@ -2,7 +2,7 @@ {% block content %} <div class="container"> <div class="well"> - <form method=get action="/inactives"> + <form method=get action="{{ url_for("feeds.inactives") }}"> <p>{{ _('Days of inactivity') }}:</p> <input type="number" name="nb_days" class="form-control" value="{{ nb_days }}" min="0" max="1000000" step="1" size="4" style="text-align: center" /> </form> diff --git a/src/web/templates/layout.html b/src/web/templates/layout.html index 15cf3e97..cf2498e2 100644 --- a/src/web/templates/layout.html +++ b/src/web/templates/layout.html @@ -71,8 +71,8 @@ <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("inactives") }}">{{ _('Inactive') }}</a></li> - <li><a href="{{ url_for("history") }}">{{ _('History') }}</a></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> </ul> </li> diff --git a/src/web/utils.py b/src/web/utils.py index 91e2bd9f..38d15f25 100755 --- a/src/web/utils.py +++ b/src/web/utils.py @@ -61,6 +61,7 @@ from flask import g from bootstrap import application as app, db from web import controllers from web.models import User, Feed, Article +from web.lib.utils import clear_string logger = logging.getLogger(__name__) diff --git a/src/web/views/article.py b/src/web/views/article.py index bb914a6b..d308d37e 100644 --- a/src/web/views/article.py +++ b/src/web/views/article.py @@ -1,31 +1,32 @@ #! /usr/bin/env python # -*- coding: utf-8 - +from flask import Blueprint, g, render_template, redirect, flash, url_for +from flask.ext.babel import gettext +from flask.ext.login import login_required -from flask import Blueprint, g, render_template, redirect - -from web import controllers, utils +from web.lib.utils import clear_string, redirect_url +from web.controllers import ArticleController from web.lib.view_utils import etag_match -from web.decorators import pyagg_default_decorator articles_bp = Blueprint('articles', __name__, url_prefix='/articles') article_bp = Blueprint('article', __name__, url_prefix='/article') @article_bp.route('/redirect/<int:article_id>', methods=['GET']) -@pyagg_default_decorator +@login_required def redirect_to_article(article_id): - article = controllers.ArticleController(g.user.id).get(id=article_id) + article = ArticleController(g.user.id).get(id=article_id) return redirect(article.link) @article_bp.route('/<int:article_id>', methods=['GET']) -@pyagg_default_decorator +@login_required @etag_match def article(article_id=None): """ Presents the content of an article. """ - article = controllers.ArticleController(g.user.id).get(id=article_id) + article = ArticleController(g.user.id).get(id=article_id) previous_article = article.previous_article() if previous_article is None: previous_article = article.source.articles[0] @@ -34,7 +35,69 @@ def article(article_id=None): next_article = article.source.articles[-1] return render_template('article.html', - head_titles=[utils.clear_string(article.title)], + head_titles=[clear_string(article.title)], article=article, previous_article=previous_article, next_article=next_article) + + +@article_bp.route('/like/<int:article_id>', methods=['GET']) +@login_required +def like(article_id=None): + """ + Mark or unmark an article as favorites. + """ + art_contr = ArticleController(g.user.id) + article = art_contr.get(id=article_id) + art_contr = art_contr.update({'id': article_id}, + {'like': not article.like}) + return redirect(redirect_url()) + + +@article_bp.route('/delete/<int:article_id>', methods=['GET']) +@login_required +def delete(article_id=None): + """ + Delete an article from the database. + """ + article = ArticleController(g.user.id).delete(article_id) + flash(gettext('Article %(article_title)s deleted', + article_title=article.title), 'success') + return redirect(url_for('home')) + + +@articles_bp.route('/history', methods=['GET']) +@articles_bp.route('/history/<int:year>', methods=['GET']) +@articles_bp.route('/history/<int:year>/<int:month>', methods=['GET']) +@login_required +def history(year=None, month=None): + counter, articles = ArticleController(g.user.id).get_history(year, month) + return render_template('history.html', articles_counter=counter, + articles=articles, year=year, month=month) + + +@article_bp.route('/mark_as/<string:new_value>', methods=['GET']) +@article_bp.route('/mark_as/<string:new_value>/article/<int:article_id>', + methods=['GET']) +@login_required +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) + filters = {'readed': not readed} + if feed_id is not None: + filters['feed_id'] = feed_id + message = 'Feed marked as %s.' + elif article_id is not None: + filters['id'] = article_id + message = 'Article marked as %s.' + else: + message = 'All article marked as %s.' + art_contr.update(filters, {"readed": readed}) + flash(gettext(message % new_value), 'info') + + if readed: + return redirect(redirect_url()) + return redirect('home') diff --git a/src/web/views/feed.py b/src/web/views/feed.py index 68d8765a..6569d1b7 100644 --- a/src/web/views/feed.py +++ b/src/web/views/feed.py @@ -217,3 +217,29 @@ def process_form(feed_id=None): flash(gettext("Downloading articles for the new feed..."), 'info') return redirect(url_for('feed.form', feed_id=new_feed.id)) + + +@feeds_bp.route('/inactives', methods=['GET']) +@login_required +def inactives(): + """ + List of inactive feeds. + """ + nb_days = int(request.args.get('nb_days', 365)) + inactives = FeedController(g.user.id).get_inactives(nb_days) + return render_template('inactives.html', + inactives=inactives, nb_days=nb_days) + + +@feed_bp.route('/duplicates/<int:feed_id>', methods=['GET']) +@login_required +def duplicates(feed_id): + """ + Return duplicates article for a feed. + """ + feed, duplicates = FeedController(g.user.id).get_duplicates(feed_id) + if len(duplicates) == 0: + flash(gettext('No duplicates in the feed "{}".').format(feed.title), + 'info') + return redirect('home') + return render_template('duplicates.html', duplicates=duplicates, feed=feed) diff --git a/src/web/views/views.py b/src/web/views/views.py index ed4ae8a7..1e7b7539 100644 --- a/src/web/views/views.py +++ b/src/web/views/views.py @@ -38,8 +38,7 @@ 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, \ - login_url + login_required, current_user, AnonymousUserMixin from flask.ext.principal import Principal, Identity, AnonymousIdentity, \ identity_changed, identity_loaded, Permission,\ RoleNeed, UserNeed @@ -49,6 +48,7 @@ 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 web.lib.view_utils import etag_match from web.models import User, Feed, Article, Role @@ -90,6 +90,7 @@ def on_identity_loaded(sender, identity): for role in current_user.roles: identity.provides.add(RoleNeed(role.name)) + @app.before_request def before_request(): g.user = current_user @@ -98,6 +99,7 @@ def before_request(): db.session.add(g.user) db.session.commit() + @login_manager.user_loader def load_user(id): # Return an instance of the User model @@ -111,15 +113,18 @@ def authentication_required(e): flash(gettext('Authentication required.'), 'info') return redirect(url_for('login')) + @app.errorhandler(403) def authentication_failed(e): flash(gettext('Forbidden.'), 'danger') return redirect(url_for('login')) + @app.errorhandler(404) def page_not_found(e): return render_template('errors/404.html'), 404 + @app.errorhandler(500) def internal_server_error(e): return render_template('errors/500.html'), 500 @@ -138,6 +143,7 @@ def get_locale(): """ return request.accept_languages.best_match(conf.LANGUAGES.keys()) + @g.babel.timezoneselector def get_timezone(): try: @@ -167,6 +173,7 @@ def login(): return form.redirect('home') return render_template('login.html', form=form) + @app.route('/logout') @login_required def logout(): @@ -367,97 +374,6 @@ def about(): return render_template('about.html') -@app.route('/mark_as/<string:new_value>', methods=['GET']) -@app.route('/mark_as/<string:new_value>/article/<int:article_id>', methods=['GET']) -@login_required -@feed_access_required -def mark_as(new_value='read', feed_id=None, article_id=None): - """ - Mark all unreaded articles as read. - """ - readed = new_value == 'read' - articles = Article.query.filter(Article.user_id == g.user.id) - if feed_id is not None: - articles = articles.filter(Article.feed_id == feed_id) - message = 'Feed marked as %s.' - elif article_id is not None: - articles = articles.filter(Article.id == article_id) - message = 'Article marked as %s.' - else: - message = 'All article marked as %s.' - articles.filter(Article.readed == (not readed)).update({"readed": readed}) - flash(gettext(message % new_value), 'info') - db.session.commit() - if readed: - return redirect(redirect_url()) - return redirect(url_for('home')) - -@app.route('/like/<int:article_id>', methods=['GET']) -@login_required -def like(article_id=None): - """ - Mark or unmark an article as favorites. - """ - Article.query.filter(Article.user_id == g.user.id, Article.id == article_id). \ - update({ - "like": not Article.query.filter(Article.id == article_id).first().like - }) - db.session.commit() - return redirect(redirect_url()) - -@app.route('/delete/<int:article_id>', methods=['GET']) -@login_required -def delete(article_id=None): - """ - Delete an article from the database. - """ - article = Article.query.filter(Article.id == article_id).first() - if article is not None and article.source.subscriber.id == g.user.id: - db.session.delete(article) - db.session.commit() - flash(gettext('Article') + ' ' + article.title + ' ' + gettext('deleted.'), 'success') - return redirect(redirect_url()) - else: - flash(gettext('This article do not exist.'), 'danger') - return redirect(url_for('home')) - - -@app.route('/inactives', methods=['GET']) -@login_required -def inactives(): - """ - List of inactive feeds. - """ - nb_days = int(request.args.get('nb_days', 365)) - user = UserController(g.user.id).get(email=g.user.email) - today = datetime.datetime.now() - inactives = [] - for feed in user.feeds: - try: - last_post = feed.articles[0].date - except IndexError: - continue - elapsed = today - last_post - if elapsed > datetime.timedelta(days=nb_days): - inactives.append((feed, elapsed)) - inactives.sort(key=lambda tup: tup[1], reverse=True) - return render_template('inactives.html', inactives=inactives, nb_days=nb_days) - -@app.route('/duplicates/<int:feed_id>', methods=['GET']) -@login_required -def duplicates(feed_id=None): - """ - Return duplicates article for a feed. - """ - feed = Feed.query.filter(Feed.user_id == g.user.id, Feed.id == feed_id).first() - duplicates = [] - duplicates = utils.compare_documents(feed) - if len(duplicates) == 0: - flash(gettext('No duplicates in the feed "{}".').format(feed.title), - 'info') - return redirect(redirect_url()) - return render_template('duplicates.html', duplicates=duplicates, feed=feed) - @app.route('/export', methods=['GET']) @login_required def export_articles(): @@ -485,12 +401,14 @@ def export_articles(): return redirect(redirect_url()) response = make_response(json_result) response.mimetype = 'application/json' - response.headers["Content-Disposition"] = 'attachment; filename=account.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(): @@ -551,17 +469,6 @@ def management(): nb_feeds=nb_feeds, nb_articles=nb_articles, nb_unread_articles=nb_unread_articles) -@app.route('/history', methods=['GET']) -@app.route('/history/<int:year>', methods=['GET']) -@app.route('/history/<int:year>/<int:month>', methods=['GET']) -@login_required -def history(year=None, month=None): - articles_counter, articles = utils.history(g.user.id, year, month) - return render_template('history.html', - articles_counter=articles_counter, - articles=articles, - year=year, month=month) - @app.route('/profile', methods=['GET', 'POST']) @login_required |