Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Dockerfile.dev
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.12 as base
FROM python:3.12-slim as base

ENV PYTHONUNBUFFERED True
ENV APP_HOME /app
Expand All @@ -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"]
46 changes: 7 additions & 39 deletions services/push.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand All @@ -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
5 changes: 1 addition & 4 deletions tasks/webhooks.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}",
}
87 changes: 2 additions & 85 deletions tests/services/push_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
89 changes: 89 additions & 0 deletions tests/utils/contents_test.py
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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():
Expand Down
Binary file added tox.result.gz
Binary file not shown.
22 changes: 1 addition & 21 deletions utils/__init__.py
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions utils/contents.py
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions utils/datetime.py
Original file line number Diff line number Diff line change
@@ -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())