kin: KIN-UI-003 Консистентная обработка ошибок в del() — использовать throwApiError
This commit is contained in:
parent
fc13245c93
commit
531275e4ce
5 changed files with 112 additions and 35 deletions
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
15
web/api.py
15
web/api.py
|
|
@ -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 для проекта."""
|
||||||
|
|
|
||||||
|
|
@ -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[] }) =>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue