Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/apps/answers/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -873,12 +873,12 @@ async def answers_existence_check(
await AppletService(session, user.id).exist_by_id(schema.applet_id)
await CheckAccessService(session, user.id).check_answer_check_access(schema.applet_id)
is_exist = await AnswerService(session, user.id, answer_session).is_answers_uploaded(
schema.applet_id, schema.activity_id, schema.submit_id
schema.applet_id, schema.activity_id, schema.submit_id, schema.created_at
)

logger.info(
f"check-existence: applet_id={schema.applet_id}, activity_id={schema.activity_id}, user_id={user.id}, "
f"submit_id={schema.submit_id}, exists={is_exist}, ip={client_ip}"
f"submit_id={schema.submit_id}, created_at={schema.created_at}, exists={is_exist}, ip={client_ip}"
)

return Response[AnswerExistenceResponse](result=AnswerExistenceResponse(exists=is_exist))
Expand Down
10 changes: 8 additions & 2 deletions src/apps/answers/crud/answers.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,16 +518,22 @@ async def get_by_applet_activity_submit_or_user_id(
activity_id: str,
user_id: uuid.UUID | None = None,
submit_id: uuid.UUID | None = None,
created_at: int | None = None,
) -> list[AnswerSchema]:
# We're not using created_at for filtering as it causes issues with mobile submissions
# The combination of applet_id, activity_id, and either user_id or submit_id should be sufficient
# created_at is used to distinguish between duplicate activities in flows
query: Query = select(AnswerSchema)
query = query.where(AnswerSchema.applet_id == applet_id)
query = query.filter(AnswerSchema.activity_history_id.startswith(activity_id))
if submit_id:
query = query.where(AnswerSchema.submit_id == submit_id)
if user_id:
query = query.where(AnswerSchema.respondent_id == user_id)
if created_at is not None:
# Convert Unix timestamp (milliseconds) to datetime for comparison
created_at_datetime = datetime.datetime.fromtimestamp(
created_at / 1000.0, tz=datetime.timezone.utc
).replace(tzinfo=None)
query = query.where(AnswerSchema.created_at == created_at_datetime)

db_result = await self._execute(query)
return db_result.scalars().all()
Expand Down
4 changes: 1 addition & 3 deletions src/apps/answers/domain/answers.py
Original file line number Diff line number Diff line change
Expand Up @@ -673,9 +673,7 @@ class AppletCompletedEntities(InternalModel):

class AnswersCheck(PublicModel):
applet_id: uuid.UUID
# TODO: created_at can be safely removed after
# the corresponding mobile PR is merged
# https://mindlogger.atlassian.net/browse/M2-9693
# Used to distinguish between duplicate activities in flows
created_at: int | None = None
activity_id: str
submit_id: uuid.UUID | None = None
Expand Down
74 changes: 74 additions & 0 deletions src/apps/answers/flow_submission_progress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from __future__ import annotations

from collections import Counter
from typing import Iterable

from sqlalchemy.ext.asyncio import AsyncSession

from apps.activity_flows.crud import FlowsHistoryCRUD
from apps.answers.crud.answers import AnswersCRUD


class FlowSubmissionProgress:
"""Track flow submission progress using occurrence counting."""

def __init__(self, session: AsyncSession, answer_session: AsyncSession):
self._session = session
self._answer_session = answer_session
self._expected_counts: Counter[str] = Counter()
self._expected_ids: set[str] = set()
self._submitted_counts: Counter[str] = Counter()
self._has_flow_history: bool = False

async def load(self, flow_history_id: str, submit_id) -> None:
"""Load flow structure and existing submissions for the submit id."""
flow_histories = await FlowsHistoryCRUD(self._session).load_full([flow_history_id], load_activities=False)
if not flow_histories:
self._has_flow_history = False
return

flow_history = flow_histories[0]
self._expected_counts = Counter(item.activity_id for item in flow_history.items)
self._expected_ids = set(self._expected_counts.keys())
self._has_flow_history = True

existing_answers = await AnswersCRUD(self._answer_session).get_by_submit_id(submit_id)
self._submitted_counts = Counter(answer.activity_history_id for answer in existing_answers or [])

@property
def has_flow_history(self) -> bool:
return self._has_flow_history

def is_complete_before_current(self) -> bool:
return self._all_expected_satisfied(self._submitted_counts.items())

def can_accept(self, activity_history_id: str) -> bool:
expected_total = self._expected_counts.get(activity_history_id, 0)
if expected_total == 0:
return False
return self._submitted_counts.get(activity_history_id, 0) < expected_total

def completion_state_after_add(self, activity_history_id: str) -> bool:
temp_counts = self._submitted_counts.copy()
temp_counts[activity_history_id] += 1
return self._all_expected_satisfied(temp_counts.items())

def contains_activity(self, activity_history_id: str) -> bool:
if not self._has_flow_history:
return False
return activity_history_id in self._expected_ids

@property
def expected_total(self) -> int:
return sum(self._expected_counts.values())

@property
def submitted_total(self) -> int:
return sum(self._submitted_counts.values())

def _all_expected_satisfied(self, submitted_items: Iterable[tuple[str, int]]) -> bool:
submitted_map = dict(submitted_items)
for activity_history_id, expected_count in self._expected_counts.items():
if submitted_map.get(activity_history_id, 0) < expected_count:
return False
return True
153 changes: 80 additions & 73 deletions src/apps/answers/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import os
import time
import uuid
from collections import Counter, defaultdict
from collections import defaultdict
from json import JSONDecodeError
from typing import Callable, List, Mapping, Optional

Expand Down Expand Up @@ -89,6 +89,7 @@
WrongRespondentForAnswerGroup,
)
from apps.answers.filters import AppletSubmitDateFilter, ReviewAppletItemFilter, SummaryActivityFilter
from apps.answers.flow_submission_progress import FlowSubmissionProgress
from apps.answers.tasks import create_report
from apps.applets.crud import AppletsCRUD
from apps.applets.domain.applet_history import Version
Expand Down Expand Up @@ -150,46 +151,54 @@ async def create_answer(self, activity_answer: AppletAnswerCreate, device_id: st
async def _create_respondent_answer(
self, activity_answer: AppletAnswerCreate, device_id: str | None
) -> AnswerSchema:
await self._validate_respondent_answer(activity_answer)
return await self._create_answer(activity_answer, device_id)
flow_progress = await self._validate_respondent_answer(activity_answer)
return await self._create_answer(activity_answer, device_id, flow_progress)

async def _create_anonymous_answer(
self, activity_answer: AppletAnswerCreate, device_id: str | None
) -> AnswerSchema:
await self._validate_anonymous_answer(activity_answer)
return await self._create_answer(activity_answer, device_id)
flow_progress = await self._validate_anonymous_answer(activity_answer)
return await self._create_answer(activity_answer, device_id, flow_progress)

async def _validate_respondent_answer(self, activity_answer: AppletAnswerCreate) -> None:
await self._validate_answer(activity_answer)
async def _validate_respondent_answer(self, activity_answer: AppletAnswerCreate) -> FlowSubmissionProgress | None:
flow_progress = await self._validate_answer(activity_answer)
await self._validate_applet_for_user_response(activity_answer.applet_id)

async def _validate_anonymous_answer(self, activity_answer: AppletAnswerCreate) -> None:
return flow_progress

async def _validate_anonymous_answer(self, activity_answer: AppletAnswerCreate) -> FlowSubmissionProgress | None:
await self._validate_applet_for_anonymous_response(activity_answer.applet_id, activity_answer.version)
await self._validate_answer(activity_answer)
return await self._validate_answer(activity_answer)

async def _validate_answer(self, applet_answer: AppletAnswerCreate) -> None: # noqa: C901
async def _validate_answer(self, applet_answer: AppletAnswerCreate) -> FlowSubmissionProgress | None: # noqa: C901
pk = self._generate_history_id(applet_answer.version)

# Timestamp-based duplicate detection: when created_at provided, check for exact duplicate
# Each unique timestamp represents a distinct submission, bypassing occurrence limits
if applet_answer.created_at is not None:
created_at_ms = int(applet_answer.created_at.timestamp() * 1000)
existing_with_timestamp = await AnswersCRUD(self.answer_session).get_by_applet_activity_submit_or_user_id(
applet_answer.applet_id,
str(applet_answer.activity_id),
None,
applet_answer.submit_id,
created_at_ms,
)
if existing_with_timestamp:
raise ValidationError("Duplicate answer with same timestamp already exists")

existed_answers = await AnswersCRUD(self.answer_session).get_by_submit_id(applet_answer.submit_id)

activity_history_id = pk(applet_answer.activity_id)
flow_history_id = pk(applet_answer.flow_id) if applet_answer.flow_id else None

activity_indexes = set() # same activity is allowed multiple times in flow
latest_activity_index = None
if flow_history_id:
flow_histories = await FlowsHistoryCRUD(self.session).load_full(
[pk(applet_answer.flow_id)], load_activities=False
)
if not flow_histories:
flow_progress: FlowSubmissionProgress | None = None
# Only use occurrence-based flow validation when created_at is NOT provided
if flow_history_id and applet_answer.created_at is None:
flow_progress = FlowSubmissionProgress(self.session, self.answer_session)
await flow_progress.load(flow_history_id, applet_answer.submit_id)
if not flow_progress.has_flow_history:
raise ValidationError("Flow not found")
flow_history = next(iter(flow_histories))

# check activity in the flow
for i, item in enumerate(flow_history.items):
if item.activity_id == activity_history_id:
activity_indexes.add(i)
latest_activity_index = len(flow_history.items) - 1
if not activity_indexes:
if not flow_progress.contains_activity(activity_history_id):
raise ValidationError("Activity not found in the flow")

if existed_answers:
Expand All @@ -208,60 +217,28 @@ async def _validate_answer(self, applet_answer: AppletAnswerCreate) -> None: #
if flow_history_id != existed_answer.flow_history_id:
raise ValidationError("Submit id duplicate error")

# check current answer is provided in right order in the flow, so prev activities already answered
prev_answers_count = len(existed_answers)
is_flow_completed = any(answer.is_flow_completed for answer in existed_answers)

# Smart flow completion check
if is_flow_completed:
# Count expected and already persisted activity occurrences
flow_activity_counts = Counter(item.activity_id for item in flow_history.items)
submitted_counts = Counter(answer.activity_history_id for answer in existed_answers)

# If all activities already persisted before this submission, the flow truly finished
if all(submitted_counts.get(act_id, 0) >= count for act_id, count in flow_activity_counts.items()):
raise ValidationError("Flow is already completed")
if flow_progress:
if flow_progress.is_complete_before_current():
raise ValidationError("Flow is already completed")

current_expected_total = flow_activity_counts.get(activity_history_id, 0)
current_submitted = submitted_counts.get(activity_history_id, 0)

# Reject duplicates that exceed expected occurrences for the activity
if current_submitted >= current_expected_total:
raise ValidationError("Flow is already completed")
if not flow_progress.can_accept(activity_history_id):
raise ValidationError("Activity submission exceeds expected occurrences for this flow")

if existed_answers:
logger.info(
"Allowing late submission for flow %s, activity %s, submit_id %s",
"Allowing flow submission for flow %s, activity %s, submit_id %s",
flow_history_id,
activity_history_id,
applet_answer.submit_id,
)

# Continue with existing order validation only if flow not marked complete
# When is_flow_completed=True, we allow out-of-order submissions
if not is_flow_completed and prev_answers_count not in activity_indexes:
assert latest_activity_index is not None
# allow latest activity for flow autocompletion FE logic
if not (
prev_answers_count < latest_activity_index + 1
and max(activity_indexes) == latest_activity_index
and applet_answer.is_flow_completed
):
raise ValidationError("Wrong activity order in the flow")

elif flow_history_id and 0 not in activity_indexes:
# check first flow answer - but allow if flow is marked as complete
# Check both existing answers and current answer for is_flow_completed
if not (
(existed_answers and any(answer.is_flow_completed for answer in existed_answers))
or applet_answer.is_flow_completed
):
raise ValidationError("Wrong activity order in the flow")

activity_history = await ActivityHistoriesCRUD(self.session).get_by_id(activity_history_id)

if not activity_history.applet_id.startswith(f"{applet_answer.applet_id}"):
raise ActivityHistoryDoeNotExist()

return flow_progress

async def _validate_applet_for_anonymous_response(self, applet_id: uuid.UUID, version: str) -> None:
await AppletHistoryService(self.session, applet_id, version).get()
# Validate applet for anonymous answer
Expand Down Expand Up @@ -346,12 +323,41 @@ async def _get_answer_relation(

return relation.relation

async def _create_answer(self, applet_answer: AppletAnswerCreate, device_id: str | None) -> AnswerSchema:
async def _create_answer(
self,
applet_answer: AppletAnswerCreate,
device_id: str | None,
flow_progress: FlowSubmissionProgress | None,
) -> AnswerSchema:
assert self.user_id
pk = self._generate_history_id(applet_answer.version)
created_at = applet_answer.created_at or datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
subject_crud = SubjectsCrud(self.session)

activity_history_id = pk(applet_answer.activity_id)
flow_history_id = pk(applet_answer.flow_id) if applet_answer.flow_id else None
client_flow_completed_flag = bool(applet_answer.is_flow_completed) if applet_answer.flow_id else None

is_flow_completed_backend = None
if applet_answer.flow_id:
if flow_progress:
is_flow_completed_backend = flow_progress.completion_state_after_add(activity_history_id)
if client_flow_completed_flag is not None and client_flow_completed_flag != is_flow_completed_backend:
logger.info(
"Flow completion mismatch for flow %s, activity %s, submit_id %s: client=%s backend=%s",
flow_history_id,
activity_history_id,
applet_answer.submit_id,
client_flow_completed_flag,
is_flow_completed_backend,
)
else:
is_flow_completed_backend = client_flow_completed_flag

migrated_data = None
if client_flow_completed_flag is not None:
migrated_data = {"client_flow_completed_flag": client_flow_completed_flag}

respondent_subject = await subject_crud.get_user_subject(
user_id=self.user_id, applet_id=applet_answer.applet_id
)
Expand Down Expand Up @@ -413,18 +419,19 @@ async def _create_answer(self, applet_answer: AppletAnswerCreate, device_id: str
applet_id=applet_answer.applet_id,
version=applet_answer.version,
applet_history_id=pk(applet_answer.applet_id),
flow_history_id=pk(applet_answer.flow_id) if applet_answer.flow_id else None,
activity_history_id=pk(applet_answer.activity_id),
flow_history_id=flow_history_id,
activity_history_id=activity_history_id,
respondent_id=self.user_id,
client=applet_answer.client.dict(),
is_flow_completed=bool(applet_answer.is_flow_completed) if applet_answer.flow_id else None,
is_flow_completed=is_flow_completed_backend,
target_subject_id=target_subject.id,
source_subject_id=source_subject.id,
input_subject_id=input_subject.id,
relation=relation,
consent_to_share=applet_answer.consent_to_share,
event_history_id=applet_answer.event_history_id,
device_id=device_id,
migrated_data=migrated_data,
)
)
item_answer = applet_answer.answer
Expand Down Expand Up @@ -1724,11 +1731,11 @@ async def get_completed_answers_data_list(
return result

async def is_answers_uploaded(
self, applet_id: uuid.UUID, activity_id: str, submit_id: uuid.UUID | None = None
self, applet_id: uuid.UUID, activity_id: str, submit_id: uuid.UUID | None = None, created_at: int | None = None
) -> bool:
# check by submit id if provided otherwise by user_id
answers = await AnswersCRUD(self.answer_session).get_by_applet_activity_submit_or_user_id(
applet_id, activity_id, self.user_id if not submit_id else None, submit_id
applet_id, activity_id, self.user_id if not submit_id else None, submit_id, created_at
)
if not answers:
return False
Expand Down
2 changes: 1 addition & 1 deletion src/apps/answers/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ def answer_create(
submit_id=uuid.uuid4(),
activity_id=applet.activities[0].id,
answer=answer_item_create,
created_at=datetime.datetime.now(datetime.UTC).replace(microsecond=0),
created_at=None, # None by default - uses occurrence-based validation
client=client_meta,
consent_to_share=False,
)
Expand Down
Loading
Loading