diff --git a/corvid/router.py b/corvid/router.py index c0d7a45..40c7f35 100644 --- a/corvid/router.py +++ b/corvid/router.py @@ -300,6 +300,126 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter: await db.commit() return await _ticket_dict(await _get_ticket_or_404(db, t.id), db) + @router.get("/api/tickets/export") + async def export_tickets( + db: AsyncSession = Depends(get_db), + _auth=Depends(require_ticket_auth), + ): + """Export all environments, tickets, and attachments as portable JSON.""" + env_rows = (await db.execute(select(Environment).order_by(Environment.id))).scalars().all() + env_by_id = {e.id: e.prefix for e in env_rows} + + ticket_rows = (await db.execute( + select(Ticket).order_by(Ticket.id) + )).scalars().all() + + att_by_ticket: dict[int, list] = {} + for t in ticket_rows: + att_by_ticket[t.id] = [ + { + 'title': a.title, + 'content': a.content, + 'created_by': a.created_by, + } + for a in t.attachments + ] + + return { + 'version': '1', + 'source': 'corvid', + 'exported_at': datetime.now(timezone.utc).isoformat(), + 'environments': [ + { + 'prefix': e.prefix, + 'name': e.name, + 'description': e.description, + } + for e in env_rows + ], + 'tickets': [ + { + '_id': t.id, + 'title': t.title, + 'description': t.description, + 'status': t.status, + 'type': t.type, + 'user': t.user, + 'effort': t.effort, + '_parent_id': t.parent_id, + 'environment_prefix': env_by_id.get(t.environment_id) if t.environment_id else None, + 'attachments': att_by_ticket.get(t.id, []), + } + for t in ticket_rows + ], + } + + @router.post("/api/tickets/import", status_code=201) + async def import_tickets( + body: dict, + db: AsyncSession = Depends(get_db), + _auth=Depends(require_ticket_auth), + ): + """Import from a portable JSON export (additive — never overwrites existing records).""" + if body.get('version') != '1': + raise HTTPException(400, "Unsupported export version") + + env_prefix_to_id: dict[str, int] = {} + for e_data in body.get('environments', []): + prefix = (e_data.get('prefix') or '').strip().upper() + if not prefix: + continue + existing = (await db.execute( + select(Environment).where(Environment.prefix == prefix) + )).scalar_one_or_none() + if existing: + env_prefix_to_id[prefix] = existing.id + else: + new_env = Environment( + prefix=prefix, + name=e_data.get('name') or prefix, + description=e_data.get('description'), + ) + db.add(new_env) + await db.flush() + env_prefix_to_id[prefix] = new_env.id + + old_to_new: dict[int, int] = {} + for t_data in body.get('tickets', []): + old_id = t_data.get('_id') + env_prefix = t_data.get('environment_prefix') + env_id = env_prefix_to_id.get(env_prefix) if env_prefix else None + + old_parent = t_data.get('_parent_id') + parent_id = old_to_new.get(int(old_parent)) if old_parent else None + + t = Ticket( + guid=str(uuid.uuid4()), + title=t_data.get('title') or 'Imported Ticket', + description=t_data.get('description'), + status=t_data.get('status') or 'open', + type=t_data.get('type') or 'feature', + user=t_data.get('user') or 'kevin', + effort=t_data.get('effort'), + parent_id=parent_id, + environment_id=env_id, + ) + db.add(t) + await db.flush() + + if old_id is not None: + old_to_new[int(old_id)] = t.id + + for att in t_data.get('attachments', []): + db.add(TicketAttachment( + ticket_id=t.id, + title=att.get('title') or 'Attachment', + content=att.get('content') or '', + created_by=att.get('created_by') or 'kevin', + )) + + await db.commit() + return {'ok': True, 'imported': len(old_to_new)} + @router.get("/api/tickets/{ticket_id}") async def get_ticket( ticket_id: int, @@ -452,126 +572,4 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter: await db.commit() return {"ok": True} - # ── Export / Import ─────────────────────────────────────────────────────── - - @router.get("/api/tickets/export") - async def export_tickets( - db: AsyncSession = Depends(get_db), - _auth=Depends(require_ticket_auth), - ): - """Export all environments, tickets, and attachments as portable JSON.""" - env_rows = (await db.execute(select(Environment).order_by(Environment.id))).scalars().all() - env_by_id = {e.id: e.prefix for e in env_rows} - - ticket_rows = (await db.execute( - select(Ticket).order_by(Ticket.id) - )).scalars().all() - - att_by_ticket: dict[int, list] = {} - for t in ticket_rows: - att_by_ticket[t.id] = [ - { - 'title': a.title, - 'content': a.content, - 'created_by': a.created_by, - } - for a in t.attachments - ] - - return { - 'version': '1', - 'source': 'corvid', - 'exported_at': datetime.now(timezone.utc).isoformat(), - 'environments': [ - { - 'prefix': e.prefix, - 'name': e.name, - 'description': e.description, - } - for e in env_rows - ], - 'tickets': [ - { - '_id': t.id, - 'title': t.title, - 'description': t.description, - 'status': t.status, - 'type': t.type, - 'user': t.user, - 'effort': t.effort, - '_parent_id': t.parent_id, - 'environment_prefix': env_by_id.get(t.environment_id) if t.environment_id else None, - 'attachments': att_by_ticket.get(t.id, []), - } - for t in ticket_rows - ], - } - - @router.post("/api/tickets/import", status_code=201) - async def import_tickets( - body: dict, - db: AsyncSession = Depends(get_db), - _auth=Depends(require_ticket_auth), - ): - """Import from a portable JSON export (additive — never overwrites existing records).""" - if body.get('version') != '1': - raise HTTPException(400, "Unsupported export version") - - env_prefix_to_id: dict[str, int] = {} - for e_data in body.get('environments', []): - prefix = (e_data.get('prefix') or '').strip().upper() - if not prefix: - continue - existing = (await db.execute( - select(Environment).where(Environment.prefix == prefix) - )).scalar_one_or_none() - if existing: - env_prefix_to_id[prefix] = existing.id - else: - new_env = Environment( - prefix=prefix, - name=e_data.get('name') or prefix, - description=e_data.get('description'), - ) - db.add(new_env) - await db.flush() - env_prefix_to_id[prefix] = new_env.id - - old_to_new: dict[int, int] = {} - for t_data in body.get('tickets', []): - old_id = t_data.get('_id') - env_prefix = t_data.get('environment_prefix') - env_id = env_prefix_to_id.get(env_prefix) if env_prefix else None - - old_parent = t_data.get('_parent_id') - parent_id = old_to_new.get(int(old_parent)) if old_parent else None - - t = Ticket( - guid=str(uuid.uuid4()), - title=t_data.get('title') or 'Imported Ticket', - description=t_data.get('description'), - status=t_data.get('status') or 'open', - type=t_data.get('type') or 'feature', - user=t_data.get('user') or 'kevin', - effort=t_data.get('effort'), - parent_id=parent_id, - environment_id=env_id, - ) - db.add(t) - await db.flush() - - if old_id is not None: - old_to_new[int(old_id)] = t.id - - for att in t_data.get('attachments', []): - db.add(TicketAttachment( - ticket_id=t.id, - title=att.get('title') or 'Attachment', - content=att.get('content') or '', - created_by=att.get('created_by') or 'kevin', - )) - - await db.commit() - return {'ok': True, 'imported': len(old_to_new)} - return router