Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,9 @@ cython_debug/
.abstra/

# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/

Expand All @@ -203,3 +203,6 @@ __marimo__/

# Streamlit
.streamlit/secrets.toml

care
.idea/
10 changes: 0 additions & 10 deletions .idea/.gitignore

This file was deleted.

14 changes: 0 additions & 14 deletions .idea/care_token_display.iml

This file was deleted.

6 changes: 0 additions & 6 deletions .idea/inspectionProfiles/profiles_settings.xml

This file was deleted.

7 changes: 0 additions & 7 deletions .idea/misc.xml

This file was deleted.

8 changes: 0 additions & 8 deletions .idea/modules.xml

This file was deleted.

6 changes: 0 additions & 6 deletions .idea/vcs.xml

This file was deleted.

2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2025, Open Healthcare Network
Copyright (c) 2026 Open Healthcare Network

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
12 changes: 6 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ classifiers = [
# TODO
]
license = { text = "MIT" }
dependencies = ["django", "celery", "djangorestframework"]
dependencies = ["django", "celery", "djangorestframework", "pydantic"]
requires-python = ">= 3.10"

[project.optional-dependencies]
Expand All @@ -30,11 +30,11 @@ line-length = 120

[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"B", # flake8-bugbear
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"B", # flake8-bugbear
"UP", # pyupgrade
]

Expand Down
5 changes: 5 additions & 0 deletions src/token_display/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@ class TokenDisplayConfig(AppConfig):
verbose_name = _("Token Display")

def ready(self):
from care.emr.registries.device_type.device_registry import DeviceTypeRegistry
from token_display.device import CARE_DEVICE_TYPE, TokenDisplayDevice

# include non-API routes (SSR Pages)
urlconf = import_module(settings.ROOT_URLCONF)
urlconf.urlpatterns += [
path(f"{PLUGIN_NAME}/", include(f"{PLUGIN_NAME}.pages"))
]

DeviceTypeRegistry.register(CARE_DEVICE_TYPE, TokenDisplayDevice)
43 changes: 43 additions & 0 deletions src/token_display/device.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from care.emr.registries.device_type.device_registry import DeviceTypeBase

from token_display.spec import (
TokenDisplayDeviceMetadataReadSpec,
TokenDisplayDeviceMetadataWriteSpec,
)

CARE_DEVICE_TYPE = "token_display"


class TokenDisplayDevice(DeviceTypeBase):
"""A physical/virtual screen that renders the SSR token board.

The device's metadata *is* the preset — which sub-queues this screen shows
and which service account authenticates its SSR page — so no extra table is
needed. Only PKs are persisted; external ids are resolved on the way in and
re-hydrated on the way out.
"""

@staticmethod
def _write(request_data, obj):
# care_fe spreads plug metadata onto the top level of the device
# request body (care_fe DeviceForm.tsx:189-194), it does not nest it
# under `care_metadata`. Mirrors camera_device/device.py:20.
validated = TokenDisplayDeviceMetadataWriteSpec.model_validate(
request_data,
context={"facility": obj.facility},
)
obj.metadata = validated.to_metadata(obj.facility)
obj.save(update_fields=["metadata"])
return obj

def handle_create(self, request_data, obj):
return self._write(request_data, obj)

def handle_update(self, request_data, obj):
return self._write(request_data, obj)

def list(self, obj):
return {}

def retrieve(self, obj):
return TokenDisplayDeviceMetadataReadSpec.from_device(obj).model_dump(mode="json")
115 changes: 115 additions & 0 deletions src/token_display/spec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Metadata specs for the `token_display` care device type.

Contract notes
--------------
* The device's ``metadata`` JSON stores **primary keys only** — never external
ids/UUIDs. External ids are the wire format; PKs are the storage format.
* Writes are validated against the owning facility, so a display can never be
pointed at a sub-queue or a service account outside its own facility.
* Reads are only ever produced for retrieves (see ``device.py``); the list
serializer returns ``{}``.
"""

from pydantic import UUID4, BaseModel, ValidationInfo, field_validator

from care.emr.models import Device, TokenSubQueue
from care.emr.resources.scheduling.token_sub_queue.spec import (
TokenSubQueueReadSpec,
)
from care.emr.resources.user.spec import UserSpec
from care.users.models import User


class TokenDisplayDeviceMetadataWriteSpec(BaseModel):
"""Request-side shape. Accepts external ids, resolves them to PKs.

