Skip to content

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 ​

AttributeTypeRequiredPurpose
manifestdictyes (new modules)Declarative metadata — see §2.

Legacy path: modules may override name, version, and dependencies as properties. Prefer manifest.

Methods to implement ​

MethodReturnsNotes
get_models()list[type[DeclarativeBase]]SQLAlchemy classes to register.
get_router()APIRouterMounted 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) ​

HookSignatureWhen
installasync def install(self, ctx: ModuleContext) -> NoneFirst install, after migrations + seed.
uninstallasync def uninstall(self, ctx: ModuleContext) -> NoneBefore data delete + downgrade.
post_upgradeasync def post_upgrade(self, ctx: ModuleContext, from_version: str) -> NoneAfter 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.

python
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.

FieldTypePurpose
module_namestrThe calling module.
dbAsyncSessionScoped to the current unit of work.
event_busEventBusPublish events during install/upgrade.
loggerlogging.LoggerPre-named app.modules.<name>.

4. Event bus ​

Located at app.core.events.event_bus. Singleton.

python
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.

EventPayload keys
clinic.createdclinic_id, country, currency, timezone, language, vat_preset, created_by, source — published by POST /auth/setup after commit; subscribers seed defaults idempotently in their own session (ADR 0018)
patient.createdpatient_id, clinic_id
patient.updatedpatient_id, clinic_id, changes
patient.archivedpatient_id, clinic_id
patient.medical_updatedpatient_id, clinic_id, user_id
appointment.scheduledappointment_id, patient_id, clinic_id
appointment.updatedappointment_id, clinic_id, changes
appointment.completedappointment_id
appointment.cancelledappointment_id
appointment.no_showappointment_id
treatment.completedtreatment_id, patient_id, clinic_id
budget.createdbudget_id, clinic_id
budget.sentbudget_id, clinic_id
budget.acceptedbudget_id, clinic_id
budget.rejectedbudget_id, clinic_id
invoice.createdinvoice_id, clinic_id
invoice.issuedinvoice_id, clinic_id
invoice.sentinvoice_id, clinic_id
invoice.paidinvoice_id, clinic_id
payment.recordedpayment_id, invoice_id, clinic_id
document.uploadeddocument_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:

python
ApiResponse[T]         # {"data": T, "message": str | None}
PaginatedApiResponse[T]  # adds total/page/page_size
ErrorResponse          # {"data": None, "message": str, "errors": [...]}

HTTP status conventions:

CodeUse
200GET / PUT success
201POST create
204DELETE
400Validation-free bad request
401Missing / bad token
403Authenticated but not permitted
404Not found
409Conflict (duplicate)
422Body validation failed

6. Slots (frontend extension points) ​

Registered via useModuleSlots().register(name, entry) and consumed via <ModuleSlot :name="..." :ctx="..." />.

Canonical names (v1) ​

SlotHostctx shape
patient.detail.tabs/patients/:id{ patient: Patient }
patient.detail.sidebar/patients/:id{ patient: Patient }
appointment.detail.actionsappointment modal{ appointment: Appointment }
dashboard.widgets/{}
settings.sections/settings{}

Entry shape ​

ts
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):

python
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 PatientTimeline

Patient 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:

MethodPurpose
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.

MethodPathPermissionNotes
GET/modulesadmin.clinic.readFull list including DB state.
GET/modules/-/activeauthenticatedFiltered for current role.
GET/modules/-/statusadmin.clinic.readCounts + pending.
GET/modules/-/doctoradmin.clinic.readDiagnostic report.
GET/modules/{name}admin.clinic.readSingle module.
POST/modules/{name}/installadmin.clinic.write202 + scheduled list.
POST/modules/{name}/uninstalladmin.clinic.write202 + restart required.
POST/modules/{name}/upgradeadmin.clinic.write202 if version changed.
POST/modules/-/restartadmin.clinic.write202 + SIGTERM to self.

9b. Roles REST (issue #46, ADR 0024) ​

All routes under /api/v1/roles, scoped to the caller's clinic.

MethodPathPermissionNotes
GET/rolesadmin.roles.read5 system roles + clinic custom roles, with effective grants.
GET/roles/catalogadmin.roles.readEvery grantable permission code.
POST/rolesadmin.roles.writeCreate a custom role. 409 on system/duplicate name, 422 on unknown code.
PUT/roles/{id}admin.roles.writeUpdate a custom role; a rename follows memberships.
DELETE/roles/{id}admin.roles.write409 while any member holds it.
PUT/roles/{id}/overridesadmin.roles.writePer-clinic grant/revoke on a system role; admin never accepts revokes.

Custom roles are assignable to members only with RBAC_FROM_DB=true. Membership writes persist the roles-row FK (role_id) alongside the role string; role deletion 409s while any member holds the role by string or FK. Under the flag, the schedules professional gate, verifactu promote check, copilot history/nudges/agent contexts, and sidebar nav filtering all resolve through the DB (ADR 0029); with the flag off they use the static map.


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:
    1. A deprecation warning that fires at runtime for at least one minor release.
    2. A CHANGELOG.md entry describing the migration path.
    3. 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 Manifest schema → 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.