Files
Solo_bot/database/web_notifications.py
T
2026-05-06 20:47:53 +00:00

275 lines
8.1 KiB
Python

from datetime import UTC, datetime
from sqlalchemy import delete, func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import User, WebNotification, WebPushSubscription
from logger import logger
async def upsert_push_subscription(
session: AsyncSession,
*,
user_id: int,
identity_id: str | None,
endpoint: str,
keys_json: dict,
) -> WebPushSubscription:
"""Upsert push subscription by endpoint (unique)."""
stmt = (
pg_insert(WebPushSubscription)
.values(
user_id=user_id,
identity_id=identity_id,
endpoint=endpoint,
keys_json=keys_json,
created_at=datetime.now(UTC),
)
.on_conflict_do_update(
index_elements=["endpoint"],
set_={
"user_id": user_id,
"identity_id": identity_id,
"keys_json": keys_json,
"created_at": datetime.now(UTC),
},
)
.returning(WebPushSubscription)
)
result = await session.execute(stmt)
return result.scalar_one()
async def get_push_subscriptions_by_user(
session: AsyncSession,
user_id: int,
) -> list[WebPushSubscription]:
result = await session.execute(select(WebPushSubscription).where(WebPushSubscription.user_id == user_id))
return list(result.scalars().all())
async def get_push_subscriptions_by_identity(
session: AsyncSession,
identity_id: str,
) -> list[WebPushSubscription]:
result = await session.execute(select(WebPushSubscription).where(WebPushSubscription.identity_id == identity_id))
return list(result.scalars().all())
async def delete_push_subscription_by_endpoint(
session: AsyncSession,
endpoint: str,
) -> None:
await session.execute(delete(WebPushSubscription).where(WebPushSubscription.endpoint == endpoint))
async def get_notifications_for_identity(
session: AsyncSession,
identity_id: str,
limit: int = 20,
offset: int = 0,
) -> list[WebNotification]:
result = await session.execute(
select(WebNotification)
.where(WebNotification.identity_id == identity_id)
.order_by(WebNotification.created_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())
async def count_unread_for_identity(
session: AsyncSession,
identity_id: str,
) -> int:
result = await session.execute(
select(func.count())
.select_from(WebNotification)
.where(
WebNotification.identity_id == identity_id,
WebNotification.read == False, # noqa: E712 SQLAlchemy expression
)
)
return result.scalar() or 0
async def mark_all_read_for_identity(
session: AsyncSession,
identity_id: str,
) -> int:
result = await session.execute(
update(WebNotification)
.where(
WebNotification.identity_id == identity_id,
WebNotification.read == False, # noqa: E712 SQLAlchemy expression
)
.values(read=True)
)
return result.rowcount
async def mark_one_read_for_identity(
session: AsyncSession,
identity_id: str,
notification_id: str,
) -> bool:
"""Mark single notification as read. Returns True if updated."""
result = await session.execute(
update(WebNotification)
.where(
WebNotification.identity_id == identity_id,
WebNotification.id == notification_id,
)
.values(read=True)
)
return (result.rowcount or 0) > 0
async def delete_one_for_identity(
session: AsyncSession,
identity_id: str,
notification_id: str,
) -> bool:
"""Delete single notification owned by identity. Returns True if deleted."""
from sqlalchemy import delete as sql_delete
result = await session.execute(
sql_delete(WebNotification)
.where(
WebNotification.identity_id == identity_id,
WebNotification.id == notification_id,
)
)
return (result.rowcount or 0) > 0
async def resolve_identity_id_by_tg_id(
session: AsyncSession,
tg_id: int,
) -> str | None:
"""Resolve identity_id from user's tg_id."""
result = await session.execute(select(User.identity_id).where(User.tg_id == tg_id))
return result.scalar_one_or_none()
async def create_notification(
session: AsyncSession,
*,
user_id: int,
identity_id: str | None,
type: str = "system",
title: str,
message: str = "",
data: dict | None = None,
) -> WebNotification:
notif = WebNotification(
user_id=user_id,
identity_id=identity_id,
type=type,
title=title,
message=message,
data=data,
)
session.add(notif)
await session.flush()
return notif
class _SafeFormatDict(dict):
def __missing__(self, key):
return "{" + key + "}"
def _render_template(template: str, **kwargs: object) -> str:
"""Safe format — unknown placeholders stay as-is."""
try:
return template.format_map(
_SafeFormatDict({k: str(v) for k, v in kwargs.items() if v is not None})
)
except Exception:
return template
def _get_web_config_str(key: str, default: str) -> str:
try:
from core.settings.web_config import WEB_CONFIG
val = WEB_CONFIG.get(key)
return str(val).strip() if val else default
except Exception:
return default
async def notify_web(
session: AsyncSession,
*,
tg_id: int,
type: str = "system",
title: str | None = None,
message: str | None = None,
data: dict | None = None,
template_vars: dict | None = None,
) -> WebNotification | None:
"""Создаёт web-уведомление по tg_id.
title/message — если None, берутся из WEB_CONFIG шаблонов по type.
template_vars — подстановки в шаблон ({email}, {amount}, {name}, {duration}).
"""
try:
identity_id = await resolve_identity_id_by_tg_id(session, tg_id)
if not identity_id:
return None
vars_ = template_vars or {}
type_key_map = {
"payment": ("WEB_NOTIFY_PAYMENT_TITLE", "WEB_NOTIFY_PAYMENT_MESSAGE"),
"key_created": ("WEB_NOTIFY_KEY_CREATED_TITLE", "WEB_NOTIFY_KEY_CREATED_MESSAGE"),
"key_expiry": ("WEB_NOTIFY_KEY_EXPIRY_TITLE", "WEB_NOTIFY_KEY_EXPIRY_MESSAGE"),
"gift_received": ("WEB_NOTIFY_GIFT_TITLE", "WEB_NOTIFY_GIFT_MESSAGE"),
}
title_key, msg_key = type_key_map.get(type, (None, None))
resolved_title = title
if resolved_title is None and title_key:
resolved_title = _render_template(_get_web_config_str(title_key, ""), **vars_)
resolved_title = resolved_title or type
resolved_message = message
if resolved_message is None and msg_key:
resolved_message = _render_template(_get_web_config_str(msg_key, ""), **vars_)
resolved_message = resolved_message or ""
notif = await create_notification(
session,
user_id=tg_id,
identity_id=identity_id,
type=type,
title=resolved_title,
message=resolved_message,
data=data,
)
try:
from services.web_push import push_enabled, send_push_to_many
if push_enabled():
subs = await get_push_subscriptions_by_identity(session, identity_id)
if subs:
sub_infos = [{"endpoint": s.endpoint, "keys": s.keys_json} for s in subs]
sent = await send_push_to_many(
sub_infos,
title=resolved_title,
body=resolved_message,
url="/dashboard/notifications",
)
logger.debug("[notify_web] push sent to {}/{} subscriptions", sent, len(sub_infos))
except Exception as push_err:
logger.warning("[notify_web] push delivery failed: {}", push_err)
return notif
except Exception:
return None