"""FlowDeck — Collections : rendus HTML des vues (A28). Les 15 helpers de rendu de l'ancien collections.py (_render_view, _render_chart, …) + CHART_MAX_GROUPS — ré-exportés pour les tests. """ from __future__ import annotations import json from app.db import get_conn from app.templating import CSP_NONCE CHART_MAX_GROUPS = 200 # ── View renderers (v1.6.0) ── def _render_view(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: if view_type == "calendar": return _render_calendar(view_type, collection, pages, config) elif view_type == "gallery": return _render_gallery(view_type, collection, pages, config) elif view_type == "list": 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) def _base_html(title: str, icon: str, view_type: str, body: str) -> str: return f""" {title} — FlowDeck

{icon} {title}

📊 Table 📋 Board 📅 Calendar 🖼️ Gallery 📝 List 📈 Timeline 📊 Gantt 📉 Chart 📋 Form 🗺️ Map 📰 Feed
{body} """ def _render_calendar(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: from datetime import date as dt_date from datetime import timedelta today = dt_date.today() # Determine month/year from config or current year = config.get("year", today.year) month = config.get("month", today.month) first = dt_date(year, month, 1) # Start from Monday of first week start = first - timedelta(days=first.weekday()) days_in_month = [] for i in range(42): # 6 weeks d = start + timedelta(days=i) days_in_month.append(d) # Map pages to dates date_pages: dict[str, list[dict]] = {} for p in pages: props = json.loads(p.get("property_values_json", "{}")) for v in props.values(): if isinstance(v, str) and v.startswith("20"): d = v[:10] date_pages.setdefault(d, []).append(p) break cells = "" for d in days_in_month: iso = d.isoformat() items = date_pages.get(iso, []) other_month = " other-month" if d.month != month else "" today_class = " today" if d == today else "" items_html = "".join( f"
{p.get('icon','📄')} {p['title'][:20]}
" for p in items ) cells += f"
{d.day}{items_html}
" prev = first - timedelta(days=1) next_month = first + timedelta(days=32) next_month = next_month.replace(day=1) return _base_html(collection["name"], collection.get("icon", "📅"), view_type, f"""
← {prev.strftime('%B')} {first.strftime('%B %Y')} {next_month.strftime('%B')} →
Mon
Tue
Wed
Thu
Fri
Sat
Sun
{cells}

{len(pages)} pages in collection

""") def _render_gallery(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: card_size = config.get("card_size", "medium") size_css = {"small": "160px", "medium": "220px", "large": "300px"}.get(card_size, "220px") cards = "" for p in pages: props = json.loads(p.get("property_values_json", "{}")) cover_url = config.get("cover_property") cover_html = "" if cover_url: for _k, v in props.items(): if isinstance(v, list) and len(v) > 0: url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0]) if url.startswith("http"): cover_html = f"
" break prop_tags = "".join( f"{str(v)[:30]}" for v in list(props.values())[:3] if v ) cards += f"""
{cover_html}
{p.get('icon','📄')} {p['title']}
{prop_tags}
""" return _base_html(collection["name"], collection.get("icon", "🖼️"), view_type, f"""

{len(pages)} cards

""") def _render_list(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: items = "" for p in pages: props = json.loads(p.get("property_values_json", "{}")) preview = " · ".join(str(v)[:60] for v in list(props.values())[:3] if v) items += f"""
{p.get('icon','📄')}
{p['title']}
{preview}
""" return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f""" {items}

{len(pages)} items

""") def _render_timeline(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: # Find date range dates = [] page_dates = [] for p in pages: props = json.loads(p.get("property_values_json", "{}")) 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][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10] else: start_val = end_val = v[:10] break if start_val: dates.append(start_val) if end_val: dates.append(end_val) page_dates.append((p, start_val, end_val or start_val)) if not dates: return _base_html(collection["name"], collection.get("icon", "📈"), view_type, "

No date data to display timeline.

") from datetime import date as dt_date min_date = min(dt_date.fromisoformat(d) for d in dates) max_date = max(dt_date.fromisoformat(d) for d in dates) total = (max_date - min_date).days or 1 bars = "" for p, start, end in page_dates: sd = dt_date.fromisoformat(start) ed = dt_date.fromisoformat(end) left = (sd - min_date).days / total * 100 width = max((ed - sd).days / total * 100, 1) bars += f"""
{p.get('icon','📄')} {p['title']}
""" return _base_html(collection["name"], collection.get("icon", "📈"), view_type, f"""
{min_date} {max_date}
{bars}

