mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-04-28 17:32:38 +02:00
feat: project RSS feed.
This commit is contained in:
parent
7c782443d3
commit
8d4584d660
11 changed files with 451 additions and 5 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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']) %}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
22
ihatemoney/templates/project_feed.xml
Normal file
22
ihatemoney/templates/project_feed.xml
Normal 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>
|
|
@ -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()
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
@ -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():
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
2
tox.ini
2
tox.ini
|
@ -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]
|
||||||
|
|
Loading…
Reference in a new issue