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) e varredura_unlocked (por usuário, true quando 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:

  1. 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
  2. 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 Lesson que diz se a varredura está liberada para um usuário (gate concluído)
  • Expor na API de aulas subject_to_varredura e varredura_unlocked para 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 em Lesson:
    • 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ça self.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 — exportar VarreduraConfig

Método de unlock

  • apps/courses/models/lesson.py — adicionar em Lesson: 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_unlocked já usa (evitar import circular courses↔engagements)
    • Opcional (perf): cachear VarreduraConfig.load() por curto TTL, no estilo do cache de is_unlocked. Não obrigatório no MVP

Serializer

  • apps/courses/serializers.py — em LessonSerializer, adicionar subject_to_varredura = serializers.BooleanField(read_only=True) e varredura_unlocked = serializers.SerializerMethodField(read_only=True), incluindo ambos em Meta.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/CourseModuleSerializer aninham via LessonSerializer (com context={"request": ...} propagado), de modo que GET /v1/courses/modules/{course_id} já retorne os dois campos. Se o aninhamento usar outro serializer, replicar os dois campos nele

Admin

  • LessonAdmin: incluir subject_to_varredura em fields (junto do bloco is_active, unlocked, sort), em list_display e em list_filter
  • Registrar VarreduraConfigAdmin(admin.ModelAdmin):
    • has_add_permission → return not VarreduraConfig.objects.exists()
    • has_delete_permission → return False
    • list_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: AddField Lesson.subject_to_varredura e CreateModel VarreduraConfig

Testes

  • tests/courses/conftest.py — fixtures gate_lesson, varredura_lesson (com subject_to_varredura=True) e varredura_config
  • tests/courses/test_models_and_services.py — is_varredura_unlocked retorna False sem LessonProgress concluído da gate, True com ele, False quando config.is_active=False mesmo com gate concluída, False quando release_from_lesson é None; VarreduraConfig.load() é idempotente
  • tests/courses/test_serializers.py — LessonSerializer expõe subject_to_varredura; varredura_unlocked=True só quando o usuário do request concluiu a gate

Seeds / fixtures

  • apps/courses/management/commands/add_sample_modules_lessons.py — marcar ao menos uma aula com subject_to_varredura=True e garantir VarreduraConfig via get_or_create(pk=1) apontando release_from_lesson para uma aula inicial (idempotente)

Como verificar

  1. makemigrations courses --check não acusa migrations pendentes; migrate aplica sem erro
  2. pytest tests/courses — todos os testes passando
  3. 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
  4. GET /v1/courses/modules/{course_id} autenticado como usuário sem progresso concluído na gate → aulas trazem subject_to_varredura correto e varredura_unlocked=false
  5. Marcar a aula-gate como concluída para o usuário e repetir o GET → varredura_unlocked=true nas aulas marcadas
  6. Com VarreduraConfig.is_active=False → varredura_unlocked=false para 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).