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:
parent
9eef5fd195
commit
f354c0ea89
1 changed files with 120 additions and 122 deletions
242
corvid/router.py
242
corvid/router.py
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue