@@ -0,0 +1,319 @@
|
||||
"""Центр уведомлений зоотехника: хранение 30 дней, список по дням, прочитано."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import case, func, or_, select
|
||||
|
||||
from app import db
|
||||
from app.models.zootech_notification import ZootechNotification
|
||||
from app.timeutil import (
|
||||
display_calendar_today,
|
||||
display_timezone_name,
|
||||
naive_utc_to_iso,
|
||||
utc_now_naive,
|
||||
zoneinfo_or_utc,
|
||||
)
|
||||
|
||||
RETENTION_DAYS = 30
|
||||
DEFAULT_LIST_LIMIT = 200
|
||||
|
||||
|
||||
def format_detail_timestamp(dt: Optional[datetime] = None) -> str:
|
||||
"""Человекочитаемая дата/время для detail (ru-RU, Москва)."""
|
||||
value = dt or utc_now_naive()
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
try:
|
||||
local = value.astimezone(zoneinfo_or_utc("Europe/Moscow"))
|
||||
except Exception:
|
||||
local = value
|
||||
months = (
|
||||
"января",
|
||||
"февраля",
|
||||
"марта",
|
||||
"апреля",
|
||||
"мая",
|
||||
"июня",
|
||||
"июля",
|
||||
"августа",
|
||||
"сентября",
|
||||
"октября",
|
||||
"ноября",
|
||||
"декабря",
|
||||
)
|
||||
month = months[local.month - 1]
|
||||
return f"{local.day} {month} {local.year}, {local.strftime('%H:%M')}"
|
||||
|
||||
|
||||
def purge_expired(retention_days: int = RETENTION_DAYS) -> int:
|
||||
cutoff = utc_now_naive() - timedelta(days=retention_days)
|
||||
rows = (
|
||||
db.session.execute(
|
||||
select(ZootechNotification).where(ZootechNotification.created_at < cutoff)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
count = len(rows)
|
||||
for row in rows:
|
||||
db.session.delete(row)
|
||||
if count:
|
||||
db.session.commit()
|
||||
return count
|
||||
|
||||
|
||||
def _normalize_kind(kind: Optional[str]) -> str:
|
||||
if kind in ("success", "error", "warning", "info"):
|
||||
return kind
|
||||
return "info"
|
||||
|
||||
|
||||
def create_notification(
|
||||
*,
|
||||
title: str,
|
||||
detail: str,
|
||||
kind: str = "info",
|
||||
category: str = "general",
|
||||
page: Optional[str] = None,
|
||||
link_kind: Optional[str] = None,
|
||||
link_id: Optional[str] = None,
|
||||
user_login: Optional[str] = None,
|
||||
) -> ZootechNotification:
|
||||
purge_expired()
|
||||
row = ZootechNotification(
|
||||
title=(title or "")[:200],
|
||||
detail=detail or "",
|
||||
kind=_normalize_kind(kind),
|
||||
category=(category or "general")[:40],
|
||||
page=(page or None),
|
||||
link_kind=(link_kind or None),
|
||||
link_id=(link_id or None),
|
||||
user_login=(user_login or None),
|
||||
created_at=utc_now_naive(),
|
||||
)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def _serialize(row: ZootechNotification) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"title": row.title,
|
||||
"detail": row.detail,
|
||||
"kind": row.kind,
|
||||
"category": row.category,
|
||||
"page": row.page,
|
||||
"linkKind": row.link_kind,
|
||||
"linkId": row.link_id,
|
||||
"read": row.read_at is not None,
|
||||
"readAt": naive_utc_to_iso(row.read_at) if row.read_at else None,
|
||||
"createdAt": naive_utc_to_iso(row.created_at),
|
||||
}
|
||||
|
||||
|
||||
def _display_tz():
|
||||
try:
|
||||
return zoneinfo_or_utc(display_timezone_name())
|
||||
except Exception:
|
||||
return timezone.utc
|
||||
|
||||
|
||||
def _parse_calendar_date(value: Optional[str]) -> date:
|
||||
if value:
|
||||
try:
|
||||
return date.fromisoformat(str(value).strip()[:10])
|
||||
except ValueError:
|
||||
pass
|
||||
return display_calendar_today()
|
||||
|
||||
|
||||
def _day_bounds_utc(target: date) -> Tuple[datetime, datetime]:
|
||||
tz = _display_tz()
|
||||
start_local = datetime.combine(target, time.min, tzinfo=tz)
|
||||
end_local = datetime.combine(target, time.max, tzinfo=tz)
|
||||
start_utc = start_local.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
end_utc = end_local.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return start_utc, end_utc
|
||||
|
||||
|
||||
def _retention_cutoff() -> datetime:
|
||||
return utc_now_naive() - timedelta(days=RETENTION_DAYS)
|
||||
|
||||
|
||||
def _daily_plan_match_clause():
|
||||
"""План на день: явная категория + старые toast-записи с category=recipe."""
|
||||
text = (
|
||||
"%из плана%",
|
||||
"%в плане%",
|
||||
"%замен%",
|
||||
"%исключ%",
|
||||
"%вернул%",
|
||||
"%снова в план%",
|
||||
)
|
||||
|
||||
def _text_matches(col):
|
||||
return or_(*[col.ilike(p) for p in text])
|
||||
|
||||
return or_(
|
||||
ZootechNotification.category == "daily_plan",
|
||||
ZootechNotification.page == "daily_plan",
|
||||
ZootechNotification.link_kind == "daily_plan",
|
||||
_text_matches(ZootechNotification.title),
|
||||
_text_matches(ZootechNotification.detail),
|
||||
)
|
||||
|
||||
|
||||
def _apply_category_filter(stmt, category: Optional[str]):
|
||||
cat = (category or "").strip().lower()
|
||||
if cat == "daily_plan":
|
||||
return stmt.where(_daily_plan_match_clause())
|
||||
if cat == "recipe":
|
||||
return stmt.where(
|
||||
ZootechNotification.category == "recipe",
|
||||
~_daily_plan_match_clause(),
|
||||
)
|
||||
if cat and cat != "all":
|
||||
return stmt.where(ZootechNotification.category == cat)
|
||||
return stmt
|
||||
|
||||
|
||||
def _apply_sort(stmt, sort: str):
|
||||
sort_key = (sort or "newest").strip().lower()
|
||||
if sort_key == "oldest":
|
||||
return stmt.order_by(ZootechNotification.created_at.asc())
|
||||
if sort_key == "unread_first":
|
||||
return stmt.order_by(
|
||||
ZootechNotification.read_at.is_(None).desc(),
|
||||
ZootechNotification.created_at.desc(),
|
||||
ZootechNotification.id.desc(),
|
||||
)
|
||||
if sort_key == "severity":
|
||||
severity_rank = case(
|
||||
(ZootechNotification.kind == "error", 0),
|
||||
(ZootechNotification.kind == "warning", 1),
|
||||
(ZootechNotification.kind == "info", 2),
|
||||
(ZootechNotification.kind == "success", 3),
|
||||
else_=4,
|
||||
)
|
||||
return stmt.order_by(
|
||||
severity_rank,
|
||||
ZootechNotification.created_at.desc(),
|
||||
ZootechNotification.id.desc(),
|
||||
)
|
||||
return stmt.order_by(
|
||||
ZootechNotification.created_at.desc(),
|
||||
ZootechNotification.id.desc(),
|
||||
)
|
||||
|
||||
|
||||
def _count_unread(*, category: Optional[str] = None, unread_only: bool = False) -> int:
|
||||
stmt = select(func.count()).select_from(ZootechNotification).where(
|
||||
ZootechNotification.created_at >= _retention_cutoff()
|
||||
)
|
||||
if unread_only:
|
||||
stmt = stmt.where(ZootechNotification.read_at.is_(None))
|
||||
stmt = _apply_category_filter(stmt, category)
|
||||
return int(db.session.execute(stmt).scalar() or 0)
|
||||
|
||||
|
||||
def _has_notifications_on_date(target: date, *, category: Optional[str] = None) -> bool:
|
||||
start_utc, end_utc = _day_bounds_utc(target)
|
||||
stmt = select(func.count()).select_from(ZootechNotification).where(
|
||||
ZootechNotification.created_at >= start_utc,
|
||||
ZootechNotification.created_at <= end_utc,
|
||||
ZootechNotification.created_at >= _retention_cutoff(),
|
||||
)
|
||||
stmt = _apply_category_filter(stmt, category)
|
||||
return int(db.session.execute(stmt).scalar() or 0) > 0
|
||||
|
||||
|
||||
def _find_prev_notification_date(
|
||||
before: date,
|
||||
*,
|
||||
category: Optional[str] = None,
|
||||
) -> Optional[date]:
|
||||
retention_start = display_calendar_today() - timedelta(days=RETENTION_DAYS - 1)
|
||||
cursor = before - timedelta(days=1)
|
||||
while cursor >= retention_start:
|
||||
if _has_notifications_on_date(cursor, category=category):
|
||||
return cursor
|
||||
cursor -= timedelta(days=1)
|
||||
return None
|
||||
|
||||
|
||||
def unread_count(*, category: Optional[str] = None) -> Dict[str, Any]:
|
||||
purge_expired()
|
||||
return {"items": [], "unreadCount": _count_unread(unread_only=True)}
|
||||
|
||||
|
||||
def list_notifications(
|
||||
*,
|
||||
date: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
unread_only: bool = False,
|
||||
sort: str = "newest",
|
||||
summary: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
purge_expired()
|
||||
target = _parse_calendar_date(date)
|
||||
unread = _count_unread(unread_only=True)
|
||||
|
||||
if summary:
|
||||
return {"items": [], "unreadCount": unread, "date": target.isoformat()}
|
||||
|
||||
start_utc, end_utc = _day_bounds_utc(target)
|
||||
stmt = select(ZootechNotification).where(
|
||||
ZootechNotification.created_at >= start_utc,
|
||||
ZootechNotification.created_at <= end_utc,
|
||||
ZootechNotification.created_at >= _retention_cutoff(),
|
||||
)
|
||||
if unread_only:
|
||||
stmt = stmt.where(ZootechNotification.read_at.is_(None))
|
||||
stmt = _apply_category_filter(stmt, category)
|
||||
stmt = _apply_sort(stmt, sort)
|
||||
rows = db.session.execute(stmt).scalars().all()
|
||||
|
||||
prev_date = _find_prev_notification_date(target, category=category)
|
||||
return {
|
||||
"items": [_serialize(r) for r in rows],
|
||||
"unreadCount": unread,
|
||||
"date": target.isoformat(),
|
||||
"prevDate": prev_date.isoformat() if prev_date else None,
|
||||
"hasMore": prev_date is not None,
|
||||
}
|
||||
|
||||
|
||||
def get_notification(notification_id: str) -> Optional[ZootechNotification]:
|
||||
purge_expired()
|
||||
return db.session.get(ZootechNotification, notification_id)
|
||||
|
||||
|
||||
def mark_read(notification_id: str) -> Optional[ZootechNotification]:
|
||||
row = get_notification(notification_id)
|
||||
if row is None:
|
||||
return None
|
||||
if row.read_at is None:
|
||||
row.read_at = utc_now_naive()
|
||||
db.session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def mark_all_read() -> int:
|
||||
purge_expired()
|
||||
rows = (
|
||||
db.session.execute(
|
||||
select(ZootechNotification).where(ZootechNotification.read_at.is_(None))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
now = utc_now_naive()
|
||||
for row in rows:
|
||||
row.read_at = now
|
||||
if rows:
|
||||
db.session.commit()
|
||||
return len(rows)
|
||||
Reference in New Issue
Block a user