kin: KIN-UI-003 Консистентная обработка ошибок в del() — использовать throwApiError

This commit is contained in:
Gros Frumos 2026-03-16 17:44:49 +02:00
parent fc13245c93
commit 531275e4ce
5 changed files with 112 additions and 35 deletions

View file

@ -100,9 +100,9 @@ def get_project(conn: sqlite3.Connection, id: str) -> dict | None:
def delete_project(conn: sqlite3.Connection, id: str) -> None:
"""Delete a project and all its related data (modules, decisions, tasks)."""
"""Delete a project and all its related data (modules, decisions, tasks, phases)."""
# Delete tables that have FK references to tasks BEFORE deleting tasks
for table in ("modules", "agent_logs", "decisions", "pipelines", "tasks"):
for table in ("modules", "agent_logs", "decisions", "pipelines", "project_phases", "tasks"):
conn.execute(f"DELETE FROM {table} WHERE project_id = ?", (id,))
conn.execute("DELETE FROM projects WHERE id = ?", (id,))
conn.commit()

View file

@ -1694,4 +1694,23 @@ def test_bootstrap_endpoint_success(bootstrap_client, tmp_path):
assert data["project"]["id"] == "goodproj"
assert "modules_count" in data
assert "decisions_count" in data
def test_delete_project_ok(client):
# Create a separate project to delete
r = client.post("/api/projects", json={"id": "del1", "name": "Del1", "path": "/del1"})
assert r.status_code == 200
r = client.delete("/api/projects/del1")
assert r.status_code == 204
assert r.content == b""
# Verify project is gone
r = client.get("/api/projects/del1")
assert r.status_code == 404
def test_delete_project_not_found(client):
r = client.delete("/api/projects/99999")
assert r.status_code == 404
assert "tasks_count" in data

View file

@ -14,7 +14,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse
from fastapi.responses import JSONResponse, FileResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, model_validator
@ -247,6 +247,19 @@ def patch_project(project_id: str, body: ProjectPatch):
return p
@app.delete("/api/projects/{project_id}", status_code=204)
def delete_project(project_id: str):
"""Delete a project and all its related data (tasks, decisions, phases, logs)."""
conn = get_conn()
p = models.get_project(conn, project_id)
if not p:
conn.close()
raise HTTPException(404, f"Project '{project_id}' not found")
models.delete_project(conn, project_id)
conn.close()
return Response(status_code=204)
@app.post("/api/projects/{project_id}/sync/obsidian")
def sync_obsidian_endpoint(project_id: str):
"""Запускает двусторонний Obsidian sync для проекта."""

View file

