From b96dc779ae8a99dbac5a7774810d8831119aa2a0 Mon Sep 17 00:00:00 2001 From: ivis-kuroda Date: Thu, 12 Jun 2025 13:42:23 +0900 Subject: [PATCH 1/3] Update contents --- services/push.py | 46 ++-------- tasks/webhooks.py | 5 +- tests/conftest.py | 2 +- tests/services/push_test.py | 87 +----------------- tests/utils/contents_test.py | 89 +++++++++++++++++++ .../{utils_test.py => datetimee_test.py} | 2 +- utils/__init__.py | 22 +---- utils/contents.py | 47 ++++++++++ utils/datetime.py | 19 ++++ 9 files changed, 168 insertions(+), 151 deletions(-) create mode 100644 tests/utils/contents_test.py rename tests/utils/{utils_test.py => datetimee_test.py} (88%) create mode 100644 utils/contents.py create mode 100644 utils/datetime.py diff --git a/services/push.py b/services/push.py index bc1b346..c739208 100644 --- a/services/push.py +++ b/services/push.py @@ -4,10 +4,9 @@ from config import get_settings from db.models import Subscription, Notification, UserProfile -from db.subscriptions import ( - delete_subscriptions, get_subscriptions, get_template, get_user -) -from utils import dt2idt +from db.subscriptions import delete_subscriptions, get_subscriptions, get_user +from utils import logger +from utils.contents import make_contents def send(subscription: Subscription, payload: dict): @@ -35,8 +34,10 @@ async def send_webpush(notification: Notification): send(subscription, payload) except WebPushException as ex: traceback.print_exc() - if ex.response is not None and ex.response.status_code in [404, 410]: - not_sent.append(subscription.endpoint) + if ex.response is not None: + logger.error(ex.response.json()) + if ex.response.status_code in [404, 410]: + not_sent.append(subscription.endpoint) return await delete_subscriptions(not_sent) if not_sent else None @@ -58,36 +59,3 @@ async def make_payload(notification: Notification, user: UserProfile) -> dict: } } return payload - - -async def make_contents(notification: Notification, user: UserProfile) -> tuple[str, str, str]: - params = make_params(notification, user) - - title, body, url = "", "", "" - template = await get_template(notification.type, user.language) - - if not template: - template = await get_template(notification.type, "en") - - if not template: - return title, body, url - - title = template.title.replace("{{ ", "{").replace(" }}", "}").format(**params) - body = template.body.replace("{{ ", "{").replace(" }}", "}").format(**params) - url = params["context_uri"] or params["object_uri"] - - return title, body, url - - -def make_params(notification: Notification, user: UserProfile): - params: dict[str, str | None] = { - "timestamp": dt2idt(notification.updated).rfc3339format(), - "target_uri": notification.target.id, - "object_uri": notification.object.id, - "object_name": notification.object.name, - "context_uri": notification.context.id if notification.context else None, - "actor_uri": notification.actor.id if notification.actor else None, - "actor_name": notification.actor.name if notification.actor else "Unknown", - "target_name": user.displayname or "Unknown", - } - return params diff --git a/tasks/webhooks.py b/tasks/webhooks.py index e0bb62d..755d1ff 100644 --- a/tasks/webhooks.py +++ b/tasks/webhooks.py @@ -1,11 +1,8 @@ import json -import logging import requests from db.models import Notification - - -logger = logging.getLogger("uvicorn.error") +from utils import logger def send_notification_to_webhook(notification: Notification, webhook_url: str) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 68cdde0..618be8c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -192,6 +192,6 @@ def valid_push_template_payload(): "description": "Test Description", "type": [""], "language": "en", - "title": "Test Title", + "title": "Test Title: {{object_name}}", "body": "Test Body: {{ object_uri }}", } diff --git a/tests/services/push_test.py b/tests/services/push_test.py index 9d6872e..7c83946 100644 --- a/tests/services/push_test.py +++ b/tests/services/push_test.py @@ -3,11 +3,9 @@ import pytest from unittest.mock import patch, MagicMock from pywebpush import WebPushException -from services.push import make_contents, make_params, make_payload, send -from db.models import PushTemplate, Subscription -from services.push import send_webpush -from db.models import Notification +from db.models import Notification, Subscription +from services.push import make_payload, send, send_webpush @pytest.fixture @@ -157,84 +155,3 @@ def test_make_pyload(mock_get_settings, mock_make_contents, valid_notification_p } } } - - -@patch("services.push.get_template") -@patch("services.push.make_params") -def test_make_contents(mock_make_params, mock_get_template, valid_notification_payload, valid_push_template_payload): - notification = Notification(**valid_notification_payload) - template = PushTemplate(**valid_push_template_payload) - mock_make_params.return_value = { - "target_uri": notification.target.id, - "context_uri": notification.context.id, - "object_uri": notification.object.id, - } - mock_get_template.return_value = template - - user = MagicMock(language="en") - title, body, url = asyncio.run(make_contents(notification, user)) - - assert title == "Test Title" - assert body == f"Test Body: {notification.object.id}" - assert url == notification.context.id - mock_make_params.assert_called_once_with(notification, user) - mock_get_template.assert_called_once_with(notification.type, user.language) - - -@patch("services.push.get_template") -@patch("services.push.make_params") -def test_make_contents_retry(mock_make_params, mock_get_template, valid_notification_payload, valid_push_template_payload): - notification = Notification(**valid_notification_payload) - template = PushTemplate(**valid_push_template_payload) - mock_make_params.return_value = { - "target_uri": notification.target.id, - "context_uri": notification.context.id, - "object_uri": notification.object.id, - } - mock_get_template.side_effect = [None, template] - - user = MagicMock(language="ja") - title, body, url = asyncio.run(make_contents(notification, user)) - - assert title == "Test Title" - assert body == f"Test Body: {notification.object.id}" - assert url == notification.context.id - mock_make_params.assert_called_once_with(notification, user) - mock_get_template.assert_any_call(notification.type, user.language) - mock_get_template.assert_any_call(notification.type, "en") - - -@patch("services.push.get_template") -@patch("services.push.make_params") -def test_make_contents_no_template(mock_make_params, mock_get_template, valid_notification_payload, valid_push_template_payload): - notification = Notification(**valid_notification_payload) - template = PushTemplate(**valid_push_template_payload) - mock_make_params.return_value = { - "target_uri": notification.target.id, - "context_uri": notification.context.id, - "object_uri": notification.object.id, - } - mock_get_template.return_value = None - - user = MagicMock(language="en") - title, body, url = asyncio.run(make_contents(notification, user)) - - assert title == "" - assert body == "" - assert url == "" - mock_make_params.assert_called_with(notification, user) - mock_get_template.assert_called_with(notification.type, user.language) - mock_get_template.assert_called_with(notification.type, "en") - - -def test_make_params(valid_notification_payload): - notification = Notification(**valid_notification_payload) - user = MagicMock(language="en", displayname="Test User") - params = make_params(notification, user) - assert params["timestamp"] is not None - assert params["target_uri"] == notification.target.id - assert params["object_uri"] == notification.object.id - assert params["context_uri"] == notification.context.id - assert params["actor_uri"] == notification.actor.id - assert params["actor_name"] == notification.actor.name - assert params["target_name"] == "Test User" diff --git a/tests/utils/contents_test.py b/tests/utils/contents_test.py new file mode 100644 index 0000000..1666e22 --- /dev/null +++ b/tests/utils/contents_test.py @@ -0,0 +1,89 @@ +import asyncio +import pytest +from unittest.mock import patch, MagicMock + +from db.models import Notification, PushTemplate +from utils.contents import make_contents, make_params + + +@patch("utils.contents.get_template") +@patch("utils.contents.make_params") +def test_make_contents(mock_make_params, mock_get_template, valid_notification_payload, valid_push_template_payload): + notification = Notification(**valid_notification_payload) + template = PushTemplate(**valid_push_template_payload) + mock_make_params.return_value = { + "target_uri": notification.target.id, + "context_uri": notification.context.id, + "object_uri": notification.object.id, + "object_name": "Sample Object", + } + mock_get_template.return_value = template + + user = MagicMock(language="en") + title, body, url = asyncio.run(make_contents(notification, user)) + + assert title == "Test Title: Sample Object" + assert body == f"Test Body: {notification.object.id}" + assert url == notification.context.id + mock_make_params.assert_called_once_with(notification, user) + mock_get_template.assert_called_once_with(notification.type, user.language) + + +@patch("utils.contents.get_template") +@patch("utils.contents.make_params") +def test_make_contents_retry(mock_make_params, mock_get_template, valid_notification_payload, valid_push_template_payload): + notification = Notification(**valid_notification_payload) + template = PushTemplate(**valid_push_template_payload) + mock_make_params.return_value = { + "target_uri": notification.target.id, + "context_uri": notification.context.id, + "object_uri": notification.object.id, + "object_name": "Sample Object", + } + mock_get_template.side_effect = [None, template] + + user = MagicMock(language="ja") + title, body, url = asyncio.run(make_contents(notification, user)) + + assert title == "Test Title: Sample Object" + assert body == f"Test Body: {notification.object.id}" + assert url == notification.context.id + mock_make_params.assert_called_once_with(notification, user) + mock_get_template.assert_any_call(notification.type, user.language) + mock_get_template.assert_any_call(notification.type, "en") + + +@patch("utils.contents.get_template") +@patch("utils.contents.make_params") +def test_make_contents_no_template(mock_make_params, mock_get_template, valid_notification_payload, valid_push_template_payload): + notification = Notification(**valid_notification_payload) + template = PushTemplate(**valid_push_template_payload) + mock_make_params.return_value = { + "target_uri": notification.target.id, + "context_uri": notification.context.id, + "object_uri": notification.object.id, + } + mock_get_template.return_value = None + + user = MagicMock(language="en") + title, body, url = asyncio.run(make_contents(notification, user)) + + assert title == "" + assert body == "" + assert url == "" + mock_make_params.assert_called_with(notification, user) + mock_get_template.assert_called_with(notification.type, user.language) + mock_get_template.assert_called_with(notification.type, "en") + + +def test_make_params(valid_notification_payload): + notification = Notification(**valid_notification_payload) + user = MagicMock(language="en", displayname="Test User") + params = make_params(notification, user) + assert params["timestamp"] is not None + assert params["target_uri"] == notification.target.id + assert params["object_uri"] == notification.object.id + assert params["context_uri"] == notification.context.id + assert params["actor_uri"] == notification.actor.id + assert params["actor_name"] == notification.actor.name + assert params["target_name"] == "Test User" diff --git a/tests/utils/utils_test.py b/tests/utils/datetimee_test.py similarity index 88% rename from tests/utils/utils_test.py rename to tests/utils/datetimee_test.py index e8bfcf8..b495eeb 100644 --- a/tests/utils/utils_test.py +++ b/tests/utils/datetimee_test.py @@ -1,6 +1,6 @@ import pytest from datetime import datetime, timedelta, timezone -from utils import InboxDatetime, datetime_to_inboxdatetime +from utils.datetime import InboxDatetime, datetime_to_inboxdatetime def test_inboxdatetime_rfc3339format(): diff --git a/utils/__init__.py b/utils/__init__.py index f3faac9..afafbf1 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,26 +1,6 @@ import logging -from datetime import datetime +from .datetime import datetime_to_inboxdatetime logger = logging.getLogger("uvicorn.error") - - -class InboxDatetime(datetime): - - def rfc3339format(self): - """Return the current time in RFC3339 format. - - Example: - >>> InboxDatetime.now(timezone.utc).rfc3339format() - "2025-03-05T14:00:00Z" - >>> InboxDatetime.now(ZoneInfo("Asia/Tokyo")).rfc3339format() - "2025-03-05T23:00:00+09:00" - """ - return self.isoformat(timespec="seconds").replace("+00:00", "Z") - - -def datetime_to_inboxdatetime(dt: datetime): - return InboxDatetime.fromisoformat(dt.isoformat()) - - dt2idt = datetime_to_inboxdatetime diff --git a/utils/contents.py b/utils/contents.py new file mode 100644 index 0000000..e0f7d56 --- /dev/null +++ b/utils/contents.py @@ -0,0 +1,47 @@ +import re + +from db.models import Notification, UserProfile +from db.subscriptions import get_template +from utils import dt2idt + + +async def make_contents( + notification: Notification, user: UserProfile +) -> tuple[str, str, str]: + params = make_params(notification, user) + + title, body, url = "", "", "" + template = await get_template(notification.type, user.language) + + if not template: + template = await get_template(notification.type, "en") + + if not template: + return title, body, url + + title = render(template.title, params) + body = render(template.body, params) + url = params["context_uri"] or params["object_uri"] + + return title, body, url + + +def render(template: str, data: dict): + pattern = r"\{\{\s*(\w+)\s*\}\}" + return re.sub(pattern, lambda match: str(data.get(match.group(1), "")), template) + + +def make_params( + notification: Notification, user: UserProfile +) -> dict[str, str | None]: + params = { + "timestamp": dt2idt(notification.updated).rfc3339format(), + "target_uri": notification.target.id, + "object_uri": notification.object.id, + "object_name": notification.object.name, + "context_uri": notification.context.id if notification.context else None, + "actor_uri": notification.actor.id if notification.actor else None, + "actor_name": notification.actor.name if notification.actor else "Unknown", + "target_name": user.displayname or "Unknown", + } + return params diff --git a/utils/datetime.py b/utils/datetime.py new file mode 100644 index 0000000..5c6cc99 --- /dev/null +++ b/utils/datetime.py @@ -0,0 +1,19 @@ +from datetime import datetime + + +class InboxDatetime(datetime): + + def rfc3339format(self): + """Return the current time in RFC3339 format. + + Example: + >>> InboxDatetime.now(timezone.utc).rfc3339format() + "2025-03-05T14:00:00Z" + >>> InboxDatetime.now(ZoneInfo("Asia/Tokyo")).rfc3339format() + "2025-03-05T23:00:00+09:00" + """ + return self.isoformat(timespec="seconds").replace("+00:00", "Z") + + +def datetime_to_inboxdatetime(dt: datetime): + return InboxDatetime.fromisoformat(dt.isoformat()) From 342da89331d966d892c85ca23b1d66cdf011c90d Mon Sep 17 00:00:00 2001 From: ivis-kuroda Date: Thu, 12 Jun 2025 13:45:46 +0900 Subject: [PATCH 2/3] Update Dockerfile switch to slim image and enable reload --- Dockerfile.dev | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile.dev b/Dockerfile.dev index 3f91614..9588765 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM python:3.12 as base +FROM python:3.12-slim as base ENV PYTHONUNBUFFERED True ENV APP_HOME /app @@ -11,4 +11,4 @@ COPY . ./ RUN apt-get update && pip install --upgrade pip RUN pip install --no-cache-dir -r requirements.txt --prefer-binary -CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"] +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080", "--reload"] From bbce18d75f61f3cd31611ef4310b609735175907 Mon Sep 17 00:00:00 2001 From: ivis-kuroda Date: Thu, 12 Jun 2025 21:45:42 +0900 Subject: [PATCH 3/3] Add tox result --- tox.result.gz | Bin 0 -> 1797 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tox.result.gz diff --git a/tox.result.gz b/tox.result.gz new file mode 100644 index 0000000000000000000000000000000000000000..6b5ae3788e76d8ae3e2b5a5268af38080fb2401f GIT binary patch literal 1797 zcmV+g2m1IQiwFo6%Svbf19WeAE^=jab!>D1rJ2c&<2DdL@A?W}2I#?zWi8$T2bh@z z$Y8ccx(^8&4T6?vn`vzzskZ$eIp*u~B`IpLBx|wc@xhWP@l~;^#43@Fxk66p`y_D1 zlkxluicXRWiAEzQd|kVn?1YE0q+DcIl8Bf5JeJJM&jsbL%%K7)1T?;;?V}ARfwRvp zIE_Ls*MRe@Eqm#E)s|z@upxp<>PPGGKJ@8WhW>Kql5r&X_&{I6F$*@~ajZ-DbIh^y zBk03+LCEPk(PR8dJmwO~!a!Sh{Svj2phCc-NJzqA5S=~NTI3PA4PhuA3*sY4M)xPV zhwip+TKe*RX3x=FpXdf$dGT&{8kzb`H`K$uhO}Y9I7+CRL!3Qz8DF9Nrk)DG$sLV{ zp-gUffi1g2BBnI<|N*}LIs{Zh=(H{zA~2z?9wfXJ&EH8;)=Q$A}pr4 zIN(l(akBuWt-RSV-X7j&57ZDFHvLfUk+M?jRFHl?z zxH-nH<;@0cjB(rS%gbWCQ$^tQc5nkv)E#0Ec5qk10bXwhi+;R8{>@{hjS0L#{=Lqd z7-wEqKBncz1q|kE7pFJEW>@4qYap*P{{wH*i$`3yPgMcD=}kP9qPHG%WEBj&`E9%) zOlF4*J%P9EZ@=pUenQiJ{;la(l;h_~y2vNf0ZzUcwZz*n4&1W3z^gmiXD-wo2g2zE zlm-Hn!H&mhATfgi$MsnNWsVmfp!A6_+u{TBT!(Ns@fzE~^Q9_fT91Gmg{2oPDzWR) zFqF;d-;g5&m?~%zP!ErWw0gz2Nh+sMD;)MT^E8GsFBMnFn$<&RxGWVs6+xR=7Go<3 ztB2H8Va-*L=siBWCi2>R`br{TMezov0{lWxr7#9wJ+M0G`}E$XWpV~yJ(zm&AUxHZ zcf$0@)E9m+;Kd+d9CWJqgy!`C>B(!F2!L0QfleMIU-h-OWnPnlz#Hb^GOyK!^I=ZT zO5aK$SP7c>Fef+h+I21boiF-~v!2z$1Ky2`^1d+_FO2@Y1KlLI=+U8Vwcx@W&TH2b zc=m7}PbO?CeHt9MEHwtlVJAdB{u1exCNp3&c+4tG(IN z%fi#i(&0E+p$OzGW4gpcV8_D6nC zxJ2lalCF(@_NT#j76xN7i7^ZS?rkWe?Vy}hA*iMH z21U$y`A`L=gmks9Z45{)2Vc>lvdYr{n%EU{OiG~nBq3BL+a^NR$F8daFTIxM$S#LW z>Rn5tsC=+A+hS7fx@aL3m3<9!;e<&Gpg4TZfbYS#lqSFU_HCW>Ia`2xwuw(bD;}TZit5s%DHvf>jlM z84uMty;+Cuc=-8Q?(xUthp!ECi=RDTrpHD3ka-hy_>|{~-ygqyMF-9#7zD`>xr`&V zZwTq2c<9gSGf5Gj=L`Mld#%|CKC)3ngrM$wFr;{4Q3Rj&@L^}_mff)xs5#~e{rH0Z z`}ZI9w5`tSHySRiYgtf~>Y8;L@m0~a3q*=X1cT3$ei)a1^DC}$lIv)T4c{7Ao literal 0 HcmV?d00001