Technical reference for the survey progress tracking and resume feature. For the user-facing guide, see Survey Progress Tracking.
Overview
CheckTick saves participant progress so they can resume incomplete surveys. The mechanism depends on the survey's visibility mode โ each mode has a different participant credential:
| Visibility | Credential | Resume mechanism | Server-side progress? |
|---|---|---|---|
authenticated |
User account | Automatic on login (SurveyProgress.user FK) |
Yes, auto-created |
token |
Access token in URL | Automatic on re-accessing the token link (SurveyProgress.access_token FK) |
Yes, auto-created |
public / unlisted |
None | Explicit resume token (opt-in) | No, unless participant opts in |
Data Model
SurveyProgress
class SurveyProgress(models.Model):
survey = models.ForeignKey(Survey, related_name="progress_records")
user = models.ForeignKey(User, null=True, blank=True) # authenticated
session_key = models.CharField(max_length=40, null=True, blank=True) # anonymous
access_token = models.ForeignKey(SurveyAccessToken, null=True, blank=True) # token surveys
# Progress data
partial_answers = models.JSONField(default=dict)
current_question_id = models.IntegerField(null=True, blank=True)
total_questions = models.IntegerField(default=0)
answered_count = models.IntegerField(default=0)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
last_question_answered_at = models.DateTimeField(null=True, blank=True)
expires_at = models.DateTimeField() # 30-day auto-cleanup
# Resume token (public surveys only)
resume_token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, null=True, blank=True)
# Lifecycle
class Status(models.TextChoices):
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
ABANDONED = "abandoned"
status = models.CharField(max_length=20, choices=Status.choices, default=Status.IN_PROGRESS)
# Resume position inside a repeat instance
current_repeat_index = models.PositiveIntegerField(null=True, blank=True)
# Section menu selection (for the planned section_menu layout)
selected_group_ids = models.JSONField(default=list, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
Survey toggles
class Survey(models.Model):
...
allow_resume = models.BooleanField(default=True)
allow_response_redaction = models.BooleanField(default=True)
SurveyResponse.receipt_token
The existing receipt_token field (UUID, nullable) is extended to public surveys on an opt-in basis. See generate_receipt_token() below.
Resume Model by Access Tier
Authenticated surveys
_get_or_create_progress in views.py creates a SurveyProgress row keyed on request.user. The unique constraint one_progress_per_user_per_survey enforces one active row per user per survey. On any subsequent login (including from a new device), the participant resumes where they left off.
Token surveys
A SurveyProgress row is created keyed on the SurveyAccessToken and the session. Re-accessing the token URL resumes the survey. The token is the credential โ no separate resume token is needed.
Public and unlisted surveys (behaviour change)
Public and unlisted surveys do not auto-save progress server-side. This is a deliberate privacy change from the previous behaviour:
- No
SurveyProgressrow is created automatically. _get_or_create_progressreturns(None, False)for public/unlisted surveys with no participant credential.- The draft-save AJAX handler returns
server_save_disabled: Trueso the client knows to uselocalStorage. - Crash recovery uses client-side
localStorage(the JS is not yet wired up โ the backend actions exist and return JSON). - The only server-side persistence is an explicit, opt-in resume token.
Rationale: public surveys have no participant credential, so any server-side progress row is protected only by the session cookie โ weak, and a PHI retention liability on shared computers.
Resume Tokens
Issuance (save_resume action)
When a public-survey participant clicks "Save and come back later", the save_resume POST action:
- Checks
survey.allow_resumeโ returns 403 if disabled. - Looks for an existing
SurveyProgressrow bysession_key(so a second click reuses the same token). - If none exists, creates one with a
resume_token(UUID) and the current answers. - Returns the resume URL as JSON:
{"success": true, "resume_url": "...", "resume_token": "..."}.
Resume route (/take/resume/<uuid:resume_token>/)
survey_take_resume in views.py:
- Calls
SurveyProgress.find_by_resume_token(token)โ returnsNoneif the token is invalid, expired, or the row is notIN_PROGRESS. - If
None, renderssurveys/resume_expired.htmlwith a 404 status โ never reveals whether the token existed. - Rejects tokens for authenticated/token surveys (resume tokens are only for public/unlisted).
- Delegates to
_handle_participant_submissionwith the progress record passed in.
Destruction on submit
progress.mark_completed() sets status = COMPLETED, completed_at = now(), and resume_token = None. The token is invalidated โ its job is done. The row is kept briefly for audit, then swept by the retention job.
Opt-Out Tokens
generate_receipt_token(opt_in=False)
def generate_receipt_token(self, opt_in: bool = False) -> uuid.UUID | None:
if not self.is_pseudonymous:
# Anonymous response โ only issue a token if the participant
# explicitly opted in and the survey allows redaction.
if not opt_in:
return None
if not getattr(self.survey, "allow_response_redaction", True):
return None
if not self.receipt_token:
self.receipt_token = uuid.uuid4()
self.save(update_fields=["receipt_token"])
return self.receipt_token
- Pseudonymous responses (authenticated/token): token issued automatically, no opt-in needed.
- Anonymous responses (public/unlisted): token issued only when
opt_in=TrueANDsurvey.allow_response_redaction=True. - The token is stored on
SurveyResponse.receipt_tokenand used with the existingDataSubjectRequest.find_by_receipt_token()workflow.
Submit handler
The submit handler reads opt_in_redaction from the POST body and passes it to generate_receipt_token:
opted_in_redaction = bool(request.POST.get("opt_in_redaction"))
token = resp.generate_receipt_token(opt_in=opted_in_redaction)
if token:
request.session[f"receipt_token_{survey.slug}"] = str(token)
The thank-you page displays the token from the session (one-time display).
Email Delivery of Tokens
Both resume and opt-out tokens can be emailed to the participant. The privacy contract:
- The email address is not stored server-side. No model field, no log line, no audit row.
- The address is accepted in the POST body, passed to
send_token_email(), and falls out of scope. - The
RedactionFiltercatches emails in logs as a safety net, but the sending code must not rely on it. - The log entry records only
survey_id,token_type, andsentstatus โ never the address.
send_token_email() in email_utils.py
def send_token_email(to_email, survey_name, token_url, token_type) -> bool:
# Builds a markdown email with the token URL and survey name.
# Delegates to send_branded_email(). No email address stored.
email_token POST action
Accepts email, token_type ("resume" or "opt_out"), and looks up the token:
- For resume tokens: looks up
SurveyProgressbysession_key. - For opt-out tokens: reads
receipt_tokenfrom the session.
Publication Workflow Toggles
Two checkboxes in the publish settings page control the feature:
allow_resume(default True): when False, noSurveyProgressrow is created for any access tier, no resume token is issued, and the participant must complete in a single session.allow_response_redaction(default True): when False, public-survey participants are not offered an opt-out token on the thank-you page. Authenticated/token surveys are unaffected (they issue areceipt_tokenwhere pseudonymous regardless of this toggle).
Both are wired into:
- survey_publish_settings view (publish and save actions)
- _apply_pending_publish_settings (encryption-setup redirect path)
- pending_publish session dict
Lifecycle: mark_completed vs delete
The submit handler now calls progress.mark_completed() instead of progress.delete():
def mark_completed(self) -> None:
self.status = self.Status.COMPLETED
self.completed_at = timezone.now()
self.resume_token = None # invalidate the resume token
self.save(update_fields=["status", "completed_at", "resume_token"])
The row is kept with status=COMPLETED for audit, then swept by the retention job. The retention job (cleanup_survey_progress) sets status=ABANDONED before deleting expired rows, so dashboards can distinguish drop-off from active progress.
Resuming Inside Branching and Repeats
Branching on resume
current_question_id is the last question the participant saw, not necessarily the next one. On resume:
- Load stored
partial_answers. - Re-run
should_show_questionforcurrent_question_idagainst stored answers. - If still visible, land there.
- If now hidden, advance forward through the branching config to the first visible question.
- If no visible question remains, treat the survey as complete.
This reuses the existing branching.py evaluation and the same ordering pipeline (_resolved_group_order_ids + _order_questions_by_group), preserving the Survey Map = Preview = Live contract.
Repeats on resume
current_repeat_index identifies which instance of a repeating section the participant was on. On resume:
- The instance at
current_repeat_indexis rendered with stored answers. - If the participant had fewer instances (e.g. deleted one), clamp to the highest valid index.
- "Add another" still works after resume.
Logging
Per the project's logging rules (medical application, never log patient data or request bodies):
- Resume events log only:
resume_token(UUID),statustransition,survey_id. - Email delivery logs only:
survey_id,token_type,sentstatus. Never the email address. - Never log
partial_answers,selected_group_ids, or any question text.
Test Coverage
Tests live in checktick_app/surveys/tests/test_resume_redaction.py (41 tests) and checktick_app/surveys/tests/test_progress_tracking.py.
Key test scenarios:
- Model fields and defaults (resume_token, status, current_repeat_index, selected_group_ids, completed_at)
- Status transitions (mark_completed, mark_abandoned, is_expired, find_by_resume_token)
- Publication workflow toggle (publish, save, disable resume, disable redaction)
- Public survey behaviour change (no auto-save, server_save_disabled response)
- Save and come back later (token creation, reuse, disabled when allow_resume=False)
- Resume route (valid token, invalid token, expired token, completed token, authenticated survey rejection)
- Opt-out token (opt-in generates token, no opt-in means no token, disabled toggle ignores opt-in, pseudonymous still gets token)
- Email delivery (sends email, invalid email rejected, no token available error, address not stored in any model)
- Submit marks completed (not deleted), resume token invalidated
Migration
Migration 0059_progress_resume_and_redaction_fields adds the new fields and backfills resume_token for existing rows (two-step: add nullable, backfill UUIDs, add unique constraint โ the standard Django pattern for unique callable defaults).
Related Documentation
- Survey Progress Tracking โ user-facing guide
- Survey Layouts (Technical) โ the section_menu layout that depends on
selected_group_ids - Branching Technical Guide โ
should_show_question, ordering pipeline - Publishing Surveys (Technical) โ visibility modes and publish settings
- Data Governance โ retention and deletion policies