No description
  • Python 98.6%
  • Shell 1%
  • HTML 0.3%
Find a file
Jonatas Oliveira f96feab2d6 ci: a pipeline passa a viver neste repositório, onde ela roda
A pipeline anterior morava no repositório que contém `backend/` e `frontend/`, e
estava morta por dois motivos ao mesmo tempo: aquele repositório **não tem remote
nenhum**, então nenhum forge o vê e nada é acionado; e desde `d6da838d` ele não
versiona nenhum arquivo destas duas pastas, então o `cd backend` do primeiro passo
falharia num checkout limpo.

Efeito prático: nada do que foi construído nas últimas semanas — catraca de lint,
gate de schema, gate de contrato, gate de autenticação — jamais rodou fora da
máquina de quem escreveu.

Seis passos: `lint` (catraca contra `lint-baseline.json`), `auth` (rota anônima sem
decisão registrada), `test`, `schema` (`alembic check` mais round-trip completo de
downgrade), `contract` (chamada do frontend para rota inexistente) e `audit`
(`pip-audit`, só na main). Cada um traz escrito o defeito que o originou; gate sem
motivo é o primeiro a ser desligado quando incomoda.

`contract` é o único que precisa dos dois repositórios, então clona o frontend —
preferindo a branch de mesmo nome, com a padrão como reserva: uma mudança que
atravessa os dois lados vive em duas branches homônimas, e clonar sempre a padrão
compararia backend novo com frontend antigo. Precisa da chave de deploy em
`CI_SSH_KEY`; sem ela o passo falha, que é o certo para um gate cujo insumo não
chegou.

Verificado o que dava para verificar daqui: a auditoria de contrato roda contra um
clone limpo do frontend, não só contra a árvore de trabalho. O clone por SSH não —
este ambiente não tem acesso ao forge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:04:13 +02:00
app test: gate de rota anônima, e o enunciado de tarefa que também vazava 2026-08-12 16:55:25 +02:00
migrations fix(schema): metadata completo, índices declarados e cadeia de migrations reexecutável 2026-08-11 17:46:28 +02:00
scripts test: gate de rota anônima, e o enunciado de tarefa que também vazava 2026-08-12 16:55:25 +02:00
tests test: gate de rota anônima, e o enunciado de tarefa que também vazava 2026-08-12 16:55:25 +02:00
.dockerignore build: add Dockerfile and dokku deploy files at repo root 2026-08-03 11:13:16 +02:00
.gitignore fix(deploy): point uvicorn at main:app and healthcheck at /api/v1/health 2026-08-03 11:13:45 +02:00
.woodpecker.yml ci: a pipeline passa a viver neste repositório, onde ela roda 2026-08-12 17:04:13 +02:00
alembic.ini feat: implement Wave 7-8 core features (student dashboard, course player, quiz, certificates, bundle domain, code challenge, reviews, drip, prerequisites, email) 2026-08-03 11:13:16 +02:00
app.json fix(deploy): point uvicorn at main:app and healthcheck at /api/v1/health 2026-08-03 11:13:45 +02:00
Dockerfile fix(deploy): point uvicorn at main:app and healthcheck at /api/v1/health 2026-08-03 11:13:45 +02:00
LICENSE Initial commit 2026-08-03 09:06:22 +00:00
lint-baseline.json test: gate de rota anônima, e o enunciado de tarefa que também vazava 2026-08-12 16:55:25 +02:00
main.py feat(ailab): geração de conteúdo e questões pela Claude API 2026-08-11 16:44:46 +02:00
Procfile feat(deploy): run alembic migrations as a dokku release task 2026-08-03 11:13:45 +02:00
pyproject.toml fix: auditoria classificava apiFetch errado e escondia 22 telas quebradas 2026-08-12 13:54:25 +02:00
README.md docs: README do backend com o que é preciso para subir 2026-08-12 07:39:21 +02:00
uv.lock feat(ailab): geração de conteúdo e questões pela Claude API 2026-08-11 16:44:46 +02:00

Open LMS Backend

Backend FastAPI do PlayfulLMS (clone open source do MasterStudy).

Development

# Install dependencies
uv sync

# Apply migrations (needs postgres up: docker compose up -d db na raiz)
uv run alembic upgrade head

# Run development server (mock auth, sem Keyrunes real)
env KEYRUNES_MOCK=true uv run uvicorn main:app --reload --port 8000

# Dados de desenvolvimento (admin, instrutor, 2 alunos, 2 cursos, cupom, pedido pago)
uv run python scripts/seed_dev.py          # idempotente
uv run python scripts/seed_dev.py --reset  # remove o que o seed criou

# Run tests
uv run pytest

Docs interativas: http://localhost:8000/docs

Variáveis de ambiente

Lidas via pydantic-settings (app/infra/config.py), de um .env no diretório do backend ou do ambiente. Os defaults servem para dev local.

