No description
  • Python 99.2%
  • Shell 0.5%
  • HTML 0.2%
Find a file
Jonatas Oliveira 20e51abbbf
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix(seed): o script semeava uma coluna que não existe mais
`seed_dev.py` construía `PaymentDB(stripe_session_id=...)`, e a migration
20250632_payment_provider renomeou a coluna para `provider_session_id`
quando os pagamentos passaram a saber de qual gateway vieram. O script
ficou para trás e morria em `TypeError` na primeira linha de pagamento.

Ninguém percebeu porque o único lugar que roda este script é o passo
`quality-gate` do CI, que nunca chegou até aqui — morria antes, no
clone. É o mesmo padrão do dia inteiro: gate quebrado escondendo defeito.

Verificado contra um banco limpo: migrations, `seed_dev.py` fecha em
"Seed complete.", uvicorn sobe e `/api/v1/health` responde ok.
2026-08-20 19:14:11 +02:00
app fix(auth): o cadastro público não escolhe mais o próprio papel 2026-08-20 14:04:21 +02:00
migrations feat: remove o AiLab, que nunca chamou a Claude API uma vez 2026-08-13 16:06:55 +02:00
scripts fix(seed): o script semeava uma coluna que não existe mais 2026-08-20 19:14:11 +02:00
tests ci: o passo test falhava por infraestrutura ausente, não por defeito 2026-08-20 14:15:53 +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: o passo audit chamava uma ferramenta que não estava instalada 2026-08-20 19:05:02 +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: uma regra só para "isto é produção", e as duas variáveis que faltavam no app.json 2026-08-19 16:25:38 +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 ci: o passo test falhava por infraestrutura ausente, não por defeito 2026-08-20 14:15:53 +02:00
main.py feat: remove o AiLab, que nunca chamou a Claude API uma vez 2026-08-13 16:06:55 +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: o namespace de produção, o Keyrunes sem CLI, e o cadastro sem papel 2026-08-20 15:06:17 +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 (produção usa space) 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 space

instructor não existe num Keyrunes recém-migrado. As migrations dele criam só superadmin, admin e users (keyrunes/src/constants.rs) — a linha TEACHER da tabela acima descreve o mapeamento, não um grupo de fábrica. Sem criar antes, o assign-user-to-group acima falha dizendo que o grupo não existe. Uma vez por namespace:

cargo run --bin cli -- create-group \
  --name instructor --namespace space \
  --description "Instrutores do LMS"

Confira com list-groups --namespace <ns>. Os apelidos instructors, teacher e teachers também mapeiam para TEACHER, mas basta um grupo — criar quatro só multiplica o lugar onde alguém pode esquecer de atribuir.

A imagem do Keyrunes em produção não tem o binário cli. O contêiner traz só /usr/local/bin/keyrunes, o servidor — então docker exec ... cli não existe, e toda receita acima é receita de máquina de desenvolvimento. Para operar produção, rode a CLI de um checkout do Keyrunes com DATABASE_URL apontando para o banco de produção. Escrever direto no schema por SQL é o último recurso: o create-group faz mais do que um INSERT, e uma linha posta à mão no schema errado é exatamente o tipo de engano que o parágrafo acima descreve.

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 em produção é space, definido em KEYRUNES_NAMESPACE. Não é o public de fábrica: esta instância do Keyrunes é compartilhada com outras aplicações, e organização própria é o que impede que os usuários e grupos de uma apareçam para a outra. O default do código continua sendo public (DEFAULT_NAMESPACE em app/infra/auth/roles.py), que é o que vale em desenvolvimento — onde a CLI pedir --namespace, use o namespace do ambiente em que você está mexendo.

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

Namespace é schema, não coluna. O Keyrunes dá a cada namespace um schema próprio no Postgres, cada um com suas tabelas users e groups; só organizations mora em public. Consultar public.users para conferir uma conta do namespace space devolve vazio e parece ausência — é a consulta que está no schema errado. SELECT * FROM space.users é a pergunta certa.

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 space --group admin

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

create-superadmin não aceita --namespace (cli.rs): ele sempre cria no public. Um superadmin do LMS tem de ser criado com register --namespace space --group superadmin, ou ele nasce numa organização que o LMS não consulta.

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. O cadastro público não escolhe papel. POST /auth/register registra sempre no grupo de aluno; um role no corpo é descartado em silêncio, para não quebrar clientes antigos que ainda mandam role="student". Foi um campo de verdade até 2026-08-20, e mandar "role": "ADMIN" devolvia um token de administrador — promover alguém é ato de operador, no grupo do Keyrunes, ou pela candidatura a instrutor que um admin aprova.
  4. 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.

Diagnóstico do ambiente

GET /api/v1/admin/diagnostics (só ADMIN) responde o que o deploy configurou e o que está errado nisso. Existe para que "por que o pagamento não libera o curso" tenha resposta sem ssh na máquina. O frontend renderiza o mesmo payload em /admin/settings, ao lado dos ajustes de runtime — as duas metades da tela respondem perguntas diferentes: os ajustes um admin muda, o diagnóstico só um deploy muda.

curl -s https://<backend>/api/v1/admin/diagnostics \
  -H "Authorization: Bearer $TOKEN" | jq
Campo O que é
environment, version O que o processo acha que é
warnings Configuração legal e quase certamente errada — leia esta primeira
variables As 23 variáveis de DIAGNOSTIC_VARS, uma linha cada
database applied, head, at_head

Nenhum segredo é devolvido. Um segredo contribui um bit — setado ou não — mais o comprimento. É o bastante para distinguir chave de verdade de string vazia ou de valor truncado no deploy, e pouco o bastante para não valer nada para quem estiver olhando por cima do ombro.

Os avisos que mais importam depois de subir estão todos em warnings_for (app/site/settings.py): KEYRUNES_JWT_SECRET vazio, SECRET_KEY ainda no valor de exemplo, KEYRUNES_MOCK ligado, DEBUG ligado, Stripe sem STRIPE_WEBHOOK_SECRET (cobra e nunca libera acesso), PayPal configurado pela metade ou apontando para o sandbox.

ENVIRONMENT muda a gravidade de dois deles — segredo do JWT vazio e SECRET_KEY de exemplo são warning em desenvolvimento e error em produção — e é o que faz os outros dois existirem: DEBUG e KEYRUNES_MOCK ligados só são reportados em produção, porque em dev é assim que se trabalha. "Produção" aqui é a mesma regra que o jwt_handler aplica, app/infra/config.py:PRODUCTION_ENVIRONMENTS; era duas listas divergentes até 567eb929.

database.at_head pega o deploy que rodou o código e não rodou a migration: applied são as revisions no banco, head é a que este build espera, lida dos arquivos de migrations/. head: null significa mais de um head na cadeia, que é problema por si — escolher um dos dois esconderia.

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 space

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.