Skip to content

leobaray/epi_system

Repository files navigation

epi.manager

Python 3.12+ Tests

Industrial PPE (Personal Protective Equipment) management system. NR-6 and eSocial S-2240 compliant. Single Python process, no web framework. In production at a Brazilian torque-converter remanufacturing plant since September 2025.


Why this exists

Brazilian labor regulation NR-6 requires every employer to (a) issue role-appropriate PPE to every worker, (b) record each delivery with the employee's signature, and (c) preserve that record for 20 years (TST jurisprudence). Without that trail under a Ministry of Labor audit, the company is exposed to uncapped labor-court damages and operational shutdown.

Off-the-shelf options on the Brazilian market are heavyweight ERP add-ons priced for companies the plant does not have. epi.manager is one Python process and one SQLite file on a LAN, sized for the team that uses it.


Demo

Dashboard — stock alerts, NR-6 status, monthly aggregates

Dashboard

Catalog — products with Certificate of Approval (CA), stock indicators

Products

PPE delivery — single-item form with signed-PDF generation on submit

New withdrawal

Withdrawal history — grouped by employee, soft-delete with audit reason

History

Pre-configured kits — common roles (welder, mechanic, electrician)

Kits

Login — session + CSRF, no third-party auth provider

Login


Features

  • PPE catalog with Certificate of Approval (CA), validity windows, manufacturer, lot and per-product service life — CA tracking is required by NR-6.
  • Employees with role, registration number and CPF.
  • PPE delivery, single item or multi-item batch, with a signed PDF per delivery. The whole batch rolls back if any item is short.
  • EPI lifecycle, proactively: from each product's service life and the last delivery, the system computes the replacement-due date per worker and surfaces overdue/upcoming swaps on its own.
  • Per-worker EPI card: everything a worker currently holds, with replacement status and one-click re-issue.
  • Function PPE matrix: the mandatory PPE of each role; the system flags by itself which active workers are missing a required item or holding an expired one.
  • Coverage-based stock health: "low" / "buy now" is measured in days of coverage (stock ÷ average consumption), not a fixed quantity — so it adapts to how much each item is actually used (5 helmets ≠ 5 ear plugs). Optional per-product manual minimum as a fallback when there is no consumption history yet. Auto-reorder also suggests how much to buy.
  • Pre-configured kits per role (welder, mechanic, electrician).
  • Compliance dashboard with a "today's pending actions" hub: replacement swaps, function gaps, reorder needs, CA expiration alerts, NR-6 90-day inspection cycle, stock thresholds, daily and monthly aggregates.
  • Audit trail preserved for the 20 years required by NR-6 (soft-delete + JSON snapshot of every deletion).
  • Self-maintenance: a background daemon purges expired sessions and snapshots the database (online backup, rotated) — no operator action.
  • Keyboard-first UI: ⌘K command palette, g + letter navigation, theme toggle.

Technical notes

  • No web framework. http.server.ThreadingHTTPServer from stdlib. Routing is a flat if/elif ladder in app.py:do_GET/do_POST, ~700 lines readable in one sitting. Trade-off discussed below.
  • Custom multipart parser. Python 3.13 removed cgi.FieldStorage; MultipartForm in app.py (~60 lines) reimplements it on top of email.parser from stdlib. No new runtime dependency.
  • Thread-safe SQLite via threading.local() connection-per-thread + WAL. Replaces an earlier singleton with check_same_thread=False, a documented anti-pattern under ThreadingHTTPServer (write-up in CRITICA.md).
  • Atomic stock decrement under concurrent withdrawal: UPDATE stock SET qty = qty - ? WHERE qty >= ? plus a rowcount == 0 check inside the transaction. TOCTOU coverage in tests/test_assignments.py uses a monkey-patched saboteur to force a depletion between check and update.
  • CSP with per-request nonce on inline <script>. script-src-attr 'unsafe-inline' (CSP3) is a known compromise for legacy onclick= attributes and is scheduled for removal once the inline JS is extracted.
  • CSRF double-submit cookie, ~30 lines, secrets.compare_digest.
  • Password hashing: PBKDF2-SHA256, 200k iterations, 16-byte per-user salt. Sessions persisted in SQLite (survive restart, not bound to a single worker).
  • CPF / LGPD: normalized to 11 digits at the service boundary, CHECK (cpf GLOB '[0-9]...') at the schema, CpfMaskFilter rewriting 11144477735111.***.***-35 in every log record before it leaves the process.
  • Soft-delete with snapshot. products.deleted_at + JSON snapshot in deletion_audit. Hard-delete would violate the 20-year retention requirement.
  • Defense-in-depth schema. CHECK constraints on every table (enums, qty > 0, cost ≥ 0, ISO date format, CPF format), ON DELETE RESTRICT/SET NULL/CASCADE per relationship intent. Direct writes via the SQLite CLI still respect the invariants.
  • Unified delivery view. v_deliveries folds individual withdrawals (assignments) and kit/batch items (withdrawal_items) into one source, so the per-worker EPI card and the replacement-due math see every delivery without having to unify the underlying tables first. Replacement date is last delivery + lifespan_days.
  • Background self-maintenance. A daemon thread purges expired sessions and takes a consistent online backup of the database (sqlite3 backup API, WAL-safe), keeping the last N rotated copies. Runs off the request hot path; fully env-configurable.

