Skip to content

Commit

Permalink
feat: project RSS feed.
Browse files Browse the repository at this point in the history
  • Loading branch information
azmeuk committed Jul 27, 2023
1 parent 7c78244 commit cd7a2bc
Show file tree
Hide file tree
Showing 9 changed files with 311 additions and 5 deletions.
2 changes: 1 addition & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
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
6 changes: 4 additions & 2 deletions ihatemoney/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,8 @@ def generate_token(self, token_type="auth"):
"""Generate a timed and serialized JsonWebToken
: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":
Expand All @@ -476,7 +477,8 @@ def verify_token(token, token_type="auth", project_id=None, max_age=3600):
:param token: Serialized TimedJsonWebToken
: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
secret key.
:param max_age: Token expiration time (in seconds). Only used with token_type "reset"
Expand Down
4 changes: 4 additions & 0 deletions ihatemoney/templates/edit_project.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ <h2>{{ _("Download project's data") }}</h2>
<h5 class="d-flex w-100 justify-content-between">
<span class="mb-1">{{ _('Bill items') }}</span>
<span>
<a href="{{ url_for(".feed", token=g.project.generate_token("feed")) }}" download class="badge badge-secondary">
<i class="icon before-text">{{ static_include("images/globe.svg") | safe }}</i>
RSS
</a>
<a href="{{ url_for('.export_project', file='bills', format='json') }}" download class="badge badge-secondary">
<i class="icon before-text">{{ static_include("images/file-alt.svg") | safe }}</i>
JSON
Expand Down
3 changes: 3 additions & 0 deletions ihatemoney/templates/list_bills.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@

{% 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 %}
<div class="sidebar_content">
Expand Down
22 changes: 22 additions & 0 deletions ihatemoney/templates/project_feed.xml
Original file line number Diff line number Diff line change
@@ -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>
227 changes: 227 additions & 0 deletions ihatemoney/tests/budget_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from urllib.parse import urlparse, urlunparse

from flask import session, url_for
from libfaketime import fake_time
import pytest
from werkzeug.security import check_password_hash, generate_password_hash

Expand All @@ -13,6 +14,7 @@
from ihatemoney.tests.common.help_functions import extract_link
from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
from ihatemoney.versioning import LoggingMode
from ihatemoney.web import build_etag


class BudgetTestCase(IhatemoneyTestCase):
Expand Down Expand Up @@ -150,6 +152,17 @@ def test_multiple_join(self):
data = self.client.get("/tartiflette/").data.decode("utf-8")
self.assertEqual(data.count('href="/raclette/"'), 1)

def test_invalid_invite_link(self):
"""Test that a 'feed' token is not valid to join a project"""
self.login("raclette")
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):
"""Test that invitation link expire after code change"""
self.login("raclette")
Expand Down Expand Up @@ -1685,6 +1698,220 @@ def test_session_projects_migration_to_list(self):
self.assertIsInstance(session["projects"], dict)
self.assertIn("raclette", session["projects"])

def test_rss_feed(self):
with fake_time("2023-07-25 12:00:00"):
self.post_project("raclette")
self.login("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_if_modified_since_header(self):
# Project creation
with fake_time("2023-07-26 13:00:00"):
self.post_project("raclette")
self.login("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.login("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")
self.login("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)


if __name__ == "__main__":
unittest.main()
49 changes: 48 additions & 1 deletion ihatemoney/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
and `add_project_id` for a quick overview)
"""
from functools import wraps
import hashlib
import json
import os
from urllib.parse import urlparse, urlunparse

from flask import (
Blueprint,
Response,
abort,
current_app,
flash,
Expand Down Expand Up @@ -154,7 +156,8 @@ def pull_project(endpoint, values):

is_admin = session.get("is_admin")
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
g.project = project
else:
Expand Down Expand Up @@ -898,6 +901,50 @@ 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
)

bills_last_modified = [
bill.versions[0].transaction.issued_at for bill in g.project.get_bills()
]
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")
@requires_admin()
def dashboard():
Expand Down
Loading

0 comments on commit cd7a2bc

Please sign in to comment.