fix: stabilize maintenance flow run/resume and procedural scrolling
This commit is contained in:
@@ -51,7 +51,9 @@ async def create_schedule(
|
|||||||
):
|
):
|
||||||
"""Create a cron schedule for a maintenance flow. One per flow."""
|
"""Create a cron schedule for a maintenance flow. One per flow."""
|
||||||
# Verify user's team owns the tree
|
# Verify user's team owns the tree
|
||||||
await _get_tree_or_403(data.tree_id, current_user, db)
|
tree = await _get_tree_or_403(data.tree_id, current_user, db)
|
||||||
|
if tree.tree_type != "maintenance":
|
||||||
|
raise HTTPException(status_code=400, detail="Schedules are only supported for maintenance flows")
|
||||||
|
|
||||||
# Check no existing schedule for this tree
|
# Check no existing schedule for this tree
|
||||||
existing = await db.execute(
|
existing = await db.execute(
|
||||||
|
|||||||
@@ -37,10 +37,13 @@ async def list_sessions(
|
|||||||
ticket_number: Optional[str] = Query(None, description="Search by ticket number (partial match)"),
|
ticket_number: Optional[str] = Query(None, description="Search by ticket number (partial match)"),
|
||||||
client_name: Optional[str] = Query(None, description="Search by client name (partial match)"),
|
client_name: Optional[str] = Query(None, description="Search by client name (partial match)"),
|
||||||
tree_name: Optional[str] = Query(None, description="Filter by tree name from snapshot"),
|
tree_name: Optional[str] = Query(None, description="Filter by tree name from snapshot"),
|
||||||
|
tree_id: Optional[UUID] = Query(None, description="Filter by tree ID"),
|
||||||
started_after: Optional[datetime] = Query(None, description="Filter sessions started after this datetime"),
|
started_after: Optional[datetime] = Query(None, description="Filter sessions started after this datetime"),
|
||||||
started_before: Optional[datetime] = Query(None, description="Filter sessions started before this datetime"),
|
started_before: Optional[datetime] = Query(None, description="Filter sessions started before this datetime"),
|
||||||
completed_after: Optional[datetime] = Query(None, description="Filter sessions completed after this datetime"),
|
completed_after: Optional[datetime] = Query(None, description="Filter sessions completed after this datetime"),
|
||||||
completed_before: Optional[datetime] = Query(None, description="Filter sessions completed before this datetime"),
|
completed_before: Optional[datetime] = Query(None, description="Filter sessions completed before this datetime"),
|
||||||
|
page: Optional[int] = Query(None, ge=1, description="1-based page number (frontend compatibility)"),
|
||||||
|
size: Optional[int] = Query(None, ge=1, le=100, description="Page size (frontend compatibility)"),
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=100)
|
limit: int = Query(50, ge=1, le=100)
|
||||||
):
|
):
|
||||||
@@ -66,6 +69,10 @@ async def list_sessions(
|
|||||||
if tree_name:
|
if tree_name:
|
||||||
query = query.where(Session.tree_snapshot['name'].astext.ilike(f"%{tree_name}%"))
|
query = query.where(Session.tree_snapshot['name'].astext.ilike(f"%{tree_name}%"))
|
||||||
|
|
||||||
|
# Tree ID filter
|
||||||
|
if tree_id:
|
||||||
|
query = query.where(Session.tree_id == tree_id)
|
||||||
|
|
||||||
# Date range filters
|
# Date range filters
|
||||||
if started_after:
|
if started_after:
|
||||||
query = query.where(Session.started_at >= started_after)
|
query = query.where(Session.started_at >= started_after)
|
||||||
@@ -76,8 +83,13 @@ async def list_sessions(
|
|||||||
if completed_before:
|
if completed_before:
|
||||||
query = query.where(Session.completed_at <= completed_before)
|
query = query.where(Session.completed_at <= completed_before)
|
||||||
|
|
||||||
|
effective_limit = size if size is not None else limit
|
||||||
|
effective_skip = skip
|
||||||
|
if page is not None:
|
||||||
|
effective_skip = (page - 1) * effective_limit
|
||||||
|
|
||||||
query = query.order_by(Session.started_at.desc())
|
query = query.order_by(Session.started_at.desc())
|
||||||
query = query.offset(skip).limit(limit)
|
query = query.offset(effective_skip).limit(effective_limit)
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
sessions = result.scalars().all()
|
sessions = result.scalars().all()
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ async def _fire_maintenance_schedule(schedule_id: str) -> None:
|
|||||||
logger.error(f"Tree {schedule.tree_id} not found for schedule {schedule_id}")
|
logger.error(f"Tree {schedule.tree_id} not found for schedule {schedule_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if tree.tree_type != "maintenance":
|
||||||
|
logger.warning(f"Skipping schedule {schedule_id}: tree {tree.id} is not a maintenance flow")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not tree.is_active or tree.status == "draft":
|
||||||
|
logger.warning(
|
||||||
|
f"Skipping schedule {schedule_id}: tree {tree.id} is inactive or draft"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# Resolve targets
|
# Resolve targets
|
||||||
targets: list[dict] = []
|
targets: list[dict] = []
|
||||||
if schedule.target_list_id:
|
if schedule.target_list_id:
|
||||||
@@ -61,7 +71,12 @@ async def _fire_maintenance_schedule(schedule_id: str) -> None:
|
|||||||
targets = [{"label": "Unassigned"}]
|
targets = [{"label": "Unassigned"}]
|
||||||
|
|
||||||
batch_id = uuid.uuid4()
|
batch_id = uuid.uuid4()
|
||||||
tree_snapshot = tree.tree_structure
|
tree_snapshot = {
|
||||||
|
**tree.tree_structure,
|
||||||
|
"name": tree.name,
|
||||||
|
"description": tree.description,
|
||||||
|
"tree_type": tree.tree_type,
|
||||||
|
}
|
||||||
|
|
||||||
sessions_to_add = []
|
sessions_to_add = []
|
||||||
for target in targets:
|
for target in targets:
|
||||||
|
|||||||
@@ -98,6 +98,23 @@ async def test_get_schedule_not_found(client: AsyncClient, auth_headers: dict):
|
|||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cannot_create_schedule_for_non_maintenance_tree(
|
||||||
|
client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||||
|
):
|
||||||
|
"""Schedules are restricted to maintenance flows."""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/maintenance-schedules",
|
||||||
|
json={
|
||||||
|
"tree_id": test_tree["id"],
|
||||||
|
"cron_expression": "0 0 1 * *",
|
||||||
|
"timezone": "UTC",
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cannot_schedule_other_teams_tree(client: AsyncClient, auth_headers: dict, test_db):
|
async def test_cannot_schedule_other_teams_tree(client: AsyncClient, auth_headers: dict, test_db):
|
||||||
"""User cannot create a schedule for a tree belonging to another team."""
|
"""User cannot create a schedule for a tree belonging to another team."""
|
||||||
|
|||||||
@@ -882,6 +882,79 @@ class TestSessions:
|
|||||||
assert len(data) >= 1
|
assert len(data) >= 1
|
||||||
assert test_tree["name"] in data[0]["tree_snapshot"]["name"]
|
assert test_tree["name"] in data[0]["tree_snapshot"]["name"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_filter_sessions_by_tree_id(
|
||||||
|
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||||
|
):
|
||||||
|
"""Test filtering sessions by tree_id."""
|
||||||
|
other_tree_response = await client.post(
|
||||||
|
"/api/v1/trees",
|
||||||
|
json={
|
||||||
|
"name": "Other Tree",
|
||||||
|
"description": "Second tree for filter test",
|
||||||
|
"category": test_tree["category"],
|
||||||
|
"tree_structure": test_tree["tree_structure"],
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert other_tree_response.status_code == 201
|
||||||
|
other_tree_id = other_tree_response.json()["id"]
|
||||||
|
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/sessions",
|
||||||
|
json={"tree_id": test_tree["id"], "ticket_number": "TREE-A"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/sessions",
|
||||||
|
json={"tree_id": other_tree_id, "ticket_number": "TREE-B"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
f"/api/v1/sessions?tree_id={test_tree['id']}",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data) >= 1
|
||||||
|
assert all(item["tree_id"] == test_tree["id"] for item in data)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_sessions_supports_size_and_page_params(
|
||||||
|
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||||
|
):
|
||||||
|
"""Test frontend-compatible page/size query params."""
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/sessions",
|
||||||
|
json={"tree_id": test_tree["id"], "ticket_number": "P1"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/sessions",
|
||||||
|
json={"tree_id": test_tree["id"], "ticket_number": "P2"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/sessions",
|
||||||
|
json={"tree_id": test_tree["id"], "ticket_number": "P3"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
first_page = await client.get("/api/v1/sessions?size=2&page=1", headers=auth_headers)
|
||||||
|
assert first_page.status_code == 200
|
||||||
|
first_data = first_page.json()
|
||||||
|
assert len(first_data) == 2
|
||||||
|
|
||||||
|
second_page = await client.get("/api/v1/sessions?size=2&page=2", headers=auth_headers)
|
||||||
|
assert second_page.status_code == 200
|
||||||
|
second_data = second_page.json()
|
||||||
|
assert len(second_data) >= 1
|
||||||
|
|
||||||
|
first_ids = {item["id"] for item in first_data}
|
||||||
|
second_ids = {item["id"] for item in second_data}
|
||||||
|
assert first_ids.isdisjoint(second_ids)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_filter_sessions_by_started_date_range(
|
async def test_filter_sessions_by_started_date_range(
|
||||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the navigation path for starting or resuming a tree/session.
|
* Get the default navigation path for starting a flow from the library.
|
||||||
*/
|
*/
|
||||||
export function getTreeNavigatePath(
|
export function getTreeNavigatePath(
|
||||||
treeId: string,
|
treeId: string,
|
||||||
@@ -20,6 +20,20 @@ export function getTreeNavigatePath(
|
|||||||
return `/trees/${treeId}/navigate`
|
return `/trees/${treeId}/navigate`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the navigation path for resuming an existing session.
|
||||||
|
* Maintenance and procedural sessions both resume in procedural navigation.
|
||||||
|
*/
|
||||||
|
export function getSessionResumePath(
|
||||||
|
treeId: string,
|
||||||
|
treeType?: string
|
||||||
|
): string {
|
||||||
|
if (treeType === 'procedural' || treeType === 'maintenance') {
|
||||||
|
return `/flows/${treeId}/navigate`
|
||||||
|
}
|
||||||
|
return `/trees/${treeId}/navigate`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the editor path for a tree.
|
* Get the editor path for a tree.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export default function MaintenanceFlowDetailPage() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const treeData = await treesApi.get(id)
|
const treeData = await treesApi.get(id)
|
||||||
|
if (treeData.tree_type !== 'maintenance') {
|
||||||
|
toast.error('This page is only for maintenance flows')
|
||||||
|
navigate('/trees?type=maintenance')
|
||||||
|
return
|
||||||
|
}
|
||||||
setTree(treeData)
|
setTree(treeData)
|
||||||
|
|
||||||
// Load recent sessions for this tree
|
// Load recent sessions for this tree
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function ProceduralNavigationPage() {
|
|||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const treeData = await treesApi.get(id)
|
const treeData = await treesApi.get(id)
|
||||||
if (treeData.tree_type !== 'procedural') {
|
if (treeData.tree_type !== 'procedural' && treeData.tree_type !== 'maintenance') {
|
||||||
navigate(`/trees/${id}/navigate`, { replace: true })
|
navigate(`/trees/${id}/navigate`, { replace: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -116,7 +116,7 @@ export function ProceduralNavigationPage() {
|
|||||||
await startSession(id, {})
|
await startSession(id, {})
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to load procedure')
|
toast.error('Failed to load flow')
|
||||||
navigate('/my-trees')
|
navigate('/my-trees')
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
@@ -340,7 +340,7 @@ export function ProceduralNavigationPage() {
|
|||||||
const currentStepState = currentStep ? stepStates.get(currentStep.id) : undefined
|
const currentStepState = currentStep ? stepStates.get(currentStep.id) : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<div className="border-b border-border px-4 py-3 sm:px-6">
|
<div className="border-b border-border px-4 py-3 sm:px-6">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
@@ -370,8 +370,8 @@ export function ProceduralNavigationPage() {
|
|||||||
{/* Left sidebar - step checklist */}
|
{/* Left sidebar - step checklist */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-r border-border bg-card transition-all duration-200',
|
'min-h-0 border-r border-border bg-card transition-all duration-200',
|
||||||
sidebarOpen ? 'w-72 p-3' : 'w-0 overflow-hidden p-0'
|
sidebarOpen ? 'w-72 overflow-y-auto p-3' : 'w-0 overflow-hidden p-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { SessionFilters } from '@/components/session/SessionFilters'
|
|||||||
import type { SessionFilterState } from '@/components/session/SessionFilters'
|
import type { SessionFilterState } from '@/components/session/SessionFilters'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { getTreeNavigatePath } from '@/lib/routing'
|
import { getSessionResumePath } from '@/lib/routing'
|
||||||
|
|
||||||
export function SessionHistoryPage() {
|
export function SessionHistoryPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -285,7 +285,7 @@ export function SessionHistoryPage() {
|
|||||||
</button>
|
</button>
|
||||||
{!session.completed_at && (
|
{!session.completed_at && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(getTreeNavigatePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
|
onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md bg-gradient-brand px-3 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
'rounded-md bg-gradient-brand px-3 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||||
'hover:opacity-90'
|
'hover:opacity-90'
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { TreeTableView } from '@/components/library/TreeTableView'
|
|||||||
import { ViewToggle } from '@/components/library/ViewToggle'
|
import { ViewToggle } from '@/components/library/ViewToggle'
|
||||||
import { SortDropdown } from '@/components/library/SortDropdown'
|
import { SortDropdown } from '@/components/library/SortDropdown'
|
||||||
import { cn, safeGetItem } from '@/lib/utils'
|
import { cn, safeGetItem } from '@/lib/utils'
|
||||||
import { getTreeNavigatePath } from '@/lib/routing'
|
import { getSessionResumePath } from '@/lib/routing'
|
||||||
import { usePermissions } from '@/hooks/usePermissions'
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
@@ -425,7 +425,7 @@ export function TreeLibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(getTreeNavigatePath(s.tree_id, s.tree_snapshot?.tree_type), { state: { sessionId: s.id } })}
|
onClick={() => navigate(getSessionResumePath(s.tree_id, s.tree_snapshot?.tree_type), { state: { sessionId: s.id } })}
|
||||||
className="flex items-center gap-1.5 rounded-md bg-gradient-brand px-3 py-1.5 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
|
className="flex items-center gap-1.5 rounded-md bg-gradient-brand px-3 py-1.5 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
|
||||||
>
|
>
|
||||||
<Play className="h-3.5 w-3.5" />
|
<Play className="h-3.5 w-3.5" />
|
||||||
|
|||||||
Reference in New Issue
Block a user