A multi-tenant, role-based CRM for trading/export businesses: each company gets its own fully separate employees, leads, clients, product catalog, quotations, and business profile. Employees generate and work leads, admins approve leads into clients, generate quotations, track payments (auto-converted to INR), documents, and see per-employee performance.
# 1. Install dependencies
pip install -r requirements.txt
# 2. Create the database + your first company and its first admin login
python seed.py
# -> follow the prompts (company name / username / full name / password)
# 3. Run the app
python run.py
# -> open http://127.0.0.1:5000, pick your company, and log inThat's it — the SQLite database is created automatically on first run at
instance/crm.db. No external database server is required.
Each company is a fully independent workspace (own employees, leads, clients, catalog, quotations, business profile). Adding one is a deliberate CLI step, not an in-app action:
python seed.py --new-company "Acme Exports"
# -> follow the prompts to create that company's first adminUsernames only need to be unique within a company — two different companies can each have an "admin".
Copy .env.example to .env and adjust as needed (secret key, host/port,
database path). See the comments in config.py for every available
setting, including the fallback currency-conversion rates used when there
is no internet connection.
| Employee | Admin | |
|---|---|---|
| Generate a new lead | ✅ | ✅ |
| Edit a lead/client's compulsory fields (company name, phone, email, client type) | ❌ | ✅ |
| Add contacts / log communications / change pipeline status on their own leads | ✅ | ✅ (any lead in their company) |
| Record client payments, documents, communications | ✅ | ✅ |
| Approve a lead → convert it to a client | ❌ | ✅ |
| Generate a quotation (for a lead, synced automatically to the client once converted) | ✅ | ✅ |
| Browse the product catalog | ✅ | ✅ |
| Manage the product catalog (add/edit/delete groups & products) | ❌ | ✅ |
| Change their own username / password | ✅ | ✅ |
| Change any employee's username / lock an employee's account | ❌ | ✅ |
| See every employee's lead & communication counts | ❌ | ✅ |
| Edit "Our Company" profile (their own company's GSTIN/PAN/bank/LUT) | ❌ | ✅ |
| Create new logins, run reports | ❌ | ✅ |
Every one of these checks is enforced server-side in the service layer (never just by hiding a button), and every record fetch also verifies it belongs to the current user's own company before returning it.
This mirrors the brief exactly: "Any changes to compulsory fields must be done by admins only" — everything else (contacts, communications, status, payments, documents) can be added by the employee working the account.
TENANT (company/workspace, picked at login)
└─ everything below belongs to exactly one tenant
LEAD
├─ company_name, phone, email (compulsory)
├─ facebook, instagram, other_social (optional)
├─ contact person(s) (1+ required, multiple allowed)
├─ communications (employee, date, mode, notes, optional follow-up)
├─ quotations (0+, see QUOTATION below)
└─ status: new → in_communication → in_follow_up → long_follow_up → quotation_submission_pending → in_client
│ admin approves
▼
CLIENT (created from an approved lead — carries every field + contact across)
├─ status: proforma invoice / purchase order / purchase invoice /
│ export invoice / commercial invoice — submission pending
├─ client_type: Supplier / Exporter / Buyer (Buyer is default)
├─ payment history (account, datetime, amount + currency, auto-converted to INR)
├─ documents (manually recorded metadata, plus every quotation made
│ against the originating lead - shown together)
└─ communications (same shape as a lead's)
QUOTATION (auto-numbered QT{YYYYMMDD}{seq}, one product-line table + a
header of shipping/bank/payment terms)
├─ linked to a lead (optional) - a converted client's quotations are derived
│ from its originating lead, not stored twice
├─ line items pulled from the product catalog, or typed free-hand
└─ invoice value = subtotal + sea freight + insurance + certification +
other charges − discount, printed with an amount-in-words line
PRODUCT CATALOG (folder tree, admin-managed, employee-browsable)
└─ groups nest to any depth; each group holds subgroups and/or products
OUR COMPANY (one row per tenant, admin-only)
├─ company_name, GSTIN, PAN, IEC, BIN
├─ LUT(s) (multiple, one per financial year)
├─ contact details (multiple phones/emails, one of each required)
├─ contact person(s) (multiple, one required)
└─ bank detail(s) (multiple, one required)
The codebase is layered so that SQL lives in one place, business rules live in another, and HTTP handling lives in a third — this is what the brief's "strictly follow SOLID principles" translates to in practice:
app/routes/*.py HTTP layer — parses requests, calls a service, flashes
the result. Contains no SQL and no business rules.
│
▼
app/services.py Business rules layer — "company name is compulsory",
"only an admin can edit compulsory fields", currency
conversion, employee/lead/communication counting,
cross-tenant ownership checks.
Depends on repository ABCs, not on SQLite directly.
│
▼
app/repositories.py Persistence layer — one class per entity
(LeadRepository, ClientRepository, TenantRepository,
...), each behind an abstract base class. This is the
only layer that writes SQL.
│
▼
app/database.py The only module that imports `sqlite3`.
How this maps to SOLID:
- Single Responsibility — each class in each layer does exactly one job
(e.g.
CurrencyServiceonly converts currency;SqliteLeadRepositoryonly persists leads). - Open/Closed — new lead/client statuses, currencies, or report types are added by extending a list/method, not by editing unrelated code.
- Liskov Substitution —
communicationsis one table/class used identically for both leads and clients (parent_typediscriminator), so any code that logs a communication works the same regardless of parent. - Interface Segregation —
UserRepositoryBase,LeadRepositoryBase,ClientRepositoryBaseare small and specific, not one giant interface. - Dependency Inversion — services take repository abstractions as
constructor arguments (see
app/__init__.py'sServiceContainer, the composition root). Swapping SQLite for PostgreSQL later means writing a newPostgres*Repositoryset and changingServiceContaineronly — routes, services, and templates never change.
Every root entity (users, leads, clients, quotations, the product catalog,
each company's own business profile) carries a company_id. Everything
else (contacts, communications, payments, documents, quotation line items)
is scoped transitively through its parent, so nothing is duplicated. Any
get_by_id-style repository lookup is a raw, unscoped PK lookup by design —
the owning service method is responsible for asserting the fetched
row's company_id matches the current user's before using it (raising a
404-style "not found" rather than a 403, so a stray ID never confirms that
another company's record exists). See LeadService.get,
ClientService.get, QuotationService.get, and ProductService.get_group
/get_product for the pattern.
crm_app/
├── run.py # entry point: python run.py
├── seed.py # create a company + its first admin (see "Adding another company" above)
├── config.py # all settings, loaded from .env
├── requirements.txt
├── app/
│ ├── __init__.py # app factory + composition root (wires everything together)
│ ├── database.py # the only file that knows this is SQLite; also owns schema migrations
│ ├── schema.sql # full table definitions
│ ├── models.py # plain data classes (Tenant, User, Lead, Client, Quotation, ...)
│ ├── repositories.py # persistence layer, one class per entity
│ ├── services.py # business rules layer
│ ├── exceptions.py # ValidationError / PermissionDeniedError / NotFoundError
│ ├── utils.py # @login_required / @admin_required + template filters (incl. amount-in-words)
│ ├── routes/ # one blueprint per area (auth, leads, clients, admin, company, products, quotations, reports, profile)
│ ├── templates/ # Jinja2 templates ("Trade Ledger" visual design)
│ └── static/css/style.css # the whole design system in one file
└── instance/
├── crm.db # created automatically on first run (not in git)
└── backups/ # manual pre-migration snapshots (not in git)
Payments must be entered in a currency other than INR. When a payment
is recorded, CurrencyService:
- Tries a live, no-API-key exchange rate lookup
(
https://api.frankfurter.app/latest). - If there's no internet connection, falls back to the static rates in
config.py→FALLBACK_RATES_TO_INR(update these occasionally by hand if you're running the app offline for long periods).
Either way, the exact rate used is stored alongside the payment, so every INR figure is always auditable.
Straight from the brief, in the order they make sense to build:
- Admin dashboard tracking employee history & company records — done
- Employee dashboard with follow-up notifications — done
- Basic monthly/quarterly/yearly activity reports — done (
/reports) - Product catalogue — done (folder-tree, admin-managed, browsable from the quotation builder)
- Auto-generating documents from client + product data — done for quotations (
/quotations); other document types (proforma invoice, export/commercial invoice) are still metadata-only under a client's Documents card and should follow the same lead-linked pattern quotations use when built - Self-service password / username change — done (
/account) - Multiple independent companies on one install — done (see "Adding another company" above)
- Moving each document type to its own dedicated database/table set as volume grows
- Overdue-payment notifications (needs an expected-payment/due-date field, which doesn't exist yet — payment_history currently only records payments received)
- Real file storage for documents (currently metadata only: name/type/date/notes)
- In-app company management (currently CLI-only via
seed.py)
- Passwords are hashed with Werkzeug's
generate_password_hash(scrypt) — never stored in plain text. - Every write endpoint re-validates permissions server-side in the service layer, not just by hiding buttons in the template — so there's no way to bypass a compulsory-field-edit restriction by posting the form URL directly. The same layer also enforces the multi-tenancy boundary — see "Multi-tenancy" above.
- The
ContactRepositoryclass is intentionally shared by both leads and clients (same table shape, different table name) instead of copy-pasted — seeapp/repositories.py. - All list/detail templates read from
LEAD_STATUSES/CLIENT_STATUSES/CLIENT_TYPES/COMMUNICATION_MODES(injected into every template viaapp/__init__.py's context processor) — add a new status or mode there and it shows up everywhere automatically. app/database.py's_migrate()is where every schema change since launch lives (added columns, rebuilt tables when a constraint changed, the multi-tenancy backfill). It's idempotent and safe to run on every startup — new migrations should follow the same guarded,PRAGMA table_info-checked pattern already there.- Schema version + backups.
app/database.pydefinesSCHEMA_VERSIONand stamps it onto every DB viaPRAGMA user_version. Any admin can download a full snapshot (SQLite DB + product images, one ZIP) and restore one from the Database Backup page (app/routes/backup.py+BackupServiceinapp/services.py). Because_migrate()runs on every restore, an older backup is carried forward automatically — so every future restructure must bumpSCHEMA_VERSIONand add a data-preserving step to_migrate()(never drop rows) to keep old backups integrable. See the comment aboveSCHEMA_VERSIONfor the exact recipe.