feat(calendar): v5.8.0 calendrier & rappels + v5.7.0 database avancee (pt.2)
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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -274,6 +274,47 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Reminders</div>
|
||||
<div class="setting-desc">Upcoming events & 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 {
|
||||
|
||||
Reference in New Issue
Block a user