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 and lot — 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.
  • Pre-configured kits per role (welder, mechanic, electrician).
  • Compliance dashboard: CA expiration alerts, NR-6 90-day inspection cycle, low and out-of-stock thresholds, daily and monthly aggregates.
  • Audit trail preserved for the 20 years required by NR-6 (soft-delete + JSON snapshot of every deletion).
  • 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.

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 36 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-v4
│   ├── log_filters.py          # CpfMaskFilter (LGPD)
│   └── responses.py            # html_page, html_page_bare, security headers, CSP nonce
│
├── services/                   # domain logic (pure-ish, take a conn)
│   ├── products.py             # CRUD + soft-delete
│   ├── employees.py            # CRUD + normalize_cpf + validate_cpf_format
│   ├── stock.py                # add_stock, update_stock
│   ├── assignments.py          # assign_epi, assign_multiple_epis (atomic)
│   ├── kits.py                 # role-based PPE bundles
│   ├── compliance.py           # NR-6 90-day cycle
│   ├── audit.py                # deletion_audit reader
│   └── dashboard.py            # aggregates, shell_status
│
├── 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

Run tests:

pytest                       # 36 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