Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
@@ -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)