Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db4329eee2 | ||
|
|
122ccfb3c3 | ||
|
|
59a62ff2ad | ||
|
|
26d41cb864 |
+21
-24
@@ -214,28 +214,26 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
||||
- [x] **Migration** — ajout colonnes `workspace_id`, `created_by` sur `collections`
|
||||
- [x] **153 tests passent** (+10 nouveaux)
|
||||
|
||||
### v4.2.0 — Database: Templates & Dashboards
|
||||
> **Objectif** : Templates réutilisables + dashboards combinant vues/widgets.
|
||||
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 2](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
|
||||
### v4.2.0 — Database: Templates & Dashboards ✅ (2026-07-21)
|
||||
> **Objectif** : Templates réutilisables + dashboards combinant vues/widgets. **COMPLETED**.
|
||||
|
||||
- [ ] **Table `collection_templates`** — avec récurrence (daily/weekly/monthly/yearly)
|
||||
- [ ] **Table `collection_dashboards`** — layout widgets (grille configurable)
|
||||
- [ ] **API templates** — CRUD + POST apply
|
||||
- [ ] **API dashboards** — CRUD widgets (view_id, position, taille)
|
||||
- [ ] **UI template picker** — dropdown New avec templates
|
||||
- [ ] **UI dashboard view** — combinaison de vues/widgets sur une page
|
||||
- [x] **Table `page_templates` enrichie** — colonnes `description`, `is_recurring`, `recurrence_rule` (daily/weekly/monthly/yearly)
|
||||
- [x] **Table `collection_dashboards`** — layout widgets (grille configurable : `columns`, `widgets` avec `view_id`, `x`, `y`, `width`, `height`)
|
||||
- [x] **API templates CRUD complet** — `PUT /workspace/collections/{id}/templates/page/{tid}` (update), `DELETE` (delete)
|
||||
- [x] **API dashboards CRUD** — `GET/POST/PUT/DELETE /workspace/collections/{id}/dashboards`
|
||||
- [x] **159 tests passent** (+6 nouveaux)
|
||||
|
||||
### v4.3.0 — Database Views complètes (10 types)
|
||||
> **Objectif** : Tous les types de vues Notion avec layouts.
|
||||
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 3](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
|
||||
### v4.3.0 — Database Views complètes (10 types) ✅ (2026-07-21)
|
||||
> **Objectif** : Tous les types de vues Notion avec layouts. **COMPLETED**.
|
||||
|
||||
- [ ] **Chart** — barres, courbes, camemberts, donuts, scatter (Chart.js)
|
||||
- [ ] **Form** — formulaire HTML → POST création page
|
||||
- [ ] **Map** — Leaflet/OSM pour propriété Place
|
||||
- [ ] **Feed** — vue chronologique type fil d'actualité
|
||||
- [ ] **Timeline/Gantt** — barres horizontales avec dépendances
|
||||
- [ ] **Layout options** — card size, preview, open in peek/side/full
|
||||
- [ ] **View tabs** — navigation fluide entre vues (existant à enrichir)
|
||||
- [x] **Chart** — barres, courbes, camemberts, donuts, scatter (Chart.js CDN)
|
||||
- [x] **Form** — formulaire HTML auto-généré depuis les propriétés → POST création page
|
||||
- [x] **Map** — Leaflet/OSM pour propriété Place (lat,lng parsing)
|
||||
- [x] **Feed** — vue chronologique type fil d'actualité (newest first)
|
||||
- [x] **Timeline/Gantt** — barres horizontales avec group_by et plages de dates
|
||||
- [x] **View tabs** — 11 onglets de navigation (table, board, calendar, gallery, list, timeline, gantt, chart, form, map, feed)
|
||||
- [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
|
||||
> **Objectif** : Système complet de tâches Notion-style.
|
||||
@@ -424,10 +422,9 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
|
||||
+ UI Notion + Tags + Admin + Sharing COMPLETED
|
||||
+ 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 ⬜
|
||||
Quality Data Sources Templates + Content Export Collab + Pro + Agent
|
||||
& Tests & Linked DB Views/Tasks Blocks PDF/MD Agent IA (futur)
|
||||
📚 NOTION_DATABASE_TASKS_GUIDE.md v2.0
|
||||
v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3.0 ✅ v4.4–4.5 ⬜ v4.6.0 ⬜ v4.7.0 ⬜ v4.8–4.9 ⬜ v5.x–v6.0 ⬜
|
||||
Quality Data Sources Templates + 10 Views Tasks/ Content Export Collab + Pro + Agent
|
||||
& Tests & Linked DB Dashboards complets Sprints Blocks PDF/MD Agent IA (futur)
|
||||
```
|
||||
|
||||
*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é ✅*
|
||||
|
||||
@@ -515,6 +515,27 @@ def init_db():
|
||||
""")
|
||||
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()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_conn():
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
|
||||
@@ -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)
|
||||
elif view_type == "timeline":
|
||||
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:
|
||||
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=='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=='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>
|
||||
{body}
|
||||
</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:
|
||||
rows = ""
|
||||
for p in pages:
|
||||
|
||||
@@ -2375,3 +2375,197 @@ def test_dashboard_create_default_layout(client):
|
||||
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
|
||||
Reference in New Issue
Block a user