Architecture

flowchart TB
    Browser[Browser]
    Server[ThreadingHTTPServer<br/>:8140]
    Handler[EpiRequestHandler<br/>do_GET / do_POST<br/>~1.1k lines]
    Auth[core/auth.py<br/>session + CSRF + PBKDF2]
    Multipart[MultipartForm<br/>email.parser<br/>custom]
    Services{{services/}}
    DB[(SQLite + WAL<br/>12 tables)]
    PDF[pdf/receipts.py<br/>ReportLab]
    Audit[(deletion_audit<br/>20-yr trail)]
    Logs[CpfMaskFilter<br/>LGPD-safe logs]

    Browser -->|HTTP on LAN; TLS planned| Server
    Server --> Handler
    Handler --> Auth
    Handler --> Multipart
    Handler --> Services
    Services --> DB
    Services --> Audit
    Services -->|out of tx| PDF
    Auth --> DB
    Services -.-> Logs

    subgraph "services/"
        S1[products]
        S2[employees]
        S3[stock]
        S4[assignments]
        S5[kits]
        S6[compliance]
        S7[audit]
        S8[dashboard]
    end
Loading

A request flow for the PPE delivery hot path:

POST /assign
  → require_auth() — session lookup in SQLite
  → validate_csrf() — double-submit cookie check
  → svc_assignments.assign_epi(conn, product, employee, qty)
       → normalize_cpf(employee_cpf)              # 111.444.777-35 → 11144477735
       → BEGIN TRANSACTION
            → check employee exists
            → UPDATE stock SET qty = qty - ? WHERE qty >= ?
              if rowcount == 0: raise (rollback)
            → INSERT INTO assignments
            → UPDATE pdf_filename
       → COMMIT
       → generate_pdf(...) outside transaction
  → 303 redirect → /assignments?msg=Delivery+recorded

Engineering decisions

Why no web framework

Flask or FastAPI would have saved ~300 lines and added one dependency. Stdlib won because (a) the deploy target is one industrial PC with cautious IT, (b) the request handler is a one-afternoon read for the next engineer, and (c) framework abstractions cost more than they save at this size. The trade-off is real: no worker pool, no graceful reload, no battle-tested middleware. Migration to Flask + Jinja2 is documented in CRITICA.md, gated on >5 concurrent users or non-LAN exposure.

Why a custom multipart parser

Python 3.13 removed cgi.FieldStorage. Most projects pin <3.13 or pull in python-multipart; this one reimplemented it on top of email.parser from stdlib in ~60 lines. No new dependency, and the upload path stayed working through the Python upgrade.

Why server-rendered HTML

No build step, no JS framework, no SPA hydration. The plant has 50 Mbps shared internet and a Windows PC running the server. Server-rendered pages stay under 50 KB and avoid a class of bugs (client/server state desync). Cost: every UI change is a Python deploy.

Why custom auth instead of a library

authlib, flask-login and friends would have been faster to write, but each is a dependency to track for CVEs over the product's 20-year lifetime. PBKDF2 is in stdlib, sessions are ~30 lines of SQLite and CSRF double-submit is another ~30. The whole auth surface lives in one file and reviews end-to-end.


Tech stack

Layer Choice Notes
Language Python 3.12+ stdlib-first
HTTP http.server.ThreadingHTTPServer no Flask / FastAPI
Persistence sqlite3 + WAL + threading.local thread-safe, single file
Templates f-string in Python server-rendered, no build step
PDF ReportLab only third-party runtime dep
Auth PBKDF2-SHA256, sessions in SQLite, CSRF double-submit stdlib only
Tests pytest + requests 105 tests, in-process server fixture
Lint ruff strict config in pyproject.toml
CSS hand-rolled tokens, ~1.2k lines no build step, dark/light themes

Project structure

epi_system/
├── app.py                      # HTTP routing, multipart parser, server bootstrap (~1.1k LOC)
├── run.py                      # entrypoint
├── pyproject.toml              # deps + ruff + pytest config
│
├── core/
│   ├── auth.py                 # login, sessions in SQLite, CSRF, PBKDF2 hashing
│   ├── db.py                   # threading.local conn, schema, migrations v1-v6, v_deliveries view
│   ├── log_filters.py          # CpfMaskFilter (LGPD)
│   ├── maintenance.py          # background daemon: session purge + DB online backup
│   └── responses.py            # html_page, html_page_bare, security headers, CSP nonce
│
├── services/                   # domain logic (pure-ish, take a conn)
│   ├── products.py             # CRUD + soft-delete + service life
│   ├── employees.py            # CRUD + normalize_cpf + validate_cpf_format
│   ├── stock.py                # add_stock, update_stock, coverage-based stock health
│   ├── assignments.py          # assign_epi, assign_multiple_epis (atomic)
│   ├── kits.py                 # role-based PPE bundles
│   ├── lifecycle.py            # replacement-due math, per-worker EPI card
│   ├── matrix.py               # function PPE matrix + automatic gap detection
│   ├── compliance.py           # NR-6 90-day cycle
│   ├── audit.py                # deletion_audit reader
│   └── dashboard.py            # aggregates, shell_status, reorder suggestions
│
├── views/render.py             # server-rendered templates (~1.6k LOC)
├── pdf/receipts.py             # ReportLab PDF generation
├── static/app.css              # design system tokens + components (~1.2k LOC)
│
├── tests/                      # 36 tests, isolated_db + in-process server fixtures
├── screenshots/                # documentation assets
├── CRITICA.md                  # self-imposed technical audit (severity-ranked)
└── README.md                   # this file

