From 73c9adea8cde0c4e0c90650cec69f2c94aa6c507 Mon Sep 17 00:00:00 2001 From: renanfulas <97930521+renanfulas@users.noreply.github.com> Date: Tue, 23 Jun 2026 06:53:41 -0300 Subject: [PATCH] feat(student-onboarding): tela de consentimento + gate de entrada (Onda B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate unificado de waiver + PAR-Q pos-auth, atras da flag STUDENT_CONSENT_GATE_ENABLED (default OFF). Empilhado na Onda A. - gate (a) em StudentIdentityRequiredMixin.dispatch: redireciona para a tela de consentimento quando falta aceite da versao vigente; pula a propria rota (sem loop) - StudentConsentForm: waiver obrigatorio + PAR-Q em toggles Sim/Nao (default Nao), botao sempre habilitado, validacao no submit - consent_workflows: decisao clear/flagged fora de view/template; grava StudentConsent versionado (so o outcome do PAR-Q); flagged marca clearance_required no membership - funil: consent_step_viewed, parq_completed, parq_flagged, clearance_pending (waiver_accepted NAO emitido — placeholder nao-vinculante, B1) - UI mobile-first (screens/consent.css): waiver rolavel sem "role ate o fim" - consent_content: 7 perguntas canonicas do PAR-Q (dirigem os toggles) Validacao local (PG 5433): 20 passed (gate B + modelo A, --create-db --migrations); 3 dispatch-tests existentes passam (gate OFF e no-op, zero regressao). Ref: docs/plans/student-parq-waiver-entry-gate-corda.md (Onda B) Co-Authored-By: Claude Opus 4.8 --- static/css/student_app/app.css | 1 + static/css/student_app/screens/consent.css | 105 ++++++++++++++ student_app/consent_content.py | 26 ++++ student_app/forms.py | 34 +++++ student_app/urls.py | 2 + student_app/views/__init__.py | 2 + student_app/views/base.py | 8 ++ student_app/views/consent_views.py | 59 ++++++++ student_app/workflows/consent_workflows.py | 120 ++++++++++++++++ templates/student_app/consent.html | 45 ++++++ tests/test_student_consent_gate.py | 157 +++++++++++++++++++++ 11 files changed, 559 insertions(+) create mode 100644 static/css/student_app/screens/consent.css create mode 100644 student_app/consent_content.py create mode 100644 student_app/views/consent_views.py create mode 100644 student_app/workflows/consent_workflows.py create mode 100644 templates/student_app/consent.html create mode 100644 tests/test_student_consent_gate.py diff --git a/static/css/student_app/app.css b/static/css/student_app/app.css index 732f4d13..1c6af7b9 100644 --- a/static/css/student_app/app.css +++ b/static/css/student_app/app.css @@ -34,3 +34,4 @@ PONTOS CRITICOS: @import url("./primitives/progress-strip.css"); @import url("./primitives/compact-state.css"); @import url("./forms.css"); +@import url("./screens/consent.css"); diff --git a/static/css/student_app/screens/consent.css b/static/css/student_app/screens/consent.css new file mode 100644 index 00000000..7c228a50 --- /dev/null +++ b/static/css/student_app/screens/consent.css @@ -0,0 +1,105 @@ +/* +ARQUIVO: tela do gate de entrada (consentimento + PAR-Q) do app do aluno. + +POR QUE ELE EXISTE: +- mantem o waiver rolavel e a triagem Sim/Nao legiveis e tapaveis no mobile, + sem "role ate o fim pra habilitar" e reusando os tokens canonicos do tema. + +PONTOS CRITICOS: +- seletores escopados em .student-consent para vencer a regra generica de label + do forms.css sem !important. +- alvo de toque >= 44px; contraste valido em light e dark. +*/ + +.student-consent { + display: grid; + gap: 16px; +} + +.student-consent .student-consent-doc__title { + margin: 0 0 8px; + font-size: 1rem; +} + +.student-consent .student-consent-doc__body { + max-height: 220px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + padding: 14px 16px; + border-radius: 16px; + border: 1px solid color-mix(in srgb, var(--theme-border-soft) 86%, transparent); + background: color-mix(in srgb, var(--theme-surface-strong) 92%, transparent); + color: var(--theme-text-secondary); + font-size: 0.92rem; + line-height: 1.6; +} + +.student-consent .student-consent-accept { + display: flex; + align-items: flex-start; + gap: 10px; + font-weight: 700; + color: var(--theme-text-primary); +} + +.student-consent .student-consent-accept input { + width: auto; + min-height: 0; + margin-top: 3px; +} + +.student-consent .student-consent-parq { + display: grid; + gap: 14px; + margin: 0; + padding: 16px; + border: 1px solid color-mix(in srgb, var(--theme-border-soft) 80%, transparent); + border-radius: 18px; +} + +.student-consent .student-consent-parq legend { + padding: 0 8px; + font-weight: 800; + color: var(--theme-text-primary); +} + +.student-consent .student-consent-question { + display: grid; + gap: 8px; +} + +.student-consent .student-consent-question__label { + font-weight: 600; + color: var(--theme-text-primary); +} + +.student-consent .student-consent-question__options ul { + list-style: none; + display: flex; + gap: 8px; + margin: 0; + padding: 0; +} + +.student-consent .student-consent-question__options li { + flex: 1; +} + +.student-consent .student-consent-question__options label { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 44px; + padding: 8px 12px; + border-radius: 14px; + border: 1px solid color-mix(in srgb, var(--theme-border-soft) 82%, transparent); + background: color-mix(in srgb, var(--theme-surface-strong) 90%, transparent); + font-weight: 700; + cursor: pointer; +} + +.student-consent .student-consent-question__options input { + width: auto; + min-height: 0; +} diff --git a/student_app/consent_content.py b/student_app/consent_content.py new file mode 100644 index 00000000..3f788192 --- /dev/null +++ b/student_app/consent_content.py @@ -0,0 +1,26 @@ +""" +ARQUIVO: conteudo estruturado do PAR-Q para a tela de consentimento (Onda B). + +POR QUE ELE EXISTE: +- as 7 perguntas canonicas que dirigem os toggles Sim/Nao da triagem. +- o texto de registro (legal/clinico) vive no StudentConsentDocument (seed); + esta lista dirige a UI. + +PONTOS CRITICOS: +- manter em sincronia com o seed do PAR-Q (PENDENTE_REVISAO_CLINICA) ate a revisao + clinica humana (gate B2 do plano). +- responder "sim" a qualquer pergunta sinaliza risco (flagged). +""" +from __future__ import annotations + +PARQ_QUESTIONS = [ + ('q1', 'Algum medico ja disse que voce tem um problema de coracao e que so deveria fazer atividade fisica supervisionada?'), + ('q2', 'Voce sente dor no peito quando faz atividade fisica?'), + ('q3', 'No ultimo mes, voce sentiu dor no peito sem estar fazendo atividade fisica?'), + ('q4', 'Voce perde o equilibrio por tontura ou ja perdeu a consciencia?'), + ('q5', 'Voce tem algum problema osseo ou articular que pode piorar com atividade fisica?'), + ('q6', 'Voce toma algum remedio para pressao ou para o coracao?'), + ('q7', 'Voce sabe de qualquer outra razao para nao fazer atividade fisica?'), +] + +PARQ_QUESTION_KEYS = [key for key, _ in PARQ_QUESTIONS] diff --git a/student_app/forms.py b/student_app/forms.py index 055c182a..b1dba222 100644 --- a/student_app/forms.py +++ b/student_app/forms.py @@ -14,6 +14,7 @@ ) from shared_support.phone_numbers import normalize_phone_number from student_identity.models import StudentIdentity, StudentIdentityStatus +from student_app.consent_content import PARQ_QUESTIONS from student_app.models import StudentExerciseMax from students.models import Student @@ -233,3 +234,36 @@ def __init__(self, *args, **kwargs): def clean_email(self): return (self.cleaned_data.get('email') or '').strip().lower() + + +class StudentConsentForm(forms.Form): + """Gate de entrada: aceite do termo + triagem PAR-Q (Onda B). + + Botao sempre habilitado; validacao no submit (waiver obrigatorio). PAR-Q em + toggles Sim/Nao com default 'nao' (estado seguro). `is_flagged` = qualquer 'sim'. + """ + + waiver_accepted = forms.BooleanField( + required=True, + label='Li e aceito o termo de responsabilidade.', + error_messages={'required': 'Marque que voce leu e aceita o termo para continuar.'}, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for key, label in PARQ_QUESTIONS: + self.fields[key] = forms.ChoiceField( + label=label, + choices=(('nao', 'Não'), ('sim', 'Sim')), + initial='nao', + widget=forms.RadioSelect, + required=True, + ) + + @property + def parq_fields(self): + return [self[key] for key, _ in PARQ_QUESTIONS] + + @property + def is_flagged(self) -> bool: + return any(self.cleaned_data.get(key) == 'sim' for key, _ in PARQ_QUESTIONS) diff --git a/student_app/urls.py b/student_app/urls.py index f23a06c4..ffe77ad0 100644 --- a/student_app/urls.py +++ b/student_app/urls.py @@ -4,6 +4,7 @@ StudentAddRmView, StudentCancelAttendanceView, StudentConfirmAttendanceView, + StudentConsentView, StudentGradeView, StudentHomeView, StudentManifestView, @@ -30,6 +31,7 @@ path('auth/', include('student_identity.urls')), path('', StudentHomeView.as_view(), name='student-app-home'), path('onboarding/', StudentOnboardingWizardView.as_view(), name='student-app-onboarding'), + path('liberacao/', StudentConsentView.as_view(), name='student-app-consent'), path('grade/', StudentGradeView.as_view(), name='student-app-grade'), path('aguardando-aprovacao/', StudentMembershipPendingView.as_view(), name='student-app-membership-pending'), path('suspenso-financeiro/', StudentSuspendedFinancialView.as_view(), name='student-app-suspended-financial'), diff --git a/student_app/views/__init__.py b/student_app/views/__init__.py index 42f9a032..cb87fbe7 100644 --- a/student_app/views/__init__.py +++ b/student_app/views/__init__.py @@ -17,6 +17,7 @@ StudentSuspendedFinancialView, StudentSwitchBoxView, ) +from .consent_views import StudentConsentView from .onboarding_views import StudentOnboardingWizardView from .public_workout_views import ( PublicWorkoutDetailView, @@ -52,6 +53,7 @@ 'StudentAddRmView', 'StudentCancelAttendanceView', 'StudentConfirmAttendanceView', + 'StudentConsentView', 'StudentGradeView', 'StudentHomeView', 'StudentInviteEntryView', diff --git a/student_app/views/base.py b/student_app/views/base.py index e9f6ae67..7c44f463 100644 --- a/student_app/views/base.py +++ b/student_app/views/base.py @@ -155,6 +155,14 @@ def dispatch(self, request, *args, **kwargs): if runtime_memberships and len(suspended_memberships) == len(runtime_memberships): return redirect('student-app-suspended-financial') return redirect('student-app-no-active-box') + if ( + getattr(settings, 'STUDENT_CONSENT_GATE_ENABLED', False) + and active_membership is not None + and request.path != reverse('student-app-consent') + ): + from student_app.workflows.consent_workflows import consent_is_pending + if consent_is_pending(identity=identity, box_root_slug=active_membership.box_root_slug): + return redirect('student-app-consent') request.student_identity = identity request.student_box_memberships = memberships request.student_active_box_root_slug = active_membership.box_root_slug if active_membership is not None else '' diff --git a/student_app/views/consent_views.py b/student_app/views/consent_views.py new file mode 100644 index 00000000..f2983f53 --- /dev/null +++ b/student_app/views/consent_views.py @@ -0,0 +1,59 @@ +""" +ARQUIVO: gate de entrada — tela de consentimento (Onda B). + +POR QUE ELE EXISTE: +- corredor unico de waiver + PAR-Q pos-auth para os 3 corredores de onboarding. + +PONTOS CRITICOS: +- usa StudentAnyMembershipMixin (resolve identidade sem exigir membership ativo). +- o gate em StudentIdentityRequiredMixin.dispatch PULA esta rota (evita loop). +- a decisao clear/flagged e a gravacao moram no workflow, nao aqui. +""" +from __future__ import annotations + +from django.shortcuts import redirect +from django.views.generic import FormView + +from student_identity.funnel_events import record_student_onboarding_event +from student_identity.models import StudentConsentDocument, StudentConsentDocumentKind +from student_app.forms import StudentConsentForm +from student_app.workflows.consent_workflows import ENTRY_GATE_JOURNEY, record_consent +from .base import StudentAnyMembershipMixin + + +class StudentConsentView(StudentAnyMembershipMixin, FormView): + template_name = 'student_app/consent.html' + form_class = StudentConsentForm + + def get(self, request, *args, **kwargs): + record_student_onboarding_event( + journey=ENTRY_GATE_JOURNEY, + event='consent_step_viewed', + target_label=request.student_identity.student_name, + description='Tela de consentimento exibida.', + metadata={ + 'box_root_slug': request.student_active_box_root_slug, + 'identity_id': request.student_identity.id, + }, + ) + return super().get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['waiver_document'] = StudentConsentDocument.objects.filter( + kind=StudentConsentDocumentKind.WAIVER, is_active=True, + ).first() + context.setdefault('student_shell_nav', 'home') + return self._attach_student_shell_context(context) + + def form_valid(self, form): + request = self.request + record_consent( + identity=request.student_identity, + box=getattr(request.student_identity, 'box', None), + box_root_slug=request.student_active_box_root_slug, + is_flagged=form.is_flagged, + ip=request.META.get('REMOTE_ADDR', ''), + user_agent=request.META.get('HTTP_USER_AGENT', ''), + ) + return redirect('student-app-home') diff --git a/student_app/workflows/consent_workflows.py b/student_app/workflows/consent_workflows.py new file mode 100644 index 00000000..54a729b9 --- /dev/null +++ b/student_app/workflows/consent_workflows.py @@ -0,0 +1,120 @@ +""" +ARQUIVO: gate de entrada — consentimento (waiver + PAR-Q), Onda B. + +POR QUE ELE EXISTE: +- concentra a DECISAO do gate (clear/flagged), a leitura da versao vigente e a + gravacao de StudentConsent fora de view/template (guardrail do plano). + +PONTOS CRITICOS: +- o waiver e gravado como aceite NAO-vinculante (placeholder) ate a Onda E; por + isso o evento `waiver_accepted` NAO e emitido aqui. +- guarda apenas o RESULTADO do PAR-Q (clear/flagged) — nunca as respostas. +- flagged marca clearance_required no membership (gate de saida vem na Onda C). +""" +from __future__ import annotations + +from dataclasses import dataclass + +from student_identity.funnel_events import record_student_onboarding_event +from student_identity.models import ( + StudentBoxMembership, + StudentConsent, + StudentConsentDocument, + StudentConsentDocumentKind, + StudentParqOutcome, +) + +ENTRY_GATE_JOURNEY = 'entry_gate' + + +def active_consent_versions() -> dict[str, str]: + """Versoes ativas de waiver e PAR-Q. Vazio se nao houver seed.""" + versions: dict[str, str] = {} + for kind in (StudentConsentDocumentKind.WAIVER, StudentConsentDocumentKind.PARQ): + document = StudentConsentDocument.objects.filter(kind=kind, is_active=True).first() + if document is not None: + versions[kind] = document.version + return versions + + +def consent_is_pending(*, identity, box_root_slug: str) -> bool: + """True se falta aceite da versao vigente de qualquer documento ativo.""" + versions = active_consent_versions() + if not versions: + return False # sem documentos ativos => gate nao se aplica + for kind, version in versions.items(): + already = StudentConsent.objects.filter( + identity=identity, + box_root_slug=box_root_slug, + document_kind=kind, + version=version, + ).exists() + if not already: + return True + return False + + +@dataclass +class ConsentResult: + outcome: str + clearance_required: bool + + +def record_consent(*, identity, box, box_root_slug, is_flagged, ip='', user_agent=''): + """Grava o aceite das versoes vigentes, decide clear/flagged e (se flagged) + marca clearance_required no membership do box ativo. Idempotente por versao.""" + versions = active_consent_versions() + outcome = StudentParqOutcome.FLAGGED if is_flagged else StudentParqOutcome.CLEAR + for kind, version in versions.items(): + StudentConsent.objects.update_or_create( + identity=identity, + box_root_slug=box_root_slug, + document_kind=kind, + version=version, + defaults={ + 'box': box, + 'ip': ip or None, + 'user_agent': (user_agent or '')[:400], + 'parq_outcome': outcome if kind == StudentConsentDocumentKind.PARQ else '', + }, + ) + + record_student_onboarding_event( + journey=ENTRY_GATE_JOURNEY, + event='parq_completed', + target_model='student_identity.StudentConsent', + target_id=str(identity.id), + target_label=identity.student_name, + description='PAR-Q concluido no gate de entrada.', + metadata={'box_root_slug': box_root_slug, 'identity_id': identity.id, 'outcome': str(outcome)}, + ) + + clearance_required = False + if is_flagged: + clearance_required = True + record_student_onboarding_event( + journey=ENTRY_GATE_JOURNEY, + event='parq_flagged', + target_model='student_identity.StudentConsent', + target_id=str(identity.id), + target_label=identity.student_name, + description='PAR-Q sinalizou risco no gate de entrada.', + metadata={'box_root_slug': box_root_slug, 'identity_id': identity.id}, + ) + membership = StudentBoxMembership.objects.filter( + identity=identity, box_root_slug=box_root_slug, + ).first() + if membership is not None and not membership.clearance_required: + membership.clearance_required = True + membership.save(update_fields=['clearance_required', 'updated_at']) + record_student_onboarding_event( + journey=ENTRY_GATE_JOURNEY, + event='clearance_pending', + target_model='student_identity.StudentBoxMembership', + target_id=str(membership.id), + target_label=identity.student_name, + description='Membership aguardando liberacao por atestado.', + metadata={'box_root_slug': box_root_slug, 'identity_id': identity.id}, + ) + + return ConsentResult(outcome=str(outcome), clearance_required=clearance_required) diff --git a/templates/student_app/consent.html b/templates/student_app/consent.html new file mode 100644 index 00000000..106562a0 --- /dev/null +++ b/templates/student_app/consent.html @@ -0,0 +1,45 @@ +{% extends "student_app/layout.html" %} + +{% block title %}Liberação de acesso | OctoBox Aluno{% endblock %} + +{% block body %} + +{% endblock %} diff --git a/tests/test_student_consent_gate.py b/tests/test_student_consent_gate.py new file mode 100644 index 00000000..45466abc --- /dev/null +++ b/tests/test_student_consent_gate.py @@ -0,0 +1,157 @@ +""" +Onda B do gate de entrada — tela de consentimento + gate no dispatch. + +Cobre o que sustenta o verde da homologacao desta onda: +- decisao clear/flagged e gravacao versionada (workflow) +- flagged marca clearance_required + emite eventos de funil +- waiver_accepted NAO e emitido (placeholder nao-vinculante, B1) +- form: outcome por respostas + waiver obrigatorio +- gate no dispatch: redireciona quando falta consentimento, e so com a flag ligada +""" +from __future__ import annotations + +from io import StringIO + +from django.core.management import call_command +from django.test import Client, TestCase, override_settings +from django.urls import reverse + +from auditing.models import AuditEvent +from shared_support.box_runtime import get_box_runtime_slug +from student_identity.infrastructure.session import build_student_session_value +from student_identity.models import ( + StudentBoxMembership, + StudentBoxMembershipStatus, + StudentConsent, + StudentConsentDocumentKind, + StudentIdentity, + StudentIdentityProvider, + StudentIdentityStatus, + StudentParqOutcome, +) +from students.models import Student + +from student_app.consent_content import PARQ_QUESTION_KEYS +from student_app.forms import StudentConsentForm +from student_app.workflows.consent_workflows import consent_is_pending, record_consent + + +def _seed(): + call_command('seed_consent_documents', stdout=StringIO()) + + +def _make_student_and_identity(subject='subject-consent'): + slug = get_box_runtime_slug() + student = Student.objects.create( + full_name='Atleta Consent', phone='5511666660000', email=f'{subject}@example.com', + ) + identity = StudentIdentity.objects.create( + student_id=student.id, student_name=student.full_name, + box_root_slug=slug, primary_box_root_slug=slug, + provider=StudentIdentityProvider.GOOGLE, provider_subject=subject, + email=f'{subject}@example.com', status=StudentIdentityStatus.ACTIVE, + ) + membership = StudentBoxMembership.objects.create( + identity=identity, student_id=student.id, box_root_slug=slug, + status=StudentBoxMembershipStatus.ACTIVE, + ) + return student, identity, membership + + +class ConsentWorkflowTests(TestCase): + def setUp(self): + _seed() + self.student, self.identity, self.membership = _make_student_and_identity() + self.slug = get_box_runtime_slug() + + def test_consent_pending_true_before_and_false_after(self): + self.assertTrue(consent_is_pending(identity=self.identity, box_root_slug=self.slug)) + record_consent(identity=self.identity, box=None, box_root_slug=self.slug, is_flagged=False) + self.assertFalse(consent_is_pending(identity=self.identity, box_root_slug=self.slug)) + + def test_record_consent_clear(self): + result = record_consent(identity=self.identity, box=None, box_root_slug=self.slug, is_flagged=False) + self.assertEqual(result.outcome, StudentParqOutcome.CLEAR) + self.assertFalse(result.clearance_required) + self.assertEqual(StudentConsent.objects.filter(identity=self.identity).count(), 2) + self.membership.refresh_from_db() + self.assertFalse(self.membership.clearance_required) + + def test_record_consent_flagged_sets_clearance_and_events(self): + result = record_consent(identity=self.identity, box=None, box_root_slug=self.slug, is_flagged=True) + self.assertEqual(result.outcome, StudentParqOutcome.FLAGGED) + self.assertTrue(result.clearance_required) + self.membership.refresh_from_db() + self.assertTrue(self.membership.clearance_required) + self.assertIsNone(self.membership.cleared_at) + parq = StudentConsent.objects.get(identity=self.identity, document_kind=StudentConsentDocumentKind.PARQ) + self.assertEqual(parq.parq_outcome, StudentParqOutcome.FLAGGED) + self.assertTrue(AuditEvent.objects.filter(action='student_onboarding.entry_gate.parq_flagged').exists()) + self.assertTrue(AuditEvent.objects.filter(action='student_onboarding.entry_gate.clearance_pending').exists()) + + def test_waiver_accepted_event_is_not_emitted(self): + # B1: aceite de waiver placeholder nao e vinculante -> sem evento waiver_accepted + record_consent(identity=self.identity, box=None, box_root_slug=self.slug, is_flagged=False) + self.assertFalse(AuditEvent.objects.filter(action__contains='waiver_accepted').exists()) + + def test_record_consent_is_idempotent(self): + record_consent(identity=self.identity, box=None, box_root_slug=self.slug, is_flagged=False) + record_consent(identity=self.identity, box=None, box_root_slug=self.slug, is_flagged=False) + self.assertEqual(StudentConsent.objects.filter(identity=self.identity).count(), 2) + + +class ConsentFormTests(TestCase): + def _data(self, *, waiver=True, flagged_key=None): + data = {key: 'nao' for key in PARQ_QUESTION_KEYS} + if flagged_key: + data[flagged_key] = 'sim' + if waiver: + data['waiver_accepted'] = 'on' + return data + + def test_clear_when_all_no(self): + form = StudentConsentForm(data=self._data()) + self.assertTrue(form.is_valid(), form.errors) + self.assertFalse(form.is_flagged) + + def test_flagged_when_any_yes(self): + form = StudentConsentForm(data=self._data(flagged_key='q2')) + self.assertTrue(form.is_valid(), form.errors) + self.assertTrue(form.is_flagged) + + def test_invalid_without_waiver(self): + form = StudentConsentForm(data=self._data(waiver=False)) + self.assertFalse(form.is_valid()) + self.assertIn('waiver_accepted', form.errors) + + +class ConsentGateRedirectTests(TestCase): + def setUp(self): + _seed() + self.client = Client() + self.student, self.identity, self.membership = _make_student_and_identity('subject-gate') + self.slug = get_box_runtime_slug() + self.client.cookies['octobox_student_session'] = build_student_session_value( + identity_id=self.identity.id, box_root_slug=self.slug, + ) + + def test_gate_off_does_not_redirect_to_consent(self): + # flag default OFF -> nenhum gate + response = self.client.get(reverse('student-app-home')) + self.assertNotEqual(response.get('Location', ''), reverse('student-app-consent')) + + @override_settings(STUDENT_CONSENT_GATE_ENABLED=True) + def test_gate_on_redirects_when_consent_pending(self): + response = self.client.get(reverse('student-app-home')) + self.assertRedirects(response, reverse('student-app-consent'), fetch_redirect_response=False) + + @override_settings(STUDENT_CONSENT_GATE_ENABLED=True) + def test_consent_path_itself_is_not_redirected(self): + response = self.client.get(reverse('student-app-consent')) + self.assertEqual(response.status_code, 200) + + @override_settings(STUDENT_CONSENT_GATE_ENABLED=True) + def test_gate_passes_after_consent_recorded(self): + record_consent(identity=self.identity, box=None, box_root_slug=self.slug, is_flagged=False) + response = self.client.get(reverse('student-app-home')) + self.assertEqual(response.status_code, 200)