mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[W9-3c] Enrich project table with task counts + CI-watch badge (#531)
Backend: ProjectSummaryResponse gains task_counts (done/active/blocked) + ci_watch_enabled. ProjectService.task_counts_for_projects does one GROUP BY project_id over TaskTable for every distinct project_id in the list (a project with no tasks is absent — route falls back to None). ci_watch_enabled is read straight off the Project row (already a column) — a 0-cost schema extension, honest signal that CI-watch is armed, no live-conclusion fan-out. project_to_summary takes an optional task_counts. No migration. Frontend: ProjectTable gains a Tasks column (done/active/blocked + health dot, amber at-risk when blocked>0) and a CI-Watch badge under the project name when ci_watch_enabled. Both desktop Table and mobile ResponsiveTableCard variants. Mock projects carry the new shape (two sample repos). Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -47,6 +47,8 @@ function project(overrides: Partial<ProjectSummary>): ProjectSummary {
|
|||||||
has_workspace: true,
|
has_workspace: true,
|
||||||
has_git_token: true,
|
has_git_token: true,
|
||||||
video_engine_enabled: false,
|
video_engine_enabled: false,
|
||||||
|
ci_watch_enabled: false,
|
||||||
|
task_counts: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { ProjectTable } from "../project-table";
|
||||||
|
import { Team } from "@/types";
|
||||||
|
import type { ProjectSummary } from "@/types";
|
||||||
|
|
||||||
|
const project: ProjectSummary = {
|
||||||
|
id: "p1",
|
||||||
|
name: "RoboCo Core",
|
||||||
|
slug: "roboco",
|
||||||
|
git_url: "https://github.com/rennf93/roboco.git",
|
||||||
|
assigned_cell: Team.BACKEND,
|
||||||
|
is_active: true,
|
||||||
|
has_workspace: true,
|
||||||
|
has_git_token: true,
|
||||||
|
video_engine_enabled: false,
|
||||||
|
ci_watch_enabled: true,
|
||||||
|
task_counts: { done: 42, active: 5, blocked: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ProjectTable", () => {
|
||||||
|
it("renders the project name, task counts, and CI-watch badge", () => {
|
||||||
|
render(<ProjectTable projects={[project]} isLoading={false} />);
|
||||||
|
expect(screen.getByText("RoboCo Core")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("42 done")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1 blocked")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("CI-Watch")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the empty state when there are no projects", () => {
|
||||||
|
render(<ProjectTable projects={[]} isLoading={false} />);
|
||||||
|
expect(screen.getByText("No projects found")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an em-dash placeholder when task_counts is null", () => {
|
||||||
|
const bare: ProjectSummary = {
|
||||||
|
...project,
|
||||||
|
id: "p2",
|
||||||
|
name: "bare-project",
|
||||||
|
ci_watch_enabled: false,
|
||||||
|
task_counts: null,
|
||||||
|
};
|
||||||
|
render(<ProjectTable projects={[bare]} isLoading={false} />);
|
||||||
|
// Renders the row (not the empty state) and the CI-Watch badge is absent.
|
||||||
|
expect(screen.getByText("bare-project")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("CI-Watch")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show the empty state while loading", () => {
|
||||||
|
render(<ProjectTable projects={undefined} isLoading={true} />);
|
||||||
|
expect(screen.queryByText("No projects found")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,8 +18,8 @@ import {
|
|||||||
ResponsiveTableCardRow,
|
ResponsiveTableCardRow,
|
||||||
} from "@/components/ui/responsive-table";
|
} from "@/components/ui/responsive-table";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { ExternalLink, Pencil, GitBranch, Key, KeyRound } from "lucide-react";
|
import { ExternalLink, Pencil, GitBranch, Key, KeyRound, Radar } from "lucide-react";
|
||||||
import type { ProjectSummary, Team } from "@/types";
|
import type { ProjectSummary, ProjectTaskCounts, Team } from "@/types";
|
||||||
import { EditProjectDialog } from "./edit-project-dialog";
|
import { EditProjectDialog } from "./edit-project-dialog";
|
||||||
|
|
||||||
interface ProjectTableProps {
|
interface ProjectTableProps {
|
||||||
@@ -62,6 +62,53 @@ function getTokenBadge(hasGitToken: boolean) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TasksCell({ counts }: { counts: ProjectTaskCounts | null }) {
|
||||||
|
if (!counts) {
|
||||||
|
return <span className="text-muted-foreground text-xs">—</span>;
|
||||||
|
}
|
||||||
|
const atRisk = counts.blocked > 0;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"h-2 w-2 rounded-full " +
|
||||||
|
(atRisk
|
||||||
|
? "bg-amber-500"
|
||||||
|
: counts.done > 0
|
||||||
|
? "bg-emerald-500"
|
||||||
|
: "bg-muted")
|
||||||
|
}
|
||||||
|
title={atRisk ? "At risk: blocked tasks" : "Healthy"}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-emerald-600 dark:text-emerald-400">
|
||||||
|
{counts.done} done
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">{counts.active} active</span>
|
||||||
|
{counts.blocked > 0 && (
|
||||||
|
<span className="text-amber-600 dark:text-amber-400">
|
||||||
|
{counts.blocked} blocked
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CiWatchBadge({ enabled }: { enabled: boolean }) {
|
||||||
|
if (!enabled) return null;
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="bg-sky-500/10 text-sky-500 border-sky-500/30"
|
||||||
|
title="CI-watch armed"
|
||||||
|
>
|
||||||
|
<Radar className="h-3 w-3 mr-1" />
|
||||||
|
CI-Watch
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
|
export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
|
||||||
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
|
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -111,6 +158,7 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Project</TableHead>
|
<TableHead>Project</TableHead>
|
||||||
<TableHead>Cell</TableHead>
|
<TableHead>Cell</TableHead>
|
||||||
|
<TableHead>Tasks</TableHead>
|
||||||
<TableHead>Token</TableHead>
|
<TableHead>Token</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="w-[100px]">Actions</TableHead>
|
<TableHead className="w-[100px]">Actions</TableHead>
|
||||||
@@ -131,6 +179,11 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
|
|||||||
<p className="text-xs text-muted-foreground font-mono">
|
<p className="text-xs text-muted-foreground font-mono">
|
||||||
{project.slug}
|
{project.slug}
|
||||||
</p>
|
</p>
|
||||||
|
{project.ci_watch_enabled && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<CiWatchBadge enabled />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -138,6 +191,9 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
|
|||||||
{teamLabels[project.assigned_cell]}
|
{teamLabels[project.assigned_cell]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TasksCell counts={project.task_counts} />
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{getTokenBadge(project.has_git_token)}
|
{getTokenBadge(project.has_git_token)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -235,22 +291,28 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
|
|||||||
{teamLabels[project.assigned_cell]}
|
{teamLabels[project.assigned_cell]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</ResponsiveTableCardRow>
|
</ResponsiveTableCardRow>
|
||||||
|
<ResponsiveTableCardRow label="Tasks">
|
||||||
|
<TasksCell counts={project.task_counts} />
|
||||||
|
</ResponsiveTableCardRow>
|
||||||
<ResponsiveTableCardRow label="Token">
|
<ResponsiveTableCardRow label="Token">
|
||||||
{getTokenBadge(project.has_git_token)}
|
{getTokenBadge(project.has_git_token)}
|
||||||
</ResponsiveTableCardRow>
|
</ResponsiveTableCardRow>
|
||||||
<ResponsiveTableCardRow label="Status">
|
<ResponsiveTableCardRow label="Status">
|
||||||
{project.is_active ? (
|
<div className="flex items-center gap-2">
|
||||||
<Badge className="bg-green-500/10 text-green-500">
|
{project.is_active ? (
|
||||||
Active
|
<Badge className="bg-green-500/10 text-green-500">
|
||||||
</Badge>
|
Active
|
||||||
) : (
|
</Badge>
|
||||||
<Badge
|
) : (
|
||||||
variant="outline"
|
<Badge
|
||||||
className="text-muted-foreground"
|
variant="outline"
|
||||||
>
|
className="text-muted-foreground"
|
||||||
Inactive
|
>
|
||||||
</Badge>
|
Inactive
|
||||||
)}
|
</Badge>
|
||||||
|
)}
|
||||||
|
{project.ci_watch_enabled && <CiWatchBadge enabled />}
|
||||||
|
</div>
|
||||||
</ResponsiveTableCardRow>
|
</ResponsiveTableCardRow>
|
||||||
</div>
|
</div>
|
||||||
</ResponsiveTableCard>
|
</ResponsiveTableCard>
|
||||||
|
|||||||
@@ -1,15 +1,59 @@
|
|||||||
import api from "./client";
|
import api from "./client";
|
||||||
|
import { Team } from "@/types";
|
||||||
import type {
|
import type {
|
||||||
Project,
|
Project,
|
||||||
ProjectCreate,
|
ProjectCreate,
|
||||||
ProjectUpdate,
|
ProjectUpdate,
|
||||||
ProjectSummary,
|
ProjectSummary,
|
||||||
Team,
|
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
import { isMockMode } from "@/lib/mock-data";
|
import { isMockMode } from "@/lib/mock-data";
|
||||||
|
|
||||||
// Mock data for offline mode
|
// Mock data for offline mode
|
||||||
const mockProjects: Project[] = [];
|
const mockProjects: Project[] = [
|
||||||
|
{
|
||||||
|
id: "proj-mock-1",
|
||||||
|
name: "roboco",
|
||||||
|
slug: "roboco",
|
||||||
|
git_url: "https://github.com/rennf93/roboco.git",
|
||||||
|
default_branch: "master",
|
||||||
|
protected_branches: ["master", "slave"],
|
||||||
|
assigned_cell: Team.BACKEND,
|
||||||
|
is_active: true,
|
||||||
|
has_git_token: false,
|
||||||
|
ci_watch_enabled: true,
|
||||||
|
ci_watch_workflow: "CI",
|
||||||
|
video_engine_enabled: false,
|
||||||
|
workspace_path: "/data/workspaces/roboco",
|
||||||
|
created_by: "ceo",
|
||||||
|
created_at: "2026-06-01T00:00:00Z",
|
||||||
|
updated_at: null,
|
||||||
|
} as Project,
|
||||||
|
{
|
||||||
|
id: "proj-mock-2",
|
||||||
|
name: "roboco-website",
|
||||||
|
slug: "roboco-website",
|
||||||
|
git_url: "https://github.com/rennf93/roboco-website.git",
|
||||||
|
default_branch: "master",
|
||||||
|
protected_branches: ["master"],
|
||||||
|
assigned_cell: Team.FRONTEND,
|
||||||
|
is_active: true,
|
||||||
|
has_git_token: false,
|
||||||
|
ci_watch_enabled: false,
|
||||||
|
ci_watch_workflow: null,
|
||||||
|
video_engine_enabled: false,
|
||||||
|
workspace_path: null,
|
||||||
|
created_by: "ceo",
|
||||||
|
created_at: "2026-06-15T00:00:00Z",
|
||||||
|
updated_at: null,
|
||||||
|
} as Project,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock task counts per project (mock-mode only — real data comes from the
|
||||||
|
// backend's grouped query). Keyed by project id.
|
||||||
|
const mockTaskCounts: Record<string, { done: number; active: number; blocked: number }> = {
|
||||||
|
"proj-mock-1": { done: 120, active: 8, blocked: 1 },
|
||||||
|
"proj-mock-2": { done: 34, active: 2, blocked: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
export interface ProjectFilters {
|
export interface ProjectFilters {
|
||||||
assigned_cell?: Team;
|
assigned_cell?: Team;
|
||||||
@@ -41,6 +85,8 @@ export const projectsApi = {
|
|||||||
has_workspace: !!p.workspace_path,
|
has_workspace: !!p.workspace_path,
|
||||||
has_git_token: false, // Mock mode has no tokens
|
has_git_token: false, // Mock mode has no tokens
|
||||||
video_engine_enabled: p.video_engine_enabled,
|
video_engine_enabled: p.video_engine_enabled,
|
||||||
|
ci_watch_enabled: !!p.ci_watch_enabled,
|
||||||
|
task_counts: mockTaskCounts[p.id] ?? null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1100,6 +1100,12 @@ export interface ProjectUpdate {
|
|||||||
sandbox_extensions?: Record<string, string[]>;
|
sandbox_extensions?: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectTaskCounts {
|
||||||
|
done: number;
|
||||||
|
active: number;
|
||||||
|
blocked: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectSummary {
|
export interface ProjectSummary {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1110,6 +1116,8 @@ export interface ProjectSummary {
|
|||||||
has_workspace: boolean;
|
has_workspace: boolean;
|
||||||
has_git_token: boolean;
|
has_git_token: boolean;
|
||||||
video_engine_enabled: boolean;
|
video_engine_enabled: boolean;
|
||||||
|
ci_watch_enabled: boolean;
|
||||||
|
task_counts: ProjectTaskCounts | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductCellMapping {
|
export interface ProductCellMapping {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from roboco.api.schemas.project import (
|
|||||||
ProjectCreateRequest,
|
ProjectCreateRequest,
|
||||||
ProjectResponse,
|
ProjectResponse,
|
||||||
ProjectSummaryResponse,
|
ProjectSummaryResponse,
|
||||||
|
ProjectTaskCounts,
|
||||||
ProjectUpdateRequest,
|
ProjectUpdateRequest,
|
||||||
SetWorkspaceRequest,
|
SetWorkspaceRequest,
|
||||||
SyncStateRequest,
|
SyncStateRequest,
|
||||||
@@ -75,7 +76,24 @@ async def list_projects(
|
|||||||
offset=offset,
|
offset=offset,
|
||||||
)
|
)
|
||||||
|
|
||||||
return [project_to_summary(p) for p in projects]
|
counts = await service.task_counts_for_projects(projects)
|
||||||
|
out: list[ProjectSummaryResponse] = []
|
||||||
|
for p in projects:
|
||||||
|
pid = cast("UUID", p.id)
|
||||||
|
c = counts.get(pid)
|
||||||
|
out.append(
|
||||||
|
project_to_summary(
|
||||||
|
p,
|
||||||
|
task_counts=(
|
||||||
|
ProjectTaskCounts(
|
||||||
|
done=c["done"], active=c["active"], blocked=c["blocked"]
|
||||||
|
)
|
||||||
|
if c
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}", response_model=ProjectResponse)
|
@router.get("/{project_id}", response_model=ProjectResponse)
|
||||||
|
|||||||
@@ -68,12 +68,23 @@ class ProjectResponse(BaseModel):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectTaskCounts(BaseModel):
|
||||||
|
"""Per-project task progress (done/active/blocked) for list views."""
|
||||||
|
|
||||||
|
done: int = 0
|
||||||
|
active: int = 0
|
||||||
|
blocked: int = 0
|
||||||
|
|
||||||
|
|
||||||
class ProjectSummaryResponse(BaseModel):
|
class ProjectSummaryResponse(BaseModel):
|
||||||
"""Compact project response for list views.
|
"""Compact project response for list views.
|
||||||
|
|
||||||
Returned by GET /api/projects; includes essential project metadata
|
Returned by GET /api/projects; includes essential project metadata
|
||||||
for list-view cards. The `video_engine_enabled` field indicates
|
for list-view cards. The `video_engine_enabled` field indicates
|
||||||
whether this project is opted in to the video engine subsystem.
|
whether this project is opted in to the video engine subsystem.
|
||||||
|
`task_counts` is a done/active/blocked breakdown from one grouped
|
||||||
|
query over tasks; `ci_watch_enabled` signals CI-watch is armed (no
|
||||||
|
live-conclusion fan-out — that's a deferred cached endpoint).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id: UUID
|
id: UUID
|
||||||
@@ -86,6 +97,8 @@ class ProjectSummaryResponse(BaseModel):
|
|||||||
has_workspace: bool = False
|
has_workspace: bool = False
|
||||||
has_git_token: bool = False
|
has_git_token: bool = False
|
||||||
video_engine_enabled: bool = False
|
video_engine_enabled: bool = False
|
||||||
|
ci_watch_enabled: bool = False
|
||||||
|
task_counts: ProjectTaskCounts | None = None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -262,7 +275,10 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def project_to_summary(project: "ProjectTable") -> ProjectSummaryResponse:
|
def project_to_summary(
|
||||||
|
project: "ProjectTable",
|
||||||
|
task_counts: "ProjectTaskCounts | None" = None,
|
||||||
|
) -> ProjectSummaryResponse:
|
||||||
"""Convert a ProjectTable to ProjectSummaryResponse."""
|
"""Convert a ProjectTable to ProjectSummaryResponse."""
|
||||||
default_branch = project.default_branch
|
default_branch = project.default_branch
|
||||||
return ProjectSummaryResponse(
|
return ProjectSummaryResponse(
|
||||||
@@ -276,5 +292,7 @@ def project_to_summary(project: "ProjectTable") -> ProjectSummaryResponse:
|
|||||||
has_workspace=bool(project.workspace_path),
|
has_workspace=bool(project.workspace_path),
|
||||||
has_git_token=bool(project.git_token_encrypted),
|
has_git_token=bool(project.git_token_encrypted),
|
||||||
video_engine_enabled=bool(project.video_engine_enabled),
|
video_engine_enabled=bool(project.video_engine_enabled),
|
||||||
|
ci_watch_enabled=bool(project.ci_watch_enabled),
|
||||||
|
task_counts=task_counts,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,19 +6,28 @@ Projects represent git repositories that agents work on.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
from typing import cast as typing_cast
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import case, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from roboco.config import settings
|
from roboco.config import settings
|
||||||
from roboco.db.tables import ProjectTable
|
from roboco.db.tables import ProjectTable, TaskTable
|
||||||
from roboco.exceptions import ValidationError
|
from roboco.exceptions import ValidationError
|
||||||
from roboco.models.base import Team
|
from roboco.models.base import TaskStatus, Team
|
||||||
from roboco.models.project import ProjectCreate, ProjectUpdate
|
from roboco.models.project import ProjectCreate, ProjectUpdate
|
||||||
from roboco.services.base import BaseService, ConflictError, NotFoundError
|
from roboco.services.base import BaseService, ConflictError, NotFoundError
|
||||||
from roboco.utils.crypto import EncryptionError, decrypt_token, encrypt_token
|
from roboco.utils.crypto import EncryptionError, decrypt_token, encrypt_token
|
||||||
|
|
||||||
|
# Statuses that are NOT active progress: completed (done), cancelled
|
||||||
|
# (abandoned), blocked (its own bucket). Everything else counts as active.
|
||||||
|
_INACTIVE_STATUSES = (
|
||||||
|
TaskStatus.COMPLETED,
|
||||||
|
TaskStatus.CANCELLED,
|
||||||
|
TaskStatus.BLOCKED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProjectService(BaseService):
|
class ProjectService(BaseService):
|
||||||
"""
|
"""
|
||||||
@@ -344,6 +353,53 @@ class ProjectService(BaseService):
|
|||||||
result = await self.session.execute(query)
|
result = await self.session.execute(query)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def task_counts_for_projects(
|
||||||
|
self, projects: list[ProjectTable]
|
||||||
|
) -> dict[UUID, dict[str, int]]:
|
||||||
|
"""Per-project task progress (done/active/blocked) — one grouped query.
|
||||||
|
|
||||||
|
One ``GROUP BY project_id`` over tasks for every distinct project_id
|
||||||
|
in the list. Returns {project_id: {done, active, blocked}}; a project
|
||||||
|
with no tasks is absent (the caller falls back to zeros).
|
||||||
|
"""
|
||||||
|
project_ids = [typing_cast("UUID", p.id) for p in projects if p.id is not None]
|
||||||
|
if not project_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(
|
||||||
|
TaskTable.project_id,
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
case((TaskTable.status == TaskStatus.COMPLETED, 1), else_=0)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
).label("done"),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
case((TaskTable.status == TaskStatus.BLOCKED, 1), else_=0)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
).label("blocked"),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
case((TaskTable.status.in_(_INACTIVE_STATUSES), 0), else_=1)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
).label("active"),
|
||||||
|
)
|
||||||
|
.where(TaskTable.project_id.in_(project_ids))
|
||||||
|
.group_by(TaskTable.project_id)
|
||||||
|
)
|
||||||
|
out: dict[UUID, dict[str, int]] = {}
|
||||||
|
for row in result.fetchall():
|
||||||
|
out[typing_cast("UUID", row.project_id)] = {
|
||||||
|
"done": int(row.done or 0),
|
||||||
|
"active": int(row.active or 0),
|
||||||
|
"blocked": int(row.blocked or 0),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
async def list_by_cell(
|
async def list_by_cell(
|
||||||
self,
|
self,
|
||||||
cell: Team,
|
cell: Team,
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Unit tests for ProjectService.task_counts_for_projects.
|
||||||
|
|
||||||
|
Mocks the SQLAlchemy AsyncSession.execute() boundary and verifies the
|
||||||
|
per-project task-count breakdown (one grouped query over tasks).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from roboco.services.project import ProjectService
|
||||||
|
|
||||||
|
_PROJECT_1 = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||||
|
_PROJECT_2 = UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||||
|
|
||||||
|
|
||||||
|
def _project(pid: UUID | None) -> MagicMock:
|
||||||
|
p = MagicMock()
|
||||||
|
p.id = pid
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _result_fetchall(rows: list[MagicMock]) -> MagicMock:
|
||||||
|
result = MagicMock()
|
||||||
|
result.fetchall = MagicMock(return_value=rows)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _row(project_id: UUID, done: int, active: int, blocked: int) -> MagicMock:
|
||||||
|
row = MagicMock()
|
||||||
|
row.project_id = project_id
|
||||||
|
row.done = done
|
||||||
|
row.active = active
|
||||||
|
row.blocked = blocked
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskCountsForProjects:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_maps_per_project_counts(self) -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
session.execute = AsyncMock(
|
||||||
|
return_value=_result_fetchall(
|
||||||
|
[
|
||||||
|
_row(_PROJECT_1, done=3, active=2, blocked=1),
|
||||||
|
_row(_PROJECT_2, done=5, active=0, blocked=0),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
svc = ProjectService(session)
|
||||||
|
out = await svc.task_counts_for_projects(
|
||||||
|
[_project(_PROJECT_1), _project(_PROJECT_2)]
|
||||||
|
)
|
||||||
|
assert out[_PROJECT_1] == {"done": 3, "active": 2, "blocked": 1}
|
||||||
|
assert out[_PROJECT_2] == {"done": 5, "active": 0, "blocked": 0}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_project_with_no_tasks_absent_from_map(self) -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
session.execute = AsyncMock(
|
||||||
|
return_value=_result_fetchall(
|
||||||
|
[_row(_PROJECT_1, done=1, active=0, blocked=0)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
svc = ProjectService(session)
|
||||||
|
out = await svc.task_counts_for_projects(
|
||||||
|
[_project(_PROJECT_1), _project(_PROJECT_2)]
|
||||||
|
)
|
||||||
|
# Project 2 has no task row -> absent (route falls back to None).
|
||||||
|
assert _PROJECT_1 in out
|
||||||
|
assert _PROJECT_2 not in out
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_projects_no_query(self) -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
session.execute = AsyncMock()
|
||||||
|
svc = ProjectService(session)
|
||||||
|
out = await svc.task_counts_for_projects([])
|
||||||
|
assert out == {}
|
||||||
|
session.execute.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skips_projects_without_id(self) -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
session.execute = AsyncMock(
|
||||||
|
return_value=_result_fetchall(
|
||||||
|
[_row(_PROJECT_1, done=2, active=1, blocked=0)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
svc = ProjectService(session)
|
||||||
|
out = await svc.task_counts_for_projects([_project(_PROJECT_1), _project(None)])
|
||||||
|
assert out[_PROJECT_1] == {"done": 2, "active": 1, "blocked": 0}
|
||||||
Reference in New Issue
Block a user