added RAG, Multiuser, TG bot
This commit is contained in:
@@ -1,73 +1,74 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.character.service import CharacterService
|
||||
from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat
|
||||
from app.db.models import Reminder
|
||||
from app.llm.client import LLMClient
|
||||
from app.reminders.service import RECURRENCE_NONE, _advance_due, _format_local
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def format_reminder_notice(row: Reminder) -> str:
|
||||
local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day)
|
||||
notice = f"📅 **Напоминание** · {row.title}\n\n_{local_when}_"
|
||||
if row.notes:
|
||||
notice += f"\n{row.notes}"
|
||||
return notice
|
||||
|
||||
|
||||
class ReminderCompletionHandler:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.llm = LLMClient()
|
||||
self.character = CharacterService()
|
||||
|
||||
async def _generate_llm_comment(self, row: Reminder, local_when: str) -> str:
|
||||
notes_part = f"\nЗаметки: {row.notes}" if row.notes else ""
|
||||
rec_part = ""
|
||||
if row.recurrence and row.recurrence != RECURRENCE_NONE:
|
||||
rec_part = f"\nПовтор: {row.recurrence}"
|
||||
|
||||
system = self.character.get_system_prompt()
|
||||
user_prompt = f"""Сработало напоминание.
|
||||
Заголовок: {row.title}
|
||||
Время: {local_when}{notes_part}{rec_part}
|
||||
|
||||
Напиши пользователю короткое сообщение (2-4 предложения) на русском: напомни о деле, поддержи или предложи действие. Без markdown и без эмодзи."""
|
||||
|
||||
result = await self.llm.complete(
|
||||
[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.8,
|
||||
visible_reply=True,
|
||||
)
|
||||
return (result.get("content") or "").strip() or f"Напоминание: {row.title}"
|
||||
|
||||
def _mark_fired(self, row: Reminder, now: datetime) -> None:
|
||||
row.last_fired_at = now
|
||||
if row.recurrence == RECURRENCE_NONE:
|
||||
row.completed_at = now
|
||||
row.enabled = False
|
||||
else:
|
||||
row.due_at = _advance_due(row.due_at, row.recurrence)
|
||||
row.last_fired_at = None
|
||||
row.updated_at = now
|
||||
|
||||
async def process(self, row: Reminder) -> None:
|
||||
local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day)
|
||||
post_notice_to_latest_chat(format_reminder_notice(row))
|
||||
|
||||
try:
|
||||
comment = await self._generate_llm_comment(row, local_when)
|
||||
if comment:
|
||||
post_character_comment_to_latest_chat(comment)
|
||||
except Exception:
|
||||
logger.exception("Reminder LLM comment failed (id=%s)", row.id)
|
||||
|
||||
self._mark_fired(row, datetime.now(timezone.utc))
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.character.service import CharacterService
|
||||
from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat
|
||||
from app.db.models import Reminder
|
||||
from app.llm.client import LLMClient
|
||||
from app.reminders.service import RECURRENCE_NONE, _advance_due, _format_local
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def format_reminder_notice(row: Reminder) -> str:
|
||||
local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day)
|
||||
notice = f"📅 **Напоминание** · {row.title}\n\n_{local_when}_"
|
||||
if row.notes:
|
||||
notice += f"\n{row.notes}"
|
||||
return notice
|
||||
|
||||
|
||||
class ReminderCompletionHandler:
|
||||
def __init__(self, db: Session, user_id: int):
|
||||
self.db = db
|
||||
self.user_id = user_id
|
||||
self.llm = LLMClient()
|
||||
self.character = CharacterService(db, user_id)
|
||||
|
||||
async def _generate_llm_comment(self, row: Reminder, local_when: str) -> str:
|
||||
notes_part = f"\nЗаметки: {row.notes}" if row.notes else ""
|
||||
rec_part = ""
|
||||
if row.recurrence and row.recurrence != RECURRENCE_NONE:
|
||||
rec_part = f"\nПовтор: {row.recurrence}"
|
||||
|
||||
system = self.character.get_system_prompt()
|
||||
user_prompt = f"""Сработало напоминание.
|
||||
Заголовок: {row.title}
|
||||
Время: {local_when}{notes_part}{rec_part}
|
||||
|
||||
Напиши пользователю короткое сообщение (2-4 предложения) на русском: напомни о деле, поддержи или предложи действие. Без markdown и без эмодзи."""
|
||||
|
||||
result = await self.llm.complete(
|
||||
[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.8,
|
||||
visible_reply=True,
|
||||
)
|
||||
return (result.get("content") or "").strip() or f"Напоминание: {row.title}"
|
||||
|
||||
def _mark_fired(self, row: Reminder, now: datetime) -> None:
|
||||
row.last_fired_at = now
|
||||
if row.recurrence == RECURRENCE_NONE:
|
||||
row.completed_at = now
|
||||
row.enabled = False
|
||||
else:
|
||||
row.due_at = _advance_due(row.due_at, row.recurrence)
|
||||
row.last_fired_at = None
|
||||
row.updated_at = now
|
||||
|
||||
async def process(self, row: Reminder) -> None:
|
||||
local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day)
|
||||
post_notice_to_latest_chat(format_reminder_notice(row), self.user_id)
|
||||
|
||||
try:
|
||||
comment = await self._generate_llm_comment(row, local_when)
|
||||
if comment:
|
||||
post_character_comment_to_latest_chat(comment, self.user_id)
|
||||
except Exception:
|
||||
logger.exception("Reminder LLM comment failed (id=%s)", row.id)
|
||||
|
||||
self._mark_fired(row, datetime.now(timezone.utc))
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.reminders.service import RemindersService
|
||||
|
||||
MAX_IN_CONTEXT = 10
|
||||
|
||||
|
||||
def get_reminders_snapshot(db: Session) -> dict[str, Any]:
|
||||
return RemindersService(db).snapshot()
|
||||
|
||||
|
||||
def format_reminders_context(snapshot: dict[str, Any]) -> str:
|
||||
lines = ["[Напоминания]"]
|
||||
upcoming = snapshot.get("upcoming") or []
|
||||
tz = snapshot.get("timezone", "Europe/Moscow")
|
||||
|
||||
if not upcoming:
|
||||
lines.append(
|
||||
"Ближайших напоминаний нет. "
|
||||
"create_reminder для «напомни через 15 минут», «завтра утром», точной даты."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
lines.append(f"Часовой пояс: {tz}. Tools: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.")
|
||||
for item in upcoming[:MAX_IN_CONTEXT]:
|
||||
rec = item.get("recurrence", "none")
|
||||
rec_label = f" · повтор: {rec}" if rec and rec != "none" else ""
|
||||
lines.append(
|
||||
f"- #{item['id']} **{item['title']}** · {item.get('due_at_local', item.get('due_at'))}{rec_label}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.reminders.service import RemindersService
|
||||
|
||||
MAX_IN_CONTEXT = 10
|
||||
|
||||
|
||||
def get_reminders_snapshot(db: Session, user_id: int) -> dict[str, Any]:
|
||||
return RemindersService(db, user_id).snapshot()
|
||||
|
||||
|
||||
def format_reminders_context(snapshot: dict[str, Any]) -> str:
|
||||
lines = ["[Напоминания]"]
|
||||
upcoming = snapshot.get("upcoming") or []
|
||||
tz = snapshot.get("timezone", "Europe/Moscow")
|
||||
|
||||
if not upcoming:
|
||||
lines.append(
|
||||
"Ближайших напоминаний нет. "
|
||||
"create_reminder для «напомни через 15 минут», «завтра утром», точной даты."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
lines.append(f"Часовой пояс: {tz}. Tools: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.")
|
||||
for item in upcoming[:MAX_IN_CONTEXT]:
|
||||
rec = item.get("recurrence", "none")
|
||||
rec_label = f" · повтор: {rec}" if rec and rec != "none" else ""
|
||||
lines.append(
|
||||
f"- #{item['id']} **{item['title']}** · {item.get('due_at_local', item.get('due_at'))}{rec_label}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -1,45 +1,50 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Reminder
|
||||
from app.reminders.completion import ReminderCompletionHandler
|
||||
from app.reminders.notify import bump_notify_seq
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def get_due_reminders(db: Session) -> list[Reminder]:
|
||||
now = _utcnow()
|
||||
stmt = (
|
||||
select(Reminder)
|
||||
.where(
|
||||
Reminder.enabled.is_(True),
|
||||
Reminder.completed_at.is_(None),
|
||||
Reminder.due_at <= now,
|
||||
)
|
||||
.order_by(Reminder.due_at.asc())
|
||||
)
|
||||
rows = list(db.scalars(stmt).all())
|
||||
return [row for row in rows if not (row.last_fired_at and row.last_fired_at >= row.due_at)]
|
||||
|
||||
|
||||
async def process_due_reminders(db: Session) -> int:
|
||||
due = get_due_reminders(db)
|
||||
if not due:
|
||||
return 0
|
||||
|
||||
handler = ReminderCompletionHandler(db)
|
||||
for row in due:
|
||||
await handler.process(row)
|
||||
|
||||
db.commit()
|
||||
bump_notify_seq(db)
|
||||
logger.info("Reminders fired: %d", len(due))
|
||||
return len(due)
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Reminder, User
|
||||
from app.reminders.completion import ReminderCompletionHandler
|
||||
from app.reminders.notify import bump_notify_seq
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def get_due_reminders(db: Session, user_id: int) -> list[Reminder]:
|
||||
now = _utcnow()
|
||||
stmt = (
|
||||
select(Reminder)
|
||||
.where(
|
||||
Reminder.user_id == user_id,
|
||||
Reminder.enabled.is_(True),
|
||||
Reminder.completed_at.is_(None),
|
||||
Reminder.due_at <= now,
|
||||
)
|
||||
.order_by(Reminder.due_at.asc())
|
||||
)
|
||||
rows = list(db.scalars(stmt).all())
|
||||
return [row for row in rows if not (row.last_fired_at and row.last_fired_at >= row.due_at)]
|
||||
|
||||
|
||||
async def process_due_reminders(db: Session) -> int:
|
||||
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
|
||||
total = 0
|
||||
for user in users:
|
||||
due = get_due_reminders(db, user.id)
|
||||
if not due:
|
||||
continue
|
||||
handler = ReminderCompletionHandler(db, user.id)
|
||||
for row in due:
|
||||
await handler.process(row)
|
||||
total += len(due)
|
||||
|
||||
if total:
|
||||
db.commit()
|
||||
bump_notify_seq(db)
|
||||
logger.info("Reminders fired: %d", total)
|
||||
return total
|
||||
|
||||
+245
-237
@@ -1,237 +1,245 @@
|
||||
import calendar
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Reminder
|
||||
from app.homelab.context import resolve_timezone
|
||||
from app.reminders.notify import bump_notify_seq, get_notify_seq
|
||||
|
||||
RECURRENCE_NONE = "none"
|
||||
RECURRENCE_DAILY = "daily"
|
||||
RECURRENCE_WEEKLY = "weekly"
|
||||
RECURRENCE_MONTHLY = "monthly"
|
||||
RECURRENCE_YEARLY = "yearly"
|
||||
VALID_RECURRENCE = frozenset({
|
||||
RECURRENCE_NONE,
|
||||
RECURRENCE_DAILY,
|
||||
RECURRENCE_WEEKLY,
|
||||
RECURRENCE_MONTHLY,
|
||||
RECURRENCE_YEARLY,
|
||||
})
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_due_at(raw: str, tz_name: str) -> datetime:
|
||||
clean = raw.strip()
|
||||
if not clean:
|
||||
raise ValueError("due_at не может быть пустым")
|
||||
try:
|
||||
dt = datetime.fromisoformat(clean.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Неверный формат даты: {raw}") from exc
|
||||
if dt.tzinfo is None:
|
||||
try:
|
||||
dt = dt.replace(tzinfo=ZoneInfo(tz_name))
|
||||
except Exception:
|
||||
dt = dt.replace(tzinfo=ZoneInfo("Europe/Moscow"))
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _advance_due(due_at: datetime, recurrence: str) -> datetime:
|
||||
if recurrence == RECURRENCE_DAILY:
|
||||
return due_at + timedelta(days=1)
|
||||
if recurrence == RECURRENCE_WEEKLY:
|
||||
return due_at + timedelta(weeks=1)
|
||||
if recurrence == RECURRENCE_MONTHLY:
|
||||
month = due_at.month + 1
|
||||
year = due_at.year
|
||||
if month > 12:
|
||||
month = 1
|
||||
year += 1
|
||||
day = min(due_at.day, calendar.monthrange(year, month)[1])
|
||||
return due_at.replace(year=year, month=month, day=day)
|
||||
if recurrence == RECURRENCE_YEARLY:
|
||||
year = due_at.year + 1
|
||||
day = min(due_at.day, calendar.monthrange(year, due_at.month)[1])
|
||||
return due_at.replace(year=year, day=day)
|
||||
return due_at
|
||||
|
||||
|
||||
def _format_local(dt: datetime, tz_name: str, *, all_day: bool = False) -> str:
|
||||
try:
|
||||
local = dt.astimezone(ZoneInfo(tz_name))
|
||||
except Exception:
|
||||
local = dt.astimezone(ZoneInfo("Europe/Moscow"))
|
||||
if all_day:
|
||||
return local.strftime("%Y-%m-%d")
|
||||
return local.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
class RemindersService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def _tz(self) -> str:
|
||||
return resolve_timezone(self.db)
|
||||
|
||||
def _to_dict(self, row: Reminder) -> dict[str, Any]:
|
||||
tz = row.timezone or self._tz()
|
||||
return {
|
||||
"id": row.id,
|
||||
"title": row.title,
|
||||
"notes": row.notes,
|
||||
"due_at": row.due_at.isoformat(),
|
||||
"due_at_local": _format_local(row.due_at, tz, all_day=row.all_day),
|
||||
"all_day": row.all_day,
|
||||
"recurrence": row.recurrence,
|
||||
"enabled": row.enabled,
|
||||
"completed_at": row.completed_at.isoformat() if row.completed_at else None,
|
||||
"timezone": tz,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
upcoming = self.list_upcoming(limit=12)
|
||||
return {
|
||||
"notify_seq": get_notify_seq(self.db),
|
||||
"upcoming": upcoming,
|
||||
"upcoming_count": len(upcoming),
|
||||
"timezone": self._tz(),
|
||||
}
|
||||
|
||||
def list_upcoming(self, *, limit: int = 30) -> list[dict[str, Any]]:
|
||||
stmt = (
|
||||
select(Reminder)
|
||||
.where(
|
||||
Reminder.enabled.is_(True),
|
||||
Reminder.completed_at.is_(None),
|
||||
)
|
||||
.order_by(Reminder.due_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
return [self._to_dict(row) for row in self.db.scalars(stmt).all()]
|
||||
|
||||
def list_in_range(
|
||||
self,
|
||||
*,
|
||||
date_from: datetime,
|
||||
date_to: datetime,
|
||||
) -> list[dict[str, Any]]:
|
||||
stmt = (
|
||||
select(Reminder)
|
||||
.where(
|
||||
Reminder.enabled.is_(True),
|
||||
Reminder.completed_at.is_(None),
|
||||
Reminder.due_at >= date_from,
|
||||
Reminder.due_at < date_to,
|
||||
)
|
||||
.order_by(Reminder.due_at.asc())
|
||||
)
|
||||
return [self._to_dict(row) for row in self.db.scalars(stmt).all()]
|
||||
|
||||
def get(self, reminder_id: int) -> dict[str, Any] | None:
|
||||
row = self.db.get(Reminder, reminder_id)
|
||||
return self._to_dict(row) if row else None
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
title: str,
|
||||
due_at: str,
|
||||
notes: str = "",
|
||||
all_day: bool = False,
|
||||
recurrence: str = RECURRENCE_NONE,
|
||||
) -> dict[str, Any]:
|
||||
clean_title = title.strip()
|
||||
if not clean_title:
|
||||
raise ValueError("Название напоминания не может быть пустым")
|
||||
rec = (recurrence or RECURRENCE_NONE).strip().lower()
|
||||
if rec not in VALID_RECURRENCE:
|
||||
raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}")
|
||||
|
||||
tz = self._tz()
|
||||
due = _parse_due_at(due_at, tz)
|
||||
row = Reminder(
|
||||
title=clean_title,
|
||||
notes=notes.strip(),
|
||||
due_at=due,
|
||||
all_day=all_day,
|
||||
recurrence=rec,
|
||||
timezone=tz,
|
||||
)
|
||||
self.db.add(row)
|
||||
self.db.commit()
|
||||
self.db.refresh(row)
|
||||
bump_notify_seq(self.db)
|
||||
return {"ok": True, "reminder": self._to_dict(row), "created": True}
|
||||
|
||||
def update(
|
||||
self,
|
||||
reminder_id: int,
|
||||
*,
|
||||
title: str | None = None,
|
||||
due_at: str | None = None,
|
||||
notes: str | None = None,
|
||||
all_day: bool | None = None,
|
||||
recurrence: str | None = None,
|
||||
enabled: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
row = self.db.get(Reminder, reminder_id)
|
||||
if not row:
|
||||
raise ValueError("Напоминание не найдено")
|
||||
|
||||
if title is not None:
|
||||
clean = title.strip()
|
||||
if not clean:
|
||||
raise ValueError("Название не может быть пустым")
|
||||
row.title = clean
|
||||
if notes is not None:
|
||||
row.notes = notes.strip()
|
||||
if due_at is not None:
|
||||
row.due_at = _parse_due_at(due_at, row.timezone or self._tz())
|
||||
row.last_fired_at = None
|
||||
if all_day is not None:
|
||||
row.all_day = all_day
|
||||
if recurrence is not None:
|
||||
rec = recurrence.strip().lower()
|
||||
if rec not in VALID_RECURRENCE:
|
||||
raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}")
|
||||
row.recurrence = rec
|
||||
if enabled is not None:
|
||||
row.enabled = enabled
|
||||
|
||||
row.updated_at = _utcnow()
|
||||
self.db.commit()
|
||||
self.db.refresh(row)
|
||||
bump_notify_seq(self.db)
|
||||
return {"ok": True, "reminder": self._to_dict(row)}
|
||||
|
||||
def delete(self, reminder_id: int) -> dict[str, Any]:
|
||||
row = self.db.get(Reminder, reminder_id)
|
||||
if not row:
|
||||
raise ValueError("Напоминание не найдено")
|
||||
title = row.title
|
||||
self.db.delete(row)
|
||||
self.db.commit()
|
||||
bump_notify_seq(self.db)
|
||||
return {"ok": True, "deleted_id": reminder_id, "title": title}
|
||||
|
||||
def complete(self, reminder_id: int) -> dict[str, Any]:
|
||||
row = self.db.get(Reminder, reminder_id)
|
||||
if not row:
|
||||
raise ValueError("Напоминание не найдено")
|
||||
now = _utcnow()
|
||||
row.completed_at = now
|
||||
row.enabled = False
|
||||
row.updated_at = now
|
||||
self.db.commit()
|
||||
self.db.refresh(row)
|
||||
bump_notify_seq(self.db)
|
||||
return {"ok": True, "reminder": self._to_dict(row)}
|
||||
import calendar
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Reminder
|
||||
from app.memory.service import MemoryService
|
||||
from app.reminders.notify import bump_notify_seq, get_notify_seq
|
||||
|
||||
RECURRENCE_NONE = "none"
|
||||
RECURRENCE_DAILY = "daily"
|
||||
RECURRENCE_WEEKLY = "weekly"
|
||||
RECURRENCE_MONTHLY = "monthly"
|
||||
RECURRENCE_YEARLY = "yearly"
|
||||
VALID_RECURRENCE = frozenset({
|
||||
RECURRENCE_NONE,
|
||||
RECURRENCE_DAILY,
|
||||
RECURRENCE_WEEKLY,
|
||||
RECURRENCE_MONTHLY,
|
||||
RECURRENCE_YEARLY,
|
||||
})
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_due_at(raw: str, tz_name: str) -> datetime:
|
||||
clean = raw.strip()
|
||||
if not clean:
|
||||
raise ValueError("due_at не может быть пустым")
|
||||
try:
|
||||
dt = datetime.fromisoformat(clean.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Неверный формат даты: {raw}") from exc
|
||||
if dt.tzinfo is None:
|
||||
try:
|
||||
dt = dt.replace(tzinfo=ZoneInfo(tz_name))
|
||||
except Exception:
|
||||
dt = dt.replace(tzinfo=ZoneInfo("Europe/Moscow"))
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _advance_due(due_at: datetime, recurrence: str) -> datetime:
|
||||
if recurrence == RECURRENCE_DAILY:
|
||||
return due_at + timedelta(days=1)
|
||||
if recurrence == RECURRENCE_WEEKLY:
|
||||
return due_at + timedelta(weeks=1)
|
||||
if recurrence == RECURRENCE_MONTHLY:
|
||||
month = due_at.month + 1
|
||||
year = due_at.year
|
||||
if month > 12:
|
||||
month = 1
|
||||
year += 1
|
||||
day = min(due_at.day, calendar.monthrange(year, month)[1])
|
||||
return due_at.replace(year=year, month=month, day=day)
|
||||
if recurrence == RECURRENCE_YEARLY:
|
||||
year = due_at.year + 1
|
||||
day = min(due_at.day, calendar.monthrange(year, due_at.month)[1])
|
||||
return due_at.replace(year=year, day=day)
|
||||
return due_at
|
||||
|
||||
|
||||
def _format_local(dt: datetime, tz_name: str, *, all_day: bool = False) -> str:
|
||||
try:
|
||||
local = dt.astimezone(ZoneInfo(tz_name))
|
||||
except Exception:
|
||||
local = dt.astimezone(ZoneInfo("Europe/Moscow"))
|
||||
if all_day:
|
||||
return local.strftime("%Y-%m-%d")
|
||||
return local.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
class RemindersService:
|
||||
def __init__(self, db: Session, user_id: int):
|
||||
self.db = db
|
||||
self.user_id = user_id
|
||||
|
||||
def _tz(self) -> str:
|
||||
profile = MemoryService(self.db, self.user_id).get_profile()
|
||||
tz = (profile.get("timezone") or "").strip()
|
||||
return tz or "Europe/Moscow"
|
||||
|
||||
def _to_dict(self, row: Reminder) -> dict[str, Any]:
|
||||
tz = row.timezone or self._tz()
|
||||
return {
|
||||
"id": row.id,
|
||||
"title": row.title,
|
||||
"notes": row.notes,
|
||||
"due_at": row.due_at.isoformat(),
|
||||
"due_at_local": _format_local(row.due_at, tz, all_day=row.all_day),
|
||||
"all_day": row.all_day,
|
||||
"recurrence": row.recurrence,
|
||||
"enabled": row.enabled,
|
||||
"completed_at": row.completed_at.isoformat() if row.completed_at else None,
|
||||
"timezone": tz,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
upcoming = self.list_upcoming(limit=12)
|
||||
return {
|
||||
"notify_seq": get_notify_seq(self.db),
|
||||
"upcoming": upcoming,
|
||||
"upcoming_count": len(upcoming),
|
||||
"timezone": self._tz(),
|
||||
}
|
||||
|
||||
def list_upcoming(self, *, limit: int = 30) -> list[dict[str, Any]]:
|
||||
stmt = (
|
||||
select(Reminder)
|
||||
.where(
|
||||
Reminder.user_id == self.user_id,
|
||||
Reminder.enabled.is_(True),
|
||||
Reminder.completed_at.is_(None),
|
||||
)
|
||||
.order_by(Reminder.due_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
return [self._to_dict(row) for row in self.db.scalars(stmt).all()]
|
||||
|
||||
def list_in_range(
|
||||
self,
|
||||
*,
|
||||
date_from: datetime,
|
||||
date_to: datetime,
|
||||
) -> list[dict[str, Any]]:
|
||||
stmt = (
|
||||
select(Reminder)
|
||||
.where(
|
||||
Reminder.user_id == self.user_id,
|
||||
Reminder.enabled.is_(True),
|
||||
Reminder.completed_at.is_(None),
|
||||
Reminder.due_at >= date_from,
|
||||
Reminder.due_at < date_to,
|
||||
)
|
||||
.order_by(Reminder.due_at.asc())
|
||||
)
|
||||
return [self._to_dict(row) for row in self.db.scalars(stmt).all()]
|
||||
|
||||
def get(self, reminder_id: int) -> dict[str, Any] | None:
|
||||
row = self.db.get(Reminder, reminder_id)
|
||||
if not row or row.user_id != self.user_id:
|
||||
return None
|
||||
return self._to_dict(row)
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
title: str,
|
||||
due_at: str,
|
||||
notes: str = "",
|
||||
all_day: bool = False,
|
||||
recurrence: str = RECURRENCE_NONE,
|
||||
) -> dict[str, Any]:
|
||||
clean_title = title.strip()
|
||||
if not clean_title:
|
||||
raise ValueError("Название напоминания не может быть пустым")
|
||||
rec = (recurrence or RECURRENCE_NONE).strip().lower()
|
||||
if rec not in VALID_RECURRENCE:
|
||||
raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}")
|
||||
|
||||
tz = self._tz()
|
||||
due = _parse_due_at(due_at, tz)
|
||||
row = Reminder(
|
||||
user_id=self.user_id,
|
||||
title=clean_title,
|
||||
notes=notes.strip(),
|
||||
due_at=due,
|
||||
all_day=all_day,
|
||||
recurrence=rec,
|
||||
timezone=tz,
|
||||
)
|
||||
self.db.add(row)
|
||||
self.db.commit()
|
||||
self.db.refresh(row)
|
||||
bump_notify_seq(self.db)
|
||||
return {"ok": True, "reminder": self._to_dict(row), "created": True}
|
||||
|
||||
def update(
|
||||
self,
|
||||
reminder_id: int,
|
||||
*,
|
||||
title: str | None = None,
|
||||
due_at: str | None = None,
|
||||
notes: str | None = None,
|
||||
all_day: bool | None = None,
|
||||
recurrence: str | None = None,
|
||||
enabled: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
row = self.db.get(Reminder, reminder_id)
|
||||
if not row or row.user_id != self.user_id:
|
||||
raise ValueError("Напоминание не найдено")
|
||||
|
||||
if title is not None:
|
||||
clean = title.strip()
|
||||
if not clean:
|
||||
raise ValueError("Название не может быть пустым")
|
||||
row.title = clean
|
||||
if notes is not None:
|
||||
row.notes = notes.strip()
|
||||
if due_at is not None:
|
||||
row.due_at = _parse_due_at(due_at, row.timezone or self._tz())
|
||||
row.last_fired_at = None
|
||||
if all_day is not None:
|
||||
row.all_day = all_day
|
||||
if recurrence is not None:
|
||||
rec = recurrence.strip().lower()
|
||||
if rec not in VALID_RECURRENCE:
|
||||
raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}")
|
||||
row.recurrence = rec
|
||||
if enabled is not None:
|
||||
row.enabled = enabled
|
||||
|
||||
row.updated_at = _utcnow()
|
||||
self.db.commit()
|
||||
self.db.refresh(row)
|
||||
bump_notify_seq(self.db)
|
||||
return {"ok": True, "reminder": self._to_dict(row)}
|
||||
|
||||
def delete(self, reminder_id: int) -> dict[str, Any]:
|
||||
row = self.db.get(Reminder, reminder_id)
|
||||
if not row or row.user_id != self.user_id:
|
||||
raise ValueError("Напоминание не найдено")
|
||||
title = row.title
|
||||
self.db.delete(row)
|
||||
self.db.commit()
|
||||
bump_notify_seq(self.db)
|
||||
return {"ok": True, "deleted_id": reminder_id, "title": title}
|
||||
|
||||
def complete(self, reminder_id: int) -> dict[str, Any]:
|
||||
row = self.db.get(Reminder, reminder_id)
|
||||
if not row or row.user_id != self.user_id:
|
||||
raise ValueError("Напоминание не найдено")
|
||||
now = _utcnow()
|
||||
row.completed_at = now
|
||||
row.enabled = False
|
||||
row.updated_at = now
|
||||
self.db.commit()
|
||||
self.db.refresh(row)
|
||||
bump_notify_seq(self.db)
|
||||
return {"ok": True, "reminder": self._to_dict(row)}
|
||||
|
||||
Reference in New Issue
Block a user