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
|
||||
Edwin Smulders
|
||||
Elizabeth Sherrock
|
||||
Éloi Rivard
|
||||
eMerzh
|
||||
Erwan Lacoudre
|
||||
Feth AREZKI
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -453,7 +453,8 @@ class Project(db.Model):
|
|||
"""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":
|
||||
|
@ -476,7 +477,8 @@ class Project(db.Model):
|
|||
|
||||
: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"
|
||||
|
|
|
@ -103,6 +103,7 @@
|
|||
{% 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.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 %}
|
||||
|
||||
{% if session['projects'] and not ((session['projects'] | length) == 1 and g.project and g.project.id in session['projects']) %}
|
||||
|
|
|
@ -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">
|
||||
|
|
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 flask import session, url_for
|
||||
from libfaketime import fake_time
|
||||
import pytest
|
||||
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.ihatemoney_testcase import IhatemoneyTestCase
|
||||
from ihatemoney.versioning import LoggingMode
|
||||
from ihatemoney.web import build_etag
|
||||
|
||||
|
||||
class BudgetTestCase(IhatemoneyTestCase):
|
||||
|
@ -150,6 +152,16 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
data = self.client.get("/tartiflette/").data.decode("utf-8")
|
||||
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):
|
||||
"""Test that invitation link expire after code change"""
|
||||
self.login("raclette")
|
||||
|
@ -1685,6 +1697,358 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
self.assertIsInstance(session["projects"], dict)
|
||||
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__":
|
||||
unittest.main()
|
||||
|
|
|
@ -56,6 +56,7 @@ class BaseTestCase(TestCase):
|
|||
default_currency="XXX",
|
||||
name=None,
|
||||
password=None,
|
||||
project_history=True,
|
||||
):
|
||||
"""Create a fake project"""
|
||||
name = name or id
|
||||
|
@ -69,6 +70,7 @@ class BaseTestCase(TestCase):
|
|||
"password": password,
|
||||
"contact_email": f"{id}@notmyidea.org",
|
||||
"default_currency": default_currency,
|
||||
"project_history": project_history,
|
||||
},
|
||||
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)
|
||||
"""
|
||||
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,
|
||||
|
@ -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:
|
||||
|
@ -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")
|
||||
@requires_admin()
|
||||
def dashboard():
|
||||
|
|
|
@ -61,6 +61,7 @@ dev =
|
|||
vermin==1.5.2
|
||||
Flask-Testing>=0.8.1
|
||||
pytest>=6.2.5
|
||||
pytest-libfaketime>=0.1.2
|
||||
tox>=3.14.6
|
||||
zest.releaser>=6.20.1
|
||||
|
||||
|
|
2
tox.ini
2
tox.ini
|
@ -7,7 +7,7 @@ passenv = TESTING_SQLALCHEMY_DATABASE_URI
|
|||
|
||||
commands =
|
||||
python --version
|
||||
py.test --pyargs ihatemoney.tests
|
||||
py.test --pyargs ihatemoney.tests {posargs}
|
||||
|
||||
deps =
|
||||
-e.[database,dev]
|
||||
|
|
Loading…
Reference in a new issue