@ -48,7 +48,8 @@ async function post<T>(path: string, body: unknown): Promise<T> {
async function del<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`, { method: 'DELETE' })
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
if (!res.ok) await throwApiError(res)
if (res.status === 204) return undefined as T
return res.json()
}
@ -270,6 +271,8 @@ export const api = {
post<DeployResult>(`/projects/${projectId}/deploy`, {}),
syncObsidian: (projectId: string) =>
post<ObsidianSyncResult>(`/projects/${projectId}/sync/obsidian`, {}),
deleteProject: (id: string) =>
del<void>(`/projects/${id}`),
deleteDecision: (projectId: string, decisionId: number) =>
del<{ deleted: number }>(`/projects/${projectId}/decisions/${decisionId}`),
createDecision: (data: { project_id: string; type: string; title: string; description: string; category?: string; tags?: string[] }) =>

View file

@ -139,6 +139,21 @@ function toggleNpRole(key: string) {
else npRoles.value.push(key)
}
// Delete project
const confirmDeleteId = ref<string | null>(null)
const deleteError = ref('')
async function deleteProject(id: string) {
deleteError.value = ''
try {
await api.deleteProject(id)
projects.value = projects.value.filter(p => p.id !== id)
confirmDeleteId.value = null
} catch (e: any) {
deleteError.value = e.message
}
}
async function createNewProject() {
npError.value = ''
if (!npRoles.value.length) {
@ -197,39 +212,66 @@ async function createNewProject() {
<p v-else-if="error" class="text-red-400 text-sm">{{ error }}</p>
<div v-else class="grid gap-3">
<router-link
v-for="p in projects" :key="p.id"
:to="`/project/${p.id}`"
class="block border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors no-underline"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-gray-200">{{ p.id }}</span>
<Badge :text="p.status" :color="statusColor(p.status)" />
<Badge v-if="p.project_type && p.project_type !== 'development'"
:text="p.project_type"
:color="p.project_type === 'operations' ? 'orange' : 'green'" />
<span class="text-sm text-gray-400">{{ p.name }}</span>
<div v-for="p in projects" :key="p.id">
<!-- Inline delete confirmation -->
<div v-if="confirmDeleteId === p.id"
class="border border-red-800 rounded-lg p-4 bg-red-950/20">
<p class="text-sm text-gray-200 mb-3">Удалить проект «{{ p.name }}»? Это действие необратимо.</p>
<div class="flex gap-2">
<button @click="deleteProject(p.id)"
title="Подтвердить удаление"
class="px-3 py-1.5 text-xs bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900">
Да, удалить
</button>
<button @click="confirmDeleteId = null"
title="Отмена удаления"
class="px-3 py-1.5 text-xs bg-gray-800 text-gray-400 border border-gray-700 rounded hover:bg-gray-700">
Отмена
</button>
</div>
<div class="flex items-center gap-3 text-xs text-gray-500">
<span v-if="costMap[p.id]">${{ costMap[p.id]?.toFixed(2) }}/wk</span>
<span>pri {{ p.priority }}</span>
<p v-if="deleteError" class="text-red-400 text-xs mt-2">{{ deleteError }}</p>
</div>
<!-- Normal project card -->
<router-link v-else
:to="`/project/${p.id}`"
class="block border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors no-underline"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-gray-200">{{ p.id }}</span>
<Badge :text="p.status" :color="statusColor(p.status)" />
<Badge v-if="p.project_type && p.project_type !== 'development'"
:text="p.project_type"
:color="p.project_type === 'operations' ? 'orange' : 'green'" />
<span class="text-sm text-gray-400">{{ p.name }}</span>
</div>
<div class="flex items-center gap-3 text-xs text-gray-500">
<span v-if="costMap[p.id]">${{ costMap[p.id]?.toFixed(2) }}/wk</span>
<span>pri {{ p.priority }}</span>
<button @click.prevent.stop="confirmDeleteId = p.id"
title="Удалить проект"
class="text-gray-600 hover:text-red-400 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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" />
</svg>
</button>
</div>
</div>
</div>
<div class="flex gap-4 text-xs">
<span class="text-gray-500">{{ p.total_tasks }} tasks</span>
<span v-if="p.active_tasks" class="text-blue-400">
<span class="inline-block w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse mr-0.5"></span>
{{ p.active_tasks }} active
</span>
<span v-if="p.review_tasks" class="text-yellow-400">{{ p.review_tasks }} awaiting review</span>
<span v-if="p.blocked_tasks" class="text-red-400">{{ p.blocked_tasks }} blocked</span>
<span v-if="p.done_tasks" class="text-green-500">{{ p.done_tasks }} done</span>
<span v-if="p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks - (p.review_tasks || 0) > 0" class="text-gray-500">
{{ p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks - (p.review_tasks || 0) }} pending
</span>
</div>
</router-link>
<div class="flex gap-4 text-xs">
<span class="text-gray-500">{{ p.total_tasks }} tasks</span>
<span v-if="p.active_tasks" class="text-blue-400">
<span class="inline-block w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse mr-0.5"></span>
{{ p.active_tasks }} active
</span>
<span v-if="p.review_tasks" class="text-yellow-400">{{ p.review_tasks }} awaiting review</span>
<span v-if="p.blocked_tasks" class="text-red-400">{{ p.blocked_tasks }} blocked</span>
<span v-if="p.done_tasks" class="text-green-500">{{ p.done_tasks }} done</span>
<span v-if="p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks - (p.review_tasks || 0) > 0" class="text-gray-500">
{{ p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks - (p.review_tasks || 0) }} pending
</span>
</div>
</router-link>
</div>
</div>
<!-- Add Project Modal -->