Critérios de aprovação
- Peso entre 95 g e 105 g
- Cor azul ou verde
- Comprimento entre 10 cm e 20 cm
A peça entra em uma caixa somente se as três regras forem atendidas. Cada caixa fecha ao chegar em 10 peças.
Controle de qualidade
Cada peça é conferida antes de seguir na linha.
Conta da apresentação. Selecione o e-mail e a senha para copiar.
Operação
A peça entra em uma caixa somente se as três regras forem atendidas. Cada caixa fecha ao chegar em 10 peças.
| Peça | Peso | Cor | Comprimento | Resultado | Caixa |
|---|
Nenhuma peça encontrada com esse filtro.
Nenhuma caixa fechada ainda. A caixa fecha ao receber a 10ª peça aprovada.
Tecnologias
O Trabalho Unifecaf junta estas peças. Cada uma faz um trabalho só.
templates/index.html. O Flask chama render_template e entrega essa página na rota inicial.trabalho5. A conexão usa SSL com certificado CA.static/js/app.js, troca as telas e chama a API. Não há script escrito dentro do HTML.127.0.0.1:5000.Funções do sistema
O operador informa identificador, peso, cor e comprimento. O sistema confere os três critérios. Se os três passam, a peça entra na caixa que está aberta. Quando essa caixa chega a 10 peças, ela fecha e a próxima aprovada abre outra.
Se o peso, a cor ou o comprimento falham, a peça fica reprovada. Todos os motivos são guardados, não só o primeiro. Essa peça não entra em caixa. Remover apaga só a peça escolhida. O relatório conta, no fim, quantas falharam em cada critério.
O cadastro só segue com identificador, peso e comprimento numéricos e cor preenchida. Identificador repetido é recusado. A inspeção roda na hora, dentro de cadastrar_peca.
A caixa só é procurada quando a peça foi aprovada. Sem caixa aberta, o sistema abre a de número seguinte.
app/servicos.py
caixa = None
fechou = False
if aprovada:
caixa = _caixa_aberta(cur) or _abrir_caixa(cur)
O peso precisa ficar entre 95 g e 105 g. A cor, sem diferenciar maiúsculas, precisa ser azul ou verde. O comprimento precisa ficar entre 10 cm e 20 cm. Cada falha entra na lista de motivos.
A peça só é aprovada se essa lista termina vazia. Por isso peso, cor e comprimento errados ficam os três registrados.
app/qualidade.py
PESO_MIN = 95.0
PESO_MAX = 105.0
COMPRIMENTO_MIN = 10.0
COMPRIMENTO_MAX = 20.0
CORES_PERMITIDAS = ("azul", "verde")
CAPACIDADE_CAIXA = 10
Este é o teste da cor. Peso e comprimento seguem o mesmo formato: se saem da faixa, um motivo é acrescentado.
app/qualidade.py
if cor_normalizada not in CORES_PERMITIDAS:
permitidas = " ou ".join(CORES_PERMITIDAS)
motivos.append(
{
"criterio": "cor",
"descricao": f"Cor '{cor}' não permitida. Aceitas: {permitidas}.",
}
)
No fim, lista vazia significa aprovada.
app/qualidade.py
return len(motivos) == 0, motivos
A peça reprovada também é gravada, com os motivos. O campo da caixa fica vazio, porque não havia caixa para ela. Assim ela não ocupa vaga.
app/servicos.py
json.dumps(motivos, ensure_ascii=False) if motivos else None,
caixa["id"] if caixa else None,
Depois de guardar uma aprovada, o programa conta as peças daquela caixa. Se a conta chega à capacidade, que é 10, a caixa passa a fechada. A próxima aprovada abre outra.
app/servicos.py
if ocupacao >= int(caixa["capacidade"]):
cur.execute(
"""
UPDATE caixas
SET status = 'fechada', fechada_em = NOW()
WHERE id = %s
""",
(caixa["id"],),
)
remover_peca apaga somente a peça escolhida. A caixa continua no histórico, mesmo vazia.
app/servicos.py
cur.execute("DELETE FROM pecas WHERE id = %s", (identificador,))
Se a peça saiu de uma caixa já fechada e a quantidade ficou abaixo de 10, essa caixa reabre só quando não existe outra aberta. Se já houver caixa em enchimento, a fechada permanece fechada, para a linha não ter duas abertas ao mesmo tempo.
app/servicos.py
if peca["caixa_status"] == "fechada" and ocupacao < capacidade:
aberta = _caixa_aberta(cur)
if aberta is None:
cur.execute(
"""
UPDATE caixas
SET status = 'aberta', fechada_em = NULL
WHERE id = %s
""",
(peca["caixa_id"],),
)
O relatório percorre as peças reprovadas. Cada critério que aparece nos motivos soma um. A mesma peça pode contar em peso, cor e comprimento.
app/servicos.py
for peca in reprovadas:
criterios = {m["criterio"] for m in (peca.get("motivos") or [])}
for criterio in criterios:
if criterio in contagem:
contagem[criterio] += 1
Na subida, init_db liga no MySQL remoto com SSL e certificado CA. Cria o banco e as tabelas só se ainda não existirem. Peças, caixas, usuários e eventos gravados permanecem.
app/db.py
cur.execute(
f"CREATE DATABASE IF NOT EXISTS `{nome}` "
"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
Login e sessão
O operador envia e-mail e senha. O servidor confere o hash e, se estiver certo, devolve dois tokens. O de acesso dura 15 minutos e vem no JSON. O de renovação dura 7 dias e vai em cookie.
app/seguranca.py
ACCESS_SEGUNDOS = 15 * 60
REFRESH_DIAS = 7
A resposta leva o token curto no corpo e grava o longo no cookie.
app/auth.py
resposta = jsonify({
"message": "Login realizado com sucesso",
"erro": None,
"accessToken": acesso,
"expiresIn": ACCESS_SEGUNDOS,
"user": publico,
})
return _definir_refresh(resposta, refresh)
O JavaScript guarda o token de acesso na variável accessToken. Ela vive só na memória da aba. Cada chamada da API envia esse valor no cabeçalho Authorization: Bearer.
static/js/app.js
let accessToken = null;
Depois do login, o valor que veio no JSON entra nessa variável. Não é gravado em arquivo nem em armazenamento do navegador.
static/js/app.js
accessToken = corpo.accessToken;
static/js/app.js
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
O localStorage permanece no navegador e qualquer script da página consegue lê-lo. O token de acesso não é colocado lá. Ele fica só na variável, e some quando a aba fecha.
O token que dura 7 dias é o de renovação, e esse vai no cookie HttpOnly. O JavaScript não lê esse cookie. Assim um script da página não copia o token longo. O de acesso, mais curto, só existe enquanto a aba está aberta.
Se uma chamada da API responde 401, o navegador pede um token novo em /api/auth/refresh, enviando o cookie. O servidor devolve outro access token, de novo só para a memória. Se a renovação falha, a tela de login volta.
static/js/app.js
if (resposta.status === 401 && repetir && !url.includes("/api/auth/")) {
const renovou = await renovarSessao();
static/js/app.js
renovando = fetch("/api/auth/refresh", { method: "POST", credentials: "include" })
Medidas de segurança
A senha digitada não é gravada. O que vai para a coluna senha_hash é o resultado do bcrypt, com custo 12. Conferir a senha compara o que foi digitado com esse hash.
app/seguranca.py
def hash_senha(senha: str) -> str:
return bcrypt.hashpw(senha.encode(), bcrypt.gensalt(rounds=12)).decode()
Se o e-mail não existe, ou se o valor no banco não é um hash bcrypt, o programa ainda assim roda a comparação contra um hash fixo. A resposta não volta mais rápido nesse caso, e por isso não revela se a conta está cadastrada. E-mail desconhecido e senha errada recebem a mesma mensagem: credenciais inválidas.
app/seguranca.py
def conferir_senha(senha: str, senha_hash: str | None) -> bool:
material = (senha_hash or "").encode()
if not material.startswith(b"$2"):
material = _DUMMY
return bcrypt.checkpw(senha.encode(), material)
Cada senha errada de uma conta que existe soma uma falha. Na quinta, o usuário fica bloqueado por 15 minutos. Enquanto esse prazo não passa, o login responde que houve tentativas demais.
app/auth.py
if falhas >= 5:
cur.execute(
"""
UPDATE usuarios
SET falhas_login = %s, bloqueado_ate = DATE_ADD(NOW(), INTERVAL 15 MINUTE)
WHERE id = %s
""",
(falhas, usuario["id"]),
)
Além do bloqueio da conta, o mesmo e-mail só pode tentar o login 30 vezes em 15 minutos. Esse contador fica na memória do processo e é verificado antes da senha. Acima do limite, a resposta é 429.
app/seguranca.py
JANELA_LOGIN = 15 * 60
MAX_LOGIN = 30
app/seguranca.py
if len(recentes) >= MAX_LOGIN:
raise ErroOperacao(
"Muitas tentativas de login. Tente novamente em alguns minutos.",
429,
"RATE_LIMIT_EXCEEDED",
)
O cookie refreshToken não pode ser lido pelo JavaScript (HttpOnly). SameSite estrito evita que outro site envie esse cookie. Secure liga quando COOKIE_SECURE é verdadeiro ou, se essa opção não estiver definida, quando o ambiente é produção. O caminho do cookie é só /api/auth.
app/seguranca.py
def opcoes_cookie() -> dict:
return {
"httponly": True,
"secure": cookie_seguro(),
"samesite": "Strict",
"max_age": REFRESH_DIAS * 24 * 60 * 60,
"path": "/api/auth",
}
Cada renovação emite outro token e substitui o que está gravado no usuário. O cookie antigo deixa de coincidir com o banco, então não serve de novo.
app/auth.py
acesso, novo = emitir_tokens(usuario["id"])
cur.execute("UPDATE usuarios SET refresh_token = %s WHERE id = %s", (novo, usuario["id"]))
O token de renovação também carrega um resumo do navegador e do IP. Se essa origem muda, a sessão é encerrada.
Toda resposta leva estes cabeçalhos. A política de conteúdo só aceita script da própria origem. O frame é negado, o navegador não tenta adivinhar o tipo do arquivo, a referência enviada é restrita e câmera, microfone e localização ficam desligados. O HSTS, que obriga HTTPS, só sai quando o ambiente é produção.
app/seguranca.py
resposta.headers["X-Content-Type-Options"] = "nosniff"
resposta.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
resposta.headers["X-Frame-Options"] = "DENY"
resposta.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
resposta.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"font-src 'self' https://fonts.gstatic.com; "
"img-src 'self' data:; "
"connect-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'"
)
if producao():
resposta.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
Login certo, login inválido, bloqueio, conta inativa, sessão suspeita, saída e troca de senha geram uma linha em eventos_seguranca, com tipo, e-mail, IP e um detalhe curto.
app/auth.py
INSERT INTO eventos_seguranca (tipo, email, ip, detalhe)
VALUES (%s, %s, %s, %s)
A senha nova precisa ter pelo menos 8 caracteres, com letra maiúscula, letra minúscula e número. Ao salvar, o hash muda e o refresh é apagado: a sessão longa deixa de valer e é preciso entrar de novo.
A senha de avaliação trabalho123 não cumpre essa política, porque não tem maiúscula. Ela entra na conta semeada; uma senha nova, trocada na tela, precisa ser forte.
app/seguranca.py
if len(senha) < 8 or not any(c.isupper() for c in senha) or not any(c.islower() for c in senha) or not any(c.isdigit() for c in senha):
app/auth.py
cur.execute(
"UPDATE usuarios SET senha_hash = %s, refresh_token = NULL WHERE id = %s",
(hash_senha(nova), usuario["id"]),
)
Inclui 16 peças de demonstração (12 aprovadas e 4 reprovadas). Identificadores que já existem são mantidos. Nenhum dado atual é apagado.