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