Both fields are optional: a display can be registered before anyone has
decided which service points it shows or which service account it runs as.
An unconfigured (or partially configured) display simply has no
``display_path`` on retrieve. What is supplied is still validated.
"""

sub_queues: list[UUID4] = []
service_account: UUID4 | None = None

@staticmethod
def _facility(info: ValidationInfo):
facility = (info.context or {}).get("facility")
if facility is None:
raise ValueError("facility context is required to validate token display metadata")
return facility

@field_validator("sub_queues")
@classmethod
def validate_sub_queues(cls, value, info: ValidationInfo):
if not value:
return value
if len(set(value)) != len(value):
raise ValueError("Duplicate sub queues are not allowed")

facility = cls._facility(info)
found = {
str(external_id): pk
for external_id, pk in TokenSubQueue.objects.filter(
external_id__in=value,
facility=facility,
).values_list("external_id", "id")
}
missing = [str(external_id) for external_id in value if str(external_id) not in found]
if missing:
raise ValueError("Active sub queues not found in this facility: " + ", ".join(missing))
return value

@field_validator("service_account")
@classmethod
def validate_service_account(cls, value, info: ValidationInfo):
if value is None:
return value
if not User.objects.filter(external_id=value, is_service_account=True).exists():
raise ValueError("Service account does not exist")
return value

def to_metadata(self, facility) -> dict:
"""Resolve external ids to PKs — the on-disk representation."""
sub_queue_ids_by_external_id = {
str(external_id): pk
for external_id, pk in TokenSubQueue.objects.filter(
external_id__in=self.sub_queues, facility=facility
).values_list("external_id", "id")
}
return {
# Order is meaningful: it is the column order on the display board.
"sub_queue_ids": [sub_queue_ids_by_external_id[str(external_id)] for external_id in self.sub_queues],
"service_account_id": (
User.objects.filter(external_id=self.service_account).values_list("id", flat=True).first()
if self.service_account
else None
),
}


class TokenDisplayDeviceMetadataReadSpec(BaseModel):
"""Retrieve-side shape. Hydrated from PKs in ``device.py``."""

sub_queues: list[dict] = []
service_account: dict | None = None
display_path: str | None = None

@classmethod
def from_device(cls, obj: Device) -> "TokenDisplayDeviceMetadataReadSpec":
from token_display.utils import build_display_path

metadata = obj.metadata or {}

sub_queue_ids = metadata.get("sub_queue_ids") or []
sub_queues_by_id = {sub_queue.id: sub_queue for sub_queue in TokenSubQueue.objects.filter(id__in=sub_queue_ids)}
# Preserve configured order; drop any sub-queue deleted out from under us.
sub_queues = [sub_queues_by_id[pk] for pk in sub_queue_ids if pk in sub_queues_by_id]

service_account = User.objects.filter(id=metadata.get("service_account_id")).first()

return cls(
sub_queues=[TokenSubQueueReadSpec.serialize(sub_queue).to_json() for sub_queue in sub_queues],
service_account=(UserSpec.serialize(service_account).to_json() if service_account else None),
display_path=build_display_path(sub_queues, service_account),
)
25 changes: 25 additions & 0 deletions src/token_display/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,28 @@ def fmt_schedule_resource_name(obj: SchedulableResource) -> str:

def fmt_token_number(token: Token) -> str:
return f"{token.category.shorthand}-{token.number:03d}"


def build_display_path(sub_queues, service_account: User | None) -> str | None:
"""Build the SSR display path for a configured token display device.

Returns ``None`` unless the device is fully configured — sub-queues *and* a
service account with a live DRF auth token. A partially configured display
has no usable URL, and returning a half-formed one (no ``?token=``, or a
token pointing at nothing) would only produce a 403 on a waiting-room TV.
"""
from django.urls import reverse
from rest_framework.authtoken.models import Token as AuthToken

if not sub_queues or service_account is None:
return None

key = AuthToken.objects.filter(user=service_account).values_list("key", flat=True).first()
if not key:
return None

path = reverse(
"sub-queues-token-display",
kwargs={"sub_queue_external_ids": ",".join(str(sub_queue.external_id) for sub_queue in sub_queues)},
)
return f"{path}?token={key}"
Loading