Initial commit — multi-tenant filtering, port constraints, chart bbox

This commit is contained in:
2026-05-04 22:41:09 -04:00
parent c3b07be67e
commit fcf1d2787a
1102 changed files with 7353 additions and 1166 deletions
Binary file not shown.
Binary file not shown.
+27 -8
View File
@@ -100,11 +100,12 @@ class TokenResponse(BaseModel):
role: str
class UserCreate(BaseModel):
username: str
nombre: str
email: Optional[str] = None
password: str
role: str = "USER"
username: str
nombre: str
email: Optional[str] = None
password: str
role: str = "USER"
company_id: Optional[str] = None
@router.post("/login", response_model=TokenResponse)
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
@@ -121,11 +122,25 @@ def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get
@router.get("/me")
def me(user: User = Depends(get_current_user)):
return {"username": user.username, "nombre": user.nombre, "role": user.role}
return {
"username": user.username,
"nombre": user.nombre,
"role": user.role,
"company_id": getattr(user, "company_id", None),
}
@router.get("/users", dependencies=[Depends(require_superadmin)])
def list_users(db: Session = Depends(get_db)):
return db.query(User).all()
users = db.query(User).all()
return [
{
"id": u.id, "username": u.username, "nombre": u.nombre,
"email": u.email, "role": u.role, "activo": u.activo,
"company_id": getattr(u, "company_id", None),
"ultimo_login": u.ultimo_login.isoformat() if u.ultimo_login else None,
}
for u in users
]
@router.post("/users", dependencies=[Depends(require_superadmin)])
def create_user(data: UserCreate, db: Session = Depends(get_db)):
@@ -138,10 +153,12 @@ def create_user(data: UserCreate, db: Session = Depends(get_db)):
email=data.email,
hashed_pw=hash_password(data.password),
role=data.role,
company_id=data.company_id,
)
db.add(user)
db.commit()
return {"ok": True, "username": user.username, "role": user.role}
return {"ok": True, "username": user.username, "role": user.role,
"company_id": user.company_id}
@router.put("/users/{username}", dependencies=[Depends(require_superadmin)])
def update_user(username: str, data: dict, db: Session = Depends(get_db)):
@@ -158,6 +175,8 @@ def update_user(username: str, data: dict, db: Session = Depends(get_db)):
user.activo = data["activo"]
if "password" in data and data["password"]:
user.hashed_pw = hash_password(data["password"])
if "company_id" in data:
user.company_id = data["company_id"] or None
db.commit()
return {"ok": True}
+110 -3
View File
@@ -17,6 +17,7 @@ from services.chart_manager import (
delete_cell, get_all_features, get_all_depths,
get_all_land, get_all_hazards, get_all_zones,
CHARTS_DIR, set_meta, get_region,
install_from_csv_zip, install_from_csv_dir,
)
router = APIRouter(prefix="/charts", tags=["charts"])
@@ -210,9 +211,28 @@ async def download_noaa(cell_id: str):
return {"installed": installed}
def _zip_contains_csvs(zip_path: Path) -> bool:
"""Return True if the ZIP has *.csv files but no *.000 ENC files."""
import zipfile as _zf
with _zf.ZipFile(zip_path) as z:
names = z.namelist()
has_csv = any(n.lower().endswith(".csv") for n in names)
has_enc = any(n.upper().endswith(".000") for n in names)
return has_csv and not has_enc
@router.post("/upload")
async def upload_chart(file: UploadFile = File(...)):
suffix = Path(file.filename).suffix.lower()
"""
Universal chart upload.
Accepts:
• .000 — single S-57 ENC cell
• .zip — either a NOAA ENC zip (contains .000) OR a CSV-based custom
chart zip (contains *.csv, no .000). The ZIP auto-detection
determines which parser is used.
"""
suffix = Path(file.filename or "").suffix.lower()
if suffix not in (".zip", ".000"):
raise HTTPException(400, "Only .zip or .000 files accepted")
@@ -223,8 +243,13 @@ async def upload_chart(file: UploadFile = File(...)):
try:
if suffix == ".zip":
installed = await asyncio.get_event_loop().run_in_executor(
None, install_from_zip, tmp_path)
# Auto-detect: CSV zip vs ENC zip
if _zip_contains_csvs(tmp_path):
installed = await asyncio.get_event_loop().run_in_executor(
None, install_from_csv_zip, tmp_path)
else:
installed = await asyncio.get_event_loop().run_in_executor(
None, install_from_zip, tmp_path)
else:
orig_name = Path(file.filename).stem.upper() if file.filename else None
cell_id = await asyncio.get_event_loop().run_in_executor(
@@ -238,6 +263,58 @@ async def upload_chart(file: UploadFile = File(...)):
return {"installed": installed}
@router.post("/upload-csv")
async def upload_csv_chart(file: UploadFile = File(...),
cell_id: str | None = None):
"""
Upload a ZIP archive containing CSV navigation-aid files to create a
custom chart cell. Use this when your source data is in DIMAR / custom
CSV format rather than S-57 .000.
The cell_id query parameter overrides the inferred name from the ZIP.
Workflow:
1. Edit BOYLAT.csv, BOYCAR.csv, BOYSPEC.csv, etc. in your local
capas_ctg/ folder.
2. Zip the entire folder.
3. POST the zip here (optionally with ?cell_id=BAHIA_DE_CARTAGENA).
4. AidsMonitoring reads the CSVs directly, preserving all light
attributes (LITCHR, SIGPER, VALNMR …) without GDAL round-trip loss.
"""
if not (file.filename or "").lower().endswith(".zip"):
raise HTTPException(400, "Only .zip files accepted for CSV upload")
data = await file.read()
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
tmp.write(data)
tmp_path = Path(tmp.name)
try:
if cell_id:
# Extract to temp dir then install with explicit cell_id
import zipfile as _zf
import tempfile as _tf
with _tf.TemporaryDirectory() as td:
td_p = Path(td)
with _zf.ZipFile(tmp_path) as z:
for member in z.namelist():
if member.lower().endswith(".csv"):
data_bytes = z.read(member)
(td_p / Path(member).name).write_bytes(data_bytes)
installed_id = await asyncio.get_event_loop().run_in_executor(
None, install_from_csv_dir, td_p, cell_id)
installed = [installed_id]
else:
installed = await asyncio.get_event_loop().run_in_executor(
None, install_from_csv_zip, tmp_path)
except Exception as e:
raise HTTPException(500, str(e))
finally:
tmp_path.unlink(missing_ok=True)
return {"installed": installed}
@router.delete("/cells/{cell_id}")
def remove_cell(cell_id: str):
delete_cell(cell_id)
@@ -305,6 +382,36 @@ async def rebuild_cache():
return {"rebuilt": rebuilt}
@router.post("/cells/{cell_id}/rebuild-from-csv")
async def rebuild_cell_from_csv(cell_id: str, file: UploadFile = File(...)):
"""
Update an existing cell's features.geojson by re-uploading its CSV zip.
Equivalent to DELETE + upload-csv but preserves meta.json settings
(e.g. region override).
"""
if not (file.filename or "").lower().endswith(".zip"):
raise HTTPException(400, "Only .zip files accepted")
data = await file.read()
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
tmp.write(data)
tmp_path = Path(tmp.name)
try:
import zipfile as _zf, tempfile as _tf
with _tf.TemporaryDirectory() as td:
td_p = Path(td)
with _zf.ZipFile(tmp_path) as z:
for member in z.namelist():
if member.lower().endswith(".csv"):
(td_p / Path(member).name).write_bytes(z.read(member))
installed_id = await asyncio.get_event_loop().run_in_executor(
None, install_from_csv_dir, td_p, cell_id)
except Exception as e:
raise HTTPException(500, str(e))
finally:
tmp_path.unlink(missing_ok=True)
return {"rebuilt": installed_id}
@router.post("/cells/{cell_id}/rebuild")
async def rebuild_cell(cell_id: str):
"""Re-parse a single ENC cell and regenerate its feature cache."""
+240
View File
@@ -0,0 +1,240 @@
"""
Organization CRUD: Ports, Companies, BuoyOwnership.
GET /org/ports → list ports
POST /org/ports → create port (admin)
PUT /org/ports/{id} → update port (admin)
GET /org/companies → list companies
POST /org/companies → create company (admin)
PUT /org/companies/{id} → update company (admin)
GET /org/companies/{company_id}/buoys → list owned buoys
POST /org/companies/{company_id}/buoys → assign buoy to company (admin)
DELETE /org/companies/{company_id}/buoys/{id} → remove ownership (admin)
GET /org/me/company → current user's company + port (for homepage)
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import Optional
import uuid
import os
import json
from database import get_db
from models.org import Port, Company, BuoyOwnership
from models.user import User
from models.aid import Aid
from routers.auth import get_current_user, require_admin
# Charts directory — one level above the backend package
_CHARTS_DIR = os.path.normpath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', 'charts')
)
def _read_chart_bbox(chart_name: str) -> list | None:
"""Return [west, south, east, north] from the chart's meta.json, or None."""
if not chart_name:
return None
meta = os.path.join(_CHARTS_DIR, chart_name, 'meta.json')
try:
with open(meta, 'r', encoding='utf-8') as f:
data = json.load(f)
bbox = data.get('bbox')
if bbox and len(bbox) == 4:
return bbox # [minLon, minLat, maxLon, maxLat]
except Exception:
pass
return None
router = APIRouter(prefix="/org", tags=["org"])
# ── Ports ─────────────────────────────────────────────────────────────────────
@router.get("/ports")
def list_ports(db: Session = Depends(get_db)):
return [_port_dict(p) for p in db.query(Port).filter(Port.activo == True).all()]
@router.post("/ports", dependencies=[Depends(require_admin)])
def create_port(data: dict, db: Session = Depends(get_db)):
if not data.get("name"):
raise HTTPException(400, "name is required")
port = Port(
id=str(uuid.uuid4()),
name=data["name"],
country=data.get("country", "Colombia"),
center_lat=data.get("center_lat"),
center_lon=data.get("center_lon"),
default_zoom=data.get("default_zoom", 12.0),
chart_name=data.get("chart_name"),
)
db.add(port); db.commit()
return _port_dict(port)
@router.put("/ports/{port_id}", dependencies=[Depends(require_admin)])
def update_port(port_id: str, data: dict, db: Session = Depends(get_db)):
port = db.query(Port).filter(Port.id == port_id).first()
if not port:
raise HTTPException(404, "Port not found")
for field in ("name", "country", "center_lat", "center_lon",
"default_zoom", "chart_name", "activo"):
if field in data:
setattr(port, field, data[field])
db.commit()
return _port_dict(port)
def _port_dict(p: Port) -> dict:
return {
"id": p.id, "name": p.name, "country": p.country,
"center_lat": p.center_lat, "center_lon": p.center_lon,
"default_zoom": p.default_zoom, "chart_name": p.chart_name,
"chart_bbox": _read_chart_bbox(p.chart_name), # [W,S,E,N] or null
"activo": p.activo,
}
# ── Companies ─────────────────────────────────────────────────────────────────
@router.get("/companies")
def list_companies(db: Session = Depends(get_db)):
return [_company_dict(c) for c in db.query(Company).all()]
@router.post("/companies", dependencies=[Depends(require_admin)])
def create_company(data: dict, db: Session = Depends(get_db)):
if not data.get("name"):
raise HTTPException(400, "name is required")
company = Company(
id=str(uuid.uuid4()),
name=data["name"],
port_id=data.get("port_id"),
contact_email=data.get("contact_email"),
contact_phone=data.get("contact_phone"),
notas=data.get("notas"),
)
db.add(company); db.commit()
return _company_dict(company)
@router.put("/companies/{company_id}", dependencies=[Depends(require_admin)])
def update_company(company_id: str, data: dict, db: Session = Depends(get_db)):
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
raise HTTPException(404, "Company not found")
for field in ("name", "port_id", "contact_email", "contact_phone", "notas", "activa"):
if field in data:
setattr(company, field, data[field])
db.commit()
return _company_dict(company)
def _company_dict(c: Company) -> dict:
return {
"id": c.id, "name": c.name, "port_id": c.port_id,
"contact_email": c.contact_email, "contact_phone": c.contact_phone,
"activa": c.activa, "notas": c.notas,
}
# ── Buoy Ownership ────────────────────────────────────────────────────────────
@router.get("/companies/{company_id}/buoys")
def list_company_buoys(company_id: str, db: Session = Depends(get_db)):
rows = db.query(BuoyOwnership).filter(BuoyOwnership.company_id == company_id).all()
result = []
for r in rows:
entry = {"id": r.id, "company_id": r.company_id,
"aid_id": r.aid_id, "mmsi": r.mmsi, "notas": r.notas}
# Enrich with aid name if available
if r.aid_id:
aid = db.query(Aid).filter(Aid.id == r.aid_id).first()
if aid:
entry["aid_nombre"] = aid.nombre
entry["mmsi"] = entry["mmsi"] or aid.mmsi
result.append(entry)
return result
@router.post("/companies/{company_id}/buoys", dependencies=[Depends(require_admin)])
def assign_buoy(company_id: str, data: dict, db: Session = Depends(get_db)):
"""
Assign a buoy to a company. Provide either aid_id or mmsi (or both).
If aid_id is given, mmsi is auto-filled from the Aid row.
"""
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
raise HTTPException(404, "Company not found")
aid_id = data.get("aid_id")
mmsi = data.get("mmsi")
if not aid_id and not mmsi:
raise HTTPException(400, "Provide aid_id or mmsi")
if aid_id:
aid = db.query(Aid).filter(Aid.id == aid_id).first()
if not aid:
raise HTTPException(404, "Aid not found")
mmsi = mmsi or aid.mmsi # fill from aid if not explicitly given
# Prevent duplicate
existing = db.query(BuoyOwnership).filter(
BuoyOwnership.company_id == company_id,
BuoyOwnership.aid_id == aid_id,
).first()
if existing:
raise HTTPException(409, "Already assigned")
row = BuoyOwnership(
id=str(uuid.uuid4()),
company_id=company_id,
aid_id=aid_id,
mmsi=mmsi,
notas=data.get("notas"),
)
db.add(row); db.commit()
return {"ok": True, "id": row.id, "mmsi": mmsi}
@router.delete("/companies/{company_id}/buoys/{ownership_id}",
dependencies=[Depends(require_admin)])
def remove_ownership(company_id: str, ownership_id: str, db: Session = Depends(get_db)):
row = db.query(BuoyOwnership).filter(
BuoyOwnership.id == ownership_id,
BuoyOwnership.company_id == company_id,
).first()
if not row:
raise HTTPException(404, "Ownership record not found")
db.delete(row); db.commit()
return {"ok": True}
# ── Current user's company / home port ───────────────────────────────────────
@router.get("/me/company")
def my_company(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Returns the company and port associated with the logged-in user.
Used by the frontend to set the default map view on login.
"""
company_id = getattr(current_user, "company_id", None)
if not company_id:
return {"company": None, "port": None}
company = db.query(Company).filter(Company.id == company_id).first()
port = db.query(Port).filter(Port.id == company.port_id).first() \
if company and company.port_id else None
return {
"company": _company_dict(company) if company else None,
"port": _port_dict(port) if port else None,
}
+268
View File
@@ -0,0 +1,268 @@
"""
Track history endpoints — DVR replay for vessels and AtoNs.
GET /tracks/vessels → list MMSIs that have track data
GET /tracks/vessels/{mmsi} → track points for one vessel (?from=&to=&limit=)
GET /tracks/atons → list AtoN MMSIs with track data
GET /tracks/atons/{mmsi} → track points for one AtoN (?from=&to=&limit=)
GET /recordings → list RecordingEvents (?mmsi=&open=)
GET /recordings/{event_id}/track → vessel track for that event window
Access control:
- ADMIN / SUPERADMIN : see ALL recordings
- USER with company : see ONLY recordings where aid_id belongs to their company's buoys
- Anonymous : see ALL (legacy / unauthenticated clients)
"""
from fastapi import APIRouter, Depends, Query, HTTPException, Header
from sqlalchemy.orm import Session
from sqlalchemy import distinct
from typing import Optional
from datetime import datetime
from database import get_db
from models.vessel import VesselTrack, AtonTrack, RecordingEvent
from models.org import BuoyOwnership
from models.user import User, Role
router = APIRouter(tags=["tracks"])
def _optional_user(
authorization: Optional[str] = Header(default=None),
db: Session = Depends(get_db),
) -> Optional[User]:
"""Resolve JWT token from Authorization header if present. Returns None if absent/invalid."""
if not authorization or not authorization.startswith("Bearer "):
return None
token = authorization.split(" ", 1)[1]
try:
from jose import jwt as _jwt, JWTError
from routers.auth import SECRET_KEY, ALGORITHM
payload = _jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if not username:
return None
return db.query(User).filter(User.username == username, User.activo == True).first()
except Exception:
return None
def _company_aid_ids(company_id: str, db: Session) -> Optional[set]:
"""Return the set of aid_ids owned by this company, or None if unrestricted."""
rows = db.query(BuoyOwnership).filter(
BuoyOwnership.company_id == company_id,
BuoyOwnership.aid_id.isnot(None),
).all()
return {r.aid_id for r in rows} if rows is not None else set()
# ── Vessel tracks ─────────────────────────────────────────────────────────────
@router.get("/tracks/vessels")
def list_tracked_vessels(db: Session = Depends(get_db)):
"""Return distinct MMSIs that have at least one VesselTrack row."""
rows = db.query(distinct(VesselTrack.mmsi)).all()
return [r[0] for r in rows]
@router.get("/tracks/vessels/{mmsi}")
def get_vessel_track(
mmsi: str,
from_dt: Optional[str] = Query(None, alias="from"),
to_dt: Optional[str] = Query(None, alias="to"),
limit: int = Query(10_000, le=100_000),
db: Session = Depends(get_db),
):
"""
Return time-ordered track points for *mmsi*.
Optionally filter by ISO-8601 timestamps: ?from=2026-05-01T00:00:00&to=2026-05-02T00:00:00
"""
q = db.query(VesselTrack).filter(VesselTrack.mmsi == mmsi)
if from_dt:
try:
q = q.filter(VesselTrack.timestamp >= datetime.fromisoformat(from_dt))
except ValueError:
raise HTTPException(400, "Invalid 'from' datetime (use ISO-8601)")
if to_dt:
try:
q = q.filter(VesselTrack.timestamp <= datetime.fromisoformat(to_dt))
except ValueError:
raise HTTPException(400, "Invalid 'to' datetime (use ISO-8601)")
rows = q.order_by(VesselTrack.timestamp.asc()).limit(limit).all()
return [
{
"mmsi": r.mmsi,
"ts": r.timestamp.isoformat() if r.timestamp else None,
"lat": r.lat,
"lon": r.lon,
"sog": r.sog,
"cog": r.cog,
"heading": r.heading,
}
for r in rows
]
# ── AtoN tracks ───────────────────────────────────────────────────────────────
@router.get("/tracks/atons")
def list_tracked_atons(db: Session = Depends(get_db)):
"""Return distinct MMSIs that have at least one AtonTrack row."""
rows = db.query(distinct(AtonTrack.mmsi)).all()
return [r[0] for r in rows]
@router.get("/tracks/atons/{mmsi}")
def get_aton_track(
mmsi: str,
from_dt: Optional[str] = Query(None, alias="from"),
to_dt: Optional[str] = Query(None, alias="to"),
limit: int = Query(10_000, le=100_000),
db: Session = Depends(get_db),
):
"""Time-ordered position history for an AIS AtoN (Type 21)."""
q = db.query(AtonTrack).filter(AtonTrack.mmsi == mmsi)
if from_dt:
try:
q = q.filter(AtonTrack.timestamp >= datetime.fromisoformat(from_dt))
except ValueError:
raise HTTPException(400, "Invalid 'from' datetime")
if to_dt:
try:
q = q.filter(AtonTrack.timestamp <= datetime.fromisoformat(to_dt))
except ValueError:
raise HTTPException(400, "Invalid 'to' datetime")
rows = q.order_by(AtonTrack.timestamp.asc()).limit(limit).all()
return [
{
"mmsi": r.mmsi,
"ts": r.timestamp.isoformat() if r.timestamp else None,
"lat": r.lat,
"lon": r.lon,
"voltage_v": r.voltage_v,
"off_position": r.off_position,
}
for r in rows
]
# ── Recording events ──────────────────────────────────────────────────────────
@router.get("/recordings")
def list_recordings(
mmsi: Optional[str] = Query(None),
open_: Optional[bool] = Query(None, alias="open"),
from_dt: Optional[str] = Query(None, alias="from"),
to_dt: Optional[str] = Query(None, alias="to"),
limit: int = Query(200, le=2000),
db: Session = Depends(get_db),
caller: Optional[User] = Depends(_optional_user),
):
"""
List RecordingEvents (most-recent first).
Supports ?mmsi=, ?open=true, ?from=YYYY-MM-DD, ?to=YYYY-MM-DD
Access control:
ADMIN/SUPERADMIN → all recordings
USER with company → only recordings for their company's aids
No token → all recordings (legacy)
"""
from models.vessel import Vessel
from models.aid import Aid
# ── Company filter for USER role ─────────────────────────────────────────
allowed_aid_ids: Optional[set] = None
if caller and caller.role == Role.USER:
cid = getattr(caller, "company_id", None)
if cid:
allowed_aid_ids = _company_aid_ids(cid, db)
q = db.query(RecordingEvent)
# Restrict to owned aids if caller is a company user
if allowed_aid_ids is not None:
q = q.filter(RecordingEvent.aid_id.in_(allowed_aid_ids))
if mmsi:
q = q.filter(RecordingEvent.mmsi == mmsi)
if open_ is not None:
q = q.filter(RecordingEvent.cerrado == (not open_))
if from_dt:
try:
q = q.filter(RecordingEvent.inicio >= datetime.fromisoformat(from_dt))
except ValueError:
pass
if to_dt:
try:
# Include the full end date day
to_end = datetime.fromisoformat(to_dt).replace(hour=23, minute=59, second=59)
q = q.filter(RecordingEvent.inicio <= to_end)
except ValueError:
pass
events = q.order_by(RecordingEvent.inicio.desc()).limit(limit).all()
# Enrich with vessel / aid names in one pass
mmsis = {e.mmsi for e in events}
aid_ids = {e.aid_id for e in events}
vessels = {v.mmsi: v.nombre for v in db.query(Vessel).filter(Vessel.mmsi.in_(mmsis)).all()}
aids = {a.id: a.nombre for a in db.query(Aid).filter(Aid.id.in_(aid_ids)).all()}
return [
{
"id": e.id,
"mmsi": e.mmsi,
"vessel_nombre": vessels.get(e.mmsi),
"aid_id": e.aid_id,
"aid_nombre": aids.get(e.aid_id),
"inicio_utc": e.inicio.isoformat() if e.inicio else None,
"fin_utc": e.fin.isoformat() if e.fin else None,
"distancia_min_m": e.distancia_min_m,
"trigger": e.trigger,
"cerrado": e.cerrado,
}
for e in events
]
@router.get("/recordings/{event_id}/track")
def get_recording_track(event_id: str, db: Session = Depends(get_db)):
"""
Return the VesselTrack points that fall within the time window of a
specific RecordingEvent — ready for DVR replay.
"""
event = db.query(RecordingEvent).filter(RecordingEvent.id == event_id).first()
if not event:
raise HTTPException(404, "Recording event not found")
q = (
db.query(VesselTrack)
.filter(VesselTrack.mmsi == event.mmsi)
.filter(VesselTrack.timestamp >= event.inicio)
)
if event.fin:
q = q.filter(VesselTrack.timestamp <= event.fin)
rows = q.order_by(VesselTrack.timestamp.asc()).all()
return {
"event": {
"id": event.id,
"mmsi": event.mmsi,
"aid_id": event.aid_id,
"inicio": event.inicio.isoformat(),
"fin": event.fin.isoformat() if event.fin else None,
"trigger": event.trigger,
},
"track": [
{
"ts": r.timestamp.isoformat() if r.timestamp else None,
"lat": r.lat,
"lon": r.lon,
"sog": r.sog,
"cog": r.cog,
"heading": r.heading,
}
for r in rows
],
}