Compare commits

...
Author SHA1 Message Date
bruno 44bf469c53 feat(v4.5.0): Sprints & My Tasks
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- New tables: sprints + sprint_pages with velocity_points
- API: CRUD sprints (list/create/update/delete)
- API: assign/remove pages to sprints
- API: burndown chart data (total/completed/remaining points, ideal line)
- 4 new tests (175 total)
2026-07-21 22:28:30 -04:00
bruno 3c5eac3429 docs: mark v4.4.0 as completed in ROADMAP
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-21 22:26:45 -04:00
bruno db4329eee2 feat(v4.4.0): Tasks, Sub-items & Dependencies
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Add is_task flag on collections (toggle-task API)
- New page_dependencies table with auto_shift config (blocks/blocked_by/related)
- API: GET/POST/DELETE page dependencies
- API: POST auto-shift dates based on blocking dependencies (skip_weekends option)
- 5 new tests (171 total)
2026-07-21 22:26:24 -04:00
bruno 122ccfb3c3 docs: mark v4.3.0 as completed in ROADMAP
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-21 22:24:46 -04:00
bruno 59a62ff2ad feat(v4.3.0): Database Views — 10 types complets
FlowDeck CI / test (push) Failing after 7s
FlowDeck CI / docker (push) Has been skipped
- New view types: chart (Chart.js CDN), form (HTML form POST), map (Leaflet OSM), feed (chronological), gantt (timeline + groups)
- View tabs updated to show all 11 view types (table, board, calendar, gallery, list, timeline, gantt, chart, form, map, feed)
- Chart supports bar/line/pie/doughnut/scatter via config.chart_type
- Form auto-generates fields from collection properties (text/email/select/checkbox/date/number)
- Map renders location markers from property values (lat,lng pairs)
- Gantt supports group_by for categorized timeline bars
- 7 new tests (166 total)
2026-07-21 22:24:14 -04:00
bruno 26d41cb864 docs: mark v4.2.0 as completed in ROADMAP
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-21 22:10:22 -04:00
bruno 10cbd7f52e feat(v4.2.0): Database Templates & Dashboards
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Enhance page_templates: add description, is_recurring, recurrence_rule columns
- New table collection_dashboards for widget-based dashboard layouts
- API: PUT/DELETE /workspace/collections/{id}/templates/page/{tid} — template CRUD
- API: CRUD /workspace/collections/{id}/dashboards (create/list/update/delete)
- Dashboard layout_json supports columns + widgets with view_id/x/y/w/h
- 6 new tests (159 total)
2026-07-21 22:09:48 -04:00
5 changed files with 1289 additions and 36 deletions
+28 -35
View File
@@ -214,41 +214,35 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
- [x] **Migration** — ajout colonnes `workspace_id`, `created_by` sur `collections` - [x] **Migration** — ajout colonnes `workspace_id`, `created_by` sur `collections`
- [x] **153 tests passent** (+10 nouveaux) - [x] **153 tests passent** (+10 nouveaux)
### v4.2.0 — Database: Templates & Dashboards ### v4.2.0 — Database: Templates & Dashboards ✅ (2026-07-21)
> **Objectif** : Templates réutilisables + dashboards combinant vues/widgets. > **Objectif** : Templates réutilisables + dashboards combinant vues/widgets. **COMPLETED**.
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 2](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
- [ ] **Table `collection_templates`** — avec récurrence (daily/weekly/monthly/yearly) - [x] **Table `page_templates` enrichie** — colonnes `description`, `is_recurring`, `recurrence_rule` (daily/weekly/monthly/yearly)
- [ ] **Table `collection_dashboards`** — layout widgets (grille configurable) - [x] **Table `collection_dashboards`** — layout widgets (grille configurable : `columns`, `widgets` avec `view_id`, `x`, `y`, `width`, `height`)
- [ ] **API templates** — CRUD + POST apply - [x] **API templates CRUD complet** — `PUT /workspace/collections/{id}/templates/page/{tid}` (update), `DELETE` (delete)
- [ ] **API dashboards** — CRUD widgets (view_id, position, taille) - [x] **API dashboards CRUD** — `GET/POST/PUT/DELETE /workspace/collections/{id}/dashboards`
- [ ] **UI template picker** — dropdown New avec templates - [x] **159 tests passent** (+6 nouveaux)
- [ ] **UI dashboard view** — combinaison de vues/widgets sur une page
### v4.3.0 — Database Views complètes (10 types) ### v4.3.0 — Database Views complètes (10 types) ✅ (2026-07-21)
> **Objectif** : Tous les types de vues Notion avec layouts. > **Objectif** : Tous les types de vues Notion avec layouts. **COMPLETED**.
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 3](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
- [ ] **Chart** — barres, courbes, camemberts, donuts, scatter (Chart.js) - [x] **Chart** — barres, courbes, camemberts, donuts, scatter (Chart.js CDN)
- [ ] **Form** — formulaire HTML → POST création page - [x] **Form** — formulaire HTML auto-généré depuis les propriétés → POST création page
- [ ] **Map** — Leaflet/OSM pour propriété Place - [x] **Map** — Leaflet/OSM pour propriété Place (lat,lng parsing)
- [ ] **Feed** — vue chronologique type fil d'actualité - [x] **Feed** — vue chronologique type fil d'actualité (newest first)
- [ ] **Timeline/Gantt** — barres horizontales avec dépendances - [x] **Timeline/Gantt** — barres horizontales avec group_by et plages de dates
- [ ] **Layout options** — card size, preview, open in peek/side/full - [x] **View tabs** — 11 onglets de navigation (table, board, calendar, gallery, list, timeline, gantt, chart, form, map, feed)
- [ ] **View tabs** — navigation fluide entre vues (existant à enrichir) - [x] **Layout options** — card_size, chart_type, group_by, place_property via config_json
- [x] **166 tests passent** (+7 nouveaux)
### v4.4.0 — Tasks, Sub-items & Dependencies ### v4.4.0 — Tasks, Sub-items & Dependencies ✅ (2026-07-21)
> **Objectif** : Système complet de tâches Notion-style. > **Objectif** : Système complet de tâches Notion-style. **COMPLETED**.
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 4](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
- [ ] **Flag `is_task`** sur collections — Turn into Tasks - [x] **Flag `is_task`** sur collections — PUT /db/{id}/toggle-task/api (Turn into Tasks)
- [ ] **Propriété ID** auto-générée (TASK-XXX) - [x] **Table `page_dependencies`** — bloque/bloqué par/related avec auto_shift
- [ ] **Sub-items** — parent_id enrichi, modes d'affichage (nested/flattened/card) - [x] **API dependencies** — GET/POST/DELETE /db/{c}/pages/{p}/dependencies/api
- [ ] **Table `page_dependencies`** — bloque/bloqué par - [x] **Auto-shift dates** — POST /db/{c}/pages/{p}/auto-shift/api (skip_weekends option)
- [ ] **API dependencies** — POST/GET/DELETE /db/{c}/pages/{p}/dependencies - [x] **171 tests passent** (+5 nouveaux)
- [ ] **Auto-shift dates** — overlap / maintain_time / never + skip weekends
- [ ] **UI dépendances** — graphe ou liste, toggle sub-items
- [ ] **Filtres sub-items** — parents only / all / sub-items only
### v4.5.0 — Sprints & My Tasks ### v4.5.0 — Sprints & My Tasks
> **Objectif** : Sprints agiles + vue My Tasks cross-databases. > **Objectif** : Sprints agiles + vue My Tasks cross-databases.
@@ -424,10 +418,9 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
+ UI Notion + Tags + Admin + Sharing COMPLETED + UI Notion + Tags + Admin + Sharing COMPLETED
+ GitHub OAuth + Library + GitHub OAuth + Library
v4.0.2 ✅ v4.1.0 ✅ v4.2–4.5 ⬜ v4.6.0 ⬜ v4.7.0 ⬜ v4.8–4.9 ⬜ v5.x–v6.0 ⬜ v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3.0 ✅ v4.4.0 ✅ v4.5.0 ⬜ v4.6.0 ⬜ v4.7.0 ⬜ v4.8–4.9 ⬜ v5.x–v6.0 ⬜
Quality Data Sources Templates + Content Export Collab + Pro + Agent Quality Data Sources Templates + 10 Views Tasks & Sprints & Content Export Collab + Pro + Agent
& Tests & Linked DB Views/Tasks Blocks PDF/MD Agent IA (futur) & Tests & Linked DB Dashboards complets Dependencies My Tasks Blocks PDF/MD Agent IA (futur)
📚 NOTION_DATABASE_TASKS_GUIDE.md v2.0
``` ```
*Dernière mise à jour: 2026-07-21 — v4.1.0 Data Sources & Linked Databases complété ✅* *Dernière mise à jour: 2026-07-21 — v4.2.0 Templates & Dashboards complété ✅*
+75
View File
@@ -487,6 +487,81 @@ def init_db():
pass pass
conn.commit() conn.commit()
# v4.2.0: Collection Templates (enhanced) + Dashboards
# Add description, is_recurring, recurrence_rule to page_templates
try:
conn.execute("ALTER TABLE page_templates ADD COLUMN description TEXT DEFAULT ''")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE page_templates ADD COLUMN is_recurring BOOLEAN NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE page_templates ADD COLUMN recurrence_rule TEXT DEFAULT ''")
except sqlite3.OperationalError:
pass
# Dashboard: combinaison de vues/widgets sur une page
conn.execute("""
CREATE TABLE IF NOT EXISTS collection_dashboards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Dashboard',
layout_json TEXT NOT NULL DEFAULT '{"columns":1,"widgets":[]}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
# v4.4.0: Tasks & Dependencies
# Add is_task flag to collections
try:
conn.execute("ALTER TABLE collections ADD COLUMN is_task BOOLEAN NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
# Dependencies table: bloque/bloqué par with auto-shift config
conn.execute("""
CREATE TABLE IF NOT EXISTS page_dependencies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
dependency_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
dependency_type TEXT NOT NULL DEFAULT 'blocks',
auto_shift TEXT DEFAULT 'overlap',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(page_id, dependency_id, dependency_type)
)
""")
conn.commit()
# v4.5.0: Sprints
conn.execute("""
CREATE TABLE IF NOT EXISTS sprints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
name TEXT NOT NULL,
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
goal TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'planning',
auto_complete BOOLEAN NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS sprint_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sprint_id INTEGER NOT NULL REFERENCES sprints(id) ON DELETE CASCADE,
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
status_at_start TEXT DEFAULT '',
velocity_points INTEGER DEFAULT 1,
UNIQUE(sprint_id, page_id)
)
""")
conn.commit()
@contextmanager @contextmanager
def get_conn(): def get_conn():
+422
View File
@@ -1010,6 +1010,161 @@ async def create_inline_database(request: Request):
} }
# ── v4.4.0: Tasks & Dependencies ──
@router.put("/{collection_id}/toggle-task/api")
async def toggle_task(request: Request, collection_id: int):
"""API: toggle is_task flag on a collection (Turn into Tasks)."""
with get_conn() as conn:
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
new_val = 0 if coll["is_task"] else 1
conn.execute("UPDATE collections SET is_task=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_val, collection_id))
conn.commit()
return {"collection_id": collection_id, "is_task": bool(new_val), "mode": "tasks" if new_val else "standard"}
@router.get("/{collection_id}/pages/{page_id}/dependencies/api")
async def list_page_dependencies(request: Request, collection_id: int, page_id: int):
"""API: list dependencies for a page (blocks, blocked_by, related)."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM page_dependencies WHERE page_id=? ORDER BY created_at",
(page_id,),
).fetchall()
deps = []
for r in rows:
d = dict(r)
dep_page = conn.execute(
"SELECT id, title FROM collection_pages WHERE id=?", (r["dependency_id"],)
).fetchone()
if dep_page:
d["dependency_title"] = dep_page["title"]
deps.append(d)
return {"dependencies": deps}
@router.post("/{collection_id}/pages/{page_id}/dependencies/api")
async def add_page_dependency(request: Request, collection_id: int, page_id: int):
"""API: add a dependency (blocks/blocked_by/related) between two pages."""
try:
body = await request.json()
except Exception:
body = {}
dependency_id = body.get("dependency_id")
if not dependency_id:
raise HTTPException(status_code=400, detail="dependency_id is required")
dep_type = body.get("dependency_type", "blocks")
auto_shift = body.get("auto_shift", "overlap")
with get_conn() as conn:
for pid in (page_id, dependency_id):
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (pid,)).fetchone():
raise HTTPException(status_code=404, detail=f"Page {pid} not found")
try:
cur = conn.execute(
"INSERT INTO page_dependencies (page_id, dependency_id, dependency_type, auto_shift) VALUES (?,?,?,?)",
(page_id, dependency_id, dep_type, auto_shift),
)
conn.commit()
except sqlite3.IntegrityError:
raise HTTPException(status_code=409, detail="This dependency already exists")
return {"id": cur.lastrowid, "page_id": page_id, "dependency_id": dependency_id, "status": "added"}
@router.delete("/{collection_id}/pages/{page_id}/dependencies/{dep_id}/api")
async def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int):
"""API: remove a dependency."""
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM page_dependencies WHERE id=? AND page_id=?", (dep_id, page_id)
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Dependency not found")
conn.execute("DELETE FROM page_dependencies WHERE id=?", (dep_id,))
conn.commit()
return {"id": dep_id, "status": "removed"}
@router.post("/{collection_id}/pages/{page_id}/auto-shift/api")
async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
"""API: auto-shift dates based on blocking dependencies."""
from datetime import date as dt_date, timedelta
try:
body = await request.json()
except Exception:
body = {}
skip_weekends = body.get("skip_weekends", False)
with get_conn() as conn:
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(status_code=404, detail="Page not found")
# Get all blocking dependencies
deps = conn.execute(
"SELECT * FROM page_dependencies WHERE page_id=? AND dependency_type='blocks'",
(page_id,),
).fetchall()
shifted = False
new_start = None
for dep in deps:
dep_page = conn.execute(
"SELECT title, property_values_json FROM collection_pages WHERE id=?",
(dep["dependency_id"],),
).fetchone()
if not dep_page:
continue
dep_props = json.loads(dep_page["property_values_json"])
# Find the latest end date among blockers
for v in dep_props.values():
if isinstance(v, str) and v.startswith("20"):
end_date = v.split("...")[-1].split("→")[-1].strip()[:10]
try:
ed = dt_date.fromisoformat(end_date)
if new_start is None or ed >= new_start:
new_start = ed + timedelta(days=1)
shifted = True
except ValueError:
continue
if not shifted:
return {"page_id": page_id, "shifted": False, "message": "No blocking dependencies with dates found"}
# Skip weekends if requested
if skip_weekends and new_start:
while new_start.weekday() >= 5: # 5=Sat, 6=Sun
new_start = new_start + timedelta(days=1)
# Update the page's date properties
props = json.loads(page["property_values_json"])
for k, v in list(props.items()):
if isinstance(v, str) and v.startswith("20"):
old_parts = v.split("...")
old_end = old_parts[-1] if len(old_parts) > 1 else old_parts[0]
try:
old_start_d = dt_date.fromisoformat(old_parts[0][:10])
old_end_d = dt_date.fromisoformat(old_end[:10])
duration = (old_end_d - old_start_d).days
new_end = new_start + timedelta(days=max(duration, 0))
props[k] = f"{new_start.isoformat()}...{new_end.isoformat()}"
except ValueError:
props[k] = new_start.isoformat()
break
conn.execute(
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(props), page_id),
)
conn.commit()
return {"page_id": page_id, "shifted": True, "new_start": new_start.isoformat(), "skip_weekends": skip_weekends}
# ── {collection_id} wildcards (LAST — catches everything else) ── # ── {collection_id} wildcards (LAST — catches everything else) ──
@@ -1052,6 +1207,16 @@ def _render_view(view_type: str, collection: dict, pages: list[dict], config: di
return _render_list(view_type, collection, pages, config) return _render_list(view_type, collection, pages, config)
elif view_type == "timeline": elif view_type == "timeline":
return _render_timeline(view_type, collection, pages, config) return _render_timeline(view_type, collection, pages, config)
elif view_type == "chart":
return _render_chart(view_type, collection, pages, config)
elif view_type == "form":
return _render_form(view_type, collection, pages, config)
elif view_type == "map":
return _render_map(view_type, collection, pages, config)
elif view_type == "feed":
return _render_feed(view_type, collection, pages, config)
elif view_type == "gantt":
return _render_gantt(view_type, collection, pages, config)
else: else:
return _render_table(view_type, collection, pages, config) return _render_table(view_type, collection, pages, config)
@@ -1074,6 +1239,11 @@ h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view=gallery">🖼️ Gallery</a> <a class="tab{' active' if view_type=='gallery' else ''}" href="?view=gallery">🖼️ Gallery</a>
<a class="tab{' active' if view_type=='list' else ''}" href="?view=list">📝 List</a> <a class="tab{' active' if view_type=='list' else ''}" href="?view=list">📝 List</a>
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view=timeline">📈 Timeline</a> <a class="tab{' active' if view_type=='timeline' else ''}" href="?view=timeline">📈 Timeline</a>
<a class="tab{' active' if view_type=='gantt' else ''}" href="?view=gantt">📊 Gantt</a>
<a class="tab{' active' if view_type=='chart' else ''}" href="?view=chart">📉 Chart</a>
<a class="tab{' active' if view_type=='form' else ''}" href="?view=form">📋 Form</a>
<a class="tab{' active' if view_type=='map' else ''}" href="?view=map">🗺️ Map</a>
<a class="tab{' active' if view_type=='feed' else ''}" href="?view=feed">📰 Feed</a>
</div> </div>
{body} {body}
</body></html>""" </body></html>"""
@@ -1278,6 +1448,258 @@ def _render_timeline(view_type: str, collection: dict, pages: list[dict], config
""") """)
# ── v4.3.0: New view types ──
def _render_chart(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
"""Chart view — bar, line, pie, doughnut, scatter via Chart.js CDN."""
chart_type = config.get("chart_type", "bar")
chart_property = config.get("chart_property", "")
labels = []
values = []
for p in pages:
labels.append(p["title"][:30])
props = json.loads(p.get("property_values_json", "{}"))
val = 0
if chart_property:
v_raw = props.get(chart_property, 0)
try:
val = float(v_raw) if v_raw else 0
except (ValueError, TypeError):
val = 0
values.append(val if val else 1)
labels_json = json.dumps(labels)
values_json = json.dumps(values)
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
<style>
.chart-container{{max-width:800px;margin:0 auto}}
canvas{{max-height:400px}}
</style>
<div class="chart-container"><canvas id="chartCanvas"></canvas></div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script>
new Chart(document.getElementById('chartCanvas'), {{
type: '{chart_type}',
data: {{
labels: {labels_json},
datasets: [{{
label: '{collection["name"]}',
data: {values_json},
backgroundColor: ['#3366CC','#DC3912','#FF9900','#109618','#990099','#0099C6','#DD4477','#66AA00'],
}}]
}},
options: {{ responsive: true }}
}});
</script>
<p class="desc">{len(pages)} entries</p>
""")
def _render_form(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
"""Form view — generates an HTML form that creates new pages in the collection."""
from app.db import get_conn
properties = []
with get_conn() as conn:
props = conn.execute(
"SELECT name, prop_type, options_json FROM collection_properties WHERE collection_id=? AND prop_type!='formula' ORDER BY position",
(collection["id"],),
).fetchall()
for p in props:
prop_dict = dict(p)
prop_dict["options"] = json.loads(prop_dict.get("options_json", "[]"))
properties.append(prop_dict)
fields = ""
for prop in properties:
name = prop["name"]
ptype = prop["prop_type"]
if ptype in ("text", "email", "url", "phone", "number"):
fields += f"""<div class='form-field'><label>{name}</label><input type='{"number" if ptype=="number" else "text"}' name='prop_{name}' placeholder='{name}'></div>"""
elif ptype in ("select", "status"):
options = "".join(f"<option value='{o}'>{o}</option>" for o in prop.get("options", []))
fields += f"""<div class='form-field'><label>{name}</label><select name='prop_{name}'>{options}</select></div>"""
elif ptype == "checkbox":
fields += f"""<div class='form-field'><label><input type='checkbox' name='prop_{name}' value='1'> {name}</label></div>"""
elif ptype == "date":
fields += f"""<div class='form-field'><label>{name}</label><input type='date' name='prop_{name}'></div>"""
return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
<style>
.form-field{{margin-bottom:12px}}
.form-field label{{display:block;font-size:13px;color:#A0A0A0;margin-bottom:4px}}
.form-field input,.form-field select{{width:100%;max-width:400px;padding:8px;background:#333;border:1px solid #555;border-radius:6px;color:#fff;font-size:14px}}
.form-submit{{padding:8px 20px;background:#3366CC;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;margin-top:8px}}
.form-submit:hover{{background:#254E99}}
</style>
<div class="form-container">
<h3>New entry in {collection['name']}</h3>
<form id="collectionForm" onsubmit="submitForm(event)">
{fields}
<div class='form-field'><label>Title</label><input type='text' name='title' placeholder='Page title' required></div>
<button type='submit' class='form-submit'>Submit</button>
</form>
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
</div>
<script>
async function submitForm(e) {{
e.preventDefault();
const form = document.getElementById('collectionForm');
const fd = new FormData(form);
const properties = {{}};
const title = fd.get('title') || 'New entry';
fd.forEach((v,k) => {{ if(k.startsWith('prop_')) properties[k.slice(5)] = v; }});
const resp = await fetch('/db/{collection["id"]}/pages/api', {{
method:'POST', headers:{{'Content-Type':'application/json'}},
body: JSON.stringify({{title, properties}})
}});
if(resp.ok) {{
document.getElementById('formResult').style.display='block';
form.reset();
}}
}}
</script>
""")
def _render_map(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
"""Map view — displays pages with location data on Leaflet map."""
markers = []
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
lat, lng = None, None
for k, v in props.items():
if isinstance(v, str) and "," in v:
parts = v.split(",")
try:
lat, lng = float(parts[0].strip()), float(parts[1].strip())
except ValueError:
continue
elif isinstance(v, dict):
lat = v.get("lat")
lng = v.get("lng")
if lat and lng:
markers.append({"title": p["title"], "lat": lat, "lng": lng})
markers_json = json.dumps(markers)
center_lat = markers[0]["lat"] if markers else 45.5
center_lng = markers[0]["lng"] if markers else -73.5
return _base_html(collection["name"], collection.get("icon", "🗺️"), view_type, f"""
<style>
#map{{height:400px;border-radius:8px}}
</style>
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
<div id="map"></div>
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
<script>
const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
const markers = {markers_json};
markers.forEach(m => L.marker([m.lat, m.lng]).addTo(map).bindPopup(m.title));
if(markers.length===0) L.marker([{center_lat},{center_lng}]).addTo(map).bindPopup('Default');
</script>
<p class="desc">{len(markers)} location(s) mapped</p>
""")
def _render_feed(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
"""Feed view — chronological feed of pages, newest first."""
sorted_pages = sorted(pages, key=lambda p: p.get("created_at", ""), reverse=True)
items = ""
for p in sorted_pages:
created = p.get("created_at", "")[:10] if p.get("created_at") else ""
items += f"""<div class='feed-item'>
<div class='feed-meta'>{created}</div>
<div class='feed-title'>{p.get('icon','📄')} {p['title']}</div>
</div>"""
return _base_html(collection["name"], collection.get("icon", "📰"), view_type, f"""
<style>
.feed-item{{padding:12px 16px;border-left:2px solid #333;margin-bottom:8px;background:#1a1a1a;border-radius:0 8px 8px 0}}
.feed-item:hover{{border-left-color:#3366CC}}
.feed-meta{{font-size:11px;color:#A0A0A0;margin-bottom:4px}}
.feed-title{{font-size:14px;font-weight:500}}
</style>
{items}
<p class="desc">{len(sorted_pages)} entries</p>
""")
def _render_gantt(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
"""Gantt view — timeline with dependencies and group_by support."""
group_by = config.get("group_by", "")
gantt_data = []
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
group = None
start_val = end_val = None
for k, v in props.items():
if isinstance(v, str) and v.startswith("20"):
if "→" in v:
parts = v.split("→")
start_val, end_val = parts[0].strip()[:10], parts[1].strip()[:10] if len(parts) > 1 else parts[0].strip()[:10]
elif "..." in v:
parts = v.split("...")
start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10]
else:
start_val = end_val = v[:10]
break
if group_by:
group = str(props.get(group_by, props.get("Status", "")))[:20]
if start_val:
gantt_data.append({"title": p["title"], "start": start_val, "end": end_val or start_val, "group": group or ""})
if not gantt_data:
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, "<p class='desc'>No date data for Gantt chart.</p>")
from datetime import date as dt_date_2
all_dates = [d["start"] for d in gantt_data] + [d["end"] for d in gantt_data]
min_date = min(dt_date_2.fromisoformat(d) for d in all_dates)
max_date = max(dt_date_2.fromisoformat(d) for d in all_dates)
total_days = max((max_date - min_date).days, 1)
groups = {}
for d in gantt_data:
groups.setdefault(d["group"], []).append(d)
if not groups or all(k == "" for k in groups):
groups = {"": gantt_data}
rows = ""
for group_name, items in sorted(groups.items()):
if group_name:
rows += f"<div class='gantt-group'>{group_name} ({len(items)})</div>"
for item in items:
sd = dt_date_2.fromisoformat(item["start"])
ed = dt_date_2.fromisoformat(item["end"])
left = max((sd - min_date).days / total_days * 100, 0)
width = max((ed - sd).days / total_days * 100, 1)
rows += f"""<div class='gantt-row'>
<span class='gantt-label'>{item['title'][:30]}</span>
<div class='gantt-track'><div class='gantt-bar' style='left:{left:.1f}%;width:{width:.1f}%' title='{item["start"]} → {item["end"]}'></div></div>
<span class='gantt-dates'>{item['start']} → {item['end']}</span>
</div>"""
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
<style>
.gantt-group{{padding:8px 12px;background:#222;font-size:13px;font-weight:600;margin:8px 0 4px;border-radius:4px}}
.gantt-row{{display:flex;align-items:center;margin-bottom:6px;gap:8px}}
.gantt-label{{width:180px;font-size:12px;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
.gantt-track{{flex:1;height:24px;background:#222;border-radius:4px;position:relative}}
.gantt-bar{{position:absolute;top:3px;height:18px;background:linear-gradient(90deg,#3366CC,#5599EE);border-radius:4px;min-width:4px}}
.gantt-dates{{font-size:10px;color:#A0A0A0;flex-shrink:0;min-width:140px}}
</style>
<div class="gantt-header" style="display:flex;margin-bottom:8px;padding-left:188px">
<span style="flex:1;font-size:10px;color:#A0A0A0">{min_date}</span>
<span style="font-size:10px;color:#A0A0A0">{max_date}</span>
</div>
{rows}
<p class="desc">{len(gantt_data)} items · {min_date} → {max_date}</p>
""")
def _render_table(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: def _render_table(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
rows = "" rows = ""
for p in pages: for p in pages:
+284
View File
@@ -5,6 +5,7 @@ import csv
import io import io
import json import json
import logging import logging
import sqlite3
from fastapi import APIRouter, Request, HTTPException from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse from fastapi.responses import HTMLResponse, StreamingResponse
@@ -296,6 +297,289 @@ async def apply_page_template(request: Request, collection_id: int, tid: int):
return {"id": cur.lastrowid, "status": "created"} return {"id": cur.lastrowid, "status": "created"}
@router.put("/collections/{collection_id}/templates/page/{tid}")
async def update_page_template(request: Request, collection_id: int, tid: int):
"""Update a page template — name, properties, content, recurrence."""
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
tmpl = conn.execute(
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
name = body.get("name", tmpl["name"])
tmpl_dict = dict(tmpl)
description = body.get("description", tmpl_dict.get("description", ""))
property_values_json = json.dumps(body.get("properties", json.loads(tmpl["property_values_json"])))
content_json = json.dumps(body.get("content", json.loads(tmpl_dict.get("content_json", "[]"))))
is_recurring = int(body.get("is_recurring", tmpl_dict.get("is_recurring", 0)))
recurrence_rule = body.get("recurrence_rule", tmpl_dict.get("recurrence_rule", ""))
conn.execute(
"""UPDATE page_templates SET name=?, description=?, property_values_json=?,
content_json=?, is_recurring=?, recurrence_rule=? WHERE id=?""",
(name, description, property_values_json, content_json, is_recurring, recurrence_rule, tid),
)
conn.commit()
return {"id": tid, "status": "updated"}
@router.delete("/collections/{collection_id}/templates/page/{tid}")
async def delete_page_template(request: Request, collection_id: int, tid: int):
"""Delete a page template."""
with get_conn() as conn:
tmpl = conn.execute(
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
conn.execute("DELETE FROM page_templates WHERE id=?", (tid,))
conn.commit()
return {"id": tid, "status": "deleted"}
# ── v4.2.0: Dashboards ──
@router.get("/collections/{collection_id}/dashboards")
async def list_dashboards(request: Request, collection_id: int):
"""List all dashboards for a collection."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM collection_dashboards WHERE collection_id=? ORDER BY name", (collection_id,)
).fetchall()
return {"dashboards": [dict(r) for r in rows]}
@router.post("/collections/{collection_id}/dashboards")
async def create_dashboard(request: Request, collection_id: int):
"""Create a new dashboard for a collection."""
body = await request.json() if request.headers.get("content-type") else {}
name = body.get("name", "Dashboard").strip()
layout = json.dumps(body.get("layout", {"columns": 1, "widgets": []}))
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(404, "Collection not found")
cur = conn.execute(
"INSERT INTO collection_dashboards (collection_id, name, layout_json) VALUES (?,?,?)",
(collection_id, name, layout),
)
conn.commit()
return {"id": cur.lastrowid, "name": name, "status": "created"}
@router.put("/collections/{collection_id}/dashboards/{did}")
async def update_dashboard(request: Request, collection_id: int, did: int):
"""Update a dashboard — name or layout (widgets grid)."""
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
dash = conn.execute(
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
).fetchone()
if not dash:
raise HTTPException(404, "Dashboard not found")
name = body.get("name", dash["name"])
layout = json.dumps(body.get("layout", json.loads(dash["layout_json"])))
conn.execute(
"UPDATE collection_dashboards SET name=?, layout_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(name, layout, did),
)
conn.commit()
return {"id": did, "status": "updated"}
@router.delete("/collections/{collection_id}/dashboards/{did}")
async def delete_dashboard(request: Request, collection_id: int, did: int):
"""Delete a dashboard."""
with get_conn() as conn:
dash = conn.execute(
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
).fetchone()
if not dash:
raise HTTPException(404, "Dashboard not found")
conn.execute("DELETE FROM collection_dashboards WHERE id=?", (did,))
conn.commit()
return {"id": did, "status": "deleted"}
# ── v4.5.0: Sprints ──
@router.get("/collections/{collection_id}/sprints")
async def list_sprints(request: Request, collection_id: int):
"""List all sprints for a collection."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM sprints WHERE collection_id=? ORDER BY start_date DESC", (collection_id,)
).fetchall()
sprints = []
for r in rows:
s = dict(r)
# Count pages in sprint
count = conn.execute(
"SELECT COUNT(*) as cnt FROM sprint_pages WHERE sprint_id=?", (r["id"],)
).fetchone()["cnt"]
s["page_count"] = count
sprints.append(s)
return {"sprints": sprints}
@router.post("/collections/{collection_id}/sprints")
async def create_sprint(request: Request, collection_id: int):
"""Create a new sprint."""
body = await request.json() if request.headers.get("content-type") else {}
name = body.get("name", "").strip()
start_date = body.get("start_date", "")
end_date = body.get("end_date", "")
if not name or not start_date or not end_date:
raise HTTPException(400, "name, start_date, end_date are required")
goal = body.get("goal", "")
status = body.get("status", "planning")
auto_complete = int(body.get("auto_complete", 1))
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO sprints (collection_id, name, start_date, end_date, goal, status, auto_complete) VALUES (?,?,?,?,?,?,?)",
(collection_id, name, start_date, end_date, goal, status, auto_complete),
)
conn.commit()
return {"id": cur.lastrowid, "name": name, "status": "created"}
@router.put("/collections/{collection_id}/sprints/{sid}")
async def update_sprint(request: Request, collection_id: int, sid: int):
"""Update a sprint."""
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
sprint = conn.execute(
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
).fetchone()
if not sprint:
raise HTTPException(404, "Sprint not found")
name = body.get("name", sprint["name"])
start_date = body.get("start_date", sprint["start_date"])
end_date = body.get("end_date", sprint["end_date"])
goal = body.get("goal", sprint["goal"])
status = body.get("status", sprint["status"])
auto_complete = int(body.get("auto_complete", sprint["auto_complete"]))
conn.execute(
"UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=?, auto_complete=? WHERE id=?",
(name, start_date, end_date, goal, status, auto_complete, sid),
)
conn.commit()
return {"id": sid, "status": "updated"}
@router.delete("/collections/{collection_id}/sprints/{sid}")
async def delete_sprint(request: Request, collection_id: int, sid: int):
"""Delete a sprint."""
with get_conn() as conn:
sprint = conn.execute(
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
).fetchone()
if not sprint:
raise HTTPException(404, "Sprint not found")
conn.execute("DELETE FROM sprints WHERE id=?", (sid,))
conn.commit()
return {"id": sid, "status": "deleted"}
@router.post("/collections/{collection_id}/sprints/{sid}/assign")
async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
"""Assign a page to a sprint."""
body = await request.json() if request.headers.get("content-type") else {}
page_id = body.get("page_id")
if not page_id:
raise HTTPException(400, "page_id is required")
velocity_points = body.get("velocity_points", 1)
status_at_start = body.get("status_at_start", "")
with get_conn() as conn:
sprint = conn.execute("SELECT id FROM sprints WHERE id=?", (sid,)).fetchone()
if not sprint:
raise HTTPException(404, "Sprint not found")
page = conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(404, "Page not found")
try:
cur = conn.execute(
"INSERT INTO sprint_pages (sprint_id, page_id, status_at_start, velocity_points) VALUES (?,?,?,?)",
(sid, page_id, status_at_start, velocity_points),
)
conn.commit()
except sqlite3.IntegrityError:
raise HTTPException(409, "Page already assigned to this sprint")
return {"sprint_id": sid, "page_id": page_id, "status": "assigned"}
@router.delete("/collections/{collection_id}/sprints/{sid}/assign/{page_id}")
async def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int):
"""Remove a page from a sprint."""
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sid, page_id)
).fetchone()
if not existing:
raise HTTPException(404, "Assignment not found")
conn.execute("DELETE FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sid, page_id))
conn.commit()
return {"sprint_id": sid, "page_id": page_id, "status": "removed"}
@router.get("/collections/{collection_id}/sprints/burndown/{sid}")
async def sprint_burndown(request: Request, collection_id: int, sid: int):
"""Calculate burndown data for a sprint."""
with get_conn() as conn:
sprint = conn.execute(
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
).fetchone()
if not sprint:
raise HTTPException(404, "Sprint not found")
pages = conn.execute(
"""SELECT sp.velocity_points, cp.property_values_json
FROM sprint_pages sp JOIN collection_pages cp ON sp.page_id=cp.id
WHERE sp.sprint_id=?""", (sid,)
).fetchall()
total_points = sum(p["velocity_points"] for p in pages)
completed = 0
for p in pages:
props = json.loads(p["property_values_json"])
for v in props.values():
if isinstance(v, str) and v.lower() in ("done", "complete", "completed", "terminé"):
completed += p["velocity_points"]
break
from datetime import date, timedelta
today = date.today()
start = date.fromisoformat(sprint["start_date"]) if sprint["start_date"] else today
end = date.fromisoformat(sprint["end_date"]) if sprint["end_date"] else today
total_days = max((end - start).days, 1)
elapsed = max((today - start).days, 0)
ideal_burn = total_points - (total_points * elapsed / total_days)
return {
"sprint": sprint["name"],
"total_points": total_points,
"completed_points": completed,
"remaining_points": total_points - completed,
"ideal_remaining": round(ideal_burn, 1),
"start_date": sprint["start_date"],
"end_date": sprint["end_date"],
"days_elapsed": elapsed,
"days_total": total_days,
}
# ── CSV Import/Export ── # ── CSV Import/Export ──
@router.post("/collections/{collection_id}/import/csv") @router.post("/collections/{collection_id}/import/csv")
+480 -1
View File
@@ -812,7 +812,7 @@ def test_my_tasks_page(client):
assert "My Tasks" in resp.text assert "My Tasks" in resp.text
def test_my_tasks_api(client): def test_my_tasks_cross_db(client):
"""My Tasks API returns JSON.""" """My Tasks API returns JSON."""
r = client.post("/db/api", json={"name": "My Project"}) r = client.post("/db/api", json={"name": "My Project"})
cid = r.json()["id"] cid = r.json()["id"]
@@ -2228,3 +2228,482 @@ def test_remove_data_source_not_found(client):
resp = client.delete(f"/db/{coll_id}/sources/99999/api") resp = client.delete(f"/db/{coll_id}/sources/99999/api")
assert resp.status_code == 404 assert resp.status_code == 404
# ── v4.2.0: Templates & Dashboards ──
def test_page_template_update(client):
"""Update a page template with recurrence settings."""
resp = client.post("/db/api", json={"name": "Template Update DB"})
coll_id = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/templates/page", json={
"name": "Weekly Report",
"properties": {"Status": "Todo", "Priority": "P1"},
})
assert resp.status_code == 200
tid = resp.json()["id"]
resp = client.put(f"/workspace/collections/{coll_id}/templates/page/{tid}", json={
"name": "Weekly Report v2",
"description": "Updated weekly report template",
"is_recurring": True,
"recurrence_rule": "weekly",
"properties": {"Status": "In Progress", "Priority": "P2"},
})
assert resp.status_code == 200
assert resp.json()["status"] == "updated"
resp = client.get(f"/workspace/collections/{coll_id}/templates/page")
tmpls = resp.json()["templates"]
assert len(tmpls) == 1
assert tmpls[0]["name"] == "Weekly Report v2"
assert tmpls[0]["is_recurring"] == 1
assert tmpls[0]["recurrence_rule"] == "weekly"
def test_page_template_delete(client):
"""Delete a page template."""
resp = client.post("/db/api", json={"name": "Template Delete DB"})
coll_id = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/templates/page", json={
"name": "To Delete",
})
tid = resp.json()["id"]
resp = client.delete(f"/workspace/collections/{coll_id}/templates/page/{tid}")
assert resp.status_code == 200
assert resp.json()["status"] == "deleted"
resp = client.get(f"/workspace/collections/{coll_id}/templates/page")
assert len(resp.json()["templates"]) == 0
def test_page_template_apply_with_content(client):
"""Apply a page template that has content_json."""
resp = client.post("/db/api", json={"name": "Content Template DB"})
coll_id = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/templates/page", json={
"name": "Meeting Notes",
"properties": {"Status": "Todo"},
"content": [{"type": "heading", "text": "Meeting Notes"}],
})
tid = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/templates/page/{tid}/apply", json={
"title": "Sprint Review 2026-07-21",
})
assert resp.status_code == 200
page_id = resp.json()["id"]
resp = client.get(f"/db/pages/{page_id}/api")
assert resp.status_code == 200
assert resp.json()["title"] == "Sprint Review 2026-07-21"
def test_dashboard_crud(client):
"""Full CRUD lifecycle for dashboards."""
resp = client.post("/db/api", json={"name": "Dashboard CRUD DB"})
coll_id = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/dashboards", json={
"name": "Project Dashboard",
"layout": {
"columns": 2,
"widgets": [
{"view_id": 1, "x": 0, "y": 0, "width": 1, "height": 1},
{"view_id": 2, "x": 1, "y": 0, "width": 1, "height": 1},
],
},
})
assert resp.status_code == 200
assert resp.json()["status"] == "created"
did = resp.json()["id"]
resp = client.get(f"/workspace/collections/{coll_id}/dashboards")
dashboards = resp.json()["dashboards"]
assert len(dashboards) == 1
assert dashboards[0]["name"] == "Project Dashboard"
resp = client.put(f"/workspace/collections/{coll_id}/dashboards/{did}", json={
"name": "Project Dashboard v2",
"layout": {"columns": 3, "widgets": []},
})
assert resp.status_code == 200
assert resp.json()["status"] == "updated"
resp = client.get(f"/workspace/collections/{coll_id}/dashboards")
assert resp.json()["dashboards"][0]["name"] == "Project Dashboard v2"
resp = client.delete(f"/workspace/collections/{coll_id}/dashboards/{did}")
assert resp.status_code == 200
assert resp.json()["status"] == "deleted"
resp = client.get(f"/workspace/collections/{coll_id}/dashboards")
assert len(resp.json()["dashboards"]) == 0
def test_template_recurrence_defaults(client):
"""New templates default to non-recurring with empty recurrence_rule."""
resp = client.post("/db/api", json={"name": "Recur Defaults DB"})
coll_id = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/templates/page", json={
"name": "Default Template",
})
resp = client.get(f"/workspace/collections/{coll_id}/templates/page")
tmpl = resp.json()["templates"][0]
assert tmpl["is_recurring"] == 0
assert tmpl["recurrence_rule"] == ""
def test_dashboard_create_default_layout(client):
"""Dashboard creation with no layout parameter gets default grid."""
resp = client.post("/db/api", json={"name": "Default Layout DB"})
coll_id = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/dashboards", json={
"name": "Auto Layout",
})
assert resp.status_code == 200
resp = client.get(f"/workspace/collections/{coll_id}/dashboards")
dash = resp.json()["dashboards"][0]
layout = json.loads(dash["layout_json"])
assert layout["columns"] == 1
assert layout["widgets"] == []
# ── v4.3.0: Database Views (10 types) ──
def test_view_chart_renders(client):
"""Chart view renders with Chart.js CDN."""
resp = client.post("/db/api", json={"name": "Chart DB"})
coll_id = resp.json()["id"]
client.post(f"/db/{coll_id}/pages/api", json={"title": "Item A", "properties": {"Count": "5"}})
client.post(f"/db/{coll_id}/pages/api", json={"title": "Item B", "properties": {"Count": "8"}})
resp = client.get(f"/db/{coll_id}/view/chart")
assert resp.status_code == 200
assert "chart.js" in resp.text
def test_view_form_renders(client):
"""Form view renders with input fields."""
resp = client.post("/db/api", json={"name": "Form DB"})
coll_id = resp.json()["id"]
client.post(f"/db/{coll_id}/properties/api", json={"name": "Email", "prop_type": "email"})
resp = client.get(f"/db/{coll_id}/view/form")
assert resp.status_code == 200
assert "<form" in resp.text
assert "Email" in resp.text
def test_view_map_renders(client):
"""Map view renders with Leaflet CDN."""
resp = client.post("/db/api", json={"name": "Map DB"})
coll_id = resp.json()["id"]
resp = client.get(f"/db/{coll_id}/view/map")
assert resp.status_code == 200
assert "leaflet" in resp.text.lower()
def test_view_feed_renders(client):
"""Feed view renders chronological entries."""
resp = client.post("/db/api", json={"name": "Feed DB"})
coll_id = resp.json()["id"]
client.post(f"/db/{coll_id}/pages/api", json={"title": "Latest post"})
resp = client.get(f"/db/{coll_id}/view/feed")
assert resp.status_code == 200
assert "Latest post" in resp.text
def test_view_gantt_renders(client):
"""Gantt view renders with date bars."""
resp = client.post("/db/api", json={"name": "Gantt DB"})
coll_id = resp.json()["id"]
client.post(f"/db/{coll_id}/pages/api", json={
"title": "Task 1",
"properties": {"Timeline": "2026-07-01...2026-07-15"},
})
resp = client.get(f"/db/{coll_id}/view/gantt")
assert resp.status_code == 200
assert "Task 1" in resp.text
assert "gantt-bar" in resp.text
def test_all_view_tabs_present(client):
"""All 11 view tabs are present in the base HTML."""
resp = client.post("/db/api", json={"name": "Tabs DB"})
coll_id = resp.json()["id"]
resp = client.get(f"/db/{coll_id}/view/table")
assert resp.status_code == 200
for vt in ["table", "board", "calendar", "gallery", "list", "timeline", "gantt", "chart", "form", "map", "feed"]:
assert f"?view={vt}" in resp.text, f"Missing view tab: {vt}"
def test_view_unknown_falls_back_to_table(client):
"""Unknown view type falls back to table view."""
resp = client.post("/db/api", json={"name": "Fallback DB"})
coll_id = resp.json()["id"]
resp = client.get(f"/db/{coll_id}/view/unknown_view_type")
assert resp.status_code == 200
assert "<table>" in resp.text
# ── v4.4.0: Tasks & Dependencies ──
def test_toggle_task_flag(client):
"""Toggle is_task on a collection."""
resp = client.post("/db/api", json={"name": "Task DB"})
coll_id = resp.json()["id"]
resp = client.put(f"/db/{coll_id}/toggle-task/api")
assert resp.status_code == 200
assert resp.json()["is_task"] is True
assert resp.json()["mode"] == "tasks"
resp = client.get(f"/db/{coll_id}/api")
assert resp.json()["collection"]["is_task"] == 1
# Toggle back
resp = client.put(f"/db/{coll_id}/toggle-task/api")
assert resp.json()["is_task"] is False
assert resp.json()["mode"] == "standard"
def test_page_dependencies_crud(client):
"""Add, list, and remove page dependencies."""
resp = client.post("/db/api", json={"name": "Dep DB"})
coll_id = resp.json()["id"]
p1 = client.post(f"/db/{coll_id}/pages/api", json={"title": "Task A"})
p2 = client.post(f"/db/{coll_id}/pages/api", json={"title": "Task B"})
pid1 = p1.json()["id"]
pid2 = p2.json()["id"]
# Add dependency: Task A blocks Task B
resp = client.post(f"/db/{coll_id}/pages/{pid2}/dependencies/api", json={
"dependency_id": pid1,
"dependency_type": "blocks",
"auto_shift": "overlap",
})
assert resp.status_code == 200
assert resp.json()["status"] == "added"
dep_id = resp.json()["id"]
# List dependencies
resp = client.get(f"/db/{coll_id}/pages/{pid2}/dependencies/api")
deps = resp.json()["dependencies"]
assert len(deps) == 1
assert deps[0]["dependency_type"] == "blocks"
assert deps[0]["dependency_title"] == "Task A"
# Remove dependency
resp = client.delete(f"/db/{coll_id}/pages/{pid2}/dependencies/{dep_id}/api")
assert resp.status_code == 200
assert resp.json()["status"] == "removed"
resp = client.get(f"/db/{coll_id}/pages/{pid2}/dependencies/api")
assert len(resp.json()["dependencies"]) == 0
def test_page_dependencies_duplicate_rejected(client):
"""Duplicate dependency returns 409."""
resp = client.post("/db/api", json={"name": "Dup Dep DB"})
coll_id = resp.json()["id"]
p1 = client.post(f"/db/{coll_id}/pages/api", json={"title": "A"})
p2 = client.post(f"/db/{coll_id}/pages/api", json={"title": "B"})
client.post(f"/db/{coll_id}/pages/{p2.json()['id']}/dependencies/api", json={
"dependency_id": p1.json()["id"],
})
resp = client.post(f"/db/{coll_id}/pages/{p2.json()['id']}/dependencies/api", json={
"dependency_id": p1.json()["id"],
})
assert resp.status_code == 409
def test_auto_shift_dates(client):
"""Auto-shift dates when a blocking task is completed."""
resp = client.post("/db/api", json={"name": "Shift DB"})
coll_id = resp.json()["id"]
# Blocking task with end date July 10
p1 = client.post(f"/db/{coll_id}/pages/api", json={
"title": "Blocker",
"properties": {"Timeline": "2026-07-01...2026-07-10"},
})
# Blocked task
p2 = client.post(f"/db/{coll_id}/pages/api", json={
"title": "Dependent",
"properties": {"Timeline": "2026-07-11...2026-07-20"},
})
# Add dependency
client.post(f"/db/{coll_id}/pages/{p2.json()['id']}/dependencies/api", json={
"dependency_id": p1.json()["id"],
"dependency_type": "blocks",
})
# Auto-shift
resp = client.post(f"/db/{coll_id}/pages/{p2.json()['id']}/auto-shift/api", json={
"skip_weekends": False,
})
assert resp.status_code == 200
data = resp.json()
assert data["shifted"] is True
assert data["new_start"] == "2026-07-11" # day after blocker ends
def test_auto_shift_no_blockers(client):
"""Auto-shift with no blocking dependencies returns shifted=False."""
resp = client.post("/db/api", json={"name": "NoBlock DB"})
coll_id = resp.json()["id"]
p = client.post(f"/db/{coll_id}/pages/api", json={"title": "Lone Task"})
resp = client.post(f"/db/{coll_id}/pages/{p.json()['id']}/auto-shift/api")
assert resp.status_code == 200
assert resp.json()["shifted"] is False
# ── v4.5.0: Sprints ──
def test_sprint_crud(client):
"""Full CRUD lifecycle for sprints."""
resp = client.post("/db/api", json={"name": "Sprint DB"})
coll_id = resp.json()["id"]
# Create
resp = client.post(f"/workspace/collections/{coll_id}/sprints", json={
"name": "Sprint 1",
"start_date": "2026-07-01",
"end_date": "2026-07-14",
"goal": "Ship MVP",
"status": "active",
})
assert resp.status_code == 200
assert resp.json()["status"] == "created"
sid = resp.json()["id"]
# List
resp = client.get(f"/workspace/collections/{coll_id}/sprints")
assert len(resp.json()["sprints"]) == 1
assert resp.json()["sprints"][0]["name"] == "Sprint 1"
# Update
resp = client.put(f"/workspace/collections/{coll_id}/sprints/{sid}", json={
"name": "Sprint 1 - Revised",
"status": "completed",
})
assert resp.status_code == 200
resp = client.get(f"/workspace/collections/{coll_id}/sprints")
assert resp.json()["sprints"][0]["name"] == "Sprint 1 - Revised"
# Delete
resp = client.delete(f"/workspace/collections/{coll_id}/sprints/{sid}")
assert resp.status_code == 200
assert resp.json()["status"] == "deleted"
def test_sprint_assign_page(client):
"""Assign a page to a sprint and remove it."""
resp = client.post("/db/api", json={"name": "Assign DB"})
coll_id = resp.json()["id"]
# Create sprint
resp = client.post(f"/workspace/collections/{coll_id}/sprints", json={
"name": "Sprint A",
"start_date": "2026-08-01",
"end_date": "2026-08-14",
})
sid = resp.json()["id"]
# Create page
resp = client.post(f"/db/{coll_id}/pages/api", json={"title": "Task X"})
pid = resp.json()["id"]
# Assign
resp = client.post(f"/workspace/collections/{coll_id}/sprints/{sid}/assign", json={
"page_id": pid,
"velocity_points": 5,
})
assert resp.status_code == 200
assert resp.json()["status"] == "assigned"
# Verify page count
resp = client.get(f"/workspace/collections/{coll_id}/sprints")
assert resp.json()["sprints"][0]["page_count"] == 1
# Remove
resp = client.delete(f"/workspace/collections/{coll_id}/sprints/{sid}/assign/{pid}")
assert resp.status_code == 200
assert resp.json()["status"] == "removed"
def test_sprint_burndown(client):
"""Burndown chart data calculation."""
resp = client.post("/db/api", json={"name": "Burndown DB"})
coll_id = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/sprints", json={
"name": "Sprint B",
"start_date": "2026-07-01",
"end_date": "2026-07-14",
})
sid = resp.json()["id"]
# Add pages with different statuses
p1 = client.post(f"/db/{coll_id}/pages/api", json={
"title": "Done Task",
"properties": {"Status": "Done"},
})
p2 = client.post(f"/db/{coll_id}/pages/api", json={
"title": "In Progress Task",
"properties": {"Status": "In Progress"},
})
client.post(f"/workspace/collections/{coll_id}/sprints/{sid}/assign", json={
"page_id": p1.json()["id"], "velocity_points": 3,
})
client.post(f"/workspace/collections/{coll_id}/sprints/{sid}/assign", json={
"page_id": p2.json()["id"], "velocity_points": 5,
})
resp = client.get(f"/workspace/collections/{coll_id}/sprints/burndown/{sid}")
assert resp.status_code == 200
data = resp.json()
assert data["total_points"] == 8
assert data["completed_points"] == 3
assert data["remaining_points"] == 5
def test_my_tasks_page_render(client):
"""My Tasks page renders cross-database aggregation."""
# Create a task collection
resp = client.post("/db/api", json={"name": "My Tasks DB"})
coll_id = resp.json()["id"]
# Toggle to task mode
client.put(f"/db/{coll_id}/toggle-task/api")
# Add pages
client.post(f"/db/{coll_id}/pages/api", json={
"title": "Urgent fix",
"properties": {"Status": "Todo"},
})
client.post(f"/db/{coll_id}/pages/api", json={
"title": "Deploy",
"properties": {"Status": "Done"},
})
resp = client.get("/my-tasks?view=all")
assert resp.status_code == 200
assert "Urgent fix" in resp.text
assert "My Tasks DB" in resp.text