{len(pages)} items · {min_date} → {max_date}

""") # ── v4.3.0: New view types ── # Multi-collection dashboards and chart aggregations cap the number of # input rows/groups at 200 (keeps the rendered HTML and export reasonable). def _chart_values(pages: list[dict], chart_property: str) -> list[float]: """Numeric values of ``chart_property`` across ``pages`` (cap 200).""" values: list[float] = [] for p in pages[:CHART_MAX_GROUPS]: props = json.loads(p.get("property_values_json", "{}") or "{}") v = props.get(chart_property) if v is None or v == "": continue try: values.append(float(v)) except (ValueError, TypeError): continue return values def _chart_aggregate(pages: list[dict], chart_property: str, aggregate: str) -> float: """Compute count|sum|avg|min|max over a property (or row count).""" values = _chart_values(pages, chart_property) if aggregate == "count": return float(len(pages[:CHART_MAX_GROUPS])) if not values: return 0.0 if aggregate == "sum": return float(sum(values)) if aggregate == "avg": return float(sum(values) / len(values)) if aggregate == "min": return float(min(values)) if aggregate == "max": return float(max(values)) return 0.0 def _fmt_number(value: float) -> str: if abs(value) >= 1e9: return f"{value / 1e9:.2f}B" if abs(value) >= 1e6: return f"{value / 1e6:.2f}M" if abs(value) >= 1e3: return f"{value / 1e3:.1f}K" if value == int(value): return str(int(value)) return f"{value:.2f}" 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, plus the v7.3.0 ``number`` KPI widget (count|sum|avg|min|max aggregate).""" chart_type = config.get("chart_type", "bar") chart_property = config.get("chart_property", "") if chart_type == "number": aggregate = config.get("aggregate", "sum" if chart_property else "count") if aggregate not in ("count", "sum", "avg", "min", "max"): aggregate = "sum" if chart_property else "count" num = _chart_aggregate(pages, chart_property, aggregate) label = config.get("title") or chart_property or collection["name"] return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
{label}
{_fmt_number(num)}
{aggregate} · {len(pages[:CHART_MAX_GROUPS])} row(s) {' of ' + chart_property if chart_property else ''}
""") labels = [] values = [] for p in pages[:CHART_MAX_GROUPS]: labels.append(str(p.get("title") or "")[:30]) props = json.loads(p.get("property_values_json", "{}") or "{}") val = 0.0 if chart_property: v_raw = props.get(chart_property, 0) try: val = float(v_raw) if v_raw else 0.0 except (ValueError, TypeError): val = 0.0 values.append(val) labels_json = json.dumps(labels) values_json = json.dumps(values) subtitle = (f"{len(pages[:CHART_MAX_GROUPS])} entries" + (f" (truncated at {CHART_MAX_GROUPS})" if len(pages) > CHART_MAX_GROUPS else "")) return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""

{subtitle}

""") 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.""" 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"""
""" elif ptype in ("select", "status"): options = "".join(f"" for o in prop.get("options", [])) fields += f"""
""" elif ptype == "checkbox": fields += f"""
""" elif ptype == "date": fields += f"""
""" return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""

New entry in {collection['name']}

{fields}
""") 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"""

{len(markers)} location(s) mapped

""") 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"""
{created}
{p.get('icon','📄')} {p['title']}
""" return _base_html(collection["name"], collection.get("icon", "📰"), view_type, f""" {items}

{len(sorted_pages)} entries

""") 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, "

No date data for Gantt chart.

") 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"
{group_name} ({len(items)})
" 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"""
{item['title'][:30]}
{item['start']} → {item['end']}
""" return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
{min_date} {max_date}
{rows}

{len(gantt_data)} items · {min_date} → {max_date}

""") def _render_table(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: rows = "" for p in pages: props = json.loads(p.get("property_values_json", "{}")) prop_cells = "".join(f"{str(v)[:80]}" for v in list(props.values())[:4]) rows += f"{p.get('icon','📄')}{p['title']}{prop_cells}" return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f""" {rows}
TitleProperties

{len(pages)} rows

""")