Modo “Todas as aulas” do trial libera todas as aulas direto

TLDR: quando um curso é liberado no trial com access_mode=all, todas as aulas desse curso passam a vir unlocked: true de imediato, sem depender de progresso ou da sequência do curso.

Contexto

Hoje o access_mode de um TrialCourse decide como o conjunto de aulas é montado, mas os dois modos têm semânticas diferentes de desbloqueio (R-016):

  • custom — as aulas marcadas em TrialCourseLesson abrem direto, ignorando a sequência.
  • all — as aulas do curso continuam sujeitas à regra normal do app (Lesson.is_unlocked()): primeira aula aberta, resto conforme o progresso.

A composição vive em LessonSerializer.get_unlocked (apps/courses/serializers.py:87-97), que manda o caso all de volta para obj.is_unlocked(...).

O comportamento esperado do modo “Todas as aulas” é literal: liberar o curso inteiro para o usuário em trial. A partir desta mudança, all passa a se comportar como custom no ponto que importa — a aula abre porque o trial liberou, não porque o usuário avançou na sequência.

Objetivos

  • Fazer com que toda aula de um curso liberado com access_mode=all retorne unlocked: true para o usuário em trial, independentemente de progresso, sequência ou do valor de Lesson.unlocked.
  • Manter intacto o comportamento de quem não está em trial: continua passando por Lesson.is_unlocked() normalmente.
  • Manter intacto o bloqueio do que está fora do trial: aula de curso custom não marcada, ou de curso fora de TrialCourse, continua unlocked: false.
  • Manter o custo de uma resolução por request, sem N+1.

Fora de escopo

  • Não muda Lesson.is_unlocked() — o model continua sem conhecer trial.
  • Não muda o unlocked do curso (R-017), que tem regra própria.
  • Não muda o admin de Trial nem o fallback de save_related que força access_mode=all quando o modo custom é salvo sem nenhuma aula marcada.
  • Não mexe em filtragem de listagens, Trial.features, expiração de trial ou SimpleLessonSerializer.
  • Não cria migration — nenhum modelo é alterado.

Regra nova

não é trial -> unlocked = regra_atual (Lesson.is_unlocked) curso access_mode=all -> unlocked = True (todas as aulas ativas do curso) curso access_mode=custom -> unlocked = True (apenas aulas marcadas em TrialCourseLesson) fora do trial -> unlocked = False

Situação Antes Depois
Usuário não é trial regra atual regra atual (inalterado)
Curso all, primeira aula true true
Curso all, aula 3 sem nenhuma conclusão anterior false true
Curso custom, aula marcada true true (inalterado)
Curso custom, aula não marcada false false (inalterado)
Curso fora de TrialCourse false false (inalterado)
Trial sem nenhum TrialCourse tudo false tudo false (inalterado)

O único quadrante que muda é o do access_mode=all.

Decisão de implementação

Com essa mudança, all_access e custom_access passam a significar exatamente a mesma coisa — “aula liberada pelo trial, abre direto”. Manter dois conjuntos com comportamento idêntico deixa no código uma distinção que não existe mais.

Escolha (recomendada): colapsar resolve_trial_lesson_access para devolver um único set de ids (ou None para não-trial), unindo os dois modos na própria consulta. TrialLessonAccess é removido.

Alternativa considerada e descartada: manter o namedtuple e apenas unir os dois campos no serializer (obj.id in access.all_access or obj.id in access.custom_access). Diff menor, mas preserva uma estrutura que sugere uma diferença semântica inexistente.

Mudanças

apps/trials/services/trial_content.py

  • Remover o namedtuple TrialLessonAccess.
  • resolve_trial_lesson_access(user) passa a devolver set[int] | None:
    • None quando o usuário não é trial — mantém o sinal de “sem restrição”. Essa distinção entre None e conjunto vazio continua sendo o ponto crítico da regra e não pode ser perdida.
    • Caso contrário, a união dos ids de aulas liberadas: aulas ativas de cursos access_mode=all mais as aulas marcadas em TrialCourseLesson de cursos access_mode=custom.
  • _lesson_ids_from_all_access_courses e _lesson_ids_from_custom_access_courses seguem existindo como as duas consultas que alimentam a união (continuam ignorando soft-deleted e is_active=False).
  • resolve_trial_course_access não muda.

apps/courses/serializers.py

LessonSerializer.get_unlocked passa a:

```python def get_unlocked(self, obj): request = self.context.get(‘request’) user = getattr(request, ‘user’, None) if not user: return False

access = self._get_trial_lesson_access(user)
if access is None:
    return obj.is_unlocked(user.id, progress=self._get_cached_progress(obj))

return obj.id in access ```

_get_trial_lesson_access continua guardando o resultado em self.context['_trial_lesson_access'], resolvendo uma vez por request. Consequência: para usuário em trial, is_unlocked() e o progresso não são mais consultados em nenhum caminho — a contagem de queries cai.

Testes

tests/trials/test_lesson_unlock_gating.py:

  • test_all_mode_opens_the_same_lessons_a_subscriber_would_see — deixa de valer; substituir por um teste que afirma que todas as aulas do curso all vêm abertas sem nenhum progresso.
  • test_all_mode_opens_the_next_lesson_after_completing_the_previous_one — deixa de fazer sentido (não depende mais de conclusão); remover.
  • Adicionar: curso all cuja aula tem Lesson.unlocked=False e nenhuma anterior concluída → true.
  • Preservar sem alteração: não-trial mantém a regra atual; custom marcada abre; custom não marcada bloqueia; curso fora do trial bloqueia; trial sem configuração bloqueia tudo.

tests/trials/test_trial_content_service.py:

  • Adaptar as asserções que hoje leem access.all_access / access.custom_access para o set único.
  • Manter os casos de borda: não-trial devolve None; trial sem TrialCourse devolve set() vazio; aulas inativas e soft-deleted ficam de fora; trial com um curso de cada modo devolve a união dos dois.

tests/courses/test_models_and_services.py não muda — testa Lesson.is_unlocked() puro, que não é alterado.

Como verificar

  1. make deps.up && python manage.py seed.
  2. No admin, editar um Trial e liberar um curso com “Todas as aulas”.
  3. Autenticar como usuário desse trial e chamar GET /v1/courses/<id>/modules: todas as aulas do curso voltam unlocked: true, sem nenhum progresso registrado — inclusive as do meio e do fim dos módulos.
  4. Liberar outro curso com “Aulas específicas” marcando só uma aula: apenas ela volta unlocked: true; as demais, false.
  5. Consultar um curso que não está no trial: todas as aulas voltam unlocked: false.
  6. Autenticar como usuário não trial e repetir: nenhuma diferença em relação a hoje (primeira aula aberta, resto conforme progresso).
  7. make test passa.

Documentação

Atualizar .project/docs/rules/trials/trial_lesson_unlock.md (R-016):

  • Trocar o Given/When/Then do access_mode=all — passa de “segue a regra normal do app” para “todas as aulas do curso vêm unlocked: true”.
  • Atualizar a constraint sobre access_mode: os dois modos agora abrem direto; o que muda entre eles é apenas quais aulas entram no conjunto (curso inteiro vs. seleção manual).
  • Atualizar a constraint sobre a composição: para usuário em trial, Lesson.is_unlocked() não é mais chamado em nenhum caso.
  • Ajustar a menção a TrialLessonAccess para o set único.
  • Bumpar updated para 2026-08-24.

A spec 20260818114553_trial_lesson_gating.md permanece como registro histórico; não é reescrita.