diff --git a/CLAUDE.md b/CLAUDE.md index 6d27f87..ec61074 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,9 +140,9 @@ Three cooperating processes (`Procfile`) plus the mobile client: - **web** — `gunicorn project.wsgi` — the DRF API and Django Admin (the primary researcher surface; see Researcher Dashboard below for the read-only monitoring API alongside it). - **worker** — `celery -A project.celery worker` — executes the JITAI / notification tasks. -- **beat** — `celery -A project.celery beat` — fires four periodic tasks (`CELERY_BEAT_SCHEDULE` - in `settings.py`). Three run every **180 s** and live in `app/tasks.py`: - `ingest_wearable_data`, `evaluate_jitai_triggers`, `send_checkin_reminders`. The fourth, +- **beat** — `celery -A project.celery beat` — fires five periodic tasks (`CELERY_BEAT_SCHEDULE` + in `settings.py`). Four run every **180 s** and live in `app/tasks.py`: + `ingest_wearable_data`, `evaluate_jitai_triggers`, `send_checkin_reminders`, `check_expo_receipts`. The fourth, `dashboard.tasks.recompute_monitoring_metrics`, runs every **600 s** — an aggregate refresh paced to the Labfront batch cadence, so a shorter interval would only reread the same rows. @@ -165,6 +165,13 @@ as one is just the slot length; it also sends the **outcome check-in reminder** **silent JITAI prompt** (`evaluate_jitai_triggers`, sent only after a newly completed EMA passes eligibility + randomization). +`send_jitai_prompt` stores Expo's ticket ID on `JITAILog.expo_ticket_id`. `check_expo_receipts` asks Expo for +the receipt 15 min to 23 h after the send (Expo keeps receipts about 24 h) and records `expo_receipt_status` / +`expo_receipt_error`. An `ok` receipt moves `delivery_status` from `accepted_by_expo` to `handed_to_provider` +(Apple/Google accepted it, not proof the participant saw it); an error receipt sets `failed` with +`delivery_error='expo receipt: '`, and `DeviceNotRegistered` clears `User.push_token`. A row already +`received_on_device` is never downgraded. Check-in reminders do not store a ticket. + **Every available decision point opens a time-locked outcome window, sent or not** (`_latest_outcome_window` in `app/views.py`, protocol §9.2). - For the first `OUTCOME_CHECK_IN_DELAY_MINUTES` (60, provisional) `/ema/next/` serves nothing but the C0 @@ -331,7 +338,7 @@ but that file doesn't exist in the repo — don't chase it.) | GET | `/telemetry/stress/{user_id}/` | Fetch recent stress samples (dashboard use) | | POST | `/telemetry/phone/` | Ingest compose surface event from mobile app | | POST | `/telemetry/engagement/` | Ingest EMA/notification engagement event from mobile app | -| GET | `/dashboard/participants/` | Per-participant sync/push/receipt status + staleness | +| GET | `/dashboard/participants/` | Per-participant sync/push/receipt status + staleness (labelled by study ID, no email) | | GET | `/dashboard/latency-events/` | Recent push→receipt latency events | | GET | `/api/monitor/cohort` | Latest cohort snapshot: benchmarks, Wilson bounds, 14-day series | | GET | `/api/monitor/grid` | `MetricsDaily` pivoted to participant × study_day for one metric | @@ -480,6 +487,10 @@ so a stray space or lowercase code in an export still resolves. It is deliberate from `user_id` rather than reused as a foreign key target, for the same reason `DistressFlag` stores only signal codes — it's a crosswalk, not a join key into anything sensitive. Set on `ParticipantEnrollmentForm` (optional, validated unique case-insensitively) or in Django Admin. +`dashboard/serializers.py`'s `participant_label` is the one place that names a participant in the monitoring +endpoints and `/dashboard/participants/`: study ID, else Labfront ID, else `user_id`. No monitoring payload +carries email. The admin "Enroll for notifications" shortcut writes a `TEST-` Labfront ID, so never use it on +a real participant (user 102 / RS02 was enrolled that way and needs the real ID set). A suppressed decision point is logged with `send_prompt=False`, `randomization_draw` and `randomization_probability` both null, `status` and `delivery_status` both `'suppressed'` (never diff --git a/Resources/JITAI_Task_Overview.docx b/Resources/JITAI_Task_Overview.docx index d626128..e2664ae 100644 Binary files a/Resources/JITAI_Task_Overview.docx and b/Resources/JITAI_Task_Overview.docx differ diff --git a/Resources/REACT_Architecture.docx b/Resources/REACT_Architecture.docx index 372008b..43434f6 100644 Binary files a/Resources/REACT_Architecture.docx and b/Resources/REACT_Architecture.docx differ diff --git a/backend/app/admin.py b/backend/app/admin.py index b7303ef..452b4dd 100644 --- a/backend/app/admin.py +++ b/backend/app/admin.py @@ -438,7 +438,7 @@ class JITAILogAdmin(ReadableAdminMixin, admin.ModelAdmin): date_hierarchy = "triggered_at" ordering = ("-triggered_at",) autocomplete_fields = ("user",) - readonly_fields = ("triggered_at", "decision_made_at", "push_sent_at", "device_received_at", "receipt_reported_at", "receipt_event_id", "suppression_reason") + readonly_fields = ("triggered_at", "decision_made_at", "push_sent_at", "device_received_at", "receipt_reported_at", "receipt_event_id", "suppression_reason", "expo_ticket_id", "expo_receipt_status", "expo_receipt_error", "expo_receipt_checked_at") fieldsets = ( ("Prompt", { "fields": ("user", "prompt_id", "triggered_at", "status"), @@ -451,6 +451,7 @@ class JITAILogAdmin(ReadableAdminMixin, admin.ModelAdmin): "delivery_status", "decision_made_at", "push_sent_at", "device_received_at", "receipt_reported_at", "receipt_event_id", "receipt_platform", "receipt_app_state", "delivery_error", + "expo_ticket_id", "expo_receipt_status", "expo_receipt_error", "expo_receipt_checked_at", ), }), ) diff --git a/backend/app/migrations/0053_jitailog_expo_receipt.py b/backend/app/migrations/0053_jitailog_expo_receipt.py new file mode 100644 index 0000000..198af15 --- /dev/null +++ b/backend/app/migrations/0053_jitailog_expo_receipt.py @@ -0,0 +1,38 @@ +# Generated by Django 6.0.6 on 2026-10-08 21:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0052_merge_20261005_0027'), + ] + + operations = [ + migrations.AddField( + model_name='jitailog', + name='expo_receipt_checked_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='jitailog', + name='expo_receipt_error', + field=models.CharField(blank=True, default='', max_length=64), + ), + migrations.AddField( + model_name='jitailog', + name='expo_receipt_status', + field=models.CharField(blank=True, default='', max_length=16), + ), + migrations.AddField( + model_name='jitailog', + name='expo_ticket_id', + field=models.CharField(blank=True, default='', max_length=64), + ), + migrations.AlterField( + model_name='jitailog', + name='delivery_status', + field=models.CharField(choices=[('pending', 'Pending'), ('not_sent', 'Not Sent'), ('suppressed', 'Suppressed'), ('accepted_by_expo', 'Accepted by Expo'), ('handed_to_provider', 'Handed to Apple/Google'), ('received_on_device', 'Received on Device'), ('failed', 'Failed')], db_index=True, default='pending', max_length=32), + ), + ] diff --git a/backend/app/models.py b/backend/app/models.py index 9bbf08a..9c4c39b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -337,6 +337,7 @@ class JITAILog(models.Model): ('not_sent', 'Not Sent'), ('suppressed', 'Suppressed'), ('accepted_by_expo', 'Accepted by Expo'), + ('handed_to_provider', 'Handed to Apple/Google'), ('received_on_device', 'Received on Device'), ('failed', 'Failed'), ] @@ -394,6 +395,10 @@ class JITAILog(models.Model): db_index=True, ) delivery_error = models.TextField(blank=True, default='') + expo_ticket_id = models.CharField(max_length=64, blank=True, default='') + expo_receipt_status = models.CharField(max_length=16, blank=True, default='') + expo_receipt_error = models.CharField(max_length=64, blank=True, default='') + expo_receipt_checked_at = models.DateTimeField(null=True, blank=True) receipt_platform = models.CharField(max_length=16, blank=True, default='') receipt_app_state = models.CharField(max_length=32, blank=True, default='') trigger_signal = models.CharField(max_length=32, null=True, blank=True) diff --git a/backend/app/notification_service.py b/backend/app/notification_service.py index 2b8cdcf..0d29f51 100644 --- a/backend/app/notification_service.py +++ b/backend/app/notification_service.py @@ -2,6 +2,7 @@ import logging import random from pathlib import Path +from types import SimpleNamespace from django.utils import timezone @@ -202,16 +203,29 @@ def mark_delivery_failed(jitai_log, error: str) -> None: jitai_log.save(update_fields=['status', 'delivery_status', 'delivery_error']) -def mark_delivery_accepted(jitai_log) -> None: +def mark_delivery_accepted(jitai_log, ticket_id: str = '') -> None: jitai_log.status = 'delivered' jitai_log.delivery_status = 'accepted_by_expo' jitai_log.push_sent_at = timezone.now() jitai_log.delivery_error = '' + jitai_log.expo_ticket_id = ticket_id jitai_log.save( - update_fields=['status', 'delivery_status', 'push_sent_at', 'delivery_error'] + update_fields=[ + 'status', 'delivery_status', 'push_sent_at', 'delivery_error', 'expo_ticket_id', + ] ) +def fetch_expo_receipts(ticket_ids: list[str]) -> list: + tickets = [SimpleNamespace(id=ticket_id) for ticket_id in ticket_ids] + return PushClient().check_receipts_multiple(tickets) + + +def receipt_error_code(receipt) -> str: + details = receipt.details if isinstance(receipt.details, dict) else {} + return str(details.get('error') or 'unknown')[:64] + + def build_jitai_push_message(user, jitai_log) -> PushMessage: return PushMessage( to=user.push_token, @@ -247,7 +261,8 @@ def send_jitai_prompt(user, jitai_log) -> bool: try: response = PushClient().publish(message) response.validate_response() - mark_delivery_accepted(jitai_log) + ticket_id = response.id if isinstance(response.id, str) else '' + mark_delivery_accepted(jitai_log, ticket_id) logger.info( "Expo push sent: user_id=%s prompt_id=%s", user.user_id, jitai_log.prompt_id, diff --git a/backend/app/serializers.py b/backend/app/serializers.py index 20de145..58f4fd0 100644 --- a/backend/app/serializers.py +++ b/backend/app/serializers.py @@ -123,12 +123,16 @@ class Meta: 'send_prompt', 'status', 'decision_made_at', 'push_sent_at', 'device_received_at', 'receipt_reported_at', 'receipt_event_id', 'delivery_status', 'delivery_error', 'receipt_platform', 'receipt_app_state', + 'expo_ticket_id', 'expo_receipt_status', 'expo_receipt_error', 'expo_receipt_checked_at', 'send_prompt', 'status', 'trigger_signal', 'ema_mood', 'ema_stress', 'ema_energy', 'eligible_prompt_ids', 'evaluated_items', 'matched_categories', 'category_drawn', 'fallback_reason', 'suppression_reason', ] - read_only_fields = ('id', 'triggered_at', 'suppression_reason') + read_only_fields = ( + 'id', 'triggered_at', 'suppression_reason', + 'expo_ticket_id', 'expo_receipt_status', 'expo_receipt_error', 'expo_receipt_checked_at', + ) class JITAIReceiptSerializer(serializers.Serializer): diff --git a/backend/app/tasks.py b/backend/app/tasks.py index e14edc6..9a05c22 100644 --- a/backend/app/tasks.py +++ b/backend/app/tasks.py @@ -12,7 +12,9 @@ CheckinReminder, EMA, HeartRateSample, HRVSample, JITAILog, StressSample, User, ) from app.notification_service import ( + fetch_expo_receipts, mark_delivery_failed, + receipt_error_code, select_control_prompt, select_prompt, send_checkin_reminder, @@ -391,6 +393,74 @@ def _evaluate_user(user, p): mark_delivery_failed(jitai_log, 'missing push token') +EXPO_RECEIPT_MIN_AGE = timedelta(minutes=15) +EXPO_RECEIPT_MAX_AGE = timedelta(hours=23) + + +def _apply_expo_receipt(jitai_log, receipt, now): + jitai_log.expo_receipt_checked_at = now + fields = ['expo_receipt_checked_at', 'expo_receipt_status', 'expo_receipt_error'] + if receipt.status == 'ok': + jitai_log.expo_receipt_status = 'ok' + jitai_log.expo_receipt_error = '' + if jitai_log.delivery_status == 'accepted_by_expo': + jitai_log.delivery_status = 'handed_to_provider' + fields.append('delivery_status') + else: + code = receipt_error_code(receipt) + jitai_log.expo_receipt_status = 'error' + jitai_log.expo_receipt_error = code + if jitai_log.delivery_status != 'received_on_device': + jitai_log.status = 'failed' + jitai_log.delivery_status = 'failed' + jitai_log.delivery_error = f'expo receipt: {code}' + fields += ['status', 'delivery_status', 'delivery_error'] + if code == 'DeviceNotRegistered': + jitai_log.user.push_token = None + jitai_log.user.save(update_fields=['push_token']) + jitai_log.save(update_fields=fields) + + +@shared_task +def check_expo_receipts(): + now = django_timezone.now() + pending = list( + JITAILog.objects.filter( + expo_ticket_id__gt='', + expo_receipt_checked_at__isnull=True, + push_sent_at__gte=now - EXPO_RECEIPT_MAX_AGE, + push_sent_at__lte=now - EXPO_RECEIPT_MIN_AGE, + ).select_related('user') + ) + if not pending: + return 0 + + try: + receipts = fetch_expo_receipts([log.expo_ticket_id for log in pending]) + except Exception: + logger.exception("check_expo_receipts: receipt request failed") + return 0 + + by_ticket = {log.expo_ticket_id: log for log in pending} + checked = 0 + for receipt in receipts: + jitai_log = by_ticket.pop(receipt.id, None) + if jitai_log is None: + continue + try: + _apply_expo_receipt(jitai_log, receipt, now) + checked += 1 + except Exception: + logger.exception( + "check_expo_receipts failed for jitai_log_id=%s", jitai_log.pk + ) + if by_ticket: + logger.warning( + "check_expo_receipts: %s ticket(s) had no receipt yet", len(by_ticket) + ) + return checked + + @shared_task def send_checkin_reminders(): now = django_timezone.now() diff --git a/backend/app/tests.py b/backend/app/tests.py index 7406dd9..cebce7f 100644 --- a/backend/app/tests.py +++ b/backend/app/tests.py @@ -5754,3 +5754,195 @@ def test_problems_are_listed_on_the_page(self): self.assertContains(response, 'unknown_question') self.assertContains(response, '999999') self.assertContains(response, 'blank screening item') + + +class ExpoTicketAndReceiptTests(TestCase): + + def setUp(self): + self.user = make_user(email='receipt@ufl.edu', push_token='ExponentPushToken[abc]') + self.now = timezone.now() + + def _log(self, ticket_id='tkt-1', minutes_ago=30, delivery_status='accepted_by_expo'): + return JITAILog.objects.create( + user=self.user, + prompt_id='P001', + trigger_reason='prompt sent', + status='delivered', + delivery_status=delivery_status, + push_sent_at=self.now - timedelta(minutes=minutes_ago), + expo_ticket_id=ticket_id, + ) + + def _receipt(self, receipt_id, status='ok', error=None): + return MagicMock( + id=receipt_id, + status=status, + details={'error': error} if error else None, + ) + + @patch('app.notification_service.PushClient') + def test_send_stores_ticket_id(self, MockPushClient): + from app.notification_service import send_jitai_prompt + log = JITAILog.objects.create(user=self.user, prompt_id='P001', trigger_reason='prompt sent') + MockPushClient.return_value.publish.return_value = MagicMock(id='ticket-abc', status='ok') + + send_jitai_prompt(self.user, log) + + log.refresh_from_db() + self.assertEqual(log.expo_ticket_id, 'ticket-abc') + self.assertEqual(log.delivery_status, 'accepted_by_expo') + + @patch('app.notification_service.PushClient') + def test_send_without_string_ticket_id_stores_blank(self, MockPushClient): + from app.notification_service import send_jitai_prompt + log = JITAILog.objects.create(user=self.user, prompt_id='P001', trigger_reason='prompt sent') + MockPushClient.return_value.publish.return_value = MagicMock() + + send_jitai_prompt(self.user, log) + + log.refresh_from_db() + self.assertEqual(log.expo_ticket_id, '') + + @patch('app.tasks.fetch_expo_receipts') + def test_ok_receipt_marks_handed_to_provider(self, mock_fetch): + from app.tasks import check_expo_receipts + log = self._log() + mock_fetch.return_value = [self._receipt('tkt-1')] + + self.assertEqual(check_expo_receipts(), 1) + + log.refresh_from_db() + self.assertEqual(log.expo_receipt_status, 'ok') + self.assertEqual(log.delivery_status, 'handed_to_provider') + self.assertIsNotNone(log.expo_receipt_checked_at) + + @patch('app.tasks.fetch_expo_receipts') + def test_error_receipt_marks_failed_with_code(self, mock_fetch): + from app.tasks import check_expo_receipts + log = self._log() + mock_fetch.return_value = [self._receipt('tkt-1', 'error', 'MessageRateExceeded')] + + check_expo_receipts() + + log.refresh_from_db() + self.assertEqual(log.expo_receipt_status, 'error') + self.assertEqual(log.expo_receipt_error, 'MessageRateExceeded') + self.assertEqual(log.delivery_status, 'failed') + self.assertEqual(log.status, 'failed') + self.assertEqual(log.delivery_error, 'expo receipt: MessageRateExceeded') + self.user.refresh_from_db() + self.assertEqual(self.user.push_token, 'ExponentPushToken[abc]') + + @patch('app.tasks.fetch_expo_receipts') + def test_device_not_registered_receipt_clears_push_token(self, mock_fetch): + from app.tasks import check_expo_receipts + self._log() + mock_fetch.return_value = [self._receipt('tkt-1', 'error', 'DeviceNotRegistered')] + + check_expo_receipts() + + self.user.refresh_from_db() + self.assertIsNone(self.user.push_token) + + @patch('app.tasks.fetch_expo_receipts') + def test_error_receipt_does_not_downgrade_received_on_device(self, mock_fetch): + from app.tasks import check_expo_receipts + log = self._log(delivery_status='received_on_device') + mock_fetch.return_value = [self._receipt('tkt-1', 'error', 'DeviceNotRegistered')] + + check_expo_receipts() + + log.refresh_from_db() + self.assertEqual(log.delivery_status, 'received_on_device') + self.assertEqual(log.expo_receipt_status, 'error') + self.assertEqual(log.expo_receipt_error, 'DeviceNotRegistered') + + @patch('app.tasks.fetch_expo_receipts') + def test_ok_receipt_does_not_overwrite_received_on_device(self, mock_fetch): + from app.tasks import check_expo_receipts + log = self._log(delivery_status='received_on_device') + mock_fetch.return_value = [self._receipt('tkt-1')] + + check_expo_receipts() + + log.refresh_from_db() + self.assertEqual(log.delivery_status, 'received_on_device') + self.assertEqual(log.expo_receipt_status, 'ok') + + @patch('app.tasks.fetch_expo_receipts') + def test_window_edges_are_skipped(self, mock_fetch): + from app.tasks import check_expo_receipts + self._log(ticket_id='too-new', minutes_ago=5) + self._log(ticket_id='too-old', minutes_ago=24 * 60) + + self.assertEqual(check_expo_receipts(), 0) + mock_fetch.assert_not_called() + + @patch('app.tasks.fetch_expo_receipts') + def test_already_checked_and_untracked_logs_are_skipped(self, mock_fetch): + from app.tasks import check_expo_receipts + checked = self._log(ticket_id='done') + JITAILog.objects.filter(pk=checked.pk).update(expo_receipt_checked_at=self.now) + self._log(ticket_id='') + + check_expo_receipts() + + mock_fetch.assert_not_called() + + @patch('app.tasks.fetch_expo_receipts') + def test_missing_receipt_leaves_log_unchecked(self, mock_fetch): + from app.tasks import check_expo_receipts + log = self._log() + mock_fetch.return_value = [] + + self.assertEqual(check_expo_receipts(), 0) + + log.refresh_from_db() + self.assertIsNone(log.expo_receipt_checked_at) + self.assertEqual(log.delivery_status, 'accepted_by_expo') + + @patch('app.tasks.fetch_expo_receipts') + def test_fetch_failure_changes_nothing(self, mock_fetch): + from app.tasks import check_expo_receipts + log = self._log() + mock_fetch.side_effect = RuntimeError('expo down') + + self.assertEqual(check_expo_receipts(), 0) + + log.refresh_from_db() + self.assertIsNone(log.expo_receipt_checked_at) + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS, DASHBOARD_API_KEY='dashboard-test-key') +class DashboardStudyIdLabelTests(TestCase): + + def setUp(self): + self.with_study = make_user(email='real.person@ufl.edu', study_id='rs02') + WearableDevice.objects.create(user=self.with_study, labfront_participant_id='TEST-1') + self.without_study = make_user(email='other@ufl.edu') + WearableDevice.objects.create(user=self.without_study, labfront_participant_id='lf-77') + self.bare = make_user(email='bare@ufl.edu') + JITAILog.objects.create( + user=self.with_study, prompt_id='P1', trigger_reason='prompt sent', + push_sent_at=timezone.now(), + ) + + def _get(self, path): + return self.client.get(path, HTTP_X_DASHBOARD_API_KEY='dashboard-test-key') + + def test_participants_use_study_id_then_labfront_then_user_id(self): + rows = {row['user_id']: row for row in self._get('/dashboard/participants/').json()} + self.assertEqual(rows[self.with_study.user_id]['participant_id'], 'RS02') + self.assertEqual(rows[self.without_study.user_id]['participant_id'], 'lf-77') + self.assertEqual(rows[self.bare.user_id]['participant_id'], str(self.bare.user_id)) + + def test_participants_payload_has_no_email(self): + body = self._get('/dashboard/participants/') + for row in body.json(): + self.assertNotIn('email', row) + self.assertNotIn('ufl.edu', body.content.decode()) + + def test_latency_events_use_study_id(self): + data = self._get('/dashboard/latency-events/').json() + self.assertEqual(data[0]['participant_id'], 'RS02') + self.assertNotIn('ufl.edu', str(data)) diff --git a/backend/app/views.py b/backend/app/views.py index 7645bcf..59c05f8 100644 --- a/backend/app/views.py +++ b/backend/app/views.py @@ -3,6 +3,7 @@ from dashboard.data.config import OUTCOME_CHECK_IN_DELAY_MINUTES, OUTCOME_WINDOW_HOURS, PARTICIPANT_TZ from dashboard.data.windows import participant_day_bounds +from dashboard.serializers import participant_label from .distress import active_distress_flag, raise_momentary_flag, resource_card from django.contrib.auth.models import User as AuthUser from rest_framework_simplejwt.tokens import RefreshToken @@ -1097,12 +1098,6 @@ def get(self, request): stale_cutoff = django_timezone.now() - timedelta(hours=stale_after_hours) - latest_participant_id = ( - WearableDevice.objects - .filter(user=OuterRef('pk')) - .order_by('-last_synced_at', '-id') - .values('labfront_participant_id')[:1] - ) latest_device_sync = ( WearableDevice.objects .filter(user=OuterRef('pk')) @@ -1124,8 +1119,8 @@ def get(self, request): users = ( User.objects + .select_related('wearabledevice') .annotate( - labfront_participant_id=Subquery(latest_participant_id), last_sync_timestamp=Subquery(latest_device_sync), last_push_timestamp=Subquery(latest_push), last_receipt_timestamp=Subquery(latest_receipt), @@ -1138,9 +1133,8 @@ def get(self, request): last_sync = user.last_sync_timestamp is_stale = last_sync is None or last_sync < stale_cutoff data.append({ - 'participant_id': user.labfront_participant_id or str(user.user_id), + 'participant_id': participant_label(user), 'user_id': user.user_id, - 'email': user.email, 'last_sync_timestamp': last_sync, 'last_push_timestamp': user.last_push_timestamp, 'last_receipt_timestamp': user.last_receipt_timestamp, @@ -1201,17 +1195,10 @@ def get(self, request): for event in log.engagementlog_set.all().order_by('occurred_at') ] - device = getattr(log.user, 'wearabledevice', None) - participant_id = ( - device.labfront_participant_id - if device is not None - else str(log.user.user_id) - ) - data.append({ 'event_id': log.id, 'message_id': log.prompt_id, - 'participant_id': participant_id, + 'participant_id': participant_label(log.user), 'user_id': log.user.user_id, 'decision_point_id': log.decision_point_id, 'decision_made_at': log.decision_made_at, diff --git a/backend/project/settings.py b/backend/project/settings.py index ad73ac4..2ce92e4 100644 --- a/backend/project/settings.py +++ b/backend/project/settings.py @@ -277,6 +277,10 @@ 'task': 'app.tasks.send_checkin_reminders', 'schedule': schedule(180.0), }, + 'check-expo-receipts': { + 'task': 'app.tasks.check_expo_receipts', + 'schedule': schedule(180.0), + }, # Slower than the three above on purpose: the trailing recompute is an # aggregate refresh, and 10 minutes matches the Labfront batch cadence, so # a shorter interval would only reread the same rows. diff --git a/dashboard/serializers.py b/dashboard/serializers.py index bf6d68f..a2c0cfa 100644 --- a/dashboard/serializers.py +++ b/dashboard/serializers.py @@ -97,12 +97,16 @@ def get_link_date(self, obj): def participant_label(user): - """The Labfront id when there is one, else the user_id. + """The study id when there is one, else the Labfront id, else the user_id. - Matches what the existing /dashboard/participants/ endpoint reports, so both - surfaces name a participant the same way. + The monitoring endpoints and /dashboard/participants/ all name a participant + through this, so they cannot drift apart. """ if user is None: return None + if user.study_id: + return user.study_id device = getattr(user, 'wearabledevice', None) - return device.labfront_participant_id if device is not None else str(user.pk) + if device is not None and device.labfront_participant_id: + return device.labfront_participant_id + return str(user.pk)