Campo unlocked no curso
TLDR: expõe
unlockedno curso para que o app saiba quais cursos o trial libera, seguindo o mesmo padrão já usado na aula (R-016), sem tocar empublished_at/is_published.
Status: proposed Created: 2026-08-19 Owner: @matheusscfr Asana: sem task vinculada
Context
O R-016 fez o unlocked da aula respeitar o trial: LessonSerializer.get_unlocked consulta resolve_trial_lesson_access e devolve false para tudo que está fora do TrialCourse do usuário (apps/trials/services/trial_content.py, apps/courses/serializers.py:78).
O curso ficou de fora. Hoje CourseSerializer e CourseModuleSerializer expõem apenas published_at/is_published, e o app usa is_published para decidir se mostra ou trava o card (types/course.ts, lib/courseUtils.ts). Sem um campo por curso, o app em trial não tem como marcar o card de um curso inteiro que não faz parte do trial — a informação só existe aula a aula, um nível abaixo do que a listagem mostra.
unlocked no curso resolve isso reaproveitando um nome que o app já entende na aula, e mantém a separação de responsabilidades: is_published continua respondendo por “já saiu / está agendado”, unlocked responde por “está liberado no seu trial”.
Objectives
- Expor
unlockedno curso emCourseSerializereCourseModuleSerializer, cobrindo listagem/courses, retrieve/courses/{id}e os cursos de uma trilha (TrailCourseSerializeraninhaCourseSerializer). - Para usuário em trial,
unlockedreflete a presença do curso emTrialCourse, independente doaccess_mode. - Para quem não está em trial,
unlockedé sempretrue— nenhum comportamento atual muda. - Resolver o acesso uma vez por request, sem N+1 na listagem.
- Não alterar o gating de aula que já está em produção.
Regra
não é trial -> unlocked = True
é trial, curso em TrialCourse -> unlocked = True
é trial, curso fora do TrialCourse -> unlocked = False
é trial sem nenhum TrialCourse -> unlocked = False (todos)
| Situação | unlocked |
|---|---|
| Usuário não é trial | true para qualquer curso |
Trial, curso liberado com access_mode=all |
true |
Trial, curso liberado com access_mode=custom |
true — o curso está liberado; quais aulas abrem é decisão do R-016 |
Trial, curso fora do TrialCourse |
false |
Trial sem nenhum TrialCourse configurado |
false em todos os cursos |
Curso não publicado (is_published=false) |
unlocked não muda — os dois campos são independentes |
access_mode não influencia o unlocked do curso: all e custom significam os dois que o curso faz parte do trial. A granularidade de custom já é resolvida no unlocked da aula.
Assim como no R-016, a distinção entre “não é trial” e “é trial sem nada liberado” é explícita: o resolver devolve None no primeiro caso e um set vazio no segundo. Trocar um pelo outro inverteria o comportamento para toda a base de assinantes.
Non-goals
- Não filtra listagens. Cursos, módulos e trilhas continuam sendo listados integralmente; o retrieve de um curso fora do trial continua respondendo
200. Nenhum queryset muda. - Não mexe em
published_at/is_published. São eixos independentes; um curso pode viris_published: true, unlocked: false. - Não adiciona
unlockedao módulo. O trial é configurado por curso, e a aula já tem a trava dela. - Não altera o gating de aula.
resolve_trial_lesson_access,TrialLessonAccesseLessonSerializer.get_unlockedficam como estão. - Não muda o app. O campo passa a existir na API; consumir no card do curso é trabalho do onion-app, fora desta spec.
Changes
apps/trials/services/trial_content.py
python
def resolve_trial_course_access(user) -> set[int] | None
- Devolve
Nonequandouser.is_trialé falso — sinal de “sem restrição”, igual aoresolve_trial_lesson_access. - Caso contrário, devolve o
setdecourse_iddosTrialCoursedo trial do usuário, sem filtrar poraccess_mode— os dois modos significam que o curso está liberado. - Uma consulta fixa, sem laço por curso. Registros soft-deleted ficam de fora pelo manager
objectsdoBaseModel. resolve_trial_lesson_access,TrialLessonAccesse os helpers existentes não mudam. A função nova reaproveita o filtro por trial do usuário que já vive no módulo.
apps/courses/serializers.py
Um mixin pequeno concentra a regra, evitando duplicá-la nos dois serializers:
```python class TrialCourseUnlockMixin: def get_unlocked(self, obj): user = getattr(self.context.get(‘request’), ‘user’, None) if not user: return True
if '_trial_course_access' not in self.context:
self.context['_trial_course_access'] = resolve_trial_course_access(user)
access = self.context['_trial_course_access']
return access is None or obj.id in access ```
CourseSerializereCourseModuleSerializerherdam o mixin, declaramunlocked = serializers.SerializerMethodField(read_only=True)no próprio corpo — como já fazem comhas_new_lessons, porque o metaclass do DRF só coleta campos declarados em bases que já são serializers — e incluemunlockedemfieldseread_only_fields.- O cache em
self.contextsegue o padrão já usado por_get_lesson_progress_cachee_trial_lesson_access: como o DRF compartilha ocontextentre as instâncias de ummany=True, a resolução roda uma vez por request, independente da quantidade de cursos. - Sem
requestou sem usuário no contexto,unlockedsaitrue— mesmo resultado de um usuário não-trial. Diferente da aula, que devolvefalsenesse caso, porque lá o default do campo é o bloqueio.
Sem migrations: nenhum modelo é alterado.
Testes
tests/trials/test_trial_content_service.py— casos novos doresolve_trial_course_access: não-trial devolveNone; trial semTrialCoursedevolvesetvazio; cursosallecustomentram os dois no conjunto;TrialCoursesoft-deleted fica de fora; o trial de outro usuário não vaza para o conjunto.tests/trials/test_course_unlock_gating.py(novo) — de ponta a ponta emGET /v1/courses,GET /v1/courses/<id>eGET /v1/trails/<id>/courses: assinante vê tudounlocked: true; usuário em trial vêtruesó nos cursos liberados efalseno resto; trial sem configuração vê tudofalse; curso comis_published=falsemantém ounlockedindependente.- Teste de spy sobre
resolve_trial_course_accessnuma serialização com vários cursos, garantindo que a resolução roda uma vez por request e não por curso — mesmo padrão do teste equivalente doR-016.
How to verify
make deps.up && python manage.py seed.- No admin, editar um
Triale liberar um curso (tanto faz oaccess_mode), deixando os demais de fora. - Autenticar como usuário desse trial e chamar
GET /v1/courses: só o curso liberado voltaunlocked: true; o resto voltafalse. GET /v1/courses/<id>de um curso fora do trial: responde200, comunlocked: falsee os módulos normalmente — nada é filtrado.GET /v1/trails/<trail_id>/courses: os cursos aninhados trazem o mesmounlockedda listagem.- Remover todos os
TrialCoursedesse trial e repetir o passo 3: todos os cursos voltamfalse. - Autenticar como usuário não trial e repetir: todos os cursos voltam
unlocked: true. - Confirmar que um curso não publicado continua com o mesmo
is_publishedde hoje, sem interferência dounlocked. make testpassa.
Documentation
- Criar
.project/docs/rules/trials/trial_course_unlock.md— a regra em Given/When/Then, com a tabela de casos e os testes vinculados, referenciando oR-016como a camada de aula. Próximo ID livre éR-017(atenção: oRULES.mdtem hoje doisR-012, então a contagem por linhas engana). - Atualizar
.project/docs/RULES.mde.project/docs/README.md— nova linha no índice. - Atualizar
.project/docs/rules/trials/trial_lesson_unlock.md— a constraint “não há bloqueio em listagens” continua verdadeira (nada é filtrado), mas ganha a nota de que o curso passa a expor umunlockedpróprio.