feat(calendar): v5.8.0 calendrier & rappels + v5.7.0 database avancee (pt.2)
FlowDeck CI / lint (push) Successful in 49s
FlowDeck CI / test (push) Successful in 5m26s
FlowDeck CI / docker (push) Successful in 43s

v5.8.0 (release 5.11.7) — Calendrier & Rappels :
- moteur de recurrence RRULE subset (daily/weekly/monthly, interval,
  count, until, byweekday, timezone) — app/services/recurrence.py
- vues calendar Jour / Semaine / Mois avec expansion des occurrences
  cote serveur (GET /db/{id}/calendar/api) et popover evenement
  (Time / Timezone / Repeat / Remind)
- rappels avant echeance (scan 60 s, table reminder_log, in-app +
  email, cible = personnes assignees) — app/services/reminders.py
- fuseaux horaires : users.timezone + reglages in-app, timezone par
  evenement, liste de zones (GET /db/timezones/api)
- notifications d'assignation sur PUT /db/pages/{id}/api et
  preferences etendues (reminders, assignments)
- template Meeting notes enrichi (Agenda, Notes — migration 12)
- migrations 11-12 ; 20 tests dedies ; suite complete 523 verte

v5.7.0 (release 5.11.6, termine avant cette session, reste dans
l'arbre sans commit) — Database Avancée Pt.2 :
- proprietes person + auto-proprietes (created/last-edited time & by)
- groupes de proprietes, vues sauvegardees par utilisateur
- swimlanes, WIP limits, cartes configurables, calendar drag & drop,
  gallery couvertures ; 12 tests dedies
This commit is contained in:
2026-09-13 22:49:14 -04:00
parent 055956a351
commit d4adf89db5
18 changed files with 3301 additions and 366 deletions
+4
View File
@@ -60,6 +60,10 @@ class Settings(BaseSettings):
project_sync_enabled: bool = True
project_sync_interval_hours: int = 1
# Reminders (v5.8.0) — background scan for due date reminders
reminders_enabled: bool = True
reminder_scan_interval_seconds: int = 60
# Email / SMTP notifications (v4.9.0) — optional. If smtp_host is empty,
# email notifications are skipped (only in-app notifications are delivered).
smtp_host: str = ""
+8 -4
View File
@@ -88,13 +88,17 @@ async def lifespan(_app: FastAPI):
from app.services.trash import trash_purge_scheduler
trash_task = asyncio.create_task(trash_purge_scheduler())
logger.info("FlowDeck v5.11.5 started on port %d", settings.app_port)
# ── Reminders (v5.8.0): due-reminder scan for database rows ──
from app.services.reminders import reminder_scheduler
reminder_task = asyncio.create_task(reminder_scheduler())
logger.info("FlowDeck v5.11.7 started on port %d", settings.app_port)
try:
yield
finally:
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task):
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task):
task.cancel()
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task):
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task):
try:
await task
except asyncio.CancelledError:
@@ -103,7 +107,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="5.11.5",
version="5.11.7",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
+90
View File
@@ -419,3 +419,93 @@ def _migration_import_framework(conn: sqlite3.Connection) -> None:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_import_jobs_created ON import_jobs(created_at)"
)
@register(10, "v5.7.0: property groups, per-user views, row covers")
def _migration_v57_db_advanced(conn: sqlite3.Connection) -> None:
"""v5.7.0 — Database Avancée (Pt. 2).
``collection_properties.group_name`` — groups properties into collapsible
sections in the table header (Notion property groups).
``collection_views.created_by`` — owner of a saved view; ``NULL`` means
a shared/legacy view visible to everyone, otherwise it is personal to a user.
``collection_pages.cover_url`` — per-row cover image (gallery/board
cards), independent from the block-page ``pages.cover_url``.
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
if "group_name" not in _pcols:
conn.execute(
"ALTER TABLE collection_properties ADD COLUMN group_name TEXT NOT NULL DEFAULT ''"
)
_vcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_views)").fetchall()}
if "created_by" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER")
if "updated_at" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN updated_at TIMESTAMP")
_cpcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
if "cover_url" not in _cpcols:
conn.execute("ALTER TABLE collection_pages ADD COLUMN cover_url TEXT DEFAULT ''")
@register(11, "v5.8.0: reminder log + user timezones")
def _migration_v58_calendar_reminders(conn: sqlite3.Connection) -> None:
"""v5.8.0 — Calendrier & Rappels.
``reminder_log`` — dedup ledger: one row per (page, occurrence date) so
a reminder fires exactly once even across restarts.
``users.timezone`` — personal IANA timezone used for "today" in calendar
views and reminder firing (empty = UTC).
"""
conn.execute(
"""CREATE TABLE IF NOT EXISTS reminder_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
occurrence_date TEXT NOT NULL,
fired_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(page_id, occurrence_date)
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_remlog_page ON reminder_log(page_id)"
)
_ucols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
if "timezone" not in _ucols:
conn.execute("ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT ''")
@register(12, "v5.8.0: enrich Meeting notes template")
def _migration_v58_meeting_template(conn: sqlite3.Connection) -> None:
"""v5.8.0 — the seeded 'Meeting notes' database template gains Agenda and
Notes text properties. Only refreshed when the row still matches the old
built-in schema (user edits are never clobbered)."""
import json as _json
row = conn.execute(
"SELECT schema_json FROM database_templates WHERE name='Meeting notes'"
).fetchone()
if not row:
return
try:
schema = _json.loads(row[0] or "[]")
except (ValueError, TypeError):
return
names = [p.get("name") for p in schema]
if "Agenda" in names or "Notes" in names:
return
if names != ["Title", "Date", "Attendees", "Status", "Action items"]:
return # customised — leave alone
idx = names.index("Action items")
schema[idx:idx] = [
{"name": "Agenda", "type": "text"},
{"name": "Notes", "type": "text"},
]
conn.execute(
"UPDATE database_templates SET schema_json=? WHERE name='Meeting notes'",
(_json.dumps(schema),),
)
conn.execute(
"UPDATE database_templates SET description=? WHERE name='Meeting notes'",
("Notes de réunion avec participants, agenda, notes et actions.",),
)
+390 -32
View File
@@ -8,10 +8,38 @@ import sqlite3
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event
from app.services.db_templates import materialize_properties
from app.services.property_types import validate_property_rule
from app.services.property_types import (
AUTO_TYPES,
apply_auto_properties,
validate_property_rule,
)
from app.services.recurrence import (
RECURRENCE_KEY,
expand_rule,
is_valid_timezone,
parse_date,
validate_rule,
)
from app.services.reminders import REMINDER_KEY, parse_lead
def _current_user(request: Request) -> dict:
"""Resolve the session user, falling back to the local admin (single-user)."""
s = request.cookies.get("flowdeck_session", "")
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
def _collection_properties(conn, collection_id: int) -> list[dict]:
return [
dict(r) for r in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
@@ -42,7 +70,7 @@ def _validate_page_properties(conn, collection_id: int, properties: dict, exclud
for prop in props:
ptype = prop["prop_type"]
if ptype == "title":
if ptype == "title" or ptype in AUTO_TYPES:
continue
pid = prop["id"]
# Values may be keyed by property id (FlowDeckDB UI) or by name (agent).
@@ -77,6 +105,61 @@ def _validate_page_properties(conn, collection_id: int, properties: dict, exclud
raise HTTPException(status_code=400, detail=f"Property '{prop['name']}': {msg}")
def _validate_meta_keys(conn, collection_id: int, properties: dict) -> None:
"""Validate the ``__recurrence__`` / ``__reminder__`` meta keys stored
alongside real property values. Raises HTTPException(400) on bad shape.
Each meta key maps a date-property id to a rule/reminder object. We verify
the target is actually a date property and the payload parses.
"""
date_ids = {
str(r["id"]) for r in conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='date'",
(collection_id,),
).fetchall()
}
rec = properties.get(RECURRENCE_KEY)
if rec not in (None, {}):
if not isinstance(rec, dict):
raise HTTPException(status_code=400, detail="Recurrence must be an object")
for prop_id, rule in rec.items():
if rule is None:
continue
if str(prop_id) not in date_ids:
raise HTTPException(status_code=400, detail="Recurrence target must be a date property")
ok, msg = validate_rule(rule)
if not ok:
raise HTTPException(status_code=400, detail=f"Recurrence: {msg}")
rem = properties.get(REMINDER_KEY)
if rem not in (None, {}):
if not isinstance(rem, dict):
raise HTTPException(status_code=400, detail="Reminder must be an object")
for prop_id, reminder in rem.items():
if reminder is None:
continue
if str(prop_id) not in date_ids:
raise HTTPException(status_code=400, detail="Reminder target must be a date property")
if not isinstance(reminder, dict):
raise HTTPException(status_code=400, detail="Reminder must be an object")
if reminder.get("unit") not in (None, "none", "minutes", "hours", "days"):
raise HTTPException(status_code=400, detail="Reminder unit must be minutes/hours/days/none")
if parse_lead(reminder) is None and reminder.get("unit") != "none":
raise HTTPException(status_code=400, detail="Reminder value must be a positive integer")
from app.services.recurrence import TIMEZONE_KEY
tzmap = properties.get(TIMEZONE_KEY)
if tzmap not in (None, {}):
if not isinstance(tzmap, dict):
raise HTTPException(status_code=400, detail="Timezone map must be an object")
for prop_id, value in tzmap.items():
if str(prop_id) not in date_ids:
raise HTTPException(status_code=400, detail="Timezone target must be a date property")
if value and not is_valid_timezone(str(value)):
raise HTTPException(status_code=400, detail=f"Unknown timezone '{value}'")
# ── API: List & Create (no path params) ──
@@ -394,27 +477,39 @@ async def update_page_api(request: Request, page_id: int):
title = body.get("title", existing["title"])
icon = body.get("icon", existing["icon"])
cover_url = body.get("cover_url", existing["cover_url"] if "cover_url" in existing.keys() else "")
position = body.get("position", existing["position"])
parent_id = body.get("parent_id", existing["parent_id"])
try:
stored = json.loads(existing["property_values_json"])
except (json.JSONDecodeError, TypeError):
stored = {}
if "properties" in body:
props = body["properties"]
# Partial PATCH semantics: merge submitted values over stored ones.
props = dict(stored)
props.update(body["properties"])
else:
try:
props = json.loads(existing["property_values_json"])
except (json.JSONDecodeError, TypeError):
props = {}
props = stored
_validate_page_properties(conn, existing["collection_id"], props, exclude_page_id=page_id)
_validate_meta_keys(conn, existing["collection_id"], props)
apply_auto_properties(
_collection_properties(conn, existing["collection_id"]),
props,
_current_user(request),
is_create=False,
)
property_values = json.dumps(props)
conn.execute(
"""UPDATE collection_pages
SET title=?, icon=?, position=?, parent_id=?, property_values_json=?,
SET title=?, icon=?, cover_url=?, position=?, parent_id=?, property_values_json=?,
updated_at=CURRENT_TIMESTAMP
WHERE id=?""",
(title, icon, position, parent_id, property_values, page_id),
(title, icon, cover_url, position, parent_id, property_values, page_id),
)
conn.commit()
@@ -425,6 +520,11 @@ async def update_page_api(request: Request, page_id: int):
"icon": icon,
"properties": props,
})
# Notify newly assigned people (person properties) — v5.8.0.
from app.services.notifications import notify_assignment
user = _current_user(request)
notify_assignment(existing["collection_id"], page_id, title,
stored, props, user.get("id"))
return {"id": page_id, "status": "updated"}
@@ -513,6 +613,173 @@ async def list_properties_api(request: Request, collection_id: int):
return {"properties": [dict(r) for r in rows]}
@router.get("/{collection_id}/members/api")
async def list_collection_members_api(request: Request, collection_id: int):
"""API: list workspace members available for a ``person`` property.
Resolves the collection's workspace and returns its members (falling back to
every active user for standalone databases without a workspace).
"""
with get_conn() as conn:
coll = conn.execute(
"SELECT workspace_id FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
ws_id = coll["workspace_id"] if "workspace_id" in coll.keys() else None
if ws_id:
rows = conn.execute(
"""SELECT u.id, u.login, u.full_name, u.avatar_url, u.avatar_color, wm.role
FROM workspace_members wm JOIN users u ON wm.user_id=u.id
WHERE wm.workspace_id=? AND u.is_active=1 ORDER BY u.full_name, u.login""",
(ws_id,),
).fetchall()
else:
rows = []
if not rows:
rows = conn.execute(
"""SELECT id, login, full_name, avatar_url, avatar_color, '' AS role
FROM users WHERE is_active=1 ORDER BY full_name, login"""
).fetchall()
return {"members": [dict(r) for r in rows]}
@router.get("/{collection_id}/calendar/api")
async def collection_calendar_api(request: Request, collection_id: int,
start: str = "", end: str = "",
date_property: str = ""):
"""API (v5.8.0): expanded calendar events for a window [start, end].
Returns every occurrence (recurrence-aware, virtual — never persisted)
of the rows in the collection whose ``date_property`` falls inside the
inclusive window. Rows without a rule yield their base date.
"""
user = _current_user(request)
s = parse_date(start)
e = parse_date(end)
if s is None or e is None or s > e:
raise HTTPException(status_code=400, detail="start/end must be YYYY-MM-DD")
if (e - s).days > 370:
raise HTTPException(status_code=400, detail="window too large (max 370 days)")
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
props = _collection_properties(conn, collection_id)
date_props = [p for p in props if p["prop_type"] == "date"]
target = None
if date_property:
target = next((p for p in date_props
if str(p["id"]) == str(date_property) or p["name"] == date_property), None)
if target is None:
raise HTTPException(status_code=400, detail="Unknown date property")
elif date_props:
target = date_props[0]
if target is None:
return {"events": [], "timezone": "", "property": None}
urow = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
user_tz = (urow["timezone"] if urow and "timezone" in urow.keys() else "") or ""
rows = conn.execute(
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=?",
(collection_id,),
).fetchall()
pid = str(target["id"])
events: list[dict] = []
for r in rows:
try:
pv = json.loads(r["property_values_json"] or "{}")
except (json.JSONDecodeError, TypeError):
continue
base_value = pv.get(pid)
if base_value is None:
base_value = pv.get(target["name"])
if parse_date(base_value) is None:
continue
rec_all = pv.get(RECURRENCE_KEY) if isinstance(pv.get(RECURRENCE_KEY), dict) else {}
rule = rec_all.get(pid) or rec_all.get(target["name"])
row_tz = user_tz
if isinstance(rule, dict) and rule.get("timezone"):
row_tz = rule["timezone"]
tzmap = pv.get("__timezone__")
if isinstance(tzmap, dict):
ev_tz = tzmap.get(pid) or tzmap.get(target["name"])
if ev_tz:
row_tz = str(ev_tz)
if rule:
dates = expand_rule(base_value, rule, s, e, max_occurrences=500)
else:
d = parse_date(base_value)
dates = [d.isoformat()] if d and s <= d <= e else []
for iso in dates:
events.append({
"date": iso,
"page_id": r["id"],
"title": r["title"],
"icon": r["icon"],
"recurring": bool(rule),
"time": str(base_value)[11:16] if len(str(base_value)) >= 16 else "",
"timezone": row_tz,
})
events.sort(key=lambda ev: (ev["date"], ev["page_id"]))
return {"events": events, "timezone": user_tz, "property": {"id": target["id"], "name": target["name"]}}
@router.get("/timezones/api")
async def timezones_api(request: Request):
"""API (v5.8.0): the user's timezone plus a picker-friendly zone list."""
user = _current_user(request)
with get_conn() as conn:
row = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
from app.services.recurrence import common_timezones
return {
"timezone": (row["timezone"] if row and "timezone" in row.keys() else "") or "",
"zones": common_timezones(),
}
@router.post("/{collection_id}/property-groups/api")
async def set_property_groups_api(request: Request, collection_id: int):
"""API: (re)assign properties to collapsible groups in the table header.
Body: ``{"groups": [{"name": "Basics", "property_ids": [1, 2]}]}``. Properties
omitted from any group have their group cleared. Empty group names clear.
"""
try:
body = await request.json()
except Exception:
body = {}
groups = body.get("groups", [])
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
conn.execute(
"UPDATE collection_properties SET group_name='' WHERE collection_id=?",
(collection_id,),
)
for grp in groups:
gname = (grp.get("name") or "").strip()
if not gname:
continue
for pid in grp.get("property_ids", []) or []:
conn.execute(
"UPDATE collection_properties SET group_name=? WHERE id=? AND collection_id=?",
(gname, pid, collection_id),
)
conn.commit()
return {"status": "updated"}
@router.post("/{collection_id}/properties/api")
async def create_property_api(request: Request, collection_id: int):
"""API: create a new property on a collection."""
@@ -531,6 +798,7 @@ async def create_property_api(request: Request, collection_id: int):
required = int(body.get("required", False))
visible = int(body.get("visible_in_views", True))
validation_json = json.dumps(body.get("validation", {}))
group_name = (body.get("group_name") or "").strip()
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
@@ -546,16 +814,17 @@ async def create_property_api(request: Request, collection_id: int):
cur = conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, options_json, number_format,
position, required, visible_in_views, validation_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
position, required, visible_in_views, validation_json, group_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(collection_id, name, prop_type, options_json, number_format, max_pos,
required, visible, validation_json),
required, visible, validation_json, group_name),
)
conn.commit()
except Exception:
raise HTTPException(status_code=409, detail=f"Property '{name}' already exists") from None
return {"id": cur.lastrowid, "name": name, "prop_type": prop_type, "status": "created"}
return {"id": cur.lastrowid, "name": name, "prop_type": prop_type,
"group_name": group_name, "status": "created"}
@router.put("/properties/{prop_id}/api")
@@ -582,12 +851,15 @@ async def update_property_api(request: Request, prop_id: int):
validation_json = json.dumps(body.get("validation", {}))
else:
validation_json = existing["validation_json"] if "validation_json" in existing.keys() else "{}"
group_name = body.get("group_name", existing["group_name"] if "group_name" in existing.keys() else "")
conn.execute(
"""UPDATE collection_properties
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?, validation_json=?
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?,
validation_json=?, group_name=?
WHERE id=?""",
(name, options_json, number_format, required, visible, validation_json, prop_id),
(name, options_json, number_format, required, visible, validation_json,
group_name, prop_id),
)
conn.commit()
@@ -812,18 +1084,22 @@ async def update_view_config(request: Request, view_id: int):
raise HTTPException(status_code=404, detail="View not found")
config = json.loads(existing["config_json"])
for key in ("group_by", "card_size", "cover_property", "visible_properties",
"filters", "sorts", "filter_conjunction", "date_property", "date_range_property"):
for key in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property",
"cover_mode", "card_properties", "visible_properties", "filters", "sorts",
"filter_conjunction", "date_property", "date_range_property",
"property_groups", "view_type"):
if key in body:
config[key] = body[key]
new_type = body.get("view_type") or existing["view_type"]
conn.execute(
"UPDATE collection_views SET config_json=?, name=COALESCE(?, name) WHERE id=?",
(json.dumps(config), body.get("name"), view_id),
"UPDATE collection_views SET config_json=?, name=COALESCE(?, name), view_type=?, "
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(config), body.get("name"), new_type, view_id),
)
conn.commit()
return {"id": view_id, "status": "updated", "config": config}
return {"id": view_id, "status": "updated", "config": config, "view_type": new_type}
@router.post("/{collection_id}/views/save-as")
@@ -836,6 +1112,8 @@ async def save_view_as(request: Request, collection_id: int):
name = body.get("name", "New View")
config = body.get("config", {})
user = _current_user(request)
user_id = user.get("id") if user else None
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
@@ -847,26 +1125,90 @@ async def save_view_as(request: Request, collection_id: int):
(collection_id,),
).fetchone()[0]
view_type = body.get("view_type", "table")
cur = conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)",
(collection_id, name, body.get("view_type", "table"), json.dumps(config), max_pos),
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json, position, created_by)
VALUES (?, ?, ?, ?, ?, ?)""",
(collection_id, name, view_type, json.dumps(config), max_pos, user_id),
)
conn.commit()
return {"id": cur.lastrowid, "name": name, "status": "saved"}
return {"id": cur.lastrowid, "name": name, "view_type": view_type,
"config_json": json.dumps(config), "created_by": user_id, "status": "saved"}
@router.get("/{collection_id}/views/api")
async def list_views_api(request: Request, collection_id: int):
"""API: list all views for a collection."""
"""API: list views for a collection visible to the current user.
Shared/legacy views (``created_by IS NULL``) are visible to everyone;
personal views (``created_by = user``) only to their owner.
"""
user = _current_user(request)
user_id = user.get("id") if user else None
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
if user_id is not None:
rows = conn.execute(
"""SELECT * FROM collection_views
WHERE collection_id=? AND (created_by IS NULL OR created_by=?)
ORDER BY position""",
(collection_id, user_id),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? AND created_by IS NULL ORDER BY position",
(collection_id,),
).fetchall()
return {"views": [dict(r) for r in rows]}
@router.delete("/views/{view_id}/api")
async def delete_view_api(request: Request, view_id: int):
"""API: delete a saved view."""
with get_conn() as conn:
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="View not found")
conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,))
conn.commit()
return {"id": view_id, "status": "deleted"}
@router.post("/views/{view_id}/duplicate")
async def duplicate_view_api(request: Request, view_id: int):
"""API: duplicate a view (config + type), owned by the current user."""
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="View not found")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
(existing["collection_id"],),
).fetchone()[0]
user = _current_user(request)
user_id = user.get("id") if user else None
name = body.get("name") or (existing["name"] + " copy")
cur = conn.execute(
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json, position, created_by)
VALUES (?, ?, ?, ?, ?, ?)""",
(existing["collection_id"], name, existing["view_type"],
existing["config_json"], max_pos, user_id),
)
conn.commit()
return {"id": cur.lastrowid, "name": name, "view_type": existing["view_type"],
"status": "duplicated"}
# ── v1.8.0 Sub-items & Dependencies ──
@@ -903,9 +1245,17 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
(page_id,),
).fetchone()[0]
sub_props = body.get("properties", {}) or {}
apply_auto_properties(
_collection_properties(conn, collection_id),
sub_props,
_current_user(request),
is_create=True,
)
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)",
(collection_id, title, page_id, max_pos, json.dumps(body.get("properties", {}))),
(collection_id, title, page_id, max_pos, json.dumps(sub_props)),
)
conn.commit()
new_id = cur.lastrowid
@@ -2036,6 +2386,7 @@ async def create_page_api(request: Request, collection_id: int):
icon = body.get("icon", "📄")
property_values = body.get("properties", {})
cover_url = body.get("cover_url", "")
gitea_issue_id = body.get("gitea_issue_id")
gitea_issue_number = body.get("gitea_issue_number")
@@ -2045,6 +2396,13 @@ async def create_page_api(request: Request, collection_id: int):
raise HTTPException(status_code=404, detail="Collection not found")
_validate_page_properties(conn, collection_id, property_values)
_validate_meta_keys(conn, collection_id, property_values)
apply_auto_properties(
_collection_properties(conn, collection_id),
property_values,
_current_user(request),
is_create=True,
)
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
@@ -2053,9 +2411,9 @@ async def create_page_api(request: Request, collection_id: int):
cur = conn.execute(
"""INSERT INTO collection_pages
(collection_id, title, icon, position, gitea_issue_id, gitea_issue_number, property_values_json)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(collection_id, title, icon, max_pos, gitea_issue_id, gitea_issue_number,
(collection_id, title, icon, cover_url, position, gitea_issue_id, gitea_issue_number, property_values_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(collection_id, title, icon, cover_url, max_pos, gitea_issue_id, gitea_issue_number,
json.dumps(property_values)),
)
conn.commit()
+35 -7
View File
@@ -385,10 +385,17 @@ async def view_page_root(request: Request, page_id: int):
(page["collection_id"],),
).fetchall()
]
cviews = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
(page["collection_id"],),
).fetchall()
]
collection_data = {
"collection": dict(col),
"properties": props,
"pages": cpages,
"views": cviews,
}
page_data["collection_id"] = page["collection_id"]
@@ -2471,10 +2478,18 @@ async def api_collection_table_data(collection_id: int):
).fetchall()
]
views = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
return {
"collection": dict(coll),
"properties": properties,
"pages": pages,
"views": views,
}
@@ -2503,20 +2518,31 @@ async def api_create_collection_page(collection_id: int, request: Request):
).fetchone()[0]
# Load default property values from collection properties
props = conn.execute(
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
props = [
dict(r) for r in conn.execute(
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
default_values = {}
for p in props:
if p["prop_type"] == "title":
default_values[str(p["id"])] = title
# Caller-provided values (e.g. board "add card in column X") win.
incoming = body.get("properties") or {}
if isinstance(incoming, dict):
default_values.update(incoming)
from app.services.property_types import apply_auto_properties
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) or {"login": "admin", "id": 1}
apply_auto_properties(props, default_values, user, is_create=True)
cur = conn.execute(
"""INSERT INTO collection_pages
(collection_id, title, icon, position, property_values_json)
VALUES (?, ?, ?, ?, ?)""",
(collection_id, title, icon, max_pos, _json.dumps(default_values)),
(collection_id, title, icon, cover_url, position, property_values_json)
VALUES (?, ?, ?, ?, ?, ?)""",
(collection_id, title, icon, body.get("cover_url", ""), max_pos,
_json.dumps(default_values)),
)
page_id = cur.lastrowid
conn.commit()
@@ -2524,6 +2550,8 @@ async def api_create_collection_page(collection_id: int, request: Request):
return {
"id": page_id,
"title": title,
"icon": icon,
"position": max_pos,
"property_values_json": default_values,
"status": "created",
}
+27 -1
View File
@@ -97,13 +97,39 @@ async def set_prefs(request: Request):
from app.services import notifications as notif
body = await request.json() if request.headers.get("content-type") else {}
prefs = notif.get_user_prefs(user["id"])
for key in ("comments", "mentions"):
for key in ("comments", "mentions", "reminders", "assignments"):
if key in body:
prefs[key] = bool(body[key])
notif.set_user_prefs(user["id"], prefs)
return {"status": "ok", "prefs": prefs}
@router.get("/timezone")
async def get_timezone(request: Request):
"""Return the current user's IANA timezone ('' = UTC)."""
user = _current_user(request)
with get_conn() as conn:
row = conn.execute("SELECT timezone FROM users WHERE id=?", (user["id"],)).fetchone()
tz = (row["timezone"] if row and "timezone" in row.keys() else "") or ""
from app.services.recurrence import common_timezones
return {"timezone": tz, "zones": common_timezones()}
@router.post("/timezone")
async def set_timezone(request: Request):
"""Update the current user's IANA timezone (empty string = UTC)."""
user = _current_user(request)
body = await request.json() if request.headers.get("content-type") else {}
tz = (body.get("timezone") or "").strip()
from app.services.recurrence import is_valid_timezone
if tz and not is_valid_timezone(tz):
raise HTTPException(status_code=400, detail=f"Unknown timezone '{tz}'")
with get_conn() as conn:
conn.execute("UPDATE users SET timezone=? WHERE id=?", (tz, user["id"]))
conn.commit()
return {"status": "ok", "timezone": tz}
@router.get("/users/search")
async def search_users(request: Request, q: str = ""):
"""User autocomplete for @mentions."""
+3 -1
View File
@@ -95,7 +95,7 @@ SEED_TEMPLATES: list[dict] = [
{
"name": "Meeting notes",
"icon": "🗒️",
"description": "Notes de réunion avec participants et décisions.",
"description": "Notes de réunion avec participants, agenda, notes et actions.",
"schema": [
{"name": "Title", "type": "title"},
{"name": "Date", "type": "date"},
@@ -104,6 +104,8 @@ SEED_TEMPLATES: list[dict] = [
{"name": "Scheduled", "color": "gray"},
{"name": "Done", "color": "green"},
]},
{"name": "Agenda", "type": "text"},
{"name": "Notes", "type": "text"},
{"name": "Action items", "type": "multi_select"},
],
},
+68 -2
View File
@@ -121,13 +121,15 @@ def get_user_prefs(user_id: int, conn=None) -> dict:
"SELECT notification_prefs FROM users WHERE id=?", (user_id,)
).fetchone()
if not row or not row["notification_prefs"]:
return {"comments": True, "mentions": True}
return {"comments": True, "mentions": True, "reminders": True, "assignments": True}
try:
prefs = json.loads(row["notification_prefs"])
except (TypeError, json.JSONDecodeError):
prefs = {}
return {"comments": bool(prefs.get("comments", True)),
"mentions": bool(prefs.get("mentions", True))}
"mentions": bool(prefs.get("mentions", True)),
"reminders": bool(prefs.get("reminders", True)),
"assignments": bool(prefs.get("assignments", True))}
def set_user_prefs(user_id: int, prefs: dict, conn=None) -> dict:
@@ -145,3 +147,67 @@ def set_user_prefs(user_id: int, prefs: dict, conn=None) -> dict:
)
conn.commit()
return prefs
def _person_ids(value) -> list[int]:
"""Extract user ids from a stored ``person`` property value."""
ids: list[int] = []
if isinstance(value, dict):
value = [value]
if isinstance(value, list):
for person in value:
if isinstance(person, dict) and person.get("id"):
try:
ids.append(int(person["id"]))
except (TypeError, ValueError):
pass
return ids
def notify_assignment(collection_id: int, page_id: int, page_title: str,
old_props: dict, new_props: dict, actor_id: int | None,
conn=None) -> list[int]:
"""Notify users newly assigned via a ``person`` property.
Compares old vs new property values keyed by property id; users present
in the new value but not the old one get an in-app + (opt-in) email
notification. Returns the notified user ids.
"""
def _run(c):
props = c.execute(
"SELECT id, name FROM collection_properties "
"WHERE collection_id=? AND prop_type='person'",
(collection_id,),
).fetchall()
newly: list[int] = []
for p in props:
key = str(p["id"])
before = set(_person_ids((old_props or {}).get(key)))
after = _person_ids((new_props or {}).get(key))
for uid in after:
if uid not in before and uid != actor_id and uid not in newly:
newly.append(uid)
for uid in newly:
create_notification(
uid, actor_id, "assignment",
title="Assigned to you",
message=page_title or "Untitled",
resource_type="db_page",
resource_id=page_id,
url=f"/collections/{collection_id}",
conn=c, commit=False,
)
mailer.notify_user(
uid, subject="[FlowDeck] Assigned to you",
body_text=page_title or "Untitled",
cta_url=f"/collections/{collection_id}",
prefs_key="assignments",
)
return newly
if conn is not None:
return _run(conn)
with get_conn() as c:
result = _run(c)
c.commit()
return result
+52 -3
View File
@@ -136,6 +136,9 @@ def validate_property_value(prop_type: str, value: Any, options: list | None = N
datetime.fromisoformat(value.replace("Z", "+00:00"))
except (ValueError, TypeError):
return False, f"'{value}' is not a valid ISO 8601 date"
elif prop_type == "person":
if not isinstance(value, list):
return False, "Person must be a list of workspace members"
elif prop_type == "url":
if not isinstance(value, str):
return False, "URL must be a string"
@@ -227,17 +230,63 @@ def validate_property_rule(
return True, ""
def user_ref(user: dict | None) -> dict | None:
"""Normalize a session user dict into the stored ``person`` value shape."""
if not user:
return None
return {
"id": user.get("id"),
"login": user.get("login") or user.get("full_name") or "",
"full_name": user.get("full_name") or "",
"avatar_url": user.get("avatar_url") or "",
"avatar_color": user.get("avatar_color") or "#3A3A3A",
}
def get_auto_property_value(prop_type: str, user: dict | None = None) -> Any:
"""Compute the value of an auto-property."""
if prop_type == "created_time" or prop_type == "last_edited_time":
return datetime.now(UTC).isoformat()
if prop_type == "created_by" or prop_type == "last_edited_by":
if user:
return {"id": user.get("id"), "login": user.get("login")}
return None
return user_ref(user)
return None
def apply_auto_properties(
properties: list[dict],
values: dict,
user: dict | None = None,
*,
is_create: bool = False,
now: str | None = None,
) -> dict:
"""Fill/refresh auto-property values (``created_time``, ``created_by``,
``last_edited_time``, ``last_edited_by``) in ``values`` (keyed by property id).
``created_*`` are only written on creation (or when missing); ``last_edited_*``
are refreshed on every call. Returns the mutated dict.
"""
if values is None:
values = {}
stamp = now or datetime.now(UTC).isoformat()
for prop in properties or []:
ptype = prop.get("prop_type")
if ptype not in AUTO_TYPES:
continue
pid = str(prop.get("id"))
if ptype == "created_time":
if is_create or pid not in values or values.get(pid) in (None, ""):
values[pid] = stamp
elif ptype == "last_edited_time":
values[pid] = stamp
elif ptype == "created_by":
if is_create or pid not in values or values.get(pid) in (None, ""):
values[pid] = user_ref(user)
elif ptype == "last_edited_by":
values[pid] = user_ref(user)
return values
def get_next_unique_id(collection_id: int, conn) -> int:
"""Get the next unique_id for a collection (max + 1)."""
row = conn.execute(
+273
View File
@@ -0,0 +1,273 @@
"""FlowDeck — Recurring events engine (v5.8.0 Calendrier & Rappels).
A recurring row stores its rule in ``property_values_json`` under the special
key ``__recurrence__``::
"__recurrence__": { "<date_prop_id>": {"freq": "weekly", "interval": 1,
"count": null, "until": null, "byweekday": [0,2,4],
"timezone": "Europe/Paris"} }
Only a RRULE subset is supported (matching the roadmap: daily/weekly/monthly
+ custom interval, COUNT, UNTIL and BYDAY for weekly). Expansion is computed
on the fly for a visible window — occurrences are virtual, never persisted.
All pure-Python (stdlib only) so it is trivially testable.
"""
from __future__ import annotations
import calendar as _cal
import datetime as dt
import logging
logger = logging.getLogger(__name__)
FREQS = ("daily", "weekly", "monthly")
# Monday-first weekday numbering (matches the UI calendar grid).
DOW_NAMES = {"mo": 0, "tu": 1, "we": 2, "th": 3, "fr": 4, "sa": 5, "su": 6}
RECURRENCE_KEY = "__recurrence__"
TIMEZONE_KEY = "__timezone__"
try: # Python >= 3.9 ships zoneinfo; keep a graceful fallback anyway.
from zoneinfo import ZoneInfo, available_timezones
def _zone(tz_name: str):
try:
return ZoneInfo(tz_name)
except Exception:
return None
def is_valid_timezone(tz_name: str) -> bool:
if not tz_name:
return True
try:
return tz_name in available_timezones() or tz_name == "UTC"
except Exception:
return False
except Exception: # pragma: no cover - environment without tz database
def _zone(tz_name: str):
return None
def is_valid_timezone(tz_name: str) -> bool:
return True # no tz database → accept anything (zoneinfo absent)
def parse_date(value) -> dt.date | None:
"""Parse a stored date property value ('YYYY-MM-DD[THH:MM]') into a date."""
s = str(value or "").strip()
if not s:
return None
s = s.replace(" ", "T")
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d"):
try:
return dt.datetime.strptime(s[:19], fmt).date()
except ValueError:
continue
try:
return dt.date.fromisoformat(s[:10])
except ValueError:
return None
def validate_rule(rule) -> tuple[bool, str]:
"""Validate a recurrence rule dict. Returns (ok, error_message)."""
if rule is None:
return True, ""
if not isinstance(rule, dict):
return False, "Recurrence must be an object"
freq = rule.get("freq")
if freq not in FREQS:
return False, f"freq must be one of {FREQS}"
interval = rule.get("interval", 1)
if not isinstance(interval, int) or isinstance(interval, bool) or interval < 1 or interval > 365:
return False, "interval must be an integer between 1 and 365"
count = rule.get("count")
if count is not None:
if not isinstance(count, int) or isinstance(count, bool) or count < 1 or count > 1000:
return False, "count must be an integer between 1 and 1000"
until = rule.get("until")
if until not in (None, "") and parse_date(until) is None:
return False, "until must be a YYYY-MM-DD date"
if count is not None and until not in (None, ""):
return False, "count and until are mutually exclusive"
bywd = rule.get("byweekday")
if bywd not in (None, []):
if not isinstance(bywd, list) or any(
(not isinstance(d, int) or isinstance(d, bool) or d < 0 or d > 6) for d in bywd
):
return False, "byweekday must be a list of integers 0-6 (Monday=0)"
if freq != "weekly":
return False, "byweekday is only valid for weekly recurrence"
tz = rule.get("timezone")
if tz not in (None, "") and not is_valid_timezone(tz):
return False, f"unknown timezone '{tz}'"
return True, ""
def _generate_occurrences(base: dt.date, rule: dict):
"""Yield every occurrence date in chronological order, honouring
freq/interval/count/until/byweekday. Infinite rules yield forever —
the caller must bound the consumption (see expand_rule)."""
freq = rule.get("freq", "daily")
interval = max(1, int(rule.get("interval") or 1))
count = rule.get("count")
until = parse_date(rule.get("until")) if rule.get("until") else None
weekdays = sorted(set(rule.get("byweekday") or [])) if freq == "weekly" else []
produced = 0
def limit_hit(d: dt.date) -> bool:
return (count is not None and produced >= count) or bool(until and d > until)
if freq == "daily":
d = base
while not limit_hit(d):
yield d
produced += 1
d += dt.timedelta(days=interval)
elif freq == "weekly":
# Monday=0 weekday numbering (matches the UI calendar grid).
days = weekdays or [(base.isoweekday() - 1) % 7]
week0 = base - dt.timedelta(days=(base.isoweekday() - 1) % 7)
w = week0
while True:
for off in days:
d = w + dt.timedelta(days=off)
if d < base or limit_hit(d):
continue
yield d
produced += 1
w += dt.timedelta(weeks=interval)
if limit_hit(w):
return
else: # monthly
i = 0
while True:
d = _nth_month_occurrence(base, i * interval)
if limit_hit(d):
return
yield d
produced += 1
i += 1
def expand_rule(base_value, rule: dict, window_start: dt.date, window_end: dt.date,
max_occurrences: int = 500) -> list[str]:
"""Return ISO dates ('YYYY-MM-DD') of occurrences inside the inclusive
window [window_start, window_end] for a rule anchored at ``base_value``.
Occurrences before the window are skipped (but still count against
``count``); the expansion is hard-capped so pathological rules can never
blow up a request.
"""
base = parse_date(base_value)
ok, _err = validate_rule(rule)
if base is None or rule is None or not ok:
return []
out: list[str] = []
scanned = 0
for d in _generate_occurrences(base, rule or {}):
if d > window_end:
break
scanned += 1
if scanned > 200000: # safety valve for old, very long-running rules
break
if d >= window_start:
out.append(d.isoformat())
if len(out) >= max_occurrences:
break
return out
def _nth_month_occurrence(base: dt.date, months_offset: int) -> dt.date:
month_index = (base.year * 12 + (base.month - 1)) + months_offset
year, month0 = divmod(month_index, 12)
month = month0 + 1
day = min(base.day, _cal.monthrange(year, month)[1])
return dt.date(year, month, day)
def now_in_tz(tz_name: str | None = None) -> dt.datetime:
"""Current time in the given IANA timezone (fallback: UTC)."""
zone = _zone(tz_name) if tz_name else None
if zone is not None:
return dt.datetime.now(zone)
return dt.datetime.now(dt.UTC)
def local_date_in_tz(tz_name: str | None = None) -> dt.date:
"""'Today' from the point of view of ``tz_name`` (fallback UTC)."""
return now_in_tz(tz_name).date()
def _zone_dt(d: dt.date, time_str: str, tz_name: str | None):
"""Combine an occurrence date + 'HH:MM' time into an aware datetime in
``tz_name`` (fallback naive→UTC). Default time 09:00 when none stored."""
hh, mm = 9, 0
if time_str and len(time_str) >= 16:
try:
hh, mm = int(time_str[11:13]), int(time_str[14:16])
except ValueError:
pass
zone = _zone(tz_name) if tz_name else None
naive = dt.datetime(d.year, d.month, d.day, hh, mm)
if zone is not None:
return naive.replace(tzinfo=zone)
return naive.replace(tzinfo=dt.UTC)
def next_occurrences(base_value, rule: dict, tz_name: str | None,
after: dt.datetime, horizon_days: int = 365,
max_count: int = 50) -> list[tuple[dt.date, dt.datetime]]:
"""Upcoming occurrences (date, aware event datetime) strictly on/after
``after`` for a row anchored at ``base_value``. Non-recurring rules
(rule falsy) yield the single base occurrence. Bounded by horizon."""
base = parse_date(base_value)
if base is None:
return []
time_str = str(base_value or "")
start = after.date()
if not rule:
event = _zone_dt(base, time_str, tz_name)
return [(base, event)] if event >= after else []
ok, _ = validate_rule(rule)
if not ok:
return []
out: list[tuple[dt.date, dt.datetime]] = []
window_end = start + dt.timedelta(days=horizon_days)
for iso in expand_rule(base_value, rule, start, window_end, max_occurrences=max_count):
d = dt.date.fromisoformat(iso)
event = _zone_dt(d, time_str, tz_name)
if event >= after:
out.append((d, event))
if len(out) >= max_count:
break
return out
# A pragmatic list for pickers (zoneinfo's full list is ~600 entries).
COMMON_TIMEZONES = [
"UTC", "Europe/Paris", "Europe/London", "Europe/Berlin", "Europe/Madrid",
"Europe/Rome", "Europe/Amsterdam", "Europe/Lisbon", "Europe/Zurich",
"Europe/Stockholm", "Europe/Warsaw", "Europe/Moscow", "Europe/Istanbul",
"America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
"America/Toronto", "America/Vancouver", "America/Mexico_City", "America/Sao_Paulo",
"America/Buenos_Aires", "Asia/Dubai", "Asia/Karachi", "Asia/Kolkata",
"Asia/Bangkok", "Asia/Singapore", "Asia/Shanghai", "Asia/Tokyo", "Asia/Seoul",
"Australia/Sydney", "Australia/Perth", "Pacific/Auckland", "Africa/Cairo",
"Africa/Lagos", "Africa/Johannesburg", "Africa/Algiers", "Africa/Casablanca",
]
def common_timezones() -> list[str]:
"""Timezone list for pickers: curated common zones + all installed ones
whose region matches, deduplicated and sorted."""
try:
from zoneinfo import available_timezones
all_zones = sorted(available_timezones())
# Merge: keep curated first, then everything else available.
extra = [z for z in all_zones if z not in COMMON_TIMEZONES]
return COMMON_TIMEZONES + extra
except Exception:
return list(COMMON_TIMEZONES)
+225
View File
@@ -0,0 +1,225 @@
"""FlowDeck — Reminder service (v5.8.0 Calendrier & Rappels).
Reminders live on database rows, next to the recurrence rule, inside
``property_values_json`` under the special key ``__reminder__``::
"__reminder__": { "<date_prop_id>": {"value": 30, "unit": "minutes"} }
A background loop scans rows with reminders, computes the next occurrence
(recurrence-aware, timezone-aware), and fires an in-app notification (+ email
when the user opted in) at ``event - lead``. Fired reminders are deduplicated
through the ``reminder_log`` table keyed by (page_id, occurrence date).
The scan is a plain synchronous function (:func:`scan_and_fire`) so tests can
drive it deterministically with an injected ``now``.
"""
from __future__ import annotations
import datetime as dt
import json
import logging
from app.db import get_conn
from app.services.recurrence import (
RECURRENCE_KEY,
TIMEZONE_KEY,
next_occurrences,
parse_date,
)
logger = logging.getLogger(__name__)
REMINDER_KEY = "__reminder__"
_UNITS = {"minutes": 60, "hours": 3600, "days": 86400}
# Fire late is still useful; ignore events that happened long ago.
FIRE_GRACE_SECONDS = 3600
def parse_lead(reminder) -> int | None:
"""Return the lead in seconds for a reminder dict, or None if disabled."""
if not isinstance(reminder, dict):
return None
unit = reminder.get("unit", "none")
if unit not in _UNITS:
return None
try:
value = int(reminder.get("value", 0))
except (TypeError, ValueError):
return None
if value <= 0:
return None
return value * _UNITS[unit]
def lead_human(reminder: dict) -> str:
try:
value = int(reminder.get("value", 0))
except (TypeError, ValueError):
return ""
unit = reminder.get("unit", "")
if value == 1 and unit.endswith("s"):
unit = unit[:-1]
return f"{value} {unit}"
def _person_targets(conn, collection_id: int, props: dict) -> list[int]:
"""User ids assigned via any ``person`` property of the row."""
targets: list[int] = []
person_props = conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='person'",
(collection_id,),
).fetchall()
for pp in person_props:
value = props.get(str(pp["id"])) or props.get(pp["name"] if "name" in pp.keys() else None)
if isinstance(value, list):
for person in value:
if isinstance(person, dict) and person.get("id"):
targets.append(int(person["id"]))
return targets
def _row_tz(conn, collection_id: int, props: dict) -> str:
"""Effective timezone of a row: per-event ``__timezone__`` first, then the
recurrence rule tz, then the created_by user's personal timezone,
then '' (UTC)."""
tzmap = props.get(TIMEZONE_KEY)
if isinstance(tzmap, dict):
for value in tzmap.values():
if value:
return str(value)
rec = props.get(RECURRENCE_KEY) or {}
for rule in rec.values():
if isinstance(rule, dict) and rule.get("timezone"):
return rule["timezone"]
auto_props = conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='created_by'",
(collection_id,),
).fetchall()
for ap in auto_props:
value = props.get(str(ap["id"]))
people = value if isinstance(value, list) else ([value] if isinstance(value, dict) else [])
for person in people:
if isinstance(person, dict) and person.get("id"):
row = conn.execute(
"SELECT timezone FROM users WHERE id=?", (person["id"],)
).fetchone()
if row and row["timezone"]:
return row["timezone"]
return ""
def scan_and_fire(now: dt.datetime | None = None) -> int:
"""Fire every reminder that is due. Returns the number of notifications
created. Safe to call repeatedly (dedup via reminder_log)."""
now = now or dt.datetime.now(dt.UTC)
fired = 0
with get_conn() as conn:
pages = conn.execute(
"SELECT id, collection_id, title, property_values_json FROM collection_pages"
).fetchall()
for page in pages:
try:
props = json.loads(page["property_values_json"] or "{}")
except (json.JSONDecodeError, TypeError):
continue
reminders = props.get(REMINDER_KEY)
if not isinstance(reminders, dict):
continue
rec_all = props.get(RECURRENCE_KEY) if isinstance(props.get(RECURRENCE_KEY), dict) else {}
for prop_key, reminder in reminders.items():
lead = parse_lead(reminder)
if lead is None:
continue
base_value = props.get(str(prop_key))
if not base_value and str(prop_key).isdigit():
# fall back to name-keyed storage
pname = conn.execute(
"SELECT name FROM collection_properties WHERE id=?", (int(prop_key),)
).fetchone()
if pname:
base_value = props.get(pname["name"])
if parse_date(base_value) is None:
continue
rule = rec_all.get(str(prop_key))
if rule is None and not str(prop_key).isdigit():
pid = conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND name=?",
(page["collection_id"], str(prop_key)),
).fetchone()
if pid:
rule = rec_all.get(str(pid["id"]))
tz_name = _row_tz(conn, page["collection_id"], props)
# Look for the occurrence whose reminder moment is in
# [now - grace, now].
events = next_occurrences(
base_value, rule, tz_name or None,
after=now - dt.timedelta(seconds=lead + FIRE_GRACE_SECONDS),
horizon_days=400, max_count=2000,
)
due = None
for _d, event in events:
fire_at = event - dt.timedelta(seconds=lead)
if fire_at <= now and now < event + dt.timedelta(seconds=FIRE_GRACE_SECONDS):
due = (event, fire_at)
break
if due is None:
continue
event, _fire_at = due
if event < now - dt.timedelta(seconds=FIRE_GRACE_SECONDS):
continue
target_ids = _person_targets(conn, page["collection_id"], props)
if not target_ids:
row = conn.execute(
"SELECT id FROM users WHERE is_admin=1 ORDER BY id LIMIT 1"
).fetchone()
target_ids = [row["id"]] if row else []
for uid in dict.fromkeys(target_ids):
cur = conn.execute(
"INSERT OR IGNORE INTO reminder_log (page_id, occurrence_date) VALUES (?, ?)",
(page["id"], event.date().isoformat()),
)
if cur.rowcount != 1:
continue
from app.services import mailer, notifications
lead_text = lead_human(reminder)
notifications.create_notification(
uid, None, "reminder",
title=f"Reminder: {page['title'] or 'Untitled'}",
message=(f"Due in {lead_text} — {event.strftime('%Y-%m-%d %H:%M')}"
if lead else
f"Happening now — {event.strftime('%Y-%m-%d %H:%M')}"),
resource_type="db_page",
resource_id=page["id"],
url=f"/collections/{page['collection_id']}",
conn=conn, commit=False,
)
mailer.notify_user(
uid,
subject=f"[FlowDeck] Reminder: {page['title'] or 'Untitled'}",
body_text=f"« {page['title'] or 'Untitled'} » le {event.strftime('%Y-%m-%d %H:%M')} ({tz_name or 'UTC'}).",
cta_url=f"/collections/{page['collection_id']}",
prefs_key="reminders",
)
fired += 1
conn.commit()
return fired
async def reminder_scheduler(interval_seconds: int = 60):
"""Background loop: scan for due reminders once a minute."""
import asyncio
from app.config import settings
if not getattr(settings, "reminders_enabled", True):
logger.info("Reminder scheduler disabled via settings")
return
await asyncio.sleep(5)
while True:
try:
n = scan_and_fire()
if n:
logger.info("Reminders: fired %d notification(s)", n)
except Exception as exc: # pragma: no cover - defensive only
logger.warning("Reminder scan failed: %s", exc)
await asyncio.sleep(max(10, int(getattr(settings, "reminder_scan_interval_seconds", interval_seconds))))
File diff suppressed because it is too large Load Diff
+59 -2
View File
@@ -274,6 +274,47 @@
</label>
</div>
</div>
<div class="setting-row">
<div>
<div class="setting-label">Reminders</div>
<div class="setting-desc">Upcoming events &amp; date reminders from your databases</div>
</div>
<div class="setting-control">
<label class="toggle-switch sm">
<input type="checkbox" x-model="notifPrefs.reminders" @change="saveNotifPrefs()">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-row">
<div>
<div class="setting-label">Assignments</div>
<div class="setting-desc">When a database row is assigned to you</div>
</div>
<div class="setting-control">
<label class="toggle-switch sm">
<input type="checkbox" x-model="notifPrefs.assignments" @change="saveNotifPrefs()">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div class="setting-group">
<h3>Timezone</h3>
<div class="setting-row">
<div>
<div class="setting-label">Your timezone</div>
<div class="setting-desc">Used by calendar views (today, week start) and reminder firing. Empty = UTC.</div>
</div>
<div class="setting-control">
<select class="settings-input" style="max-width:260px" x-model="userTimezone" @change="saveTimezone()">
<option value="">UTC</option>
<template x-for="z in userZones" :key="z">
<option :value="z" x-text="z"></option>
</template>
</select>
</div>
</div>
</div>
</div>
@@ -915,7 +956,8 @@ function settingsInit() {
defaultView: localStorage.getItem('fd_default_view') || 'tree',
userInfo: {full_name: '{{ user.full_name or "" }}', login: '{{ user.login or "" }}', email: '{{ user.email or "" }}'},
userIsAdmin: {{ 'true' if user.get('is_admin') else 'false' }},
notifPrefs: {comments: true, mentions: true},
notifPrefs: {comments: true, mentions: true, reminders: true, assignments: true},
userTimezone: '', userZones: [],
// Auth & Integrations
authMethod: '{{ auth_method }}',
giteaLinked: false,
@@ -984,7 +1026,13 @@ function settingsInit() {
try {
var r = await fetch('/api/notifications/prefs', {credentials:'same-origin'});
var d = await r.json();
this.notifPrefs = Object.assign({comments:true, mentions:true}, d.prefs || {});
this.notifPrefs = Object.assign({comments:true, mentions:true, reminders:true, assignments:true}, d.prefs || {});
} catch(e) {}
try {
var tz = await fetch('/api/notifications/timezone', {credentials:'same-origin'});
var td = await tz.json();
this.userTimezone = td.timezone || '';
this.userZones = td.zones || [];
} catch(e) {}
},
async saveNotifPrefs() {
@@ -995,6 +1043,15 @@ function settingsInit() {
});
} catch(e) {}
},
async saveTimezone() {
try {
var r = await fetch('/api/notifications/timezone', {
method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({timezone: this.userTimezone})
});
if (r.ok && typeof toast === 'function') toast('Timezone saved');
} catch(e) {}
},
async loadTags() {
try {