Campo unlocked no curso

TLDR: expõe unlocked no curso para que o app saiba quais cursos o trial libera, seguindo o mesmo padrão já usado na aula (R-016), sem tocar em published_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 unlocked no curso em CourseSerializer e CourseModuleSerializer, cobrindo listagem /courses, retrieve /courses/{id} e os cursos de uma trilha (TrailCourseSerializer aninha CourseSerializer).
  • Para usuário em trial, unlocked reflete a presença do curso em TrialCourse, independente do access_mode.
  • Para quem não está em trial, unlocked é sempre true — 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 vir is_published: true, unlocked: false.
  • Não adiciona unlocked ao 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, TrialLessonAccess e LessonSerializer.get_unlocked ficam 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 None quando user.is_trial é falso — sinal de “sem restrição”, igual ao resolve_trial_lesson_access.
  • Caso contrário, devolve o set de course_id dos TrialCourse do trial do usuário, sem filtrar por access_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 objects do BaseModel.
  • resolve_trial_lesson_access, TrialLessonAccess e 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 ```
  • CourseSerializer e CourseModuleSerializer herdam o mixin, declaram unlocked = serializers.SerializerMethodField(read_only=True) no próprio corpo — como já fazem com has_new_lessons, porque o metaclass do DRF só coleta campos declarados em bases que já são serializers — e incluem unlocked em fields e read_only_fields.
  • O cache em self.context segue o padrão já usado por _get_lesson_progress_cache e _trial_lesson_access: 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 cursos.
  • Sem request ou sem usuário no contexto, unlocked sai true — mesmo resultado de um usuário não-trial. Diferente da aula, que devolve false nesse caso, porque lá o default do campo é o bloqueio.

Sem migrations: nenhum modelo é alterado.

Testes

  • tests/trials/test_trial_content_service.py — casos novos do resolve_trial_course_access: não-trial devolve None; trial sem TrialCourse devolve set vazio; cursos all e custom entram os dois no conjunto; TrialCourse soft-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 em GET /v1/courses, GET /v1/courses/<id> e GET /v1/trails/<id>/courses: assinante vê tudo unlocked: true; usuário em trial vê true só nos cursos liberados e false no resto; trial sem configuração vê tudo false; curso com is_published=false mantém o unlocked independente.
  • Teste de spy sobre resolve_trial_course_access numa 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 do R-016.

How to verify

  1. make deps.up && python manage.py seed.
  2. No admin, editar um Trial e liberar um curso (tanto faz o access_mode), deixando os demais de fora.
  3. Autenticar como usuário desse trial e chamar GET /v1/courses: só o curso liberado volta unlocked: true; o resto volta false.
  4. GET /v1/courses/<id> de um curso fora do trial: responde 200, com unlocked: false e os módulos normalmente — nada é filtrado.
  5. GET /v1/trails/<trail_id>/courses: os cursos aninhados trazem o mesmo unlocked da listagem.
  6. Remover todos os TrialCourse desse trial e repetir o passo 3: todos os cursos voltam false.
  7. Autenticar como usuário não trial e repetir: todos os cursos voltam unlocked: true.
  8. Confirmar que um curso não publicado continua com o mesmo is_published de hoje, sem interferência do unlocked.
  9. make test passa.

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 o R-016 como a camada de aula. Próximo ID livre é R-017 (atenção: o RULES.md tem hoje dois R-012, então a contagem por linhas engana).
  • Atualizar .project/docs/RULES.md e .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 um unlocked próprio.