2626 war_room_task_list ,
2727 war_room_task_reopen ,
2828 war_room_task_update ,
29+ war_room_task_used_tags ,
2930)
3031from app .models .errors import BusinessProcessingError
3132from 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+
5588def _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
82118def _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 ()
103185def 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