Split the push delivery library into a provider architecture. devplacepy/push becomes a package: a PushProvider protocol with a registry, the existing Web Push implementation moved unchanged behind it, a new APNs provider, a store owning every push_registration access, and a delivery loop that groups a user's subscriptions by provider, prepares each provider's payload once and sends over a single shared client. APNs delivers over HTTP/2 with an ES256 provider token cached per credential fingerprint, so a worker signs at most one token per 45 minutes. Registrations carry a hexadecimal device token; 410 and the Unregistered class of reasons soft delete the subscription exactly like a gone Web Push endpoint. All provider configuration is edited at /admin/services/push through the same ConfigField surface every other subsystem uses, assembled from the registry so a future provider needs no edit to the service. A provider that is disabled, unconfigured or holding an unusable credential accepts no registrations and is skipped during delivery, never failing the other providers. POST /push.json accepts a registration for any active provider; a body without a provider field is a Web Push body, so existing clients are unchanged. GET /push.json keeps publicKey at the top level and adds the active providers. push_registration gains provider and token columns, ensured in init_db with a converging backfill; existing rows are never rewritten. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
67 lines
1.6 KiB
Python
67 lines
1.6 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from devplacepy.database import get_setting
|
|
from devplacepy.services.base import ConfigField
|
|
|
|
ACCEPTED = "accepted"
|
|
DEAD = "dead"
|
|
REJECTED = "rejected"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Delivery:
|
|
status: str
|
|
detail: str = ""
|
|
|
|
|
|
class PushProvider(ABC):
|
|
name = ""
|
|
label = ""
|
|
config_fields: list[ConfigField] = []
|
|
|
|
@property
|
|
def enabled_key(self) -> str:
|
|
return f"push_{self.name}_enabled"
|
|
|
|
def enabled_field(self) -> ConfigField:
|
|
return ConfigField(
|
|
self.enabled_key,
|
|
"Enabled",
|
|
type="bool",
|
|
default=True,
|
|
help=f"Deliver notifications through {self.label}.",
|
|
group=self.label,
|
|
)
|
|
|
|
def all_fields(self) -> list[ConfigField]:
|
|
return [self.enabled_field(), *self.config_fields]
|
|
|
|
def is_enabled(self) -> bool:
|
|
return get_setting(self.enabled_key, "1") == "1"
|
|
|
|
def is_active(self) -> bool:
|
|
return self.is_enabled() and self.is_configured()
|
|
|
|
def client_config(self) -> dict[str, Any]:
|
|
return {}
|
|
|
|
@abstractmethod
|
|
def is_configured(self) -> bool: ...
|
|
|
|
@abstractmethod
|
|
def parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None: ...
|
|
|
|
@abstractmethod
|
|
def prepare(self, payload: dict[str, Any]) -> str: ...
|
|
|
|
@abstractmethod
|
|
async def deliver(
|
|
self, client: httpx.AsyncClient, registration: dict[str, Any], prepared: str
|
|
) -> Delivery: ...
|