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: 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 # 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(f"DELETE FROM {table} WHERE project_id = ?", (id,))
conn.execute("DELETE FROM projects WHERE id = ?", (id,)) conn.execute("DELETE FROM projects WHERE id = ?", (id,))
conn.commit() conn.commit()

View file

@ -1694,4 +1694,23 @@ def test_bootstrap_endpoint_success(bootstrap_client, tmp_path):
assert data["project"]["id"] == "goodproj" assert data["project"]["id"] == "goodproj"
assert "modules_count" in data assert "modules_count" in data
assert "decisions_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 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 import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware 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 fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, model_validator from pydantic import BaseModel, model_validator
@ -247,6 +247,19 @@ def patch_project(project_id: str, body: ProjectPatch):
return p 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") @app.post("/api/projects/{project_id}/sync/obsidian")
def sync_obsidian_endpoint(project_id: str): def sync_obsidian_endpoint(project_id: str):
"""Запускает двусторонний Obsidian sync для проекта.""" """Запускает двусторонний 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> { async function del<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`, { method: 'DELETE' }) 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() return res.json()
} }
@ -270,6 +271,8 @@ export const api = {
post<DeployResult>(`/projects/${projectId}/deploy`, {}), post<DeployResult>(`/projects/${projectId}/deploy`, {}),
syncObsidian: (projectId: string) => syncObsidian: (projectId: string) =>
post<ObsidianSyncResult>(`/projects/${projectId}/sync/obsidian`, {}), post<ObsidianSyncResult>(`/projects/${projectId}/sync/obsidian`, {}),
deleteProject: (id: string) =>
del<void>(`/projects/${id}`),
deleteDecision: (projectId: string, decisionId: number) => deleteDecision: (projectId: string, decisionId: number) =>
del<{ deleted: number }>(`/projects/${projectId}/decisions/${decisionId}`), del<{ deleted: number }>(`/projects/${projectId}/decisions/${decisionId}`),
createDecision: (data: { project_id: string; type: string; title: string; description: string; category?: string; tags?: string[] }) => 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) 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() { async function createNewProject() {
npError.value = '' npError.value = ''
if (!npRoles.value.length) { if (!npRoles.value.length) {
@ -197,8 +212,27 @@ async function createNewProject() {
<p v-else-if="error" class="text-red-400 text-sm">{{ error }}</p> <p v-else-if="error" class="text-red-400 text-sm">{{ error }}</p>
<div v-else class="grid gap-3"> <div v-else class="grid gap-3">
<router-link <div v-for="p in projects" :key="p.id">
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>
<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}`" :to="`/project/${p.id}`"
class="block border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors no-underline" class="block border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors no-underline"
> >
@ -214,6 +248,13 @@ async function createNewProject() {
<div class="flex items-center gap-3 text-xs text-gray-500"> <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 v-if="costMap[p.id]">${{ costMap[p.id]?.toFixed(2) }}/wk</span>
<span>pri {{ p.priority }}</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>
<div class="flex gap-4 text-xs"> <div class="flex gap-4 text-xs">
@ -231,6 +272,7 @@ async function createNewProject() {
</div> </div>
</router-link> </router-link>
</div> </div>
</div>
<!-- Add Project Modal --> <!-- Add Project Modal -->
<Modal v-if="showAdd" title="Add Project" @close="showAdd = false"> <Modal v-if="showAdd" title="Add Project" @close="showAdd = false">