"""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"""
{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"""{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"""{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"""{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"""{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"""{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"""{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"{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"| Title | Properties |
|---|
{len(pages)} rows
""")