diff --git a/student_app/urls.py b/student_app/urls.py index ffe77ad0..85c0f837 100644 --- a/student_app/urls.py +++ b/student_app/urls.py @@ -3,6 +3,7 @@ from .views import ( StudentAddRmView, StudentCancelAttendanceView, + StudentClearanceView, StudentConfirmAttendanceView, StudentConsentView, StudentGradeView, @@ -32,6 +33,7 @@ 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('aguardando-liberacao/', StudentClearanceView.as_view(), name='student-app-clearance'), 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 cb87fbe7..22117997 100644 --- a/student_app/views/__init__.py +++ b/student_app/views/__init__.py @@ -17,7 +17,7 @@ StudentSuspendedFinancialView, StudentSwitchBoxView, ) -from .consent_views import StudentConsentView +from .consent_views import StudentClearanceView, StudentConsentView from .onboarding_views import StudentOnboardingWizardView from .public_workout_views import ( PublicWorkoutDetailView, @@ -52,6 +52,7 @@ 'PublicWorkoutServiceWorkerView', 'StudentAddRmView', 'StudentCancelAttendanceView', + 'StudentClearanceView', 'StudentConfirmAttendanceView', 'StudentConsentView', 'StudentGradeView', diff --git a/student_app/views/base.py b/student_app/views/base.py index 7c44f463..0ac45315 100644 --- a/student_app/views/base.py +++ b/student_app/views/base.py @@ -163,6 +163,14 @@ def dispatch(self, request, *args, **kwargs): 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') + if ( + getattr(settings, 'STUDENT_CONSENT_GATE_ENABLED', False) + and active_membership is not None + and active_membership.clearance_required + and active_membership.cleared_at is None + and request.path != reverse('student-app-clearance') + ): + return redirect('student-app-clearance') 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 index f2983f53..69cdf8e1 100644 --- a/student_app/views/consent_views.py +++ b/student_app/views/consent_views.py @@ -12,7 +12,7 @@ from __future__ import annotations from django.shortcuts import redirect -from django.views.generic import FormView +from django.views.generic import FormView, TemplateView from student_identity.funnel_events import record_student_onboarding_event from student_identity.models import StudentConsentDocument, StudentConsentDocumentKind @@ -57,3 +57,28 @@ def form_valid(self, form): user_agent=request.META.get('HTTP_USER_AGENT', ''), ) return redirect('student-app-home') + + +class StudentClearanceView(StudentAnyMembershipMixin, TemplateView): + """Parede de bloqueio do gate de entrada (Onda C). + + Mostrada quando o membership do box ativo esta clearance_required e ainda nao + foi liberado. Nao e dead-end: instrui a levar o atestado ao box. A liberacao e + manual (Onda D). O gate (b) em dispatch pula esta rota (evita loop). + """ + + template_name = 'student_app/clearance.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + membership = next( + ( + m for m in self.request.student_box_memberships + if m.box_root_slug == self.request.student_active_box_root_slug + ), + None, + ) + context['clearance_membership'] = membership + context['clearance_box'] = getattr(membership, 'box', None) if membership is not None else None + context.setdefault('student_shell_nav', 'home') + return self._attach_student_shell_context(context) diff --git a/templates/student_app/clearance.html b/templates/student_app/clearance.html new file mode 100644 index 00000000..480fd44a --- /dev/null +++ b/templates/student_app/clearance.html @@ -0,0 +1,22 @@ +{% extends "student_app/layout.html" %} + +{% block title %}Liberação pendente | OctoBox Aluno{% endblock %} + +{% block body %} +
+

Quase lá

+

Falta a liberação do seu box para você treinar.

+

+ Pela sua triagem de saúde, por segurança pedimos um atestado médico antes do primeiro treino. + Leve o atestado ao seu box{% if clearance_box %} {{ clearance_box.display_name }}{% endif %} + e a equipe libera seu acesso na hora. +

+ +

+ Assim que o box confirmar o atestado, seu acesso abre automaticamente. + Se você já entregou e esta tela continua, fale com a equipe. +

+
+{% endblock %} diff --git a/tests/test_student_consent_wall.py b/tests/test_student_consent_wall.py new file mode 100644 index 00000000..1b6a7a64 --- /dev/null +++ b/tests/test_student_consent_wall.py @@ -0,0 +1,92 @@ +""" +Onda C do gate de entrada — parede de clearance + gate (b). + +Cobre o que sustenta o verde da homologacao desta onda: +- aluno flagged e BARRADO na entrada (redirect para a parede) +- a propria parede renderiza (sem loop) +- aluno liberado (cleared_at) passa +- aluno sem flag nao ve a parede +- gate OFF e no-op +""" +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 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, + StudentIdentity, + StudentIdentityProvider, + StudentIdentityStatus, +) +from students.models import Student + +from student_app.workflows.consent_workflows import record_consent + + +def _seed(): + call_command('seed_consent_documents', stdout=StringIO()) + + +class ClearanceWallTests(TestCase): + def setUp(self): + _seed() + self.client = Client() + self.slug = get_box_runtime_slug() + self.student = Student.objects.create( + full_name='Atleta Wall', phone='5511666661111', email='wall@example.com', + ) + self.identity = StudentIdentity.objects.create( + student_id=self.student.id, student_name=self.student.full_name, + box_root_slug=self.slug, primary_box_root_slug=self.slug, + provider=StudentIdentityProvider.GOOGLE, provider_subject='subject-wall', + email='wall@example.com', status=StudentIdentityStatus.ACTIVE, + ) + self.membership = StudentBoxMembership.objects.create( + identity=self.identity, student_id=self.student.id, box_root_slug=self.slug, + status=StudentBoxMembershipStatus.ACTIVE, + ) + self.client.cookies['octobox_student_session'] = build_student_session_value( + identity_id=self.identity.id, box_root_slug=self.slug, + ) + + def _flag(self): + record_consent(identity=self.identity, box=None, box_root_slug=self.slug, is_flagged=True) + self.membership.refresh_from_db() + + @override_settings(STUDENT_CONSENT_GATE_ENABLED=True) + def test_flagged_member_is_redirected_to_clearance_wall(self): + self._flag() + response = self.client.get(reverse('student-app-home')) + self.assertRedirects(response, reverse('student-app-clearance'), fetch_redirect_response=False) + + @override_settings(STUDENT_CONSENT_GATE_ENABLED=True) + def test_clearance_path_itself_renders(self): + self._flag() + response = self.client.get(reverse('student-app-clearance')) + self.assertEqual(response.status_code, 200) + + @override_settings(STUDENT_CONSENT_GATE_ENABLED=True) + def test_cleared_member_passes(self): + self._flag() + self.membership.mark_cleared(by=None) + self.membership.save(update_fields=['cleared_at', 'cleared_by', 'updated_at']) + response = self.client.get(reverse('student-app-home')) + self.assertEqual(response.status_code, 200) + + @override_settings(STUDENT_CONSENT_GATE_ENABLED=True) + def test_clear_member_not_redirected_to_wall(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) + + def test_flag_off_does_not_show_wall(self): + self._flag() + response = self.client.get(reverse('student-app-home')) + self.assertNotEqual(response.get('Location', ''), reverse('student-app-clearance'))