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
+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."""