feat: project RSS feed.

This commit is contained in:
Éloi Rivard 2023-04-23 00:38:27 +02:00 committed by zorun
parent 7c782443d3
commit 8d4584d660
11 changed files with 451 additions and 5 deletions

View file

@ -23,6 +23,7 @@ DavidRThrashJr
donkers donkers
Edwin Smulders Edwin Smulders
Elizabeth Sherrock Elizabeth Sherrock
Éloi Rivard
eMerzh eMerzh
Erwan Lacoudre Erwan Lacoudre
Feth AREZKI Feth AREZKI

View file

@ -1,3 +1,3 @@
include *.rst include *.rst
recursive-include ihatemoney *.rst *.py *.yaml *.po *.mo *.html *.css *.js *.eot *.svg *.woff *.txt *.png *.webp *.ini *.cfg *.j2 *.jpg *.gif *.ico recursive-include ihatemoney *.rst *.py *.yaml *.po *.mo *.html *.css *.js *.eot *.svg *.woff *.txt *.png *.webp *.ini *.cfg *.j2 *.jpg *.gif *.ico *.xml
include LICENSE CONTRIBUTORS CHANGELOG.rst include LICENSE CONTRIBUTORS CHANGELOG.rst

View file

@ -453,7 +453,8 @@ class Project(db.Model):
"""Generate a timed and serialized JsonWebToken """Generate a timed and serialized JsonWebToken
:param token_type: Either "auth" for authentication (invalidated when project code changed), :param token_type: Either "auth" for authentication (invalidated when project code changed),
or "reset" for password reset (invalidated after expiration) or "reset" for password reset (invalidated after expiration),
or "feed" for project feeds (invalidated when project code changed)
""" """
if token_type == "reset": if token_type == "reset":
@ -476,7 +477,8 @@ class Project(db.Model):
:param token: Serialized TimedJsonWebToken :param token: Serialized TimedJsonWebToken
:param token_type: Either "auth" for authentication (invalidated when project code changed), :param token_type: Either "auth" for authentication (invalidated when project code changed),
or "reset" for password reset (invalidated after expiration) or "reset" for password reset (invalidated after expiration),
or "feed" for project feeds (invalidated when project code changed)
:param project_id: Project ID. Used for token_type "auth" to use the password as serializer :param project_id: Project ID. Used for token_type "auth" to use the password as serializer
secret key. secret key.
:param max_age: Token expiration time (in seconds). Only used with token_type "reset" :param max_age: Token expiration time (in seconds). Only used with token_type "reset"

View file

@ -103,6 +103,7 @@
{% if g.project %} {% if g.project %}
<li><a class="dropdown-item" href="{{ url_for("main.history") }}">{{ _("History") }}</a></li> <li><a class="dropdown-item" href="{{ url_for("main.history") }}">{{ _("History") }}</a></li>
<li><a class="dropdown-item" href="{{ url_for("main.edit_project") }}">{{ _("Settings") }}</a></li> <li><a class="dropdown-item" href="{{ url_for("main.edit_project") }}">{{ _("Settings") }}</a></li>
<li><a class="dropdown-item" href="{{ url_for("main.feed", token=g.project.generate_token("feed")) }}">{{ _("RSS Feed") }}</a></li>
{% endif %} {% endif %}
{% if session['projects'] and not ((session['projects'] | length) == 1 and g.project and g.project.id in session['projects']) %} {% if session['projects'] and not ((session['projects'] | length) == 1 and g.project and g.project.id in session['projects']) %}

View file

