54 lines
11 KiB
Python
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
|