R-017 — Desbloqueio de cursos para usuário em trial

TLDR: o curso expõe unlocked, que responde só pelo trial. Quem não é trial vê true em tudo; quem está em trial vê true apenas nos cursos ligados ao TrialCourse do seu trial. access_mode não influencia — quais aulas abrem é decisão do R-016.

Given / When / Then

Dado um usuário cujo subscription_status não é "trial" Quando ele consulta cursos Então todo curso vem unlocked: true

Dado um usuário em trial e um curso liberado no TrialCourse, com qualquer access_mode Quando ele consulta cursos Então esse curso vem unlocked: true

Dado um usuário em trial e um curso que não está no TrialCourse Quando ele consulta cursos Então esse curso vem unlocked: false, mas continua aparecendo na listagem e o detalhe dele continua respondendo 200

Dado um usuário em trial sem nenhum TrialCourse configurado Quando ele consulta cursos Então todos vêm unlocked: false

Dado um curso não publicado (is_published: false) Quando qualquer usuário o consulta Então o unlocked não muda por causa disso — publicação e liberação são eixos independentes

Dado um usuário em trial cujo subscription_status passa a enabled (conversão) Quando ele consulta cursos na requisição seguinte Então todos voltam unlocked: true, sem esperar a expiração do cache de autenticação

Constraints

  • Quem responde se o usuário é trial é User.is_trial. A regra vive no model — o serviço e o serializer apenas consomem, nunca comparam a string por conta própria.
  • resolve_trial_course_access devolve None quando o usuário não é trial e um set de course_id caso contrário. None = sem restrição; set vazio = é trial sem nada liberado (bloqueia tudo). Confundir os dois inverte o comportamento para toda a base de assinantes.
  • O conjunto não filtra por access_mode: all e custom significam os dois que o curso faz parte do trial. A granularidade de custom é resolvida no unlocked da aula (R-016).
  • A composição vive no TrialCourseUnlockMixin, herdado por CourseSerializer e CourseModuleSerializer. Cada serializer declara o SerializerMethodField no próprio corpo, porque o metaclass do DRF só coleta campos declarados em bases que já são serializers.
  • A resolução roda uma vez por request, guardada em serializer.context['_trial_course_access'], independente da quantidade de cursos.
  • Sem request ou sem usuário no contexto, unlocked sai true — o default do curso é liberado. É o oposto da aula, cujo default é o bloqueio.
  • unlocked é independente de published_at/is_published: um curso pode vir is_published: true, unlocked: false e vice-versa.
  • A regra vale em GET /v1/courses, GET /v1/courses/<id> e GET /v1/trails/<id>/courses — este último porque o TrailCourseSerializer aninha CourseSerializer.
  • TrialCourse soft-deleted fica fora do conjunto.
  • Não há bloqueio em listagens nem no detalhe: nenhum queryset é filtrado, nada responde 403/404 por causa do trial.
  • Módulo não tem unlocked — o trial é configurado por curso, e a aula já tem a trava dela.

Linked test

tests/trials/test_trial_content_service.py tests/trials/test_course_unlock_gating.py