Variável Default Notas
DATABASE_URL postgresql+asyncpg://postgres:postgres@localhost:5434/openlms Porta 5434 = docker compose up db da raiz
SECRET_KEY change-me-in-production Trocar em produção
ENVIRONMENT / DEBUG development / false
CORS_ORIGINS http://localhost:3000,http://localhost:5173 Lista separada por vírgula
FRONTEND_URL http://localhost:5173 Usado em redirects/e-mails
KEYRUNES_API_URL http://localhost:8080 Em produção: https://sso.singularjourney.org
KEYRUNES_API_KEY (vazio) Bearer token enviado ao Keyrunes
KEYRUNES_MOCK false true = auth fake, sem Keyrunes (ver abaixo)
KEYRUNES_NAMESPACE public Namespace/tenant enviado no login e registro
KEYRUNES_JWT_SECRET (vazio) Obrigatório em produção. Mesmo valor do JWT_SECRET do Keyrunes; vazio = assinatura não verificada
KEYRUNES_JWT_ISSUER keyrunes Claim iss exigido no token
KEYRUNES_JWKS_URL (vazio) Não usado — o Keyrunes é HS256 simétrico, não expõe JWKS
STORAGE_TYPE local local ou bunny
BUNNY_STORAGE_API_KEY / BUNNY_STORAGE_ZONE / BUNNY_CDN_URL (vazio) Só com STORAGE_TYPE=bunny
LOCAL_STORAGE_PATH ./uploads Só com STORAGE_TYPE=local
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET / STRIPE_PUBLIC_KEY (vazio) Pagamentos

Autenticação e autorização (Keyrunes)

O backend não tem auth própria: login, registro e senha são delegados ao Keyrunes. A tabela local users guarda apenas perfil e papel, ligada pelo campo keyrunes_id, e é sincronizada (upsert) a cada login/registro por UserRepository.upsert_from_keyrunes.

De grupo do Keyrunes a papel do LMS

O Keyrunes emite o claim groups (mais namespace e organization_id) e não emite role. O mapeamento está em app/infra/auth/roles.py:

Grupo no Keyrunes UserRole
superadmin, admin ADMIN
instructor, instructors, teacher, teachers TEACHER
users e qualquer outro STUDENT

Quem está em vários grupos recebe o papel mais alto. Promover alguém a instrutor é operação do Keyrunes:

cargo run --bin cli -- assign-user-to-group \
  --username prof --group-name instructor --namespace public

Namespace

Namespace no Keyrunes é o identificador de organização/tenant (coluna organizations.namespace, única, ≤63 chars). Toda chamada de login/registro e toda operação de CLI aceita --namespace.

O namespace do PlayfulLMS é public — o default de fábrica do Keyrunes (DEFAULT_NAMESPACE = "public" em src/constants.rs), atribuído à "Default Organization" (organization_id = 1) pela migration 20260101120000_add_schema_fields.sql. Não criamos organização dedicada, então não há namespace customizado a configurar: onde a CLI pedir --namespace, use public ou simplesmente omita (é o valor default).

Grupos padrão dentro do namespace: superadmin, admin, users.

Criando o primeiro usuário admin

O primeiro admin é criado no Keyrunes, pela CLI dele — não existe endpoint de bootstrap no PlayfulLMS. No diretório do Keyrunes (keyrunes/), com DATABASE_URL apontando para o banco do Keyrunes:

# migrations do Keyrunes (cria org default + grupos superadmin/admin/users)
cargo run --bin cli -- migrate

# primeiro superadmin — vai para o namespace "public", organization_id 1,
# e é adicionado ao grupo "superadmin"
cargo run --bin cli -- create-superadmin \
  --email admin@example.com \
  --username admin \
  --password 'senha-forte'

Atalho equivalente via Makefile do Keyrunes:

make cli-superadmin ADMIN_EMAIL=admin@example.com ADMIN_USERNAME=admin ADMIN_PASSWORD='senha-forte'

Usuários seguintes podem ir direto para um grupo:

cargo run --bin cli -- register \
  --email prof@example.com --username prof --password 'senha' \
  --namespace public --group admin

Comandos úteis: list-users --namespace public, list-groups --namespace public, assign-user-to-group --username X --group-name admin --namespace public.

Modo mock (KEYRUNES_MOCK=true)

Para desenvolver sem uma instância do Keyrunes. O papel é inferido da parte local do e-mail, então dá para exercitar qualquer rota sem override de dependência:

E-mail no login Grupo Papel
admin@example.com admin ADMIN
prof@example.com, teacher@…, instructor@… instructor TEACHER
qualquer outro users STUDENT

O token mock é mock_jwt_v2.<base64url(json)> e carrega o claim set completo (sub, email, username, groups, namespace, organization_id). Tokens no formato antigo mock_jwt_<id> continuam sendo aceitos e caem em STUDENT.

Verificação de assinatura do JWT

O Keyrunes assina com HS256 e segredo compartilhado (JWT_SECRET do lado dele) — não existe JWKS para buscar chave pública. Configure KEYRUNES_JWT_SECRET com o mesmo valor e o backend passa a verificar assinatura, exp (30s de tolerância de relógio) e iss.