@ -44,6 +44,9 @@
{% endblock %} {% endblock %}
{% block head %}
<link href="{{ url_for(".feed", token=g.project.generate_token("feed")) }}" type="application/rss+xml" rel="alternate" title="{{ g.project.name }}" />
{% endblock %}
{% block sidebar %} {% block sidebar %}
<div class="sidebar_content"> <div class="sidebar_content">

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:atom="http://www.w3.org/2005/Atom"
>
<channel>
<title>I Hate Money — {{ g.project.name }}</title>
<description>{% trans project_name=g.project.name %}Latest bills from {{ project_name }}{% endtrans %}</description>
<atom:link href="{{ url_for(".feed", token=g.project.generate_token("feed"), _external=True) }}" rel="self" type="application/rss+xml" />
<link>{{ url_for(".list_bills", _external=True) }}</link>
{% for (weights, bill) in bills.items -%}
<item>
<title>{{ bill.what }} - {{ bill.amount|currency(bill.original_currency) }}</title>
<guid isPermaLink="false">{{ bill.id }}</guid>
<dc:creator>{{ bill.payer }}</dc:creator>
{% if bill.external_link %}<link>{{ bill.external_link }}</link>{% endif -%}
<description>{{ bill.date|dateformat("long") }} - {{ bill.owers|join(', ', 'name') }} : {{ (bill.amount/weights)|currency(bill.original_currency) }}</description>
<pubDate>{{ bill.creation_date.strftime("%a, %d %b %Y %T") }} +0000</pubDate>
</item>
{% endfor -%}
</channel>
</rss>

View file

