66import os
77import time
88import uuid
9- from collections import Counter , defaultdict
9+ from collections import defaultdict
1010from json import JSONDecodeError
1111from typing import Callable , List , Mapping , Optional
1212
8989 WrongRespondentForAnswerGroup ,
9090)
9191from apps .answers .filters import AppletSubmitDateFilter , ReviewAppletItemFilter , SummaryActivityFilter
92+ from apps .answers .flow_submission_progress import FlowSubmissionProgress
9293from apps .answers .tasks import create_report
9394from apps .applets .crud import AppletsCRUD
9495from apps .applets .domain .applet_history import Version
@@ -150,46 +151,54 @@ async def create_answer(self, activity_answer: AppletAnswerCreate, device_id: st
150151 async def _create_respondent_answer (
151152 self , activity_answer : AppletAnswerCreate , device_id : str | None
152153 ) -> AnswerSchema :
153- await self ._validate_respondent_answer (activity_answer )
154- return await self ._create_answer (activity_answer , device_id )
154+ flow_progress = await self ._validate_respondent_answer (activity_answer )
155+ return await self ._create_answer (activity_answer , device_id , flow_progress )
155156
156157 async def _create_anonymous_answer (
157158 self , activity_answer : AppletAnswerCreate , device_id : str | None
158159 ) -> AnswerSchema :
159- await self ._validate_anonymous_answer (activity_answer )
160- return await self ._create_answer (activity_answer , device_id )
160+ flow_progress = await self ._validate_anonymous_answer (activity_answer )
161+ return await self ._create_answer (activity_answer , device_id , flow_progress )
161162
162- async def _validate_respondent_answer (self , activity_answer : AppletAnswerCreate ) -> None :
163- await self ._validate_answer (activity_answer )
163+ async def _validate_respondent_answer (self , activity_answer : AppletAnswerCreate ) -> FlowSubmissionProgress | None :
164+ flow_progress = await self ._validate_answer (activity_answer )
164165 await self ._validate_applet_for_user_response (activity_answer .applet_id )
165166
166- async def _validate_anonymous_answer (self , activity_answer : AppletAnswerCreate ) -> None :
167+ return flow_progress
168+
169+ async def _validate_anonymous_answer (self , activity_answer : AppletAnswerCreate ) -> FlowSubmissionProgress | None :
167170 await self ._validate_applet_for_anonymous_response (activity_answer .applet_id , activity_answer .version )
168- await self ._validate_answer (activity_answer )
171+ return await self ._validate_answer (activity_answer )
169172
170- async def _validate_answer (self , applet_answer : AppletAnswerCreate ) -> None : # noqa: C901
173+ async def _validate_answer (self , applet_answer : AppletAnswerCreate ) -> FlowSubmissionProgress | None : # noqa: C901
171174 pk = self ._generate_history_id (applet_answer .version )
175+
176+ # Timestamp-based duplicate detection: when created_at provided, check for exact duplicate
177+ # Each unique timestamp represents a distinct submission, bypassing occurrence limits
178+ if applet_answer .created_at is not None :
179+ created_at_ms = int (applet_answer .created_at .timestamp () * 1000 )
180+ existing_with_timestamp = await AnswersCRUD (self .answer_session ).get_by_applet_activity_submit_or_user_id (
181+ applet_answer .applet_id ,
182+ str (applet_answer .activity_id ),
183+ None ,
184+ applet_answer .submit_id ,
185+ created_at_ms ,
186+ )
187+ if existing_with_timestamp :
188+ raise ValidationError ("Duplicate answer with same timestamp already exists" )
189+
172190 existed_answers = await AnswersCRUD (self .answer_session ).get_by_submit_id (applet_answer .submit_id )
173191
174192 activity_history_id = pk (applet_answer .activity_id )
175193 flow_history_id = pk (applet_answer .flow_id ) if applet_answer .flow_id else None
176-
177- activity_indexes = set () # same activity is allowed multiple times in flow
178- latest_activity_index = None
179- if flow_history_id :
180- flow_histories = await FlowsHistoryCRUD (self .session ).load_full (
181- [pk (applet_answer .flow_id )], load_activities = False
182- )
183- if not flow_histories :
194+ flow_progress : FlowSubmissionProgress | None = None
195+ # Only use occurrence-based flow validation when created_at is NOT provided
196+ if flow_history_id and applet_answer .created_at is None :
197+ flow_progress = FlowSubmissionProgress (self .session , self .answer_session )
198+ await flow_progress .load (flow_history_id , applet_answer .submit_id )
199+ if not flow_progress .has_flow_history :
184200 raise ValidationError ("Flow not found" )
185- flow_history = next (iter (flow_histories ))
186-
187- # check activity in the flow
188- for i , item in enumerate (flow_history .items ):
189- if item .activity_id == activity_history_id :
190- activity_indexes .add (i )
191- latest_activity_index = len (flow_history .items ) - 1
192- if not activity_indexes :
201+ if not flow_progress .contains_activity (activity_history_id ):
193202 raise ValidationError ("Activity not found in the flow" )
194203
195204 if existed_answers :
@@ -208,60 +217,28 @@ async def _validate_answer(self, applet_answer: AppletAnswerCreate) -> None: #
208217 if flow_history_id != existed_answer .flow_history_id :
209218 raise ValidationError ("Submit id duplicate error" )
210219
211- # check current answer is provided in right order in the flow, so prev activities already answered
212- prev_answers_count = len (existed_answers )
213- is_flow_completed = any (answer .is_flow_completed for answer in existed_answers )
214-
215- # Smart flow completion check
216- if is_flow_completed :
217- # Count expected and already persisted activity occurrences
218- flow_activity_counts = Counter (item .activity_id for item in flow_history .items )
219- submitted_counts = Counter (answer .activity_history_id for answer in existed_answers )
220-
221- # If all activities already persisted before this submission, the flow truly finished
222- if all (submitted_counts .get (act_id , 0 ) >= count for act_id , count in flow_activity_counts .items ()):
223- raise ValidationError ("Flow is already completed" )
220+ if flow_progress :
221+ if flow_progress .is_complete_before_current ():
222+ raise ValidationError ("Flow is already completed" )
224223
225- current_expected_total = flow_activity_counts .get (activity_history_id , 0 )
226- current_submitted = submitted_counts .get (activity_history_id , 0 )
227-
228- # Reject duplicates that exceed expected occurrences for the activity
229- if current_submitted >= current_expected_total :
230- raise ValidationError ("Flow is already completed" )
224+ if not flow_progress .can_accept (activity_history_id ):
225+ raise ValidationError ("Activity submission exceeds expected occurrences for this flow" )
231226
227+ if existed_answers :
232228 logger .info (
233- "Allowing late submission for flow %s, activity %s, submit_id %s" ,
229+ "Allowing flow submission for flow %s, activity %s, submit_id %s" ,
234230 flow_history_id ,
235231 activity_history_id ,
236232 applet_answer .submit_id ,
237233 )
238234
239- # Continue with existing order validation only if flow not marked complete
240- # When is_flow_completed=True, we allow out-of-order submissions
241- if not is_flow_completed and prev_answers_count not in activity_indexes :
242- assert latest_activity_index is not None
243- # allow latest activity for flow autocompletion FE logic
244- if not (
245- prev_answers_count < latest_activity_index + 1
246- and max (activity_indexes ) == latest_activity_index
247- and applet_answer .is_flow_completed
248- ):
249- raise ValidationError ("Wrong activity order in the flow" )
250-
251- elif flow_history_id and 0 not in activity_indexes :
252- # check first flow answer - but allow if flow is marked as complete
253- # Check both existing answers and current answer for is_flow_completed
254- if not (
255- (existed_answers and any (answer .is_flow_completed for answer in existed_answers ))
256- or applet_answer .is_flow_completed
257- ):
258- raise ValidationError ("Wrong activity order in the flow" )
259-
260235 activity_history = await ActivityHistoriesCRUD (self .session ).get_by_id (activity_history_id )
261236
262237 if not activity_history .applet_id .startswith (f"{ applet_answer .applet_id } " ):
263238 raise ActivityHistoryDoeNotExist ()
264239
240+ return flow_progress
241+
265242 async def _validate_applet_for_anonymous_response (self , applet_id : uuid .UUID , version : str ) -> None :
266243 await AppletHistoryService (self .session , applet_id , version ).get ()
267244 # Validate applet for anonymous answer
@@ -346,12 +323,41 @@ async def _get_answer_relation(
346323
347324 return relation .relation
348325
349- async def _create_answer (self , applet_answer : AppletAnswerCreate , device_id : str | None ) -> AnswerSchema :
326+ async def _create_answer (
327+ self ,
328+ applet_answer : AppletAnswerCreate ,
329+ device_id : str | None ,
330+ flow_progress : FlowSubmissionProgress | None ,
331+ ) -> AnswerSchema :
350332 assert self .user_id
351333 pk = self ._generate_history_id (applet_answer .version )
352334 created_at = applet_answer .created_at or datetime .datetime .now (datetime .UTC ).replace (tzinfo = None )
353335 subject_crud = SubjectsCrud (self .session )
354336
337+ activity_history_id = pk (applet_answer .activity_id )
338+ flow_history_id = pk (applet_answer .flow_id ) if applet_answer .flow_id else None
339+ client_flow_completed_flag = bool (applet_answer .is_flow_completed ) if applet_answer .flow_id else None
340+
341+ is_flow_completed_backend = None
342+ if applet_answer .flow_id :
343+ if flow_progress :
344+ is_flow_completed_backend = flow_progress .completion_state_after_add (activity_history_id )
345+ if client_flow_completed_flag is not None and client_flow_completed_flag != is_flow_completed_backend :
346+ logger .info (
347+ "Flow completion mismatch for flow %s, activity %s, submit_id %s: client=%s backend=%s" ,
348+ flow_history_id ,
349+ activity_history_id ,
350+ applet_answer .submit_id ,
351+ client_flow_completed_flag ,
352+ is_flow_completed_backend ,
353+ )
354+ else :
355+ is_flow_completed_backend = client_flow_completed_flag
356+
357+ migrated_data = None
358+ if client_flow_completed_flag is not None :
359+ migrated_data = {"client_flow_completed_flag" : client_flow_completed_flag }
360+
355361 respondent_subject = await subject_crud .get_user_subject (
356362 user_id = self .user_id , applet_id = applet_answer .applet_id
357363 )
@@ -413,18 +419,19 @@ async def _create_answer(self, applet_answer: AppletAnswerCreate, device_id: str
413419 applet_id = applet_answer .applet_id ,
414420 version = applet_answer .version ,
415421 applet_history_id = pk (applet_answer .applet_id ),
416- flow_history_id = pk ( applet_answer . flow_id ) if applet_answer . flow_id else None ,
417- activity_history_id = pk ( applet_answer . activity_id ) ,
422+ flow_history_id = flow_history_id ,
423+ activity_history_id = activity_history_id ,
418424 respondent_id = self .user_id ,
419425 client = applet_answer .client .dict (),
420- is_flow_completed = bool ( applet_answer . is_flow_completed ) if applet_answer . flow_id else None ,
426+ is_flow_completed = is_flow_completed_backend ,
421427 target_subject_id = target_subject .id ,
422428 source_subject_id = source_subject .id ,
423429 input_subject_id = input_subject .id ,
424430 relation = relation ,
425431 consent_to_share = applet_answer .consent_to_share ,
426432 event_history_id = applet_answer .event_history_id ,
427433 device_id = device_id ,
434+ migrated_data = migrated_data ,
428435 )
429436 )
430437 item_answer = applet_answer .answer
@@ -1724,11 +1731,11 @@ async def get_completed_answers_data_list(
17241731 return result
17251732
17261733 async def is_answers_uploaded (
1727- self , applet_id : uuid .UUID , activity_id : str , submit_id : uuid .UUID | None = None
1734+ self , applet_id : uuid .UUID , activity_id : str , submit_id : uuid .UUID | None = None , created_at : int | None = None
17281735 ) -> bool :
17291736 # check by submit id if provided otherwise by user_id
17301737 answers = await AnswersCRUD (self .answer_session ).get_by_applet_activity_submit_or_user_id (
1731- applet_id , activity_id , self .user_id if not submit_id else None , submit_id
1738+ applet_id , activity_id , self .user_id if not submit_id else None , submit_id , created_at
17321739 )
17331740 if not answers :
17341741 return False
0 commit comments