tickethub/backend/app/main.py

54 lines
11 KiB
Python

"""
TicketHub - Lightweight Issue Tracking System
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from contextlib import asynccontextmanager
import os
from app.routers import tickets, projects, webhooks, health
from app.services.database import init_db
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(
title="TicketHub",
description="Lightweight open-source ticket/issue tracking system",
version="1.0.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API routes
app.include_router(health.router, prefix="/api", tags=["health"])
app.include_router(projects.router, prefix="/api/projects", tags=["projects"])
app.include_router(tickets.router, prefix="/api/tickets", tags=["tickets"])
app.include_router(webhooks.router, prefix="/api/webhooks", tags=["webhooks"])
# Serve embedded frontend
HTML = """<!DOCTYPE html>
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>TicketHub</title>
<script src="https://cdn.tailwindcss.com"></script><style>body{font-family:Inter,system-ui,sans-serif}.status-open{background:#dbeafe;color:#1e40af}.status-in_progress{background:#fef3c7;color:#92400e}.status-resolved{background:#d1fae5;color:#065f46}.status-closed{background:#e5e7eb;color:#374151}.priority-low{border-left:4px solid #10b981}.priority-medium{border-left:4px solid #f59e0b}.priority-high{border-left:4px solid #f97316}.priority-critical{border-left:4px solid #ef4444}</style></head>
<body class="bg-gray-50"><div id="app" class="min-h-screen"><header class="bg-white shadow-sm border-b"><div class="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between"><div class="flex items-center gap-2"><span class="text-2xl">🎫</span><h1 class="text-xl font-bold">TicketHub</h1></div><button onclick="showProjectModal()" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">+ New Project</button></div></header>
<main class="max-w-7xl mx-auto px-4 py-6"><div class="mb-6"><h2 class="text-lg font-semibold mb-3">Projects</h2><div id="projects-list" class="flex gap-3 flex-wrap"></div></div>
<div class="bg-white rounded-xl shadow-sm border"><div class="p-4 border-b flex items-center justify-between"><h2 class="text-lg font-semibold">Tickets</h2><button onclick="showTicketModal()" id="new-ticket-btn" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 hidden">+ New Ticket</button></div><div id="tickets-list" class="divide-y"><div class="p-8 text-center text-gray-500">Select a project</div></div></div></main></div>
<div id="project-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50"><div class="bg-white rounded-xl p-6 w-full max-w-md mx-4"><h3 class="text-lg font-semibold mb-4">New Project</h3><form id="project-form" onsubmit="createProject(event)"><div class="space-y-4"><div><label class="block text-sm font-medium mb-1">Name</label><input type="text" name="name" required class="w-full border rounded-lg px-3 py-2"></div><div><label class="block text-sm font-medium mb-1">Key</label><input type="text" name="key" required pattern="[A-Za-z]{2,10}" class="w-full border rounded-lg px-3 py-2 uppercase"></div><div><label class="block text-sm font-medium mb-1">Webhook URL</label><input type="url" name="webhook_url" class="w-full border rounded-lg px-3 py-2"></div></div><div class="flex gap-3 mt-6"><button type="button" onclick="hideProjectModal()" class="flex-1 border rounded-lg py-2">Cancel</button><button type="submit" class="flex-1 bg-blue-600 text-white rounded-lg py-2">Create</button></div></form></div></div>
<div id="ticket-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50"><div class="bg-white rounded-xl p-6 w-full max-w-lg mx-4"><h3 class="text-lg font-semibold mb-4">New Ticket</h3><form id="ticket-form" onsubmit="createTicket(event)"><div class="space-y-4"><div><label class="block text-sm font-medium mb-1">Title</label><input type="text" name="title" required class="w-full border rounded-lg px-3 py-2"></div><div><label class="block text-sm font-medium mb-1">Description</label><textarea name="description" rows="4" required class="w-full border rounded-lg px-3 py-2"></textarea></div><div><label class="block text-sm font-medium mb-1">Priority</label><select name="priority" class="w-full border rounded-lg px-3 py-2"><option value="low">Low</option><option value="medium" selected>Medium</option><option value="high">High</option><option value="critical">Critical</option></select></div></div><div class="flex gap-3 mt-6"><button type="button" onclick="hideTicketModal()" class="flex-1 border rounded-lg py-2">Cancel</button><button type="submit" class="flex-1 bg-green-600 text-white rounded-lg py-2">Create</button></div></form></div></div>
<div id="detail-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50"><div class="bg-white rounded-xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-hidden flex flex-col"><div class="p-4 border-b flex justify-between"><h3 id="detail-key" class="text-lg font-semibold"></h3><button onclick="hideDetail()" class="text-gray-500">✕</button></div><div class="p-6 overflow-y-auto flex-1"><div id="detail-content"></div><div class="mt-6 border-t pt-4"><h4 class="font-medium mb-3">Comments</h4><div id="comments" class="space-y-3 mb-4"></div><form onsubmit="addComment(event)" class="flex gap-2"><input type="text" id="comment-input" class="flex-1 border rounded-lg px-3 py-2" placeholder="Add comment..."><button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-lg">Send</button></form></div></div></div></div>
<script>const API='/api';let curProj=null,curTkt=null;loadProjects();async function loadProjects(){const r=await fetch(API+'/projects'),p=await r.json(),c=document.getElementById('projects-list');if(!p.length){c.innerHTML='<div class="text-gray-500">No projects yet</div>';return}c.innerHTML=p.map(x=>`<button onclick="selectProject(${x.id})" class="px-4 py-2 rounded-lg border ${curProj?.id===x.id?'bg-blue-100 border-blue-300':'bg-white hover:bg-gray-50'}"><span class="font-medium">${x.key}</span> <span class="text-gray-500">(${x.ticket_count})</span></button>`).join('')}async function selectProject(id){const r=await fetch(API+'/projects/'+id);curProj=await r.json();loadProjects();loadTickets();document.getElementById('new-ticket-btn').classList.remove('hidden')}async function loadTickets(){if(!curProj)return;const r=await fetch(API+'/tickets?project_id='+curProj.id),t=await r.json(),c=document.getElementById('tickets-list');if(!t.length){c.innerHTML='<div class="p-8 text-center text-gray-500">No tickets</div>';return}c.innerHTML=t.map(x=>`<div onclick="showDetail(${x.id})" class="p-4 hover:bg-gray-50 cursor-pointer priority-${x.priority}"><div class="flex justify-between"><div><span class="text-sm font-mono text-gray-500">${x.key}</span><span class="font-medium ml-2">${x.title}</span></div><span class="px-2 py-1 rounded text-xs font-medium status-${x.status}">${x.status.replace('_',' ')}</span></div><p class="text-sm text-gray-500 mt-1 truncate">${x.description}</p></div>`).join('')}async function showDetail(id){const r=await fetch(API+'/tickets/'+id);curTkt=await r.json();document.getElementById('detail-key').textContent=curTkt.key;document.getElementById('detail-content').innerHTML=`<h2 class="text-xl font-semibold">${curTkt.title}</h2><div class="flex gap-2 mt-2"><span class="px-2 py-1 rounded text-xs font-medium status-${curTkt.status}">${curTkt.status.replace('_',' ')}</span><span class="px-2 py-1 rounded text-xs bg-gray-100">${curTkt.priority}</span></div><p class="mt-4 whitespace-pre-wrap">${curTkt.description}</p><select onchange="updateStatus(this.value)" class="mt-4 border rounded px-2 py-1 text-sm"><option value="open" ${curTkt.status==='open'?'selected':''}>Open</option><option value="in_progress" ${curTkt.status==='in_progress'?'selected':''}>In Progress</option><option value="resolved" ${curTkt.status==='resolved'?'selected':''}>Resolved</option><option value="closed" ${curTkt.status==='closed'?'selected':''}>Closed</option></select>`;loadComments();document.getElementById('detail-modal').classList.remove('hidden');document.getElementById('detail-modal').classList.add('flex')}async function loadComments(){const r=await fetch(API+'/tickets/'+curTkt.id+'/comments'),c=await r.json();document.getElementById('comments').innerHTML=c.length?c.map(x=>`<div class="bg-gray-50 rounded-lg p-3"><div class="flex justify-between text-sm"><span class="font-medium">${x.author}</span><span class="text-gray-500">${new Date(x.created_at).toLocaleString()}</span></div><pre class="mt-1 whitespace-pre-wrap font-sans text-sm">${x.content}</pre></div>`).join(''):'<div class="text-gray-500 text-sm">No comments</div>'}async function addComment(e){e.preventDefault();const i=document.getElementById('comment-input');if(!i.value.trim())return;await fetch(API+'/tickets/'+curTkt.id+'/comments',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({author:'user',content:i.value})});i.value='';loadComments()}async function updateStatus(s){await fetch(API+'/tickets/'+curTkt.id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:s})});loadTickets()}function hideDetail(){document.getElementById('detail-modal').classList.add('hidden');document.getElementById('detail-modal').classList.remove('flex')}function showProjectModal(){document.getElementById('project-modal').classList.remove('hidden');document.getElementById('project-modal').classList.add('flex')}function hideProjectModal(){document.getElementById('project-modal').classList.add('hidden');document.getElementById('project-modal').classList.remove('flex')}function showTicketModal(){document.getElementById('ticket-modal').classList.remove('hidden');document.getElementById('ticket-modal').classList.add('flex')}function hideTicketModal(){document.getElementById('ticket-modal').classList.add('hidden');document.getElementById('ticket-modal').classList.remove('flex')}async function createProject(e){e.preventDefault();const f=e.target;await fetch(API+'/projects',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:f.name.value,key:f.key.value.toUpperCase(),webhook_url:f.webhook_url.value||null})});f.reset();hideProjectModal();loadProjects()}async function createTicket(e){e.preventDefault();const f=e.target;await fetch(API+'/tickets',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({project_id:curProj.id,title:f.title.value,description:f.description.value,priority:f.priority.value})});f.reset();hideTicketModal();loadTickets();loadProjects()}</script></body></html>"""
@app.get("/", response_class=HTMLResponse)
async def root():
return HTML