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

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.