DentalPin Core API
Public contract that DentalPin modules (official + community) can rely on. Everything in this document is covered by the deprecation policy in §7 — change-without-notice is a bug.
Scope: Fase B complete. The patient/appointment/timeline split is reflected throughout; the clinical module no longer exists. Marked sections are additive; breaking changes bump the core version's major.
1. BaseModule
Located at app.core.plugins.BaseModule. Abstract class every module inherits from.
Class attributes
| Attribute | Type | Required | Purpose |
|---|---|---|---|
manifest | dict | yes (new modules) | Declarative metadata — see §2. |
Legacy path: modules may override name, version, and dependencies as properties. Prefer manifest.
Methods to implement
| Method | Returns | Notes |
|---|---|---|
get_models() | list[type[DeclarativeBase]] | SQLAlchemy classes to register. |
get_router() | APIRouter | Mounted at /api/v1/<name>/. |
get_permissions() | list[str] | Module-local strings; registry namespaces them. |
get_event_handlers() | dict[str, Callable] | Optional. |
Lifecycle hooks (async, optional)
| Hook | Signature | When |
|---|---|---|
install | async def install(self, ctx: ModuleContext) -> None | First install, after migrations + seed. |
uninstall | async def uninstall(self, ctx: ModuleContext) -> None | Before data delete + downgrade. |
post_upgrade | async def post_upgrade(self, ctx: ModuleContext, from_version: str) -> None | After upgrade migrations + re-seed. |
Default implementations are no-ops.
Derived
get_manifest() -> Manifest — parsed, validated dataclass. The registry uses this; modules rarely call it directly.
2. Manifest schema
Full schema. Fields validated at discovery time by Manifest.from_dict and at CI time by app.core.plugins.manifest_validator.
MANIFEST = {
# Identity — required
"name": "billing", # snake_case, unique
"version": "1.0.0", # semver X.Y.Z
# Compat
"min_core_version": "1.0.0", # optional
"max_core_version": "2.0.0", # optional
# Metadata — recommended
"summary": "Invoices + payments",
"author": "DentalPin Core Team",
"license": "BSL-1.1",
# Trust tier — required
"category": "official", # "official" | "community"
# Dependencies — required (list, may be empty)
"depends": ["patients", "catalog"],
# Policies — required booleans
"installable": True, # false = hidden from UI
"auto_install": True, # officials only
"removable": False, # officials are pinned
# Seed data — optional
"data_files": ["data/defaults.yaml"],
# RBAC declarative — recommended
"role_permissions": {
"admin": ["*"],
"dentist": ["*"],
"hygienist": ["read"],
},
# Frontend integration — optional
"frontend": {
"layer_path": "frontend", # community only
"navigation": [
{
"label": "nav.billing", # i18n key
"to": "/billing/invoices",
"icon": "i-lucide-receipt",
"permission": "billing.invoices.read",
"order": 40,
}
],
},
}3. ModuleContext
Passed to lifecycle hooks. Dataclass at app.core.plugins.ModuleContext.
| Field | Type | Purpose |
|---|---|---|
module_name | str | The calling module. |
db | AsyncSession | Scoped to the current unit of work. |
event_bus | EventBus | Publish events during install/upgrade. |
logger | logging.Logger | Pre-named app.modules.<name>. |
4. Event bus
Located at app.core.events.event_bus. Singleton.
from app.core.events import event_bus
event_bus.publish("inventory.restocked", {"item_id": "..."})
event_bus.subscribe("patient.created", my_handler)
event_bus.unsubscribe("patient.created", my_handler)Sync + async handlers supported. Async handlers are scheduled as background tasks. Exceptions are logged but do not propagate to publishers.
Core event catalog (stable)
All payload keys are strings. UUIDs are serialized as strings.
| Event | Payload keys |
|---|---|
patient.created | patient_id, clinic_id |
patient.updated | patient_id, clinic_id, changes |
patient.archived | patient_id, clinic_id |
patient.medical_updated | patient_id, clinic_id, user_id |
appointment.scheduled | appointment_id, patient_id, clinic_id |
appointment.updated | appointment_id, clinic_id, changes |
appointment.completed | appointment_id |
appointment.cancelled | appointment_id |
appointment.no_show | appointment_id |
treatment.completed | treatment_id, patient_id, clinic_id |
budget.created | budget_id, clinic_id |
budget.sent | budget_id, clinic_id |
budget.accepted | budget_id, clinic_id |
budget.rejected | budget_id, clinic_id |
invoice.created | invoice_id, clinic_id |
invoice.issued | invoice_id, clinic_id |
invoice.sent | invoice_id, clinic_id |
invoice.paid | invoice_id, clinic_id |
payment.recorded | payment_id, invoice_id, clinic_id |
document.uploaded | document_id, patient_id, clinic_id, title, document_type |
See backend/app/core/events/types.py for the full EventType enum.
5. Shared schemas
app.core.schemas:
ApiResponse[T] # {"data": T, "message": str | None}
PaginatedApiResponse[T] # adds total/page/page_size
ErrorResponse # {"data": None, "message": str, "errors": [...]}HTTP status conventions:
| Code | Use |
|---|---|
| 200 | GET / PUT success |
| 201 | POST create |
| 204 | DELETE |
| 400 | Validation-free bad request |
| 401 | Missing / bad token |
| 403 | Authenticated but not permitted |
| 404 | Not found |
| 409 | Conflict (duplicate) |
| 422 | Body validation failed |
6. Slots (frontend extension points)
Registered via useModuleSlots().register(name, entry) and consumed via <ModuleSlot :name="..." :ctx="..." />.
Canonical names (v1)
| Slot | Host | ctx shape |
|---|---|---|
patient.detail.tabs | /patients/:id | { patient: Patient } |
patient.detail.sidebar | /patients/:id | { patient: Patient } |
appointment.detail.actions | appointment modal | { appointment: Appointment } |
dashboard.widgets | / | {} |
settings.sections | /settings | {} |
Entry shape
interface SlotEntry<Ctx = unknown> {
id: string // unique, stable — enables HMR idempotence
component: Component // Vue component; defineAsyncComponent OK
order?: number // lower renders first
permission?: string // namespaced; hidden when can() is false
condition?: (ctx: Ctx) => boolean
}7. Importable core models
Safe to import from modules (stable across the v2 line):
from app.core.auth.models import Clinic, ClinicMembership, User
from app.modules.patients.models import Patient
from app.modules.agenda.models import Appointment, Cabinet
from app.modules.patient_timeline.models import PatientTimelinePatient moved out of core on purpose: it's a real module with its own manifest, version and migration surface (auto_install: True, removable: False). A lab-oriented fork could replace it with a lab_clients module without forking core.
8. ModuleService
app.core.plugins.ModuleService. Public methods:
| Method | Purpose |
|---|---|
reconcile_with_db() | Sync in-memory registry to core_module. |
list_modules() | Combined disk + DB view. |
get_info(name) | Single module details. |
status() | Summary (counts by state, pending, errored). |
doctor() | Diagnostic checks (orphans, missing deps, manifest errors). |
orphan(name) | Mark a missing-from-disk module as uninstalled. |
install(name, force=False) | Schedule install; returns dependency chain. |
uninstall(name, force=False) | Schedule uninstall; blocks legacy + reverse-dep. |
upgrade(name) | Schedule upgrade when manifest version diverges. |
All methods are async. ModuleOperationError wraps policy violations (blocked uninstall, unknown module, etc.).
9. Module lifecycle REST
All routes under /api/v1/modules.
| Method | Path | Permission | Notes |
|---|---|---|---|
| GET | /modules | admin.clinic.read | Full list including DB state. |
| GET | /modules/-/active | authenticated | Filtered for current role. |
| GET | /modules/-/status | admin.clinic.read | Counts + pending. |
| GET | /modules/-/doctor | admin.clinic.read | Diagnostic report. |
| GET | /modules/{name} | admin.clinic.read | Single module. |
| POST | /modules/{name}/install | admin.clinic.write | 202 + scheduled list. |
| POST | /modules/{name}/uninstall | admin.clinic.write | 202 + restart required. |
| POST | /modules/{name}/upgrade | admin.clinic.write | 202 if version changed. |
| POST | /modules/-/restart | admin.clinic.write | 202 + SIGTERM to self. |
10. Deprecation policy
Until DentalPin v1.0 is tagged:
- Breaking changes can land with an announcement in the CHANGELOG.
From v1.0 onwards:
- Additive changes (new fields, new slots, new events) are shipped freely at minor versions.
- Breaking changes require:
- A deprecation warning that fires at runtime for at least one minor release.
- A
CHANGELOG.mdentry describing the migration path. - A major version bump when removed.
- Unannounced breaking changes are bugs and will be reverted.
Modules that declare min_core_version get an error at install time if the current core is older. Use this to require new slots / events.
11. Versioning the core
The core itself follows semver. Modules read the current version from app.__version__ (placeholder until v1.0 tag). After v1.0, the CI pipeline checks every PR touching backend/app/core/ for:
- Added events → record in this document + CHANGELOG.
- Removed or renamed events/slots → require deprecation window.
- Changed
Manifestschema → migration path documented.
Where to go next
docs/technical/creating-modules.md— the full authoring guide.docs/user-manual/operations.md— install/restart/backup/troubleshooting for admins.backend/tests/fixtures/sample_module/— minimal working example.