feat: add ticket import/export endpoints

GET /api/tickets/export — portable JSON dump of all environments,
tickets, and attachments (version 1 format with _id/_parent_id for
relationship reconstruction).

POST /api/tickets/import — additive import; upserts environments by
prefix, creates tickets preserving parent-child structure, creates
attachments. Compatible with the matching Domus implementation so
tickets can be transferred between the two systems.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
caoimhinr 2026-06-03 20:56:04 +02:00
parent 0097055c15
commit 9eef5fd195

View file

@ -1,6 +1,7 @@
import hmac import hmac
import re import re
import uuid import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
from pydantic import BaseModel from pydantic import BaseModel
@ -451,4 +452,126 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter:
await db.commit() await db.commit()
return {"ok": True} 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 return router