Varredura de padrão — backend (admin + liberação por aula)
TLDR: Permitir no admin do Django marcar aulas como “passíveis de varredura” e configurar (singleton) a partir de qual aula a varredura é liberada; expor na API de aulas os campos
subject_to_varredura(estático) evarredura_unlocked(por usuário,truequando ele concluiu a aula-gate).
Contexto
A “varredura de padrão” é uma conversa com a LLM (modo therapist) servida pelo onion-chat via websocket — já implementada e fora do escopo desta spec. O que falta é o backend dar suporte a duas configurações de negócio, editáveis no admin do Django:
- Quais aulas são “passíveis de varredura” — aulas que, no app, exibem um botão de varredura e um modal ao terminar o vídeo
- A partir de qual aula a varredura fica liberada para o usuário — um portão (gate) único e global. Enquanto o usuário não concluir essa aula-gate, a varredura não aparece, mesmo nas aulas marcadas
Decisões de produto já fechadas:
- Gate único global (não por curso): uma config singleton aponta para 1 aula
- Unlock = concluir a aula-gate: reaproveita LessonProgress.completed_at
- O controle de “primeira vez vs. segunda vez” na varredura é client-side (AsyncStorage) no app
Código relevante hoje:
- apps/courses/models/lesson.py — model Lesson (já tem unlocked: bool, sort, previous_lesson, método is_unlocked(user_id, progress=None) que consulta o progresso do usuário com cache)
- apps/courses/serializers.py — LessonSerializer (já expõe unlocked via SerializerMethodField); ModuleLessonSerializer/CourseModuleSerializer aninham as aulas no endpoint GET /v1/courses/modules/{course_id}
- apps/courses/admin.py — LessonAdmin (com fields, list_display, list_filter, LessonAdminForm)
- apps/engagements/models/progress.py — LessonProgress (unique_together (user, lesson), completed_at)
- tests/courses/conftest.py — fixtures pytest; o projeto usa pytest puro, não factory_boy
Objetivos
- Campo booleano por aula para marcá-la como passível de varredura, editável no admin
- Configuração singleton no admin com a aula-gate de liberação (e flag para ativar/desativar a feature globalmente)
- Método no
Lessonque diz se a varredura está liberada para um usuário (gate concluído) - Expor na API de aulas
subject_to_varreduraevarredura_unlockedpara o app decidir exibir botão/modal - Testes cobrindo o método de unlock e a serialização; fixtures e seed mínimos
Fora de escopo
- Nenhum tracking de “varredura já feita” no backend — fica no app
- A conversa com a LLM em si (servida pelo onion-chat)
Mudanças
Contrato de API (seam com o onion-app)
Cada aula serializada (no detalhe de aula e aninhada em módulos) passa a conter:
| Campo | Tipo | Origem | Significado |
|---|---|---|---|
subject_to_varredura |
bool |
campo do model | Aula marcada como passível de varredura (estático, igual para todos) |
varredura_unlocked |
bool |
calculado por usuário | true se e somente se a config está ativa, há aula-gate definida e o usuário concluiu a aula-gate |
Regra no app: botão/modal de varredura aparecem quando subject_to_varredura && varredura_unlocked.
Model — campo na aula
apps/courses/models/lesson.py— adicionar emLesson:subject_to_varredura = models.BooleanField(default=False, verbose_name="Passível de varredura", help_text="Exibe o botão e o modal de varredura de padrão nesta aula (após a liberação).")
Model — configuração singleton da varredura
apps/courses/models/varredura.py(novo) —VarreduraConfig(BaseModel):is_active = models.BooleanField(default=True, verbose_name="Varredura ativa")release_from_lesson = models.ForeignKey('courses.Lesson', on_delete=models.SET_NULL, null=True, blank=True, related_name='+', verbose_name="Liberar a partir da aula")- Padrão singleton:
save()forçaself.pk = 1;@classmethod load(cls)→get_or_create(pk=1);delete()no-op Meta:verbose_name = "Configuração da Varredura"(singular e plural)
apps/courses/models/__init__.py— exportarVarreduraConfig
Método de unlock
apps/courses/models/lesson.py— adicionar emLesson:python def is_varredura_unlocked(self, user_id) -> bool: from apps.courses.models.varredura import VarreduraConfig # import local p/ evitar ciclo config = VarreduraConfig.load() if not config.is_active or not config.release_from_lesson_id: return False from apps.engagements.models.progress import LessonProgress return LessonProgress.objects.filter( user_id=user_id, lesson_id=config.release_from_lesson_id, completed_at__isnull=False, ).exists()- Seguir o mesmo padrão de import local que
is_unlockedjá usa (evitar import circular courses↔engagements) - Opcional (perf): cachear
VarreduraConfig.load()por curto TTL, no estilo do cache deis_unlocked. Não obrigatório no MVP
- Seguir o mesmo padrão de import local que
Serializer
apps/courses/serializers.py— emLessonSerializer, adicionarsubject_to_varredura = serializers.BooleanField(read_only=True)evarredura_unlocked = serializers.SerializerMethodField(read_only=True), incluindo ambos emMeta.fields:python def get_varredura_unlocked(self, obj): request = self.context.get("request") user = getattr(request, "user", None) if not user or not user.is_authenticated: return False return obj.is_varredura_unlocked(user.id)- Verificar que
ModuleLessonSerializer/CourseModuleSerializeraninham viaLessonSerializer(comcontext={"request": ...}propagado), de modo queGET /v1/courses/modules/{course_id}já retorne os dois campos. Se o aninhamento usar outro serializer, replicar os dois campos nele
Admin
LessonAdmin: incluirsubject_to_varreduraemfields(junto do blocois_active,unlocked,sort), emlist_displaye emlist_filter- Registrar
VarreduraConfigAdmin(admin.ModelAdmin):has_add_permission→return not VarreduraConfig.objects.exists()has_delete_permission→return Falselist_display = ['__str__', 'is_active', 'release_from_lesson']raw_id_fields = ['release_from_lesson'](a base de aulas pode ser grande)auditlog.register(VarreduraConfig)seguindo o padrão do arquivo
Migration
apps/courses/migrations/00XX_varredura.py:AddFieldLesson.subject_to_varreduraeCreateModelVarreduraConfig
Testes
tests/courses/conftest.py— fixturesgate_lesson,varredura_lesson(comsubject_to_varredura=True) evarredura_configtests/courses/test_models_and_services.py—is_varredura_unlockedretornaFalsesemLessonProgressconcluído da gate,Truecom ele,Falsequandoconfig.is_active=Falsemesmo com gate concluída,Falsequandorelease_from_lessonéNone;VarreduraConfig.load()é idempotentetests/courses/test_serializers.py—LessonSerializerexpõesubject_to_varredura;varredura_unlocked=Truesó quando o usuário dorequestconcluiu a gate
Seeds / fixtures
apps/courses/management/commands/add_sample_modules_lessons.py— marcar ao menos uma aula comsubject_to_varredura=Truee garantirVarreduraConfigviaget_or_create(pk=1)apontandorelease_from_lessonpara uma aula inicial (idempotente)
Como verificar
makemigrations courses --checknão acusa migrations pendentes;migrateaplica sem erropytest tests/courses— todos os testes passando- Admin: marcar Passível de varredura numa aula e salvar; em Configuração da Varredura, definir a aula-gate e
is_active=True. Não é possível criar uma segunda config nem deletar a existente GET /v1/courses/modules/{course_id}autenticado como usuário sem progresso concluído na gate → aulas trazemsubject_to_varreduracorreto evarredura_unlocked=false- Marcar a aula-gate como concluída para o usuário e repetir o GET →
varredura_unlocked=truenas aulas marcadas - Com
VarreduraConfig.is_active=False→varredura_unlocked=falsepara todos
Documentação
Documentar a regra de negócio da varredura: aula passível de varredura, gate único global e critério de liberação (conclusão da aula-gate).