Bloqueio de aulas fora do trial

TLDR: faz o campo unlocked das aulas respeitar o que o trial libera, de modo que o backend passe a gerenciar o acesso ao conteúdo sem nenhuma mudança no onion-app.

Status: proposed Created: 2026-08-18 Owner: @matheusscfr Asana: [Trial] Criar logica para liberação das features


Context

A configuração de conteúdo do trial existe desde a criação do app trials — TrialCourse (com access_mode all/custom) e TrialCourseLesson — mas nenhum endpoint lê essas tabelas. Hoje um usuário em trial enxerga e assiste exatamente o mesmo que um assinante pago.

Do outro lado, já existe um mecanismo de bloqueio maduro e em produção: LessonSerializer expõe unlocked por aula (apps/courses/serializers.py:78), calculado por Lesson.is_unlocked() (apps/courses/models/lesson.py:173). O onion-app já respeita esse campo — components/studies/LessonsList.tsx:59 (canOpen = !isScheduled && item.unlocked) e app/(tabs)/studies.tsx:353.

Aproveitar esse canal significa que o app não muda nada: o backend passa a devolver unlocked: false para o que está fora do trial, e a UI de cadeado que já existe faz o resto. E, diferente de um contrato descritivo consumido pelo cliente, o bloqueio vale também para quem chama a API diretamente.

Objectives

  • Fazer unlocked refletir o conteúdo liberado pelo trial do usuário.
  • Respeitar TrialCourse.access_mode: all libera o curso inteiro mantendo a sequência atual, custom libera apenas as aulas em TrialCourseLesson e as abre direto.
  • Garantir que nenhuma aula liberada pelo admin fique inalcançável.
  • Não introduzir N+1: o custo deve ser de uma consulta por request, não por aula.
  • Não alterar em nada o comportamento para quem não está em trial.

Regra

O trial é um teto para o que existe fora dele, e o access_mode do curso decide se a sequência pedagógica continua valendo dentro dele:

não é trial -> unlocked = regra_atual curso access_mode=all -> unlocked = regra_atual curso access_mode=custom -> unlocked = True (aula marcada em TrialCourseLesson) fora do trial -> unlocked = False

Situação Resultado
Usuário não é trial Nada muda — regra atual isolada
Aula com unlocked=True, fora do trial Bloqueada — o trial vence o override
Curso liberado com access_mode=all Regra atual normal: a sequência do curso continua valendo
Aula marcada em TrialCourseLesson (access_mode=custom) Liberada direto, ignorando a sequência
Aula de um curso custom que não foi marcada Bloqueada
Curso inteiro fora de TrialCourse Todas as aulas bloqueadas; o curso continua aparecendo na listagem, como vitrine
Usuário trial sem nenhum TrialCourse configurado Todas as aulas bloqueadas

Exemplo de custom — trial libera o Curso A com as aulas 1, 2 e 7:

Aula unlocked
1, 2, 7 true — abertas de imediato, sem depender de conclusão
3, 4, 5, 6 false

Exemplo de all — trial libera o Curso B inteiro: o unlocked de cada aula sai igualzinho ao de um assinante, aula a aula conforme o progresso.

Por que custom ignora a sequência

Interseção pura tornaria conteúdo liberado inalcançável. Lesson.is_unlocked() exige o pré-requisito concluído e devolve False quando não há módulo anterior — a primeira aula só abre por unlocked=True explícito (o padrão do campo é False; o seed marca a primeira aula de cada módulo em seed.py:333).

Cenário concreto:

Módulo 1 Aula 1 unlocked=True fora do trial -> bloqueada pelo teto Aula 2 unlocked=False fora do trial -> bloqueada Aula 3 unlocked=False marcada no trial

A aula 3 exige a 2, que exige a 1. A aula 1 está fora do trial, então nunca pode ser concluída — e a aula 3, explicitamente liberada pelo admin, ficaria inacessível para sempre, sem nenhum aviso no painel.

Marcar aula por aula em access_mode=custom já é uma decisão deliberada de curadoria (“quero que ele veja exatamente isto”), então ela vence a sequência. Já access_mode=all libera o curso inteiro, e aí a jornada normal — inclusive a primeira aula com unlocked=True — está toda dentro do trial e funciona sem travar.

A distinção entre “não é trial” (libera tudo) e “é trial sem nada configurado” (bloqueia tudo) é explícita no código: o primeiro caso devolve None, o segundo devolve conjuntos vazios.

Non-goals

  • Não filtra listagens. Cursos, módulos e trilhas continuam sendo listados integralmente; só o unlocked de cada aula muda. Nenhum queryset de /courses ou /trails é alterado.
  • Não bloqueia outras áreas. Áudios, livros, lives, hábitos, sonhos e chat seguem liberados — Trial.features, access_library_audios e audio_limit continuam sem consumo. Escopo separado.
  • Não altera o objeto trial do sign-in nem do perfil.
  • Não cria endpoint novo (isso é a spec 20260818104741_trial_detail_endpoint.md).
  • Não mexe em SimpleLessonSerializer (usado no “continuar assistindo”), que não expõe unlocked. Consequência aceita: uma aula já iniciada antes do trial pode continuar aparecendo ali — o bloqueio efetivo acontece ao abrir a aula.
  • Não trata expiração: um trial expirado não é tratado aqui (o corte de acesso por expiração é a spec 20260817155648_subscription_access_expiration.md).