Sem o segredo:

ENVIRONMENT Comportamento
development (default) Aceita o token sem verificar, com log de aviso
production / prod / staging Recusa todo token (401)

HS256 é aceito — permitir RS256 junto abriria confusão de algoritmo, e alg: none é rejeitado.

⚠️ Gaps conhecidos da integração

  1. full_name não existe no Keyrunes. É campo só do LMS, gravado na tabela local no registro. Um usuário criado direto pela CLI do Keyrunes ganha full_name = <parte local do e-mail> no primeiro login.
  2. O registro faz dois round-trips. POST /api/register do Keyrunes não devolve token, então o endpoint registra e em seguida faz login para abrir a sessão.
  3. user["user_id"] do token é o id do Keyrunes, não users.id. Vários routers antigos passam esse valor direto para colunas que são FK de users.id. Onde isso importa, use a dependência get_current_db_user (app/infra/auth/user_deps.py), que resolve a linha local. Auditar os demais routers continua pendente.

Fórum por curso

Privado do curso: course_access / topic_access / post_access (app/infra/auth/course_access.py) resolvem quem o caller é em relação ao curso e devolvem um CourseAccess. O gate roda na leitura também — aluno não matriculado recebe 403 na própria listagem de tópicos, porque mostrar títulos já seria vazamento.

Quem Pode
Aluno matriculado Ler, abrir tópico, responder, editar/excluir o que é seu, aceitar solução no próprio tópico
Instrutor do curso (dono ou co-instrutor) Tudo acima + fixar, fechar, editar/excluir de qualquer um, responder em tópico fechado
Admin Tudo, sem precisar de matrícula
Qualquer outro 403

Regras que valem saber:

  • Um nível de aninhamento. Responder a uma resposta achata no pai.
  • Uma solução por tópico, garantida por índice unique parcial (WHERE is_solution). Aceitar outra move a marca.
  • Tópico fechado bloqueia o aluno, não a equipe.
  • reply_count e last_post_at são denormalizados; touch_topic() recalcula depois de criar ou excluir resposta.
  • A notificação de resposta é best-effort: o post já está commitado, então falha de e-mail é logada e não propaga.

Instrutores

/instructors é o diretório público: nome, bio, avatar e contagem de cursos publicados. Nunca devolve e-mail — para isso existe /users, que é admin.

/instructor-applications é o fluxo "seja instrutor": qualquer aluno logado candidata-se (uma candidatura aberta por pessoa, garantida por índice unique parcial em status = 'pending'), e o admin aprova ou recusa.

Aprovar promove o papel local para TEACHER. O grupo no Keyrunes precisa ser atribuído por operador — o Keyrunes é o dono da autorização e este serviço não escreve nele:

cargo run --bin cli -- assign-user-to-group \
  --username <user> --group-name instructor --namespace public

Carrinho

Uma linha em carts por usuário, com cart_items sob unique (cart_id, product_id) — adicionar o mesmo produto duas vezes soma quantidade em vez de criar linha. Carrinho anônimo não tem linha no banco: fica em localStorage e é enviado para POST /cart/merge no login.

Regras em app/cart/services.py:

Situação Resultado
Produto não publicado ou gratuito 400
Curso que o usuário já cursa 409
Mesmo curso/bundle/membership duas vezes 409 (quantidade fica em 1)
Produto digital repetido soma a quantidade
Cupom que expirou depois de aplicado descartado na próxima leitura, sem erro

POST /payments/checkout aceita product_id (compra direta), product_ids (lista) ou from_cart: true. Um desconto vale para o pedido inteiro, então é rateado entre as linhas proporcionalmente ao preço, com a última absorvendo o arredondamento — as linhas somam exatamente o valor cobrado.

As escritas do carrinho commitam explicitamente. A dependência get_db só commita depois de a resposta sair, e sem isso uma leitura imediata via o estado antigo — e um segundo POST rápido furava o unique index em vez de receber 409.

Enums: nome no banco, valor na API

Sete das oito colunas enum persistem o nome do membro (BEGINNER), enquanto o domínio e a API usam o valor (beginner). A exceção é users.role, que persiste o valor. Escrever a string crua faz o Postgres recusar o insert:

invalid input value for enum course_level_enum: "beginner"

Por isso toda escrita em coluna enum passa por app/infra/db_enums.to_db_enum, que aceita membro de enum, nome ou valor e devolve o membro mapeado. tests/unit/infra/test_models_enums.py compara os labels do modelo com o CREATE TYPE das migrations e falha se alguém introduzir uma divergência nova.

Papéis no backend

UserRole (app/domain/user/auth_entities.py): ADMIN, TEACHER, STUDENT. Dependências prontas em app/infra/auth/deps.py: require_admin, require_teacher (ADMIN+TEACHER), require_student (todos).

get_current_user devolve user_id, email, username, role, groups, namespace, organization_id e token.