save(file_data, relative_path) -> str # storage_key |
Persist file_data (bytes or file-like object) at and return the storage key used to retrieve it later</td>
</tr>
delete(storage_key) -> None |
Delete the file identified by storage_key from the active backend |
send(storage_key, download_name=None) |
Return a Flask response that streams the file as a download; download_name is the suggested filename |
exists(storage_key) -> bool |
Return True if a file with the given storage_key is present in the active backend |
</tbody>
</table>
activity_logger
FileActivityLogger | DbActivityLogger; pluggable via set_activity_logger()
Methods of activity_logger
| Signature |
Description |
log(action, category='system', details=None, user=None) -> None |
Append an audit entry with a UTC timestamp; details may be a string or a JSON-serialisable dict |
get_entries(limit=100, category=None, offset=0) -> list[dict] |
Return up to entries (newest first), optionally filtered by category and paginated by offset</td>
</tr>
</tbody>
</table>
scheduler
TaskScheduler — single in-process daemon thread running registered periodic jobs
Methods of scheduler
| Signature |
Description |
add_job(job_id, func, job_type='interval', interval=3600, time_str='03:00', description='') -> None |
Register a periodic job; job_type is 'interval' (every seconds) or 'daily' (at HH:MM local time)</td>
</tr>
get_jobs() -> list[dict] |
Return a snapshot of registered jobs including id, type, schedule, last_run, next_run, running flag and last_error |
</tbody>
</table>
invoice_service
InvoiceService — safe invoice CRUD with PAID-locking and PDF attachment helpers
Methods of invoice_service
| Signature |
Description |
get(invoice_id) -> Invoice | None |
Fetch an Invoice by primary key, or return None if it does not exist |
attach_pdf(invoice_or_id, file_data, original_filename=None) -> str # storage_key |
Store the PDF via core.storage, update pdf_hash and pdf_storage_key, and log the action; raises ValueError if the invoice is PAID with a sealed PDF |
currency_service
CurrencyService — exchange-rate API with default ECB provider and pluggable custom providers
Methods of currency_service
| Signature |
Description |
get_rate(from_currency, to_currency, date_str) -> (rate, actual_date) |
Return the rate where 1 from_currency = rate to_currency on date_str (YYYY-MM-DD); tries the active custom provider first, then falls back to ECB |
convert(amount, from_currency, to_currency, date_str) -> (converted_amount, rate, actual_date) |
Convert amount across currencies using get_rate; returns the converted amount, the rate applied and the date the rate was sourced from |
## Module lifecycle
`ModuleManager` runs each module through five phases on application startup. Each phase has a clearly bounded job, which keeps modules independent and makes failures easy to localise.
1. **discover** — scan the `modules/` directory for subdirectories that contain an `index.py`. The directory name becomes the candidate module id; modules disabled in settings are skipped before any code is imported.
2. **load** — import `modules..index`, locate the `BaseModule` subclass and instantiate it with a reference to the core. An exception in any module's `__init__` is logged and isolated; the rest of the application continues to start.
3. **register_models** — call `module.register_models(db)`. The module declares its own SQLAlchemy model classes against the shared `db` instance and returns them in a dict so the manager can expose them to other modules and migrations.
4. **register_routes** — call `module.register_routes(app)`. The module creates one or more Flask `Blueprint` objects (typically with a unique `url_prefix`) and registers them on the application. This is the only place modules touch URL routing.
5. **on_enable** — call `module.on_enable()`, which runs once after every module has registered routes. It is the right place for cross-module setup: registering periodic jobs with `scheduler.add_job`, adding navigation entries, or warming caches that depend on data owned by other modules.
## Capabilities
Modules expose typed extension points through capabilities. A producer returns a list of capability dicts from `get_capabilities()`; a consumer asks the `ModuleManager` for capabilities of a given `type` (and optionally `method`) and invokes them. This keeps integrations symmetric — neither side imports the other — and means new providers can be added by enabling a module, without changing existing code.
A capability is a plain dict with at least `type`, `method`, `name` and `action` keys, plus any provider-specific metadata. The example below shows a `pdf_signature` module exposing a visual signature capability and a consumer (e.g. an invoice route) finding and calling it:
```python
# Producer (in modules/pdf_signature/index.py)
def get_capabilities(self):
return [{
'type': 'pdf_sign',
'method': 'visual',
'name': 'Visual Signature',
'accepts': ['pdf'],
'action': self._sign_visual,
}]
# Consumer (any other module via core.module_manager)
signers = core.module_manager.find_capabilities('pdf_sign', method='visual')
for s in signers:
result = s['action']({'invoice_id': 42})
```
The same pattern is used for storage backends, activity logger backends, country-specific tax packs and PDF verifiers. `find_capabilities` returns an empty list when nothing is registered, so consumers handle "no provider available" as a normal case rather than an error.
## Building a minimal module
The shortest possible module is a class that subclasses `BaseModule`, declares an id and name, registers one model and registers one route. The example below lives at `docs/_includes/code/minimal_module.py` and is included verbatim so the snippet stays in sync with what is actually shipped:
```python
from module_manager import BaseModule
from flask import Blueprint, render_template
class HelloModule(BaseModule):
@property
def module_id(self):
return "hello"
@property
def name(self):
return "Hello"
def register_models(self, db):
class HelloNote(db.Model):
__tablename__ = "hello_note"
id = db.Column(db.Integer, primary_key=True)
text = db.Column(db.String(200), nullable=False)
self.HelloNote = HelloNote
return {"HelloNote": HelloNote}
def register_routes(self, app):
bp = Blueprint("hello", __name__, url_prefix="/hello")
@bp.route("/")
def index():
notes = self.HelloNote.query.all()
return render_template("hello/index.html", notes=notes)
app.register_blueprint(bp)
```
Drop this file into `modules/hello/index.py`, add an empty `modules/hello/__init__.py` and a `templates/hello/index.html`, and the next time the app starts `ModuleManager` will discover it, register the `HelloNote` table, and mount `/hello/` automatically.
## Authentication providers
Authentication in ContaAutónomo is pluggable. The core ships with `auth_routes.py`, which owns the `/auth/login/` and `/auth/callback/` URLs, but the actual identity verification is delegated to providers contributed by modules. A module advertises its providers by overriding `get_auth_providers()` on its `BaseModule` subclass and returning a list of provider objects (each implementing `id`, `display_name`, `start_login(request)` and `handle_callback(request)`).
Two provider families are supported out of the box:
- **OAuth providers** (Google, GitHub) — the module redirects the user to the provider's authorize URL during `start_login`, then exchanges the returned code for tokens during `handle_callback` and maps the verified email or `sub` claim to a local user.
- **SAML providers** — the module emits a SAML AuthnRequest in `start_login`, validates the signed assertion in `handle_callback` and creates or links the local user from the assertion attributes.
`auth_routes.py` keeps no provider-specific state itself: it asks `module_manager.find_capabilities('auth_provider')` for the registered providers, dispatches by `provider.id`, and renders the login page from whatever the modules contributed. Adding SSO for a new identity source is therefore a matter of writing a new module — the core does not change.
## Project structure
The repository follows a flat, predictable layout. Core files live at the root, modules live under `modules//`, and each module owns its templates and static assets. A trimmed two-level tree (with one sample module fully expanded) looks like this:
contaautonomo/
├── app.py
├── module_manager.py
├── auth_routes.py
├── auth.py
├── modules/
│ └── expenses/
│ ├── __init__.py
│ ├── index.py
│ └── templates/
│ └── expenses/
│ └── list.html
├── templates/
│ ├── base.html
│ └── index.html
├── static/
├── instance/
│ └── data.db
└── docker-compose.yml
The `instance/` directory holds the SQLite database and any uploaded files in the default `LocalStorageBackend`; it is gitignored. `docker-compose.yml` at the root brings the whole stack up with a single `docker compose up -d` for self-hosted deployments.
| | |