Implement automatic reminder engine with admin and company UI.
Add schema v6, read-only scan rules for unsubmitted flows, gaps and pending reviews, deduplicated delivery with append-only event history, daily scan thread, and full API/frontend integration with 18 new tests. Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
951b353765
commit
f17636183d
@@ -6,11 +6,14 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http.cookies import SimpleCookie
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from bank_importer import auth, importing, master_data, matching, multipart, personal_transit
|
||||
from bank_importer import auth, importing, master_data, matching, multipart, personal_transit, reminders
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
|
||||
|
||||
@@ -100,6 +103,26 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if company_event_match:
|
||||
self._handle_company_transfer_event_detail(int(company_event_match.group(1)))
|
||||
return
|
||||
if path == "/api/admin/reminders/pending":
|
||||
self._handle_admin_reminders_pending()
|
||||
return
|
||||
if path == "/api/admin/reminders":
|
||||
self._handle_admin_reminders(query)
|
||||
return
|
||||
if path == "/api/admin/reminder-settings":
|
||||
self._handle_admin_reminder_settings_get()
|
||||
return
|
||||
admin_reminder_match = re.fullmatch(r"/api/admin/reminders/(\d+)", path)
|
||||
if admin_reminder_match:
|
||||
self._handle_admin_reminder_detail(int(admin_reminder_match.group(1)))
|
||||
return
|
||||
if path == "/api/company/reminders":
|
||||
self._handle_company_reminders()
|
||||
return
|
||||
company_reminder_match = re.fullmatch(r"/api/company/reminders/(\d+)", path)
|
||||
if company_reminder_match:
|
||||
self._handle_company_reminder_detail(int(company_reminder_match.group(1)))
|
||||
return
|
||||
|
||||
if path == "/admin.html" and not self._guard_page("admin"):
|
||||
return
|
||||
@@ -167,6 +190,30 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if mapping_review:
|
||||
self._handle_admin_review_personal_mapping(int(mapping_review.group(1)))
|
||||
return
|
||||
if path == "/api/admin/reminders/scan":
|
||||
self._handle_admin_reminders_scan()
|
||||
return
|
||||
if path == "/api/admin/reminders/send":
|
||||
self._handle_admin_reminders_send()
|
||||
return
|
||||
if path == "/api/admin/reminders/manual":
|
||||
self._handle_admin_reminders_manual()
|
||||
return
|
||||
admin_resend_match = re.fullmatch(r"/api/admin/reminders/(\d+)/resend", path)
|
||||
if admin_resend_match:
|
||||
self._handle_admin_reminder_resend(int(admin_resend_match.group(1)))
|
||||
return
|
||||
if path == "/api/admin/reminder-settings":
|
||||
self._handle_admin_reminder_settings_put()
|
||||
return
|
||||
company_status_match = re.fullmatch(
|
||||
r"/api/company/reminders/(\d+)/(acknowledge|resolve)", path
|
||||
)
|
||||
if company_status_match:
|
||||
self._handle_company_reminder_status(
|
||||
int(company_status_match.group(1)), company_status_match.group(2)
|
||||
)
|
||||
return
|
||||
self._send_json(404, {"status": "error", "message": "接口不存在。"})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1996,6 +2043,260 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
return None
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reminders
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_admin_reminders_pending(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
findings = reminders.scan_findings(connection)
|
||||
items = [reminders.finding_to_dict(item) for item in findings]
|
||||
companies = len({item["company_id"] for item in items})
|
||||
self._send_json(
|
||||
200,
|
||||
{
|
||||
"status": "ok",
|
||||
"findings": items,
|
||||
"summary": {"companies": companies, "items": len(items)},
|
||||
},
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_reminders_scan(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
result = reminders.run_scan(connection, actor=user, ip=self._client_ip)
|
||||
items = [reminders.finding_to_dict(item) for item in result["findings"]]
|
||||
companies = len({item["company_id"] for item in items})
|
||||
self._send_json(
|
||||
200,
|
||||
{
|
||||
"status": "ok",
|
||||
"findings": items,
|
||||
"counts": result["counts"],
|
||||
"summary": {"companies": companies, "items": len(items)},
|
||||
},
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_reminders_send(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
data = self._read_json_body()
|
||||
if data is None:
|
||||
return
|
||||
raw_keys = data.get("dedupe_keys")
|
||||
if not isinstance(raw_keys, list) or not raw_keys:
|
||||
self._send_json(400, {"status": "error", "message": "请指定 dedupe_keys。"})
|
||||
return
|
||||
dedupe_keys = [str(key) for key in raw_keys]
|
||||
sent = reminders.deliver_many(
|
||||
connection, dedupe_keys, actor=user, ip=self._client_ip
|
||||
)
|
||||
self._send_json(200, {"status": "ok", "sent": sent, "count": len(sent)})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_reminders_manual(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
data = self._read_json_body()
|
||||
if data is None:
|
||||
return
|
||||
try:
|
||||
company_id = int(data.get("company_id"))
|
||||
except (TypeError, ValueError):
|
||||
self._send_json(400, {"status": "error", "message": "company_id 无效。"})
|
||||
return
|
||||
display_type = str(data.get("display_type") or data.get("type") or "").strip()
|
||||
content = str(data.get("content") or "").strip()
|
||||
deadline = str(data.get("deadline") or "").strip() or None
|
||||
if not display_type or not content:
|
||||
self._send_json(400, {"status": "error", "message": "类型与内容不能为空。"})
|
||||
return
|
||||
try:
|
||||
reminder_id = reminders.send_manual(
|
||||
connection,
|
||||
company_id=company_id,
|
||||
display_type=display_type,
|
||||
content=content,
|
||||
deadline=deadline,
|
||||
actor=user,
|
||||
ip=self._client_ip,
|
||||
)
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", "reminder_id": reminder_id})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_reminder_resend(self, reminder_id: int) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
try:
|
||||
reminders.resend_reminder(
|
||||
connection, reminder_id, actor=user, ip=self._client_ip
|
||||
)
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
self._send_json(200, {"status": "ok"})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_reminders(self, query: dict[str, list[str]]) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
source = (query.get("source") or [None])[0]
|
||||
if source not in {None, "auto", "manual", "system"}:
|
||||
self._send_json(400, {"status": "error", "message": "source 参数无效。"})
|
||||
return
|
||||
filter_source = "auto" if source == "system" else source
|
||||
items = reminders.list_admin_reminders(connection, source=filter_source)
|
||||
stats = {"unread": 0, "doing": 0, "done": 0}
|
||||
for item in items:
|
||||
stats[item["status_ui"]] += 1
|
||||
self._send_json(200, {"status": "ok", "reminders": items, "stats": stats})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_reminder_detail(self, reminder_id: int) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
detail = reminders.get_reminder_detail(connection, reminder_id)
|
||||
if detail is None:
|
||||
self._send_json(404, {"status": "error", "message": "提醒不存在。"})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", "reminder": detail})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_reminder_settings_get(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
self._send_json(200, {"status": "ok", "settings": reminders.get_settings(connection)})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_reminder_settings_put(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
data = self._read_json_body()
|
||||
if data is None:
|
||||
return
|
||||
updates = data.get("settings") if isinstance(data.get("settings"), dict) else data
|
||||
if not isinstance(updates, dict):
|
||||
self._send_json(400, {"status": "error", "message": "settings 格式无效。"})
|
||||
return
|
||||
try:
|
||||
settings = reminders.update_settings(connection, updates)
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
auth.audit(
|
||||
connection,
|
||||
"reminder_settings_update",
|
||||
actor=user,
|
||||
detail=json.dumps(settings, ensure_ascii=False),
|
||||
ip=self._client_ip,
|
||||
)
|
||||
self._send_json(200, {"status": "ok", "settings": settings})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_company_reminders(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_user(connection)
|
||||
if user is None:
|
||||
return
|
||||
if user["role"] != "company":
|
||||
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
|
||||
return
|
||||
items = reminders.list_company_reminders(connection, int(user["company_id"]))
|
||||
unread = reminders.company_unread_count(connection, int(user["company_id"]))
|
||||
stats = {"unread": 0, "doing": 0, "done": 0}
|
||||
for item in items:
|
||||
stats[item["status_ui"]] += 1
|
||||
self._send_json(
|
||||
200,
|
||||
{"status": "ok", "reminders": items, "unread_count": unread, "stats": stats},
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_company_reminder_detail(self, reminder_id: int) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_user(connection)
|
||||
if user is None:
|
||||
return
|
||||
if user["role"] != "company":
|
||||
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
|
||||
return
|
||||
detail = reminders.get_reminder_detail(connection, reminder_id)
|
||||
if detail is None or int(detail["company_id"]) != int(user["company_id"]):
|
||||
self._send_json(404, {"status": "error", "message": "提醒不存在。"})
|
||||
return
|
||||
self._send_json(200, {"status": "ok", "reminder": detail})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_company_reminder_status(self, reminder_id: int, action: str) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_user(connection)
|
||||
if user is None:
|
||||
return
|
||||
if user["role"] != "company":
|
||||
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
|
||||
return
|
||||
new_status = "acknowledged" if action == "acknowledge" else "resolved"
|
||||
ok = reminders.update_reminder_status(
|
||||
connection,
|
||||
reminder_id,
|
||||
new_status,
|
||||
company_id=int(user["company_id"]),
|
||||
actor=user,
|
||||
)
|
||||
if not ok:
|
||||
self._send_json(404, {"status": "error", "message": "提醒不存在。"})
|
||||
return
|
||||
self._send_json(200, {"status": "ok"})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@staticmethod
|
||||
def _sheet_payload(row) -> dict[str, object]:
|
||||
"""Serialize one persisted per-sheet review record."""
|
||||
@@ -2068,6 +2369,44 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
self.wfile.write(content)
|
||||
|
||||
|
||||
def _parse_scan_time(value: str) -> tuple[int, int]:
|
||||
parts = value.strip().split(":")
|
||||
if len(parts) != 2:
|
||||
return 8, 0
|
||||
try:
|
||||
return int(parts[0]), int(parts[1])
|
||||
except ValueError:
|
||||
return 8, 0
|
||||
|
||||
|
||||
def _seconds_until_scan(scan_time: str) -> float:
|
||||
hour, minute = _parse_scan_time(scan_time)
|
||||
now = datetime.now(timezone.utc)
|
||||
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if target <= now:
|
||||
target += timedelta(days=1)
|
||||
return (target - now).total_seconds()
|
||||
|
||||
|
||||
def _scheduled_reminder_scan_loop() -> None:
|
||||
"""Daily scan thread; audit-only, does not auto-deliver to companies."""
|
||||
while True:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
settings = reminders.get_settings(connection)
|
||||
scan_time = settings.get("scan_time", "08:00")
|
||||
finally:
|
||||
connection.close()
|
||||
time.sleep(max(1.0, _seconds_until_scan(scan_time)))
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
reminders.run_scan(connection, actor=None, ip=None)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def ensure_bootstrap_admin(connection) -> str | None:
|
||||
"""Create the first admin when none exists; returns the generated password."""
|
||||
existing = connection.execute(
|
||||
@@ -2096,6 +2435,8 @@ def main() -> None:
|
||||
# Printed once to stdout; never written to any log file.
|
||||
print(f"Bootstrap admin initial password (shown once): {initial_password}")
|
||||
server = ThreadingHTTPServer((host, port), AppHandler)
|
||||
scan_thread = threading.Thread(target=_scheduled_reminder_scan_loop, daemon=True)
|
||||
scan_thread.start()
|
||||
print(f"Serving on http://{host}:{port}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user