Skip to content

Commit e6e3388

Browse files
author
whitekernel
committed
[ADD] war room task subtasks + tag/status filters — parent_task_id column & indexes, war_room_task_used_tags helper, list endpoint accepts q/status_id/tag/parent_task_id, business layer enforces single-level nesting and same-room parent
1 parent 61ad232 commit e6e3388

3 files changed

Lines changed: 609 additions & 37 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Add parent_task_id + search indexes on war_room_task.
2+
3+
Subtasks are single-level: a war-room task may point at another
4+
war-room task in the same room via `parent_task_id`. Enforcement of
5+
the "no grand-children" rule lives in the business layer (a task
6+
that is itself a child cannot be given children) so the DB stays
7+
simple. `ON DELETE CASCADE` on the FK: removing a parent removes its
8+
subtasks, matching the UX expectation ("delete a task, delete its
9+
subtasks").
10+
11+
Also adds three helper indexes used by the new search/filter list:
12+
- `parent_task_id` for expand-children lookups
13+
- `status_id` for status-filter scans
14+
- `war_room_id, parent_task_id` composite to cheaply pull the
15+
top-level tree on the tasks page (parent_task_id IS NULL).
16+
17+
Idempotent via `_has_table` / `_table_has_column` so re-running on
18+
environments that already applied the migration is a no-op.
19+
20+
Revision ID: d5e6f7a8b9c0
21+
Revises: c3d4e5f6a9b0
22+
Create Date: 2026-07-13 10:00:00.000000
23+
"""
24+
import sqlalchemy as sa
25+
from alembic import op
26+
27+
from app.alembic.alembic_utils import _has_table, _table_has_column
28+
29+
30+
revision = 'd5e6f7a8b9c0'
31+
down_revision = 'c3d4e5f6a9b0'
32+
branch_labels = None
33+
depends_on = None
34+
35+
36+
def upgrade():
37+
if not _has_table('war_room_task'):
38+
return
39+
40+
if not _table_has_column('war_room_task', 'parent_task_id'):
41+
op.add_column(
42+
'war_room_task',
43+
sa.Column(
44+
'parent_task_id', sa.BigInteger(),
45+
sa.ForeignKey('war_room_task.task_id', ondelete='CASCADE'),
46+
nullable=True,
47+
),
48+
)
49+
op.create_index(
50+
'ix_war_room_task_parent_task_id',
51+
'war_room_task',
52+
['parent_task_id'],
53+
)
54+
55+
op.execute(
56+
'CREATE INDEX IF NOT EXISTS ix_war_room_task_status_id '
57+
'ON war_room_task (status_id)'
58+
)
59+
op.execute(
60+
'CREATE INDEX IF NOT EXISTS ix_war_room_task_war_room_parent '
61+
'ON war_room_task (war_room_id, parent_task_id)'
62+
)
63+
64+
65+
def downgrade():
66+
if not _has_table('war_room_task'):
67+
return
68+
69+
op.execute('DROP INDEX IF EXISTS ix_war_room_task_war_room_parent')
70+
op.execute('DROP INDEX IF EXISTS ix_war_room_task_status_id')
71+
72+
if _table_has_column('war_room_task', 'parent_task_id'):
73+
op.execute('DROP INDEX IF EXISTS ix_war_room_task_parent_task_id')
74+
op.drop_column('war_room_task', 'parent_task_id')

source/app/blueprints/rest/v2/war_rooms/tasks.py

Lines changed: 178 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
war_room_task_list,
2727
war_room_task_reopen,
2828
war_room_task_update,
29+
war_room_task_used_tags,
2930
)
3031
from app.models.errors import BusinessProcessingError
3132
from app.models.errors import ObjectNotFoundError
@@ -52,13 +53,47 @@ def _parse_due(raw):
5253
raise BusinessProcessingError('due_at must be an ISO date string')
5354

5455

56+
def _parse_int_list(raw):
57+
"""Parse a repeatable query-string int param into a list.
58+
59+
Accepts `?status_id=1&status_id=2` or `?status_id=1,2`. Silently
60+
drops non-integer entries so a bad tag doesn't 400 the whole list
61+
call.
62+
"""
63+
if raw is None:
64+
return []
65+
if isinstance(raw, list):
66+
parts = raw
67+
else:
68+
parts = [p for p in str(raw).split(',') if p]
69+
out = []
70+
for p in parts:
71+
try:
72+
out.append(int(p))
73+
except (TypeError, ValueError):
74+
continue
75+
return out
76+
77+
78+
def _parse_str_list(raw):
79+
if raw is None:
80+
return []
81+
if isinstance(raw, list):
82+
parts = raw
83+
else:
84+
parts = str(raw).split(',')
85+
return [p.strip() for p in parts if p and p.strip()]
86+
87+
5588
def _serialize_row(row):
5689
return {
5790
'task_id': row.task_id,
5891
'war_room_id': row.war_room_id,
5992
'title': row.title,
6093
'description': row.description,
6194
'status_id': row.status_id,
95+
'status_name': getattr(row, 'status_name', None),
96+
'status_bscolor': getattr(row, 'status_bscolor', None),
6297
'assignee_id': row.assignee_id,
6398
'assignee_login': row.assignee_login,
6499
'assignee_name': row.assignee_name,
@@ -76,36 +111,173 @@ def _serialize_row(row):
76111
'closed_by_login': getattr(row, 'closed_by_login', None),
77112
'closed_by_name': getattr(row, 'closed_by_name', None),
78113
'tags': row.tags,
114+
'parent_task_id': getattr(row, 'parent_task_id', None),
79115
}
80116

81117

82118
def _serialize_obj(task):
119+
"""Serialize a plain `WarRoomTask` row (no join info).
120+
121+
Used by the mutation endpoints. Status name / actor logins are
122+
resolved via a light relationship lookup so the SPA doesn't have
123+
to re-request the row after every write.
124+
"""
125+
status_name = task.status.status_name if task.status else None
126+
status_bscolor = task.status.status_bscolor if task.status else None
127+
assignee = task.assignee
128+
creator = task.created_by
129+
closer = task.closed_by
83130
return {
84131
'task_id': task.task_id,
85132
'war_room_id': task.war_room_id,
86133
'title': task.title,
87134
'description': task.description,
88135
'status_id': task.status_id,
136+
'status_name': status_name,
137+
'status_bscolor': status_bscolor,
89138
'assignee_id': task.assignee_id,
139+
'assignee_login': assignee.user if assignee else None,
140+
'assignee_name': assignee.name if assignee else None,
90141
'due_at': task.due_at.isoformat() if task.due_at else None,
91142
'source_case_id': task.source_case_id,
92143
'source_case_task_id': task.source_case_task_id,
93144
'created_at': task.created_at.isoformat() if task.created_at else None,
94145
'created_by_id': task.created_by_id,
146+
'created_by_login': creator.user if creator else None,
147+
'created_by_name': creator.name if creator else None,
95148
'closed_at': task.closed_at.isoformat() if task.closed_at else None,
96149
'closed_by_id': task.closed_by_id,
150+
'closed_by_login': closer.user if closer else None,
151+
'closed_by_name': closer.name if closer else None,
97152
'tags': task.tags,
153+
'parent_task_id': getattr(task, 'parent_task_id', None),
98154
}
99155

100156

157+
def _parse_bool(raw, default=True):
158+
if raw is None:
159+
return default
160+
return str(raw).strip().lower() not in ('0', 'false', 'no', 'off')
161+
162+
163+
def _parse_date_arg(raw):
164+
"""Parse an ISO date/datetime string; returns None if empty/invalid.
165+
166+
Kept lenient — a bad value silently drops the endpoint rather
167+
than 400-ing the whole list. That matches the tolerance of the
168+
other filter parsers on this endpoint.
169+
"""
170+
if not raw:
171+
return None
172+
s = str(raw).strip()
173+
if not s:
174+
return None
175+
try:
176+
if len(s) == 10:
177+
return datetime.fromisoformat(s)
178+
return datetime.fromisoformat(s.replace('Z', '+00:00'))
179+
except ValueError:
180+
return None
181+
182+
101183
@war_rooms_tasks_blueprint.get('')
102184
@ac_api_requires()
103185
def list_tasks(war_room_id):
104186
err = require_war_room_read(war_room_id)
105187
if err is not None:
106188
return err
107-
rows = war_room_task_list(war_room_id)
108-
return response_api_success(data=[_serialize_row(r) for r in rows])
189+
q = request.args.get('q') or None
190+
status_ids = _parse_int_list(request.args.getlist('status_id')
191+
or request.args.get('status_id'))
192+
assignee_ids_raw = (request.args.getlist('assignee_id')
193+
or request.args.get('assignee_id'))
194+
# Accept `0` to mean Unassigned; parse ints then reinject the flag.
195+
assignee_ids = []
196+
if assignee_ids_raw:
197+
if isinstance(assignee_ids_raw, list):
198+
parts = assignee_ids_raw
199+
else:
200+
parts = str(assignee_ids_raw).split(',')
201+
for p in parts:
202+
p = str(p).strip().lower()
203+
if p in ('0', 'unassigned', 'null', 'none'):
204+
assignee_ids.append(0)
205+
continue
206+
try:
207+
assignee_ids.append(int(p))
208+
except ValueError:
209+
continue
210+
tags = _parse_str_list(request.args.getlist('tag')
211+
or request.args.get('tag'))
212+
parent = request.args.get('parent_task_id')
213+
if parent is None:
214+
parent_task_id = -1
215+
else:
216+
p = str(parent).strip().lower()
217+
if p in ('', 'null', 'none', 'top', 'root'):
218+
parent_task_id = 0
219+
else:
220+
try:
221+
parent_task_id = int(p)
222+
except ValueError:
223+
parent_task_id = -1
224+
include_closed = _parse_bool(request.args.get('include_closed'), True)
225+
due_from = _parse_date_arg(request.args.get('due_from'))
226+
due_to = _parse_date_arg(request.args.get('due_to'))
227+
include_no_due = _parse_bool(request.args.get('include_no_due'), True)
228+
229+
# Pagination is opt-in: `page` present → return the envelope;
230+
# absent → return the raw array (back-compat with earlier callers).
231+
page_raw = request.args.get('page')
232+
if page_raw is None:
233+
rows = war_room_task_list(
234+
war_room_id,
235+
q=q,
236+
status_ids=status_ids or None,
237+
tags=tags or None,
238+
assignee_ids=assignee_ids or None,
239+
parent_task_id=parent_task_id,
240+
due_from=due_from,
241+
due_to=due_to,
242+
include_no_due=include_no_due,
243+
include_closed=include_closed,
244+
)
245+
return response_api_success(data=[_serialize_row(r) for r in rows])
246+
247+
try:
248+
page = int(page_raw)
249+
except (TypeError, ValueError):
250+
page = 1
251+
try:
252+
per_page = int(request.args.get('per_page', 25))
253+
except (TypeError, ValueError):
254+
per_page = 25
255+
envelope = war_room_task_list(
256+
war_room_id,
257+
q=q,
258+
status_ids=status_ids or None,
259+
tags=tags or None,
260+
assignee_ids=assignee_ids or None,
261+
parent_task_id=parent_task_id,
262+
due_from=due_from,
263+
due_to=due_to,
264+
include_no_due=include_no_due,
265+
include_closed=include_closed,
266+
page=page,
267+
per_page=per_page,
268+
)
269+
envelope['data'] = [_serialize_row(r) for r in envelope['data']]
270+
return response_api_success(data=envelope)
271+
272+
273+
@war_rooms_tasks_blueprint.get('/tags')
274+
@ac_api_requires()
275+
def list_used_tags(war_room_id):
276+
"""Distinct tag values already in use in this war room."""
277+
err = require_war_room_read(war_room_id)
278+
if err is not None:
279+
return err
280+
return response_api_success(data=war_room_task_used_tags(war_room_id))
109281

110282

111283
@war_rooms_tasks_blueprint.post('')
@@ -129,6 +301,7 @@ def create_task(war_room_id):
129301
source_case_id=raw.get('source_case_id'),
130302
source_case_task_id=raw.get('source_case_task_id'),
131303
tags=raw.get('tags'),
304+
parent_task_id=raw.get('parent_task_id'),
132305
created_by_id=iris_current_user.id,
133306
)
134307
except BusinessProcessingError as e:
@@ -154,9 +327,11 @@ def update_task(war_room_id, task_id):
154327
try:
155328
fields = {k: raw[k] for k in raw if k in
156329
('title', 'description', 'status_id', 'assignee_id',
157-
'source_case_id', 'source_case_task_id', 'tags')}
330+
'source_case_id', 'source_case_task_id', 'tags',
331+
'parent_task_id')}
158332
if 'due_at' in raw:
159333
fields['due_at'] = _parse_due(raw['due_at'])
334+
fields['updated_by_id'] = iris_current_user.id
160335
task = war_room_task_update(war_room_id, task_id, **fields)
161336
except ObjectNotFoundError:
162337
return response_api_not_found()

0 commit comments

Comments
 (0)