1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
"""
Team overview: what everyone is working on across projects, plus per-user weekly
sprint point capacity. There is no database; capacity is persisted via capacity_config.py.
"""
from __future__ import annotations
import re
from collections import defaultdict
from typing import Annotated, Any
from urllib.parse import quote
from fastapi import APIRouter, Depends, Form, HTTPException, Query
from fastapi.responses import HTMLResponse, RedirectResponse
from pygitweb.auth import (
access_token_from_request,
ensure_active_user_if_auth_enabled,
has_permission,
principal_from_session,
)
from pygitweb.capacity_config import current_iso_week, get_user_week_capacity, set_user_week_capacity
from pygitweb.config import settings
from pygitweb.dependencies import filter_projects_by_read_access
from pygitweb.permissions import Permission
from pygitweb.projects import git_get_projects_list
from pygitweb.tasks import get_all_project_tasks
from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
team_router = APIRouter(tags=["team"])
# Tasks in these statuses count as a person's current workload.
OPEN_STATUSES = frozenset({"TODO", "IN_PROGRESS", "IN_REVIEW"})
_ISO_WEEK_RE = re.compile(r"^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])$")
def _gather_open_tasks_by_assignee(token: str | None) -> dict[str, list[dict[str, Any]]]:
projects = filter_projects_by_read_access(
git_get_projects_list(paranoid=settings.STRICT_EXPORT, export_ok=settings.EXPORT_OK),
token,
)
by_assignee: dict[str, list[dict[str, Any]]] = defaultdict(list)
for pr in projects:
project = pr.get("path", "")
if not project:
continue
try:
tasks = get_all_project_tasks(project)
except Exception:
continue
for t in tasks:
if t.get("status") not in OPEN_STATUSES:
continue
assignee = (t.get("assignee") or "").strip() or "Unassigned"
t["project"] = project
by_assignee[assignee].append(t)
return by_assignee
def _ensure_can_set_capacity(token: str | None, target_user: str) -> None:
if not settings.AUTH:
return
ensure_active_user_if_auth_enabled(token)
principal = principal_from_session(token)
if principal is not None and principal.username == target_user:
return
if has_permission(Permission.SETTINGS, token=token):
return
raise HTTPException(status_code=403, detail="Insufficient permissions")
@team_router.get("", response_class=HTMLResponse)
def team_page(
token: Annotated[str | None, Depends(access_token_from_request)],
week: Annotated[str | None, Query()] = None,
) -> HTMLResponse:
"""Team overview: open tasks grouped by assignee, with weekly point capacity vs. load."""
iso_week = (week or current_iso_week()).strip()
if not _ISO_WEEK_RE.match(iso_week):
iso_week = current_iso_week()
by_assignee = _gather_open_tasks_by_assignee(token)
principal = principal_from_session(token)
current_username = principal.username if principal else None
can_edit_others = (not settings.AUTH) or has_permission(Permission.SETTINGS, token=token)
groups: list[dict[str, Any]] = []
for assignee in sorted(by_assignee, key=lambda a: (a == "Unassigned", a.lower())):
tasks = sorted(by_assignee[assignee], key=lambda t: (t.get("priority") or "", t.get("title") or ""))
points_total = sum(t.get("points") or 0 for t in tasks)
is_unassigned = assignee == "Unassigned"
capacity = None if is_unassigned else get_user_week_capacity(assignee, iso_week)
rows: list[list[str]] = []
for t in tasks:
project_enc = quote(t["project"], safe="/")
board_enc = quote(t["board"], safe="")
task_enc = quote(t["ref"], safe="")
project_url = f"/project/{project_enc}"
task_url = f"{project_url}/board/?b={board_enc}&task={task_enc}"
rows.append([
f"<a href='{task_url}'>{jinja_escape(t.get('title') or '(untitled)')}</a>",
f"<a href='{project_url}'>{jinja_escape(t['project'])}</a>",
jinja_escape(t.get("board") or "") or "",
jinja_escape((t.get("status") or "").replace("_", " ").title()) or "",
jinja_escape((t.get("priority") or "").title()) or "",
str(t.get("points") if t.get("points") is not None else ""),
])
table_html = env.get_template("table.html").render(
cols=["Task", "Project", "Board", "Status", "Priority", "Points"],
rows=rows,
)
can_edit = (not is_unassigned) and (can_edit_others or assignee == current_username)
capacity_url = f"/team/capacity?user={quote(assignee, safe='')}&week={quote(iso_week, safe='')}"
groups.append({
"assignee": jinja_escape(assignee),
"points_total": points_total,
"capacity": capacity,
"over_capacity": capacity is not None and points_total > capacity,
"can_edit": can_edit,
"capacity_url": capacity_url,
"table_html": table_html,
})
pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Team", site_name=settings.SITE_NAME)
body = env.get_template("team.html").render(iso_week=iso_week, groups=groups)
return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
@team_router.get("/capacity", response_class=HTMLResponse)
def team_capacity_form(
token: Annotated[str | None, Depends(access_token_from_request)],
user: Annotated[str | None, Query()] = None,
week: Annotated[str | None, Query()] = None,
) -> HTMLResponse:
"""Form to set a user's sprint point capacity for a given ISO week."""
ensure_active_user_if_auth_enabled(token)
principal = principal_from_session(token)
target_user = (user or (principal.username if principal else "")).strip()
if not target_user:
raise HTTPException(status_code=400, detail="user is required")
iso_week = (week or current_iso_week()).strip()
if not _ISO_WEEK_RE.match(iso_week):
iso_week = current_iso_week()
current_points = get_user_week_capacity(target_user, iso_week)
pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Set capacity", site_name=settings.SITE_NAME)
body = env.get_template("team_capacity_form.html").render(
target_user=jinja_escape(target_user),
iso_week=jinja_escape(iso_week),
current_points=current_points,
)
return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
@team_router.post("/capacity/submit", response_class=HTMLResponse)
def team_capacity_submit(
token: Annotated[str | None, Depends(access_token_from_request)],
user: Annotated[str, Form()],
week: Annotated[str, Form()],
points: Annotated[str, Form()],
) -> RedirectResponse:
"""Save a user's sprint point capacity for a given ISO week."""
target_user = user.strip()
iso_week = week.strip()
if not target_user:
raise HTTPException(status_code=400, detail="user is required")
if not _ISO_WEEK_RE.match(iso_week):
raise HTTPException(status_code=400, detail="week must be in ISO format YYYY-Www")
_ensure_can_set_capacity(token, target_user)
try:
points_val = int(points)
except ValueError as exc:
raise HTTPException(status_code=400, detail="points must be an integer") from exc
if points_val < 0:
raise HTTPException(status_code=400, detail="points must be non-negative")
set_user_week_capacity(target_user, iso_week, points_val)
return RedirectResponse(url=f"/team?week={quote(iso_week, safe='')}", status_code=303)