Web application for managing personal protective equipment (PPE, EPI in Portuguese) in Brazilian workplaces: product catalogue, stock, assignment to employees, signed PDF receipts, and the compliance trail required by the NR-6 regulation and the eSocial S-2240 event.
A Brazilian employer must be able to prove, per employee and per item, which PPE was handed out, when, under which Certificado de Aprovação (CA) number, and when that CA expires. Most small operations track this in spreadsheets, which fall apart precisely when a labour inspection asks for the signed receipt. This system keeps the whole chain — product → stock movement → assignment → signed receipt → periodic inspection checklist — in one auditable place, and it derives the pending work (expiring CAs, low stock, overdue replacements, missing checklists) automatically.
It is built deliberately on the Python standard library: http.server for the HTTP layer and sqlite3 for storage. No web framework, no ORM, no message broker. The only runtime dependency is ReportLab, and only for generating PDF receipts. It starts from a single python run.py on any machine with Python 3.12.
| Dashboard | Register assignment |
|---|---|
![]() |
![]() |
| Products & stock | Kits |
|---|---|
![]() |
![]() |
Catalogue, stock and kits
- Product catalogue with manufacturer, cost, CA number and CA expiry date, sizes, lot, and INMETRO fields.
- Per-product stock with balance, low-stock and out-of-stock detection.
- Kits — named bundles of products handed out as a single withdrawal (
services/kits.py). - Risk matrix mapping a job role to the PPE it requires; the dashboard surfaces employees missing required items (
services/matrix.py).
Assignment and lifecycle
- Individual and multi-item (kit) withdrawals, each deducting stock in one transaction.
- Soft-delete of a withdrawal restores the stock it consumed and writes an audit record.
- Replacement prediction: from each product's post-delivery useful life (
lifespan_days) and the last delivery per employee, the system computes when a swap is due and flags overdue / due-soon items (services/lifecycle.py). - PDF delivery receipt for signature, with a per-document SHA-256 for tamper-evidence and the NR-6 / S-2240 citation (
pdf/receipts.py, via ReportLab). - Employee registry with CPF validation, including the two check digits (
services/employees.py,tests/test_cpf.py).
Compliance
- Periodic inspection checklists on a 90-day cycle with overdue detection:
is_checklist_overdue()/get_checklist_status()report how many days remain or how far past due the next inspection is (services/compliance.py). - PDF checklist upload, validated by magic bytes and size, stored under
checklists_uploads/. - Audit log of destructive operations (deleted withdrawals, products, kits) with an item snapshot (
services/audit.py).
Security (each item is exercised by the test suite)
- Passwords hashed with PBKDF2-HMAC-SHA256, 200k iterations, per-user random salt, compared with
secrets.compare_digest. On a login for a non-existent user a dummy hash is still verified, so the failed path costs the same time and cannot be used to enumerate accounts (core/auth.py,tests/test_login_timing_m06.py). - Login lockout with exponential backoff per
(IP, username)— a 30 s base doubling to a 15 min ceiling, both tunable viaEPI_LOGIN_LOCKOUT_BASE/EPI_LOGIN_LOCKOUT_MAX(tests/test_login_ratelimit_b05.py). - CSRF double-submit token on every mutating form; session and CSRF cookies are
HttpOnly; SameSite=Strict(tests/test_csrf_multi_form.py,tests/test_csrf_cookie_httponly.py). - Role-based access:
require_auth/require_admingate destructive routes deny-by-default (tests/test_route_security_rbac.py). - Strict Content-Security-Policy with a per-request nonce, plus HSTS,
X-Frame-Options: DENY,Permissions-PolicyandX-Content-Type-Options: nosniffon every response (core/responses.py). - Path-traversal guards on every file-serving route; PDF uploads are rejected unless they start with
%PDF-; backups are written0600in a0700directory (tests/test_backup_acl_b06.py). - Output escaping and JSON-in-HTML hardening against stored XSS (
tests/test_xss_c01.py,tests/test_xss_c02.py). - CPFs are masked (
111.***.***-35) by a logging filter before any record leaves the process — LGPD hygiene for logs (core/log_filters.py). ruffruns with theS(bandit) security ruleset enabled (pyproject.toml).
Operations
- A background daemon thread purges expired sessions and takes a consistent snapshot of the database via SQLite's online backup API, with retention of the N most recent copies — configurable by env, no cron required (
core/maintenance.py).
Frontend
- Server-rendered HTML, no build step, no JavaScript framework.
- Inter and FontAwesome are self-hosted under
static/; the app makes no external requests and works fully offline. - Keyboard command palette (
⌘K),g-prefixed navigation shortcuts, light/dark theme, and accessibility exercised by tests: modal focus trapping, focus restoration, form labelling and search delegation (tests/test_a11y_*.py).
| Layer | Choice |
|---|---|
| Runtime | Python ≥ 3.12 |
| HTTP | http.server.ThreadingHTTPServer (standard library) |
| Storage | SQLite via sqlite3, WAL mode, one connection per thread |
| ReportLab ≥ 4.2 — the only runtime dependency | |
| Lint / types | ruff (E, F, I, B, UP, SIM, C4, PIE, RET, S), mypy |
| Tests | pytest — 222 tests across 32 modules |
Three layers split by domain (products, stock, assignments, employees, kits, matrix, compliance, audit) rather than by technical role. app.py is a thin router; services/ holds business rules and owns every SQL statement; views/ renders HTML; core/ provides cross-cutting infrastructure.
run.py entry point — python run.py [port], default 8140
└── app.py routing, request dispatch, multipart parsing (ThreadingHTTPServer)
├── views/ HTML rendering, one module per domain
├── services/ business rules + all SQL, one module per domain
├── core/ auth (PBKDF2, CSRF, lockout), db, responses/CSP,
│ log masking, background maintenance
└── pdf/ signed-receipt generation (ReportLab)
Request flow:
flowchart LR
C[Browser] -->|GET / POST| A[app.py router]
A --> AU{require_auth / CSRF / require_admin}
AU -->|denied| R[302 login / 401 / 403]
AU -->|allowed| S[services/*]
S --> DB[(SQLite - thread-local conn, WAL)]
S --> V[views/* - HTML]
S --> P[pdf/receipts.py]
V --> RESP[core/responses.py<br/>CSP nonce + security headers]
RESP --> C
Two engineering decisions worth calling out:
- One SQLite connection per thread (
core/db.py, kept inthreading.local). A single shared connection withcheck_same_thread=FalseunderThreadingHTTPServerlets two threads enterwith conn:at once and silently corrupts transactions; the per-thread connection avoids it. - A custom multipart parser (
app.py, built onemail.parser) replacescgi.FieldStorage, which was removed in Python 3.13 — so file uploads keep working on current interpreters without adding a dependency.
Deliveries made individually (assignments) and via kit batches (withdrawal_items) are unified by a SQL view v_deliveries, so the employee PPE card and the replacement forecast read from one source without merging the two write paths.
Requires Python 3.12 or newer.
git clone https://github.com/leobaray/epi_system.git
cd epi_system
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt # ReportLab, for PDF receiptsQuick demo — load a realistic dataset (23 employees, 21 products, 6 kits, several hundred back-dated deliveries) and a dev admin whose random password is printed once:
python seed.py # WARNING: wipes operational tables, then reseeds demo data
python run.py # serves on http://localhost:8140From an empty database — set the bootstrap admin via env and skip the seed:
export EPI_ADMIN_USER=admin
export EPI_ADMIN_PASS='choose-a-strong-password'
python run.py # creates the admin on first start if the users table is emptyrun.py accepts an optional port: python run.py 9000. Authentication can be disabled for local development with EPI_AUTH_ENABLED=0.
Development extras and the test suite:
pip install -e ".[dev]"
pytest
ruff check .
mypy .| Variable | Default | Purpose |
|---|---|---|
EPI_AUTH_ENABLED |
1 |
Toggle authentication |
EPI_SESSION_TTL |
28800 |
Session lifetime (seconds) |
EPI_LOGIN_MAX_FAILURES |
5 |
Failures before lockout |
EPI_LOGIN_LOCKOUT_BASE / EPI_LOGIN_LOCKOUT_MAX |
30 / 900 |
Backoff base / ceiling (seconds) |
EPI_BACKUP_ENABLED / EPI_BACKUP_KEEP |
1 / 7 |
Automatic backups and retention |
EPI_COOKIE_SECURE |
0 |
Force the Secure cookie flag (auto when behind HTTPS) |
app.py HTTP handler, routing, multipart upload parsing
run.py entry point
seed.py demo-data loader + bootstrap admin (dev utility)
core/ auth, database, HTTP responses/CSP, log masking, maintenance
services/ business logic + SQL, one module per domain
views/ HTML rendering, one module per domain
pdf/ signed-receipt generation
static/ CSS, self-hosted Inter + FontAwesome
tests/ 32 modules — domain, security and accessibility
screenshots/ interface captures used in this README
checklists_uploads/ inspection-checklist template + uploads
Version 0.2.0. It is a functional, single-tenant application built for a real small-industry use case in Brazil, and it is honest about what it is not:
- Single tenant: one company per database. There is no multi-tenancy.
- No built-in TLS: it terminates plain HTTP and binds
0.0.0.0on purpose (LAN access from plant machines). Put it behind a reverse proxy with TLS if it is exposed beyond a trusted network — theSecurecookie flag and HSTS are already wired for that case. - CHECK constraints and
ON DELETErules apply to new databases only (SQLite cannot add them viaALTER TABLE); the service layer enforces the same invariants, and DB constraints are defence-in-depth. - The Content-Security-Policy still allows
script-src-attr 'unsafe-inline'for legacyonclickhandlers; migrating those to full event delegation is a known follow-up.
Released under the MIT License.