@ -5,6 +5,7 @@ import unittest
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
from flask import session, url_for from flask import session, url_for
from libfaketime import fake_time
import pytest import pytest
from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.security import check_password_hash, generate_password_hash
@ -13,6 +14,7 @@ from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.tests.common.help_functions import extract_link from ihatemoney.tests.common.help_functions import extract_link
from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
from ihatemoney.versioning import LoggingMode from ihatemoney.versioning import LoggingMode
from ihatemoney.web import build_etag
class BudgetTestCase(IhatemoneyTestCase): class BudgetTestCase(IhatemoneyTestCase):
@ -150,6 +152,16 @@ class BudgetTestCase(IhatemoneyTestCase):
data = self.client.get("/tartiflette/").data.decode("utf-8") data = self.client.get("/tartiflette/").data.decode("utf-8")
self.assertEqual(data.count('href="/raclette/"'), 1) self.assertEqual(data.count('href="/raclette/"'), 1)
def test_invalid_invite_link_with_feed_token(self):
"""Test that a 'feed' token is not valid to join a project"""
self.post_project("raclette")
project = self.get_project("raclette")
invite_link = url_for(
".join_project", project_id="raclette", token=project.generate_token("feed")
)
response = self.client.get(invite_link, follow_redirects=True)
assert "Provided token is invalid" in response.data.decode()
def test_invite_code_invalidation(self): def test_invite_code_invalidation(self):
"""Test that invitation link expire after code change""" """Test that invitation link expire after code change"""
self.login("raclette") self.login("raclette")
@ -1685,6 +1697,358 @@ class BudgetTestCase(IhatemoneyTestCase):
self.assertIsInstance(session["projects"], dict) self.assertIsInstance(session["projects"], dict)
self.assertIn("raclette", session["projects"]) self.assertIn("raclette", session["projects"])
def test_rss_feed(self):
"""
Tests that the RSS feed output content is expected.
"""
with fake_time("2023-07-25 12:00:00"):
self.post_project("raclette")
self.client.post("/raclette/members/add", data={"name": "george"})
self.client.post("/raclette/members/add", data={"name": "peter"})
self.client.post("/raclette/members/add", data={"name": "steven"})
self.client.post(
"/raclette/add",
data={
"date": "2016-12-31",
"what": "fromage à raclette",
"payer": 1,
"payed_for": [1, 2, 3],
"amount": "12",
"original_currency": "EUR",
},
)
self.client.post(
"/raclette/add",
data={
"date": "2016-12-30",
"what": "charcuterie",
"payer": 2,
"payed_for": [1, 2],
"amount": "15",
"original_currency": "EUR",
},
)
self.client.post(
"/raclette/add",
data={
"date": "2016-12-29",
"what": "vin blanc",
"payer": 2,
"payed_for": [1, 2],
"amount": "10",
"original_currency": "EUR",
},
)
project = self.get_project("raclette")
token = project.generate_token("feed")
resp = self.client.get(f"/raclette/feed/{token}.xml")
expected_rss_content = f"""<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:atom="http://www.w3.org/2005/Atom"
>
<channel>
<title>I Hate Money raclette</title>
<description>Latest bills from raclette</description>
<atom:link href="http://localhost/raclette/feed/{token}.xml" rel="self" type="application/rss+xml" />
<link>http://localhost/raclette/</link>
<item>
<title>fromage à raclette - 12.00</title>
<guid isPermaLink="false">1</guid>
<dc:creator>george</dc:creator>
<description>December 31, 2016 - george, peter, steven : 4.00</description>
<pubDate>Tue, 25 Jul 2023 00:00:00 +0000</pubDate>
</item>
<item>
<title>charcuterie - 15.00</title>
<guid isPermaLink="false">2</guid>
<dc:creator>peter</dc:creator>
<description>December 30, 2016 - george, peter : 7.50</description>
<pubDate>Tue, 25 Jul 2023 00:00:00 +0000</pubDate>
</item>
<item>
<title>vin blanc - 10.00</title>
<guid isPermaLink="false">3</guid>
<dc:creator>peter</dc:creator>
<description>December 29, 2016 - george, peter : 5.00</description>
<pubDate>Tue, 25 Jul 2023 00:00:00 +0000</pubDate>
</item>
</channel>
</rss>""" # noqa: E501
assert resp.data.decode() == expected_rss_content
def test_rss_feed_history_disabled(self):
"""
Tests that RSS feeds is correctly rendered even if the project
history is disabled.
"""
with fake_time("2023-07-25 12:00:00"):
self.post_project("raclette", project_history=False)
self.client.post("/raclette/members/add", data={"name": "george"})
self.client.post("/raclette/members/add", data={"name": "peter"})
self.client.post("/raclette/members/add", data={"name": "steven"})
self.client.post(
"/raclette/add",
data={
"date": "2016-12-31",
"what": "fromage à raclette",
"payer": 1,
"payed_for": [1, 2, 3],
"amount": "12",
"original_currency": "EUR",
},
)
self.client.post(
"/raclette/add",
data={
"date": "2016-12-30",
"what": "charcuterie",
"payer": 2,
"payed_for": [1, 2],
"amount": "15",
"original_currency": "EUR",
},
)
self.client.post(
"/raclette/add",
data={
"date": "2016-12-29",
"what": "vin blanc",
"payer": 2,
"payed_for": [1, 2],
"amount": "10",
"original_currency": "EUR",
},
)
project = self.get_project("raclette")
token = project.generate_token("feed")
resp = self.client.get(f"/raclette/feed/{token}.xml")
expected_rss_content = f"""<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:atom="http://www.w3.org/2005/Atom"
>
<channel>
<title>I Hate Money raclette</title>
<description>Latest bills from raclette</description>
<atom:link href="http://localhost/raclette/feed/{token}.xml" rel="self" type="application/rss+xml" />
<link>http://localhost/raclette/</link>
<item>
<title>fromage à raclette - 12.00</title>
<guid isPermaLink="false">1</guid>
<dc:creator>george</dc:creator>
<description>December 31, 2016 - george, peter, steven : 4.00</description>
<pubDate>Tue, 25 Jul 2023 00:00:00 +0000</pubDate>
</item>
<item>
<title>charcuterie - 15.00</title>
<guid isPermaLink="false">2</guid>
<dc:creator>peter</dc:creator>
<description>December 30, 2016 - george, peter : 7.50</description>
<pubDate>Tue, 25 Jul 2023 00:00:00 +0000</pubDate>
</item>
<item>
<title>vin blanc - 10.00</title>
<guid isPermaLink="false">3</guid>
<dc:creator>peter</dc:creator>
<description>December 29, 2016 - george, peter : 5.00</description>
<pubDate>Tue, 25 Jul 2023 00:00:00 +0000</pubDate>
</item>
</channel>
</rss>""" # noqa: E501
assert resp.data.decode() == expected_rss_content
def test_rss_if_modified_since_header(self):
# Project creation
with fake_time("2023-07-26 13:00:00"):
self.post_project("raclette")
self.client.post("/raclette/members/add", data={"name": "george"})
project = self.get_project("raclette")
token = project.generate_token("feed")
resp = self.client.get(f"/raclette/feed/{token}.xml")
assert resp.status_code == 200
assert resp.headers.get("Last-Modified") == "Wed, 26 Jul 2023 13:00:00 UTC"
resp = self.client.get(
f"/raclette/feed/{token}.xml",
headers={"If-Modified-Since": "Tue, 26 Jul 2023 12:00:00 UTC"},
)
assert resp.status_code == 200
resp = self.client.get(
f"/raclette/feed/{token}.xml",
headers={"If-Modified-Since": "Tue, 26 Jul 2023 14:00:00 UTC"},
)
assert resp.status_code == 304
# Add bill
with fake_time("2023-07-27 13:00:00"):
self.login("raclette")
resp = self.client.post(
"/raclette/add",
data={
"date": "2016-12-31",
"what": "fromage à raclette",
"payer": 1,
"payed_for": [1],
"amount": "12",
"original_currency": "EUR",
},
follow_redirects=True,
)
assert resp.status_code == 200
assert "The bill has been added" in resp.data.decode()
resp = self.client.get(
f"/raclette/feed/{token}.xml",
headers={"If-Modified-Since": "Tue, 27 Jul 2023 12:00:00 UTC"},
)
assert resp.headers.get("Last-Modified") == "Thu, 27 Jul 2023 13:00:00 UTC"
assert resp.status_code == 200
resp = self.client.get(
f"/raclette/feed/{token}.xml",
headers={"If-Modified-Since": "Tue, 27 Jul 2023 14:00:00 UTC"},
)
assert resp.status_code == 304
def test_rss_etag_headers(self):
# Project creation
with fake_time("2023-07-26 13:00:00"):
self.post_project("raclette")
self.client.post("/raclette/members/add", data={"name": "george"})
project = self.get_project("raclette")
token = project.generate_token("feed")
resp = self.client.get(f"/raclette/feed/{token}.xml")
assert resp.headers.get("ETag") == build_etag(
project.id, "2023-07-26T13:00:00"
)
assert resp.status_code == 200
resp = self.client.get(
f"/raclette/feed/{token}.xml",
headers={
"If-None-Match": build_etag(project.id, "2023-07-26T12:00:00"),
},
)
assert resp.status_code == 200
resp = self.client.get(
f"/raclette/feed/{token}.xml",
headers={
"If-None-Match": build_etag(project.id, "2023-07-26T13:00:00"),
},
)
assert resp.status_code == 304
# Add bill
with fake_time("2023-07-27 13:00:00"):
self.login("raclette")
resp = self.client.post(
"/raclette/add",
data={
"date": "2016-12-31",
"what": "fromage à raclette",
"payer": 1,
"payed_for": [1],
"amount": "12",
"original_currency": "EUR",
},
follow_redirects=True,
)
assert resp.status_code == 200
assert "The bill has been added" in resp.data.decode()
resp = self.client.get(
f"/raclette/feed/{token}.xml",
headers={
"If-None-Match": build_etag(project.id, "2023-07-27T12:00:00"),
},
)
assert resp.headers.get("ETag") == build_etag(project.id, "2023-07-27T13:00:00")
assert resp.status_code == 200
resp = self.client.get(
f"/raclette/feed/{token}.xml",
headers={
"If-None-Match": build_etag(project.id, "2023-07-27T13:00:00"),
},
)
assert resp.status_code == 304
def test_rss_feed_bad_token(self):
self.post_project("raclette")
project = self.get_project("raclette")
token = project.generate_token("feed")
resp = self.client.get(f"/raclette/feed/{token}.xml")
self.assertEqual(resp.status_code, 200)
resp = self.client.get("/raclette/feed/invalid-token.xml")
self.assertEqual(resp.status_code, 404)
def test_rss_feed_different_project_with_same_password(
self,
):
"""
Test that a 'feed' token is not valid to access the feed of
another project with the same password.
"""
self.post_project("raclette", password="password")
self.post_project("reblochon", password="password")
project = self.get_project("raclette")
token = project.generate_token("feed")
resp = self.client.get(f"/reblochon/feed/{token}.xml")
self.assertEqual(resp.status_code, 404)
def test_rss_feed_different_project_with_different_password(
self,
):
"""
Test that a 'feed' token is not valid to access the feed of
another project with a different password.
"""
self.post_project("raclette", password="password")
self.post_project("reblochon", password="another-password")
project = self.get_project("raclette")
token = project.generate_token("feed")
resp = self.client.get(f"/reblochon/feed/{token}.xml")
self.assertEqual(resp.status_code, 404)
def test_rss_feed_invalidated_token(self):
"""
Tests that a feed URL becames invalid when the project password changes.
"""
self.post_project("raclette")
project = self.get_project("raclette")
token = project.generate_token("feed")
resp = self.client.get(f"/raclette/feed/{token}.xml")
self.assertEqual(resp.status_code, 200)
self.client.post(
"/raclette/edit",
data={
"name": "raclette",
"contact_email": "zorglub@notmyidea.org",
"password": "didoudida",
"default_currency": "XXX",
},
follow_redirects=True,
)
resp = self.client.get(f"/raclette/feed/{token}.xml")
self.assertEqual(resp.status_code, 404)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -56,6 +56,7 @@ class BaseTestCase(TestCase):
default_currency="XXX", default_currency="XXX",
name=None, name=None,
password=None, password=None,
project_history=True,
): ):
"""Create a fake project""" """Create a fake project"""
name = name or id name = name or id
@ -69,6 +70,7 @@ class BaseTestCase(TestCase):
"password": password, "password": password,
"contact_email": f"{id}@notmyidea.org", "contact_email": f"{id}@notmyidea.org",
"default_currency": default_currency, "default_currency": default_currency,
"project_history": project_history,
}, },
follow_redirects=follow_redirects, follow_redirects=follow_redirects,
) )