Changes

apps/trials/services/trial_content.py (novo)

```python TrialLessonAccess = namedtuple(“TrialLessonAccess”, [“all_access”, “custom_access”])

def resolve_trial_lesson_access(user) -> TrialLessonAccess | None ```

  • Devolve None quando o usuário não é trial (subscription_status != "trial") — sinal de “sem restrição”.
  • custom_access — ids das aulas marcadas em TrialCourseLesson, de cursos com access_mode=custom. Liberadas sem passar pela sequência.
  • all_access — ids das aulas ativas de cursos com access_mode=all. Continuam sujeitas à regra atual.
  • Os dois conjuntos podem ser vazios (trial sem nada configurado bloqueia tudo).
  • Ignora registros soft-deleted e is_active=False.
  • Duas consultas fixas (uma por access_mode), sem laço por curso.

Quem responde se o usuário é trial é User.is_trial (property no model, junto da constante User.SUBSCRIPTION_STATUS_TRIAL). O serviço apenas consome.

apps/courses/serializers.py

LessonSerializer.get_unlocked passa a decidir pelo access_mode:

```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 or obj.id in access.all_access:
    return obj.is_unlocked(user.id, progress=self._get_cached_progress(obj))

return obj.id in access.custom_access ```

A primeira condição junta os dois casos que levam à regra atual: não é trial, ou é trial num curso all. A última linha resolve os outros dois de uma vez — está em custom_access (abre direto) ou não está em conjunto nenhum (fora do trial, bloqueia).

_get_trial_lesson_access guarda o resultado em self.context, no mesmo padrão já usado por _get_cached_progress (context.setdefault('_lesson_progress_cache', {})). Como o DRF compartilha o context entre as instâncias de um many=True, a resolução roda uma vez por request, independente da quantidade de aulas. Para aulas fora do trial, is_unlocked() nem é chamado — o que reduz queries em relação a hoje.

Lesson.is_unlocked() não muda — continua respondendo apenas pela regra de progresso/sequência. A composição fica no serializer, que é onde existe acesso ao usuário e ao cache de request; empurrar a checagem para o model exigiria carregar o User a cada aula.

Testes

  • tests/trials/test_trial_content_service.py (novo) — resolve_trial_lesson_access: não-trial devolve None; trial sem TrialCourse devolve os dois conjuntos vazios; access_mode=all popula só all_access; custom popula só custom_access; aulas/módulos inativos e soft-deleted ficam de fora; um trial com um curso de cada modo popula os dois conjuntos.
  • tests/courses/ — unlocked para:
    • não-trial: comportamento atual preservado (o caso de regressão mais importante);
    • custom com a aula marcada e a anterior não concluída → liberada (o caso que motivou a Saída A);
    • custom com aula não marcada, mesmo com unlocked=True → bloqueada;
    • all com anterior concluída → liberada; com anterior pendente → bloqueada;
    • curso fora do trial → bloqueada;
    • trial sem configuração → tudo bloqueado.
  • Teste de contagem de queries com django_assert_num_queries sobre uma listagem de módulo com várias aulas, garantindo que o número não cresce com a quantidade de aulas.

Sem migrations: nenhum modelo é alterado.

How to verify

  1. make deps.up && python manage.py seed.
  2. No admin, editar um Trial e liberar dois cursos: um com “Todas as aulas”, outro com “Aulas específicas” marcando só a terceira aula do primeiro módulo — de propósito fora de ordem, para validar a Saída A.
  3. Autenticar como usuário desse trial e chamar GET /v1/courses/<id>/modules do curso custom: a terceira aula volta unlocked: true mesmo sem nenhuma conclusão anterior; as outras voltam unlocked: false.
  4. Repetir no curso all: o unlocked se comporta exatamente como hoje (primeira aula aberta, resto conforme progresso).
  5. Abrir o app com esse usuário: o cadeado aparece nas aulas fora do trial, sem nenhuma alteração no cliente.
  6. Autenticar como um usuário não trial e repetir: nenhuma diferença em relação a hoje.
  7. make test passa.

Documentation

Criar .project/docs/rules/trials/trial_lesson_unlock.md — a regra acima em Given/When/Then, com a tabela de casos, a justificativa de custom ignorar a sequência e os testes vinculados. Indexar em RULES.md e README.md.

Nota: o RULES.md da main tem hoje dois R-012 (user_trial_converted_at_on_checkout_approval e user_trial_register). A numeração desta regra deve considerar isso na hora de escolher o próximo ID livre.