Getting started

Requirements: Python 3.12+ on Linux, macOS, or Windows. ~50 MB disk.

git clone https://github.com/leobaray/epi_system.git
cd epi_system

# Runtime deps (only ReportLab)
pip install -r requirements.txt

# Dev deps (ruff, pytest, requests)
pip install -e ".[dev]"

# Bootstrap an admin and run
EPI_ADMIN_USER=admin EPI_ADMIN_PASS=changeme python run.py
# → http://localhost:8140/

Environment:

Variable Default Purpose
EPI_AUTH_ENABLED 1 0 to disable auth (LAN-only setups)
EPI_SESSION_TTL 28800 Session lifetime in seconds (8h)
EPI_COOKIE_SECURE 0 Force Secure flag on session cookie
EPI_ADMIN_USER / EPI_ADMIN_PASS First-run admin bootstrap
EPI_BACKUP_ENABLED 1 0 to disable the automatic DB backup
EPI_BACKUP_INTERVAL 86400 Minimum seconds between backups (24h)
EPI_BACKUP_KEEP 7 How many rotated backups to retain
EPI_MAINTENANCE_INTERVAL 3600 Session-purge / maintenance loop period

Backups at rest. Each snapshot is a full copy of the database and therefore contains PII (CPFs, names), password hashes and sessions. The backups/ directory and its files are created with restrictive permissions (0o700 dir / 0o600 files on POSIX; on Windows, ACLs follow the host/process account). backups/ is git-ignored. Retention is the last EPI_BACKUP_KEEP rotated copies (default 7). For stronger postures, store the directory on an encrypted volume or add at-rest encryption at the host level — kept out of the app to avoid bundling a crypto dependency.

Run tests:

pytest                       # 105 tests
pytest --cov=services --cov=core --cov-report=term-missing

Scale & impact

In production at a single industrial site since 2025-09:

  • 23 active employees, 21 PPE products in catalog, 854 units in stock.
  • Every individual withdrawal — including high-turnover consumables like ear plugs and gloves — generates a signed PDF.
  • Replaces a paper-and-spreadsheet workflow that left the site with no per-delivery PPE trail and out of compliance with NR-6.

In the first month in production, an auditor asked for the full PPE delivery history of one specific employee. The system produced the report in seconds; before, that record did not exist anywhere.


What I would do differently

After several months in production, four items stand out. The full list with severity is in CRITICA.md.

  • Extract a thin routing layer. The flat if/elif ladder in app.py worked while the URL space was small, but it has crossed the point where adding a route requires reading 50 lines around it for context. A ~30-line decorator-based router (no Flask dependency) is on the roadmap.
  • Async PDF generation. PDF generation runs synchronously outside the request transaction. Under bursty multi-user delivery (a full-shift kit assignment), it can block a request for 200–500 ms. An outbox table + worker would smooth that out.
  • Replace f-string templates with Jinja2. Manual safe_escape() is XSS-prone by construction in any non-trivial template. CSP buys time; Jinja2.Environment(autoescape=True) is the right long-term fix.
  • Move from raw sqlite3 to SQLAlchemy + Alembic — not for the ORM (queries are simple), but for versioned migrations. The current ad-hoc block in core/db.py does not handle column renames.

About the author

Solo developer, 21, Curitiba, Brazil. Built epi.manager in personal time as the only software engineer at the company that uses it.

Other projects in the same period:

  • cnn — visual identification of automotive torque-converter models from a phone photo. Jetpack Compose Android client + PyTorch backend over FastAPI.
  • magnum-site — the company's corporate site (Portuguese SEO #1 for several torque-converter terms).
  • A RAG system over ~3,500 internal industrial documents (~45 pages each) with a "tutor mode" that adapts vocabulary to the user's technical level. Internal, no public repo.

Stack across projects: Python, Dart, Kotlin, TypeScript.

Looking for remote engineering roles with international teams.


Licensed under the MIT License — see LICENSE.

About

Python stdlib HTTP industrial PPE manager: ThreadingHTTPServer + threading.local SQLite handle concurrent writes, ReportLab generates NR-6 / eSocial S-2240 compliant signed PDF receipts

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors