- Python 99.2%
- Shell 0.5%
- HTML 0.2%
|
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
`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. |
||
|---|---|---|
| app | ||
| migrations | ||
| scripts | ||
| tests | ||
| .dockerignore | ||
| .gitignore | ||
| .woodpecker.yml | ||
| alembic.ini | ||
| app.json | ||
| Dockerfile | ||
| LICENSE | ||
| lint-baseline.json | ||
| main.py | ||
| Procfile | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
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
instructornão existe num Keyrunes recém-migrado. As migrations dele criam sósuperadmin,admineusers(keyrunes/src/constants.rs) — a linhaTEACHERda tabela acima descreve o mapeamento, não um grupo de fábrica. Sem criar antes, oassign-user-to-groupacima 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 apelidosinstructors,teachereteacherstambém mapeiam paraTEACHER, 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ãodocker exec ... clinão existe, e toda receita acima é receita de máquina de desenvolvimento. Para operar produção, rode a CLI de um checkout do Keyrunes comDATABASE_URLapontando para o banco de produção. Escrever direto no schema por SQL é o último recurso: ocreate-groupfaz mais do que umINSERT, 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
usersegroups; sóorganizationsmora empublic. Consultarpublic.userspara conferir uma conta do namespacespacedevolve 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-superadminnão aceita--namespace(cli.rs): ele sempre cria nopublic. Um superadmin do LMS tem de ser criado comregister --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) |
Só HS256 é aceito — permitir RS256 junto abriria confusão de algoritmo, e
alg: none é rejeitado.
⚠️ Gaps conhecidos da integração
full_namenão existe no Keyrunes. É campo só do LMS, gravado na tabela local no registro. Um usuário criado direto pela CLI do Keyrunes ganhafull_name = <parte local do e-mail>no primeiro login.- O registro faz dois round-trips.
POST /api/registerdo Keyrunes não devolve token, então o endpoint registra e em seguida faz login para abrir a sessão. - O cadastro público não escolhe papel.
POST /auth/registerregistra sempre no grupo de aluno; umroleno corpo é descartado em silêncio, para não quebrar clientes antigos que ainda mandamrole="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. user["user_id"]do token é o id do Keyrunes, nãousers.id. Vários routers antigos passam esse valor direto para colunas que são FK deusers.id. Onde isso importa, use a dependênciaget_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_countelast_post_atsã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_dbsó 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.