View file

@ -9,12 +9,14 @@ some shortcuts to make your life better when coding (see `pull_project`
and `add_project_id` for a quick overview) and `add_project_id` for a quick overview)
""" """
from functools import wraps from functools import wraps
import hashlib
import json import json
import os import os
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
from flask import ( from flask import (
Blueprint, Blueprint,
Response,
abort, abort,
current_app, current_app,
flash, flash,
@ -154,7 +156,8 @@ def pull_project(endpoint, values):
is_admin = session.get("is_admin") is_admin = session.get("is_admin")
is_invitation = endpoint == "main.join_project" is_invitation = endpoint == "main.join_project"
if session.get(project.id) or is_admin or is_invitation: is_feed = endpoint == "main.feed"
if session.get(project.id) or is_admin or is_invitation or is_feed:
# add project into kwargs and call the original function # add project into kwargs and call the original function
g.project = project g.project = project
else: else:
@ -898,6 +901,53 @@ def statistics():
) )
def build_etag(project_id, last_modified):
return hashlib.md5(
(current_app.config["SECRET_KEY"] + project_id + last_modified).encode()
).hexdigest()
@main.route("/<project_id>/feed/<string:token>.xml")
def feed(token):
verified_project_id = Project.verify_token(
token, token_type="feed", project_id=g.project.id
)
if verified_project_id != g.project.id:
abort(404)
weighted_bills = g.project.get_bill_weights_ordered().paginate(
per_page=100, error_out=True
)
# This computes the last modification datetime for the project or
# any of the 100 latest bills. This is done by reading the issued_at
# attribute generated by sqlalchemy-continuum.
bills_last_modified = [
bill.versions[0].transaction.issued_at for _, bill in weighted_bills.items
]
project_last_modified = g.project.versions[0].transaction.issued_at
last_modified = max(bills_last_modified + [project_last_modified])
etag = build_etag(g.project.id, last_modified.isoformat())
if request.if_none_match and etag in request.if_none_match:
return "", 304
if (
request.if_modified_since
and request.if_modified_since.replace(tzinfo=None) >= last_modified
):
return "", 304
return Response(
render_template("project_feed.xml", bills=weighted_bills),
mimetype="application/rss+xml",
headers={
"ETag": etag,
"Last-Modified": last_modified.strftime("%a, %d %b %Y %H:%M:%S UTC"),
},
)
@main.route("/dashboard") @main.route("/dashboard")
@requires_admin() @requires_admin()
def dashboard(): def dashboard():

View file

@ -61,6 +61,7 @@ dev =
vermin==1.5.2 vermin==1.5.2
Flask-Testing>=0.8.1 Flask-Testing>=0.8.1
pytest>=6.2.5 pytest>=6.2.5
pytest-libfaketime>=0.1.2
tox>=3.14.6 tox>=3.14.6
zest.releaser>=6.20.1 zest.releaser>=6.20.1

View file

@ -7,7 +7,7 @@ passenv = TESTING_SQLALCHEMY_DATABASE_URI
commands = commands =
python --version python --version
py.test --pyargs ihatemoney.tests py.test --pyargs ihatemoney.tests {posargs}
deps = deps =
-e.[database,dev] -e.[database,dev]