fix: register export/import before parameterised ticket route

GET /api/tickets/export was being matched by GET /api/tickets/{ticket_id}
because FastAPI matches in registration order. Moved export/import routes
before the /{ticket_id} route so they are resolved first.

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

View file

@ -300,6 +300,126 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter:
await db.commit() await db.commit()
return await _ticket_dict(await _get_ticket_or_404(db, t.id), db) 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}") @router.get("/api/tickets/{ticket_id}")
async def get_ticket( async def get_ticket(
ticket_id: int, ticket_id: int,
@ -452,126 +572,4 @@ 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