AP1.13b: Gate-Abhaengigkeiten auf der Detailseite pflegen.
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 2m11s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 2m11s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
DELETE fuer Roadmap-Kanten, Duplikat-Schutz beim Anlegen, GateDependenciesSection mit ausgehenden/eingehenden Kanten auf der Gate-Detailseite. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7f03504c2d
commit
7cd8f4b90e
|
|
@ -98,6 +98,7 @@ app.include_router(backlog.router)
|
|||
app.include_router(milestones.router)
|
||||
app.include_router(roadmap.initiative_router)
|
||||
app.include_router(roadmap.items_router)
|
||||
app.include_router(roadmap.deps_router)
|
||||
app.include_router(roadmap.criteria_router)
|
||||
app.include_router(evidence.router)
|
||||
app.include_router(decisions.router)
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ class ReopenRequest(BaseModel):
|
|||
|
||||
initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"])
|
||||
items_router = APIRouter(prefix="/api/roadmap-items", tags=["roadmap"])
|
||||
deps_router = APIRouter(prefix="/api/roadmap-dependencies", tags=["roadmap"])
|
||||
|
||||
|
||||
@initiative_router.get("/{initiative_id}/roadmap")
|
||||
|
|
@ -270,6 +271,21 @@ def create_roadmap_item_criterion(
|
|||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@deps_router.delete("/{dependency_id}", status_code=204)
|
||||
def delete_roadmap_dependency(
|
||||
dependency_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||
):
|
||||
try:
|
||||
roadmap_service.delete_dependency(
|
||||
tenant_id=ctx.tenant_id,
|
||||
dependency_id=dependency_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
criteria_router = APIRouter(prefix="/api/roadmap-criteria", tags=["roadmap"])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -542,6 +542,17 @@ def add_dependency(
|
|||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM roadmap_item_dependencies
|
||||
WHERE tenant_id = %s AND from_item_id = %s AND to_item_id = %s
|
||||
AND dependency_type = %s
|
||||
""",
|
||||
(tenant_id, from_item_id, to_item_id, dependency_type),
|
||||
)
|
||||
if cur.fetchone():
|
||||
raise ValueError("Abhängigkeit existiert bereits")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO roadmap_item_dependencies (
|
||||
|
|
@ -573,6 +584,49 @@ def add_dependency(
|
|||
return row
|
||||
|
||||
|
||||
def delete_dependency(
|
||||
*, tenant_id: str, dependency_id: str, user_id: Optional[str] = None
|
||||
) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, from_item_id, to_item_id, dependency_type
|
||||
FROM roadmap_item_dependencies
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
(dependency_id, tenant_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise ValueError("Abhängigkeit nicht gefunden")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM roadmap_item_dependencies
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
(dependency_id, tenant_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_audit(
|
||||
"roadmap_item.dependency_removed",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={
|
||||
"dependency_id": dependency_id,
|
||||
"from_item_id": str(row["from_item_id"]),
|
||||
"to_item_id": str(row["to_item_id"]),
|
||||
"dependency_type": row["dependency_type"],
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def verify_reached(
|
||||
*, tenant_id: str, item_id: str, user_id: Optional[str] = None
|
||||
) -> dict[str, Any]:
|
||||
|
|
|
|||
|
|
@ -146,3 +146,53 @@ def test_initiative_roadmap_dependencies_list(client):
|
|||
assert deps[0]["from_item_id"] == gate_a["id"]
|
||||
assert deps[0]["to_item_id"] == gate_b["id"]
|
||||
assert deps[0]["dependency_type"] == "requires"
|
||||
|
||||
|
||||
def test_roadmap_dependency_delete(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
token = _login(client, user)
|
||||
initiative_id = _create_initiative(client, token).json()["id"]
|
||||
gate_a = _create_roadmap_item(client, token, initiative_id, title="Gate A").json()
|
||||
gate_b = _create_roadmap_item(client, token, initiative_id, title="Gate B").json()
|
||||
|
||||
created = client.post(
|
||||
f"/api/roadmap-items/{gate_a['id']}/dependencies",
|
||||
json={"to_item_id": gate_b["id"], "dependency_type": "requires"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
dep_id = created.json()["id"]
|
||||
|
||||
deleted = client.delete(
|
||||
f"/api/roadmap-dependencies/{dep_id}",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert deleted.status_code == 204
|
||||
|
||||
listed = client.get(
|
||||
f"/api/initiatives/{initiative_id}/roadmap/dependencies",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert listed.json() == []
|
||||
|
||||
|
||||
def test_roadmap_dependency_duplicate_rejected(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
token = _login(client, user)
|
||||
initiative_id = _create_initiative(client, token).json()["id"]
|
||||
gate_a = _create_roadmap_item(client, token, initiative_id, title="Gate A").json()
|
||||
gate_b = _create_roadmap_item(client, token, initiative_id, title="Gate B").json()
|
||||
body = {"to_item_id": gate_b["id"], "dependency_type": "requires"}
|
||||
|
||||
assert client.post(
|
||||
f"/api/roadmap-items/{gate_a['id']}/dependencies",
|
||||
json=body,
|
||||
headers=_auth(token),
|
||||
).status_code == 201
|
||||
|
||||
dup = client.post(
|
||||
f"/api/roadmap-items/{gate_a['id']}/dependencies",
|
||||
json=body,
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert dup.status_code == 400
|
||||
assert "existiert bereits" in dup.json()["detail"]
|
||||
|
|
|
|||
|
|
@ -96,3 +96,7 @@ export function addRoadmapItemDependency(itemId, body) {
|
|||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteRoadmapDependency(dependencyId) {
|
||||
return apiFetch(`/api/roadmap-dependencies/${dependencyId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
|
|
|||
201
frontend/src/components/GateDependenciesSection.jsx
Normal file
201
frontend/src/components/GateDependenciesSection.jsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { gatePath } from '../utils/routes.js'
|
||||
import { gateTitleById } from './GateSelect.jsx'
|
||||
|
||||
const DEPENDENCY_TYPE_LABELS = {
|
||||
requires: 'Voraussetzung',
|
||||
blocks: 'Blockiert',
|
||||
related: 'Bezug',
|
||||
}
|
||||
|
||||
const OUTGOING_PHRASES = {
|
||||
requires: 'benötigt',
|
||||
blocks: 'blockiert',
|
||||
related: 'Bezug zu',
|
||||
}
|
||||
|
||||
const INCOMING_PHRASES = {
|
||||
requires: 'Voraussetzung für',
|
||||
blocks: 'Wird blockiert von',
|
||||
related: 'Bezug von',
|
||||
}
|
||||
|
||||
function groupDependencies(itemId, dependencies) {
|
||||
/** @type {{ outgoing: typeof dependencies, incoming: typeof dependencies }} */
|
||||
const groups = { outgoing: [], incoming: [] }
|
||||
for (const dep of dependencies) {
|
||||
if (dep.from_item_id === itemId) groups.outgoing.push(dep)
|
||||
else if (dep.to_item_id === itemId) groups.incoming.push(dep)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function DependencyRow({ dep, itemId, siblingItems, direction, canManage, busy, onDelete }) {
|
||||
const otherId = direction === 'outgoing' ? dep.to_item_id : dep.from_item_id
|
||||
const otherTitle = gateTitleById(siblingItems, otherId) || otherId
|
||||
const phrase =
|
||||
direction === 'outgoing'
|
||||
? OUTGOING_PHRASES[dep.dependency_type] || dep.dependency_type
|
||||
: INCOMING_PHRASES[dep.dependency_type] || dep.dependency_type
|
||||
|
||||
return (
|
||||
<li className="list-item card-list-item gate-dependency-item">
|
||||
<div className="list-item-main">
|
||||
<p className="gate-dependency-item__phrase">
|
||||
{direction === 'outgoing' ? (
|
||||
<>
|
||||
Dieses Gate <strong>{phrase}</strong>{' '}
|
||||
<Link to={gatePath(otherId)}>{otherTitle}</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>{phrase}</strong>{' '}
|
||||
<Link to={gatePath(otherId)}>{otherTitle}</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="muted list-item-sub">
|
||||
{DEPENDENCY_TYPE_LABELS[dep.dependency_type] || dep.dependency_type}
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="list-item-meta action-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => onDelete(dep.id)}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function GateDependenciesSection({
|
||||
itemId,
|
||||
siblingItems = [],
|
||||
dependencies = [],
|
||||
canManage,
|
||||
busy,
|
||||
onAdd,
|
||||
onDelete,
|
||||
}) {
|
||||
const candidates = siblingItems.filter((item) => item.id !== itemId)
|
||||
const { outgoing, incoming } = groupDependencies(itemId, dependencies)
|
||||
const hasAny = outgoing.length > 0 || incoming.length > 0
|
||||
|
||||
async function handleAddSubmit(event) {
|
||||
event.preventDefault()
|
||||
const form = event.target
|
||||
const toItemId = form.to_item_id.value
|
||||
const dependencyType = form.dependency_type.value
|
||||
if (!toItemId) return
|
||||
await onAdd({
|
||||
to_item_id: toItemId,
|
||||
dependency_type: dependencyType,
|
||||
})
|
||||
form.reset()
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="gate-dependencies-section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h3>Abhängigkeiten</h3>
|
||||
<p className="section-lead muted">
|
||||
Kanten für den Gate-Graph — ausgehende Abhängigkeiten kannst du hier anlegen und
|
||||
entfernen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasAny && (
|
||||
<p className="muted gate-dependencies-section__empty">
|
||||
Noch keine Kanten — im Graph erscheinen Gates sonst nach Reihenfolge.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{outgoing.length > 0 && (
|
||||
<>
|
||||
<h4 className="gate-dependencies-section__group-title">Ausgehend</h4>
|
||||
<ul className="item-list gate-dependency-list">
|
||||
{outgoing.map((dep) => (
|
||||
<DependencyRow
|
||||
key={dep.id}
|
||||
dep={dep}
|
||||
itemId={itemId}
|
||||
siblingItems={siblingItems}
|
||||
direction="outgoing"
|
||||
canManage={canManage}
|
||||
busy={busy}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{incoming.length > 0 && (
|
||||
<>
|
||||
<h4 className="gate-dependencies-section__group-title">Eingehend</h4>
|
||||
<ul className="item-list gate-dependency-list">
|
||||
{incoming.map((dep) => (
|
||||
<DependencyRow
|
||||
key={dep.id}
|
||||
dep={dep}
|
||||
itemId={itemId}
|
||||
siblingItems={siblingItems}
|
||||
direction="incoming"
|
||||
canManage={canManage}
|
||||
busy={busy}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canManage && candidates.length > 0 && (
|
||||
<form className="inline-form-block gate-dependency-form" onSubmit={handleAddSubmit}>
|
||||
<h4>Kante hinzufügen</h4>
|
||||
<p className="muted form-hint gate-dependency-form__hint">
|
||||
Dieses Gate …
|
||||
</p>
|
||||
<label>
|
||||
Beziehung
|
||||
<select name="dependency_type" defaultValue="requires">
|
||||
<option value="requires">benötigt (Voraussetzung)</option>
|
||||
<option value="blocks">blockiert</option>
|
||||
<option value="related">steht in Bezug zu</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Anderes Gate
|
||||
<select name="to_item_id" required defaultValue="">
|
||||
<option value="" disabled>
|
||||
Gate wählen …
|
||||
</option>
|
||||
{candidates.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
Kante anlegen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{canManage && candidates.length === 0 && (
|
||||
<p className="muted gate-dependencies-section__empty">
|
||||
Mindestens ein weiteres Gate im Vorhaben nötig, um Kanten zu definieren.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -73,8 +73,8 @@ export function GateMapView({ initiativeId, items }) {
|
|||
<div>
|
||||
<h2>Zielzustände (Graph)</h2>
|
||||
<p className="section-lead muted">
|
||||
Read-only Übersicht der Gate-Abhängigkeiten — Bearbeitung auf der Gate-Detailseite
|
||||
(AP1.13b).
|
||||
Read-only Übersicht der Gate-Abhängigkeiten — Kanten auf der Gate-Detailseite pflegen,
|
||||
Verify und Kriterien dort ebenfalls.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -177,8 +177,8 @@ export function GateMapView({ initiativeId, items }) {
|
|||
|
||||
{dependencies.length === 0 && (
|
||||
<p className="muted gate-map-view__hint">
|
||||
Noch keine Kanten — Gates sind nach Reihenfolge angeordnet. Abhängigkeiten können auf den
|
||||
Gate-Detailseiten gepflegt werden (AP1.13b).
|
||||
Noch keine Kanten — auf der Gate-Detailseite unter „Abhängigkeiten“ anlegen; ohne Kanten
|
||||
gelten Gates nach Reihenfolge.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ import {
|
|||
verifyRoadmapItemReached,
|
||||
reopenRoadmapItem,
|
||||
updateRoadmapItem,
|
||||
listRoadmapItemDependencies,
|
||||
addRoadmapItemDependency,
|
||||
deleteRoadmapDependency,
|
||||
listInitiativeRoadmapItems,
|
||||
} from '../../api/roadmap.js'
|
||||
import { ErrorState } from '../../components/ErrorState.jsx'
|
||||
import { LoadingState } from '../../components/LoadingState.jsx'
|
||||
|
|
@ -31,6 +35,7 @@ import { useInitiativeOperations } from '../../context/InitiativeOperationsConte
|
|||
import { scopedPath } from '../../utils/routes.js'
|
||||
import { listGateContributions } from '../../api/journey.js'
|
||||
import { GateContributionsSection } from '../../components/GateContributionsSection.jsx'
|
||||
import { GateDependenciesSection } from '../../components/GateDependenciesSection.jsx'
|
||||
import { Modal } from '../../components/Modal.jsx'
|
||||
import { RoadmapItemForm } from '../../components/RoadmapItemForm.jsx'
|
||||
|
||||
|
|
@ -100,6 +105,8 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
|||
const [contributionsLoading, setContributionsLoading] = useState(true)
|
||||
const [contributionsError, setContributionsError] = useState(null)
|
||||
const [editingMeta, setEditingMeta] = useState(false)
|
||||
const [dependencies, setDependencies] = useState([])
|
||||
const [siblingItems, setSiblingItems] = useState([])
|
||||
|
||||
const loadContributions = useCallback(async () => {
|
||||
setContributionsLoading(true)
|
||||
|
|
@ -124,12 +131,25 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
|||
setItem(itemData)
|
||||
setCriteria(criteriaData.items || [])
|
||||
setProgress(criteriaData.progress || null)
|
||||
|
||||
const resolvedInitiativeId = initiativeId || itemData.initiative_id
|
||||
if (resolvedInitiativeId) {
|
||||
const [depsData, siblings] = await Promise.all([
|
||||
listRoadmapItemDependencies(itemId),
|
||||
listInitiativeRoadmapItems(resolvedInitiativeId),
|
||||
])
|
||||
setDependencies(Array.isArray(depsData) ? depsData : [])
|
||||
setSiblingItems(Array.isArray(siblings) ? siblings : [])
|
||||
} else {
|
||||
setDependencies([])
|
||||
setSiblingItems([])
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [itemId])
|
||||
}, [itemId, initiativeId])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
|
|
@ -207,6 +227,16 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
|||
error={contributionsError}
|
||||
/>
|
||||
|
||||
<GateDependenciesSection
|
||||
itemId={itemId}
|
||||
siblingItems={siblingItems}
|
||||
dependencies={dependencies}
|
||||
canManage={canManage}
|
||||
busy={busy}
|
||||
onAdd={(body) => runAction(() => addRoadmapItemDependency(itemId, body))}
|
||||
onDelete={(dependencyId) => runAction(() => deleteRoadmapDependency(dependencyId))}
|
||||
/>
|
||||
|
||||
<ul className="item-list criterion-list">
|
||||
{criteria.map((crit) => (
|
||||
<li key={crit.id} className="list-item card-list-item">
|
||||
|
|
|
|||
|
|
@ -1760,3 +1760,27 @@
|
|||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.gate-dependencies-section {
|
||||
margin: 1.25rem 0;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--jk-border, #dde3ea);
|
||||
}
|
||||
|
||||
.gate-dependencies-section__group-title {
|
||||
margin: 0.75rem 0 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.gate-dependencies-section__empty {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.gate-dependency-item__phrase {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gate-dependency-form__hint {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user