feat: Professional UI v2

- Sidebar navigation with project list
- Project details view with stats
- Edit/delete project functionality
- Better ticket list with priority icons
- Improved modals and forms
- Responsive design
This commit is contained in:
Ricel Leite 2026-02-18 18:17:08 -03:00
parent 03f68061b1
commit f695884784
3 changed files with 1142 additions and 11 deletions

File diff suppressed because one or more lines are too long

568
backend/app/main_v2.py Normal file
View File

@ -0,0 +1,568 @@
"""
TicketHub - Lightweight Issue Tracking System
Professional Edition
"""
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="2.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"])
# Professional Frontend
HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TicketHub</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<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; }
.sidebar { width: 280px; }
.main-content { margin-left: 280px; }
@media (max-width: 768px) {
.sidebar { width: 100%; position: relative; }
.main-content { margin-left: 0; }
}
</style>
</head>
<body class="bg-gray-50">
<div id="app" class="min-h-screen flex">
<!-- Sidebar -->
<aside class="sidebar fixed h-full bg-white border-r border-gray-200 flex flex-col">
<div class="p-4 border-b border-gray-200">
<div class="flex items-center gap-2">
<span class="text-2xl">🎫</span>
<h1 class="text-xl font-bold text-gray-900">TicketHub</h1>
</div>
<p class="text-xs text-gray-500 mt-1">Issue Tracking System</p>
</div>
<div class="p-4 border-b border-gray-200">
<button onclick="showProjectModal()" class="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
New Project
</button>
</div>
<div class="flex-1 overflow-y-auto p-4">
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Projects</h3>
<div id="projects-list" class="space-y-1"></div>
</div>
<div class="p-4 border-t border-gray-200 text-xs text-gray-500">
<div id="stats" class="space-y-1"></div>
</div>
</aside>
<!-- Main Content -->
<main class="main-content flex-1 p-6">
<div id="welcome-view" class="h-full flex items-center justify-center">
<div class="text-center">
<span class="text-6xl">📋</span>
<h2 class="text-xl font-semibold text-gray-700 mt-4">Select a project</h2>
<p class="text-gray-500 mt-2">Choose a project from the sidebar or create a new one</p>
</div>
</div>
<div id="project-view" class="hidden">
<!-- Project Header -->
<div class="bg-white rounded-xl shadow-sm border p-6 mb-6">
<div class="flex justify-between items-start">
<div>
<div class="flex items-center gap-3">
<span class="text-3xl" id="proj-icon">📁</span>
<div>
<h2 class="text-2xl font-bold text-gray-900" id="proj-name"></h2>
<p class="text-gray-500" id="proj-key"></p>
</div>
</div>
<p class="text-gray-600 mt-3" id="proj-desc"></p>
</div>
<div class="flex gap-2">
<button onclick="showEditProjectModal()" class="px-3 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
</button>
<button onclick="deleteProject()" class="px-3 py-2 text-red-600 hover:bg-red-50 rounded-lg">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
</button>
</div>
</div>
<!-- Project Stats -->
<div class="grid grid-cols-4 gap-4 mt-6">
<div class="bg-blue-50 rounded-lg p-3 text-center">
<div class="text-2xl font-bold text-blue-600" id="stat-total">0</div>
<div class="text-xs text-blue-600">Total</div>
</div>
<div class="bg-yellow-50 rounded-lg p-3 text-center">
<div class="text-2xl font-bold text-yellow-600" id="stat-open">0</div>
<div class="text-xs text-yellow-600">Open</div>
</div>
<div class="bg-purple-50 rounded-lg p-3 text-center">
<div class="text-2xl font-bold text-purple-600" id="stat-progress">0</div>
<div class="text-xs text-purple-600">In Progress</div>
</div>
<div class="bg-green-50 rounded-lg p-3 text-center">
<div class="text-2xl font-bold text-green-600" id="stat-resolved">0</div>
<div class="text-xs text-green-600">Resolved</div>
</div>
</div>
<!-- Webhook Config -->
<div class="mt-6 p-4 bg-gray-50 rounded-lg">
<div class="flex items-center gap-2 text-sm">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path></svg>
<span class="text-gray-600">Webhook:</span>
<code class="text-xs bg-white px-2 py-1 rounded border" id="proj-webhook">Not configured</code>
</div>
</div>
</div>
<!-- Tickets Section -->
<div class="bg-white rounded-xl shadow-sm border">
<div class="p-4 border-b flex items-center justify-between">
<h3 class="font-semibold text-gray-900">Tickets</h3>
<button onclick="showTicketModal()" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
New Ticket
</button>
</div>
<div id="tickets-list" class="divide-y">
<div class="p-8 text-center text-gray-500">No tickets yet</div>
</div>
</div>
</div>
</main>
</div>
<!-- Create Project Modal -->
<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 shadow-xl">
<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 text-gray-700 mb-1">Project Name</label>
<input type="text" name="name" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="My Project">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Key (2-10 letters)</label>
<input type="text" name="key" required pattern="[A-Za-z]{2,10}" class="w-full border border-gray-300 rounded-lg px-3 py-2 uppercase focus:ring-2 focus:ring-blue-500" placeholder="PROJ">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea name="description" rows="2" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500" placeholder="Optional description"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Webhook URL</label>
<input type="url" name="webhook_url" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500" placeholder="https://...">
<p class="text-xs text-gray-500 mt-1">Receive notifications when tickets are created/updated</p>
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="button" onclick="hideProjectModal()" class="flex-1 border border-gray-300 rounded-lg py-2 hover:bg-gray-50">Cancel</button>
<button type="submit" class="flex-1 bg-blue-600 text-white rounded-lg py-2 hover:bg-blue-700">Create</button>
</div>
</form>
</div>
</div>
<!-- Edit Project Modal -->
<div id="edit-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 shadow-xl">
<h3 class="text-lg font-semibold mb-4">Edit Project</h3>
<form id="edit-project-form" onsubmit="updateProject(event)">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Project Name</label>
<input type="text" name="name" id="edit-proj-name" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea name="description" id="edit-proj-desc" rows="2" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Webhook URL</label>
<input type="url" name="webhook_url" id="edit-proj-webhook" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500">
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="button" onclick="hideEditProjectModal()" class="flex-1 border border-gray-300 rounded-lg py-2 hover:bg-gray-50">Cancel</button>
<button type="submit" class="flex-1 bg-blue-600 text-white rounded-lg py-2 hover:bg-blue-700">Save</button>
</div>
</form>
</div>
</div>
<!-- Create Ticket Modal -->
<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 shadow-xl">
<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 text-gray-700 mb-1">Title</label>
<input type="text" name="title" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea name="description" rows="4" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Priority</label>
<select name="priority" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500">
<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 border-gray-300 rounded-lg py-2 hover:bg-gray-50">Cancel</button>
<button type="submit" class="flex-1 bg-green-600 text-white rounded-lg py-2 hover:bg-green-700">Create</button>
</div>
</form>
</div>
</div>
<!-- Ticket Detail Modal -->
<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 shadow-xl">
<div class="p-4 border-b flex justify-between items-center bg-gray-50">
<div class="flex items-center gap-3">
<span class="font-mono text-blue-600" id="detail-key"></span>
<span class="px-2 py-1 rounded text-xs font-medium" id="detail-status"></span>
</div>
<button onclick="hideDetail()" class="text-gray-500 hover:text-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</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-semibold text-gray-900 mb-3">Comments</h4>
<div id="comments" class="space-y-3 mb-4 max-h-64 overflow-y-auto"></div>
<form onsubmit="addComment(event)" class="flex gap-2">
<input type="text" id="comment-input" class="flex-1 border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500" placeholder="Add a comment...">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">Send</button>
</form>
</div>
</div>
</div>
</div>
<script>
const API = '/api';
let curProj = null;
let curTkt = null;
let allProjects = [];
// Load projects on start
loadProjects();
async function loadProjects() {
const r = await fetch(API + '/projects');
allProjects = await r.json();
const c = document.getElementById('projects-list');
if (!allProjects.length) {
c.innerHTML = '<div class="text-gray-400 text-sm">No projects yet</div>';
updateGlobalStats([]);
return;
}
c.innerHTML = allProjects.map(x => `
<button onclick="selectProject(${x.id})"
class="w-full text-left px-3 py-2 rounded-lg transition-colors ${curProj?.id === x.id ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-100 text-gray-700'}">
<div class="flex justify-between items-center">
<span class="font-medium">${x.key}</span>
<span class="text-xs ${curProj?.id === x.id ? 'text-blue-500' : 'text-gray-400'}">${x.ticket_count}</span>
</div>
<div class="text-xs ${curProj?.id === x.id ? 'text-blue-500' : 'text-gray-500'} truncate">${x.name}</div>
</button>
`).join('');
updateGlobalStats(allProjects);
}
function updateGlobalStats(projects) {
const total = projects.reduce((a, p) => a + (p.ticket_count || 0), 0);
document.getElementById('stats').innerHTML = `
<div>Total Projects: ${projects.length}</div>
<div>Total Tickets: ${total}</div>
`;
}
async function selectProject(id) {
const r = await fetch(API + '/projects/' + id);
curProj = await r.json();
document.getElementById('welcome-view').classList.add('hidden');
document.getElementById('project-view').classList.remove('hidden');
document.getElementById('proj-name').textContent = curProj.name;
document.getElementById('proj-key').textContent = curProj.key;
document.getElementById('proj-desc').textContent = curProj.description || 'No description';
document.getElementById('proj-webhook').textContent = curProj.webhook_url || 'Not configured';
loadProjects();
loadTickets();
}
async function loadTickets() {
if (!curProj) return;
const r = await fetch(API + '/tickets?project_id=' + curProj.id);
const tickets = await r.json();
// Update stats
document.getElementById('stat-total').textContent = tickets.length;
document.getElementById('stat-open').textContent = tickets.filter(t => t.status === 'open').length;
document.getElementById('stat-progress').textContent = tickets.filter(t => t.status === 'in_progress').length;
document.getElementById('stat-resolved').textContent = tickets.filter(t => t.status === 'resolved' || t.status === 'closed').length;
const c = document.getElementById('tickets-list');
if (!tickets.length) {
c.innerHTML = '<div class="p-8 text-center text-gray-500">No tickets yet</div>';
return;
}
const priorityIcon = {low: '🟢', medium: '🟡', high: '🟠', critical: '🔴'};
c.innerHTML = tickets.map(x => `
<div onclick="showDetail(${x.id})" class="p-4 hover:bg-gray-50 cursor-pointer priority-${x.priority}">
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-mono text-gray-500">${x.key}</span>
<span>${priorityIcon[x.priority] || '🟡'}</span>
</div>
<h4 class="font-medium text-gray-900 mt-1">${x.title}</h4>
<p class="text-sm text-gray-500 mt-1 line-clamp-2">${x.description}</p>
</div>
<span class="px-2 py-1 rounded text-xs font-medium status-${x.status} ml-4">${x.status.replace('_', ' ')}</span>
</div>
</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-status').textContent = curTkt.status.replace('_', ' ');
document.getElementById('detail-status').className = 'px-2 py-1 rounded text-xs font-medium status-' + curTkt.status;
const priorityIcon = {low: '🟢', medium: '🟡', high: '🟠', critical: '🔴'};
document.getElementById('detail-content').innerHTML = `
<h2 class="text-xl font-semibold text-gray-900">${curTkt.title}</h2>
<div class="flex items-center gap-3 mt-3">
<span class="text-sm">${priorityIcon[curTkt.priority] || '🟡'} ${curTkt.priority}</span>
<span class="text-sm text-gray-400">Created ${new Date(curTkt.created_at).toLocaleDateString()}</span>
</div>
<div class="mt-4 p-4 bg-gray-50 rounded-lg">
<pre class="whitespace-pre-wrap font-sans text-sm text-gray-700">${curTkt.description}</pre>
</div>
<div class="mt-4">
<label class="text-sm font-medium text-gray-700">Status</label>
<select onchange="updateStatus(this.value)" class="ml-2 border border-gray-300 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>
</div>
`;
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');
const comments = await r.json();
document.getElementById('comments').innerHTML = comments.length ? comments.map(x => `
<div class="bg-gray-50 rounded-lg p-3">
<div class="flex justify-between text-sm">
<span class="font-medium text-gray-900">${x.author}</span>
<span class="text-gray-500">${new Date(x.created_at).toLocaleString()}</span>
</div>
<pre class="mt-2 whitespace-pre-wrap font-sans text-sm text-gray-700">${x.content}</pre>
</div>
`).join('') : '<div class="text-gray-500 text-sm">No comments yet</div>';
}
async function addComment(e) {
e.preventDefault();
const input = document.getElementById('comment-input');
if (!input.value.trim()) return;
await fetch(API + '/tickets/' + curTkt.id + '/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ author: 'User', content: input.value })
});
input.value = '';
loadComments();
}
async function updateStatus(status) {
await fetch(API + '/tickets/' + curTkt.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
loadTickets();
}
async function deleteProject() {
if (!confirm('Delete this project and all its tickets?')) return;
await fetch(API + '/projects/' + curProj.id, { method: 'DELETE' });
curProj = null;
document.getElementById('welcome-view').classList.remove('hidden');
document.getElementById('project-view').classList.add('hidden');
loadProjects();
}
function showEditProjectModal() {
document.getElementById('edit-proj-name').value = curProj.name;
document.getElementById('edit-proj-desc').value = curProj.description || '';
document.getElementById('edit-proj-webhook').value = curProj.webhook_url || '';
document.getElementById('edit-project-modal').classList.remove('hidden');
document.getElementById('edit-project-modal').classList.add('flex');
}
function hideEditProjectModal() {
document.getElementById('edit-project-modal').classList.add('hidden');
document.getElementById('edit-project-modal').classList.remove('flex');
}
async function updateProject(e) {
e.preventDefault();
const form = e.target;
await fetch(API + '/projects/' + curProj.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.name.value,
description: form.description.value || null,
webhook_url: form.webhook_url.value || null
})
});
hideEditProjectModal();
selectProject(curProj.id);
}
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 form = e.target;
await fetch(API + '/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.name.value,
key: form.key.value.toUpperCase(),
description: form.description.value || null,
webhook_url: form.webhook_url.value || null
})
});
form.reset();
hideProjectModal();
loadProjects();
}
async function createTicket(e) {
e.preventDefault();
const form = e.target;
await fetch(API + '/tickets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project_id: curProj.id,
title: form.title.value,
description: form.description.value,
priority: form.priority.value
})
});
form.reset();
hideTicketModal();
loadTickets();
loadProjects();
}
</script>
</body>
</html>"""
@app.get("/", response_class=HTMLResponse)
async def root():
return HTML

View File

@ -63,3 +63,51 @@ async def delete_project(project_id: int):
await db.commit() await db.commit()
await db.close() await db.close()
return {"status": "deleted"} return {"status": "deleted"}
from pydantic import BaseModel
from typing import Optional
class ProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
webhook_url: Optional[str] = None
@router.patch("/{project_id}", response_model=Project)
async def update_project(project_id: int, update: ProjectUpdate):
db = await get_db()
# Check if exists
cursor = await db.execute("SELECT * FROM projects WHERE id = ?", (project_id,))
if not await cursor.fetchone():
await db.close()
raise HTTPException(status_code=404, detail="Project not found")
# Build update query
updates = []
params = []
if update.name is not None:
updates.append("name = ?")
params.append(update.name)
if update.description is not None:
updates.append("description = ?")
params.append(update.description if update.description else None)
if update.webhook_url is not None:
updates.append("webhook_url = ?")
params.append(update.webhook_url if update.webhook_url else None)
if updates:
params.append(project_id)
await db.execute(f"UPDATE projects SET {', '.join(updates)} WHERE id = ?", params)
await db.commit()
cursor = await db.execute("""
SELECT p.*, COUNT(t.id) as ticket_count
FROM projects p
LEFT JOIN tickets t ON p.id = t.project_id
WHERE p.id = ?
GROUP BY p.id
""", (project_id,))
row = await cursor.fetchone()
await db.close()
return dict(row)