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:
+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."""
|
||||
|
||||
Reference in New Issue
Block a user