Bloqueio de aulas fora do trial
TLDR: faz o campo
unlockeddas 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
unlockedrefletir o conteúdo liberado pelo trial do usuário. - Respeitar
TrialCourse.access_mode:alllibera o curso inteiro mantendo a sequência atual,customlibera apenas as aulas emTrialCourseLessone 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
unlockedde cada aula muda. Nenhum queryset de/coursesou/trailsé alterado. - Não bloqueia outras áreas. Áudios, livros, lives, hábitos, sonhos e chat seguem liberados —
Trial.features,access_library_audioseaudio_limitcontinuam sem consumo. Escopo separado. - Não altera o objeto
trialdo 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õeunlocked. 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
Nonequando o usuário não é trial (subscription_status != "trial") — sinal de “sem restrição”. custom_access— ids das aulas marcadas emTrialCourseLesson, de cursos comaccess_mode=custom. Liberadas sem passar pela sequência.all_access— ids das aulas ativas de cursos comaccess_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 devolveNone; trial semTrialCoursedevolve os dois conjuntos vazios;access_mode=allpopula sóall_access;custompopula 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/—unlockedpara:- não-trial: comportamento atual preservado (o caso de regressão mais importante);
customcom a aula marcada e a anterior não concluída → liberada (o caso que motivou a Saída A);customcom aula não marcada, mesmo comunlocked=True→ bloqueada;allcom 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_queriessobre 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
make deps.up && python manage.py seed.- No admin, editar um
Triale 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. - Autenticar como usuário desse trial e chamar
GET /v1/courses/<id>/modulesdo cursocustom: a terceira aula voltaunlocked: truemesmo sem nenhuma conclusão anterior; as outras voltamunlocked: false. - Repetir no curso
all: ounlockedse comporta exatamente como hoje (primeira aula aberta, resto conforme progresso). - Abrir o app com esse usuário: o cadeado aparece nas aulas fora do trial, sem nenhuma alteração no cliente.
- Autenticar como um usuário não trial e repetir: nenhuma diferença em relação a hoje.
make testpassa.
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.