canvas 0.63.0__py3-none-any.whl → 0.89.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/METADATA +4 -1
  2. {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/RECORD +184 -98
  3. {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/WHEEL +1 -1
  4. canvas_cli/apps/emit/event_fixtures/UNKNOWN.ndjson +1 -0
  5. canvas_cli/apps/logs/logs.py +386 -22
  6. canvas_cli/main.py +3 -1
  7. canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/tests/test_models.py +46 -4
  8. canvas_cli/utils/context/context.py +13 -13
  9. canvas_cli/utils/validators/manifest_schema.py +26 -1
  10. canvas_generated/messages/effects_pb2.py +5 -5
  11. canvas_generated/messages/effects_pb2.pyi +108 -2
  12. canvas_generated/messages/events_pb2.py +6 -6
  13. canvas_generated/messages/events_pb2.pyi +282 -2
  14. canvas_sdk/clients/__init__.py +1 -0
  15. canvas_sdk/clients/llms/__init__.py +17 -0
  16. canvas_sdk/clients/llms/libraries/__init__.py +11 -0
  17. canvas_sdk/clients/llms/libraries/llm_anthropic.py +87 -0
  18. canvas_sdk/clients/llms/libraries/llm_api.py +143 -0
  19. canvas_sdk/clients/llms/libraries/llm_google.py +92 -0
  20. canvas_sdk/clients/llms/libraries/llm_openai.py +98 -0
  21. canvas_sdk/clients/llms/structures/__init__.py +9 -0
  22. canvas_sdk/clients/llms/structures/llm_response.py +33 -0
  23. canvas_sdk/clients/llms/structures/llm_tokens.py +53 -0
  24. canvas_sdk/clients/llms/structures/llm_turn.py +47 -0
  25. canvas_sdk/clients/llms/structures/settings/__init__.py +13 -0
  26. canvas_sdk/clients/llms/structures/settings/llm_settings.py +27 -0
  27. canvas_sdk/clients/llms/structures/settings/llm_settings_anthropic.py +43 -0
  28. canvas_sdk/clients/llms/structures/settings/llm_settings_gemini.py +40 -0
  29. canvas_sdk/clients/llms/structures/settings/llm_settings_gpt4.py +40 -0
  30. canvas_sdk/clients/llms/structures/settings/llm_settings_gpt5.py +48 -0
  31. canvas_sdk/clients/third_party.py +3 -0
  32. canvas_sdk/commands/__init__.py +12 -0
  33. canvas_sdk/commands/base.py +33 -2
  34. canvas_sdk/commands/commands/adjust_prescription.py +4 -0
  35. canvas_sdk/commands/commands/custom_command.py +86 -0
  36. canvas_sdk/commands/commands/family_history.py +17 -1
  37. canvas_sdk/commands/commands/immunization_statement.py +42 -2
  38. canvas_sdk/commands/commands/medication_statement.py +16 -1
  39. canvas_sdk/commands/commands/past_surgical_history.py +16 -1
  40. canvas_sdk/commands/commands/perform.py +18 -1
  41. canvas_sdk/commands/commands/prescribe.py +8 -9
  42. canvas_sdk/commands/commands/refill.py +5 -5
  43. canvas_sdk/commands/commands/resolve_condition.py +5 -5
  44. canvas_sdk/commands/commands/review/__init__.py +3 -0
  45. canvas_sdk/commands/commands/review/base.py +72 -0
  46. canvas_sdk/commands/commands/review/imaging.py +13 -0
  47. canvas_sdk/commands/commands/review/lab.py +13 -0
  48. canvas_sdk/commands/commands/review/referral.py +13 -0
  49. canvas_sdk/commands/commands/review/uncategorized_document.py +13 -0
  50. canvas_sdk/commands/validation.py +43 -0
  51. canvas_sdk/effects/batch_originate.py +22 -0
  52. canvas_sdk/effects/calendar/__init__.py +13 -3
  53. canvas_sdk/effects/calendar/{create_calendar.py → calendar.py} +19 -5
  54. canvas_sdk/effects/calendar/event.py +172 -0
  55. canvas_sdk/effects/claim_label.py +93 -0
  56. canvas_sdk/effects/claim_line_item.py +47 -0
  57. canvas_sdk/effects/claim_queue.py +49 -0
  58. canvas_sdk/effects/fax/__init__.py +3 -0
  59. canvas_sdk/effects/fax/base.py +77 -0
  60. canvas_sdk/effects/fax/note.py +42 -0
  61. canvas_sdk/effects/metadata.py +15 -1
  62. canvas_sdk/effects/note/__init__.py +8 -1
  63. canvas_sdk/effects/note/appointment.py +135 -7
  64. canvas_sdk/effects/note/base.py +17 -0
  65. canvas_sdk/effects/note/message.py +22 -14
  66. canvas_sdk/effects/note/note.py +150 -1
  67. canvas_sdk/effects/observation/__init__.py +11 -0
  68. canvas_sdk/effects/observation/base.py +206 -0
  69. canvas_sdk/effects/patient/__init__.py +2 -0
  70. canvas_sdk/effects/patient/base.py +8 -0
  71. canvas_sdk/effects/payment/__init__.py +11 -0
  72. canvas_sdk/effects/payment/base.py +355 -0
  73. canvas_sdk/effects/payment/post_claim_payment.py +49 -0
  74. canvas_sdk/effects/send_contact_verification.py +42 -0
  75. canvas_sdk/effects/task/__init__.py +2 -1
  76. canvas_sdk/effects/task/task.py +30 -0
  77. canvas_sdk/effects/validation/__init__.py +3 -0
  78. canvas_sdk/effects/validation/base.py +92 -0
  79. canvas_sdk/events/base.py +15 -0
  80. canvas_sdk/handlers/application.py +7 -7
  81. canvas_sdk/handlers/simple_api/api.py +1 -4
  82. canvas_sdk/handlers/simple_api/websocket.py +1 -4
  83. canvas_sdk/handlers/utils.py +14 -0
  84. canvas_sdk/questionnaires/utils.py +1 -0
  85. canvas_sdk/templates/utils.py +17 -4
  86. canvas_sdk/test_utils/factories/FACTORY_GUIDE.md +362 -0
  87. canvas_sdk/test_utils/factories/__init__.py +115 -0
  88. canvas_sdk/test_utils/factories/calendar.py +24 -0
  89. canvas_sdk/test_utils/factories/claim.py +81 -0
  90. canvas_sdk/test_utils/factories/claim_diagnosis_code.py +16 -0
  91. canvas_sdk/test_utils/factories/coverage.py +17 -0
  92. canvas_sdk/test_utils/factories/imaging.py +74 -0
  93. canvas_sdk/test_utils/factories/lab.py +192 -0
  94. canvas_sdk/test_utils/factories/medication_history.py +75 -0
  95. canvas_sdk/test_utils/factories/note.py +52 -0
  96. canvas_sdk/test_utils/factories/organization.py +50 -0
  97. canvas_sdk/test_utils/factories/practicelocation.py +88 -0
  98. canvas_sdk/test_utils/factories/referral.py +81 -0
  99. canvas_sdk/test_utils/factories/staff.py +111 -0
  100. canvas_sdk/test_utils/factories/task.py +66 -0
  101. canvas_sdk/test_utils/factories/uncategorized_clinical_document.py +48 -0
  102. canvas_sdk/utils/metrics.py +4 -1
  103. canvas_sdk/v1/data/__init__.py +66 -7
  104. canvas_sdk/v1/data/allergy_intolerance.py +5 -11
  105. canvas_sdk/v1/data/appointment.py +18 -4
  106. canvas_sdk/v1/data/assessment.py +2 -12
  107. canvas_sdk/v1/data/banner_alert.py +2 -4
  108. canvas_sdk/v1/data/base.py +53 -14
  109. canvas_sdk/v1/data/billing.py +8 -11
  110. canvas_sdk/v1/data/calendar.py +64 -0
  111. canvas_sdk/v1/data/care_team.py +4 -10
  112. canvas_sdk/v1/data/claim.py +172 -66
  113. canvas_sdk/v1/data/claim_diagnosis_code.py +19 -0
  114. canvas_sdk/v1/data/claim_line_item.py +2 -5
  115. canvas_sdk/v1/data/coding.py +19 -0
  116. canvas_sdk/v1/data/command.py +2 -4
  117. canvas_sdk/v1/data/common.py +10 -0
  118. canvas_sdk/v1/data/compound_medication.py +3 -4
  119. canvas_sdk/v1/data/condition.py +4 -9
  120. canvas_sdk/v1/data/coverage.py +66 -26
  121. canvas_sdk/v1/data/detected_issue.py +20 -20
  122. canvas_sdk/v1/data/device.py +2 -14
  123. canvas_sdk/v1/data/discount.py +2 -5
  124. canvas_sdk/v1/data/encounter.py +44 -0
  125. canvas_sdk/v1/data/facility.py +1 -0
  126. canvas_sdk/v1/data/goal.py +2 -14
  127. canvas_sdk/v1/data/imaging.py +4 -30
  128. canvas_sdk/v1/data/immunization.py +7 -15
  129. canvas_sdk/v1/data/lab.py +12 -65
  130. canvas_sdk/v1/data/line_item_transaction.py +2 -5
  131. canvas_sdk/v1/data/medication.py +3 -8
  132. canvas_sdk/v1/data/medication_history.py +142 -0
  133. canvas_sdk/v1/data/medication_statement.py +41 -0
  134. canvas_sdk/v1/data/message.py +4 -8
  135. canvas_sdk/v1/data/note.py +37 -38
  136. canvas_sdk/v1/data/observation.py +9 -36
  137. canvas_sdk/v1/data/organization.py +70 -9
  138. canvas_sdk/v1/data/patient.py +8 -12
  139. canvas_sdk/v1/data/patient_consent.py +4 -14
  140. canvas_sdk/v1/data/payment_collection.py +2 -5
  141. canvas_sdk/v1/data/posting.py +3 -9
  142. canvas_sdk/v1/data/practicelocation.py +66 -7
  143. canvas_sdk/v1/data/protocol_override.py +3 -4
  144. canvas_sdk/v1/data/protocol_result.py +3 -3
  145. canvas_sdk/v1/data/questionnaire.py +10 -26
  146. canvas_sdk/v1/data/reason_for_visit.py +2 -6
  147. canvas_sdk/v1/data/referral.py +41 -17
  148. canvas_sdk/v1/data/staff.py +34 -26
  149. canvas_sdk/v1/data/stop_medication_event.py +27 -0
  150. canvas_sdk/v1/data/task.py +30 -11
  151. canvas_sdk/v1/data/team.py +2 -4
  152. canvas_sdk/v1/data/uncategorized_clinical_document.py +84 -0
  153. canvas_sdk/v1/data/user.py +14 -0
  154. canvas_sdk/v1/data/utils.py +5 -0
  155. canvas_sdk/value_set/v2026/__init__.py +1 -0
  156. canvas_sdk/value_set/v2026/adverse_event.py +157 -0
  157. canvas_sdk/value_set/v2026/allergy.py +116 -0
  158. canvas_sdk/value_set/v2026/assessment.py +466 -0
  159. canvas_sdk/value_set/v2026/communication.py +496 -0
  160. canvas_sdk/value_set/v2026/condition.py +52934 -0
  161. canvas_sdk/value_set/v2026/device.py +315 -0
  162. canvas_sdk/value_set/v2026/diagnostic_study.py +5243 -0
  163. canvas_sdk/value_set/v2026/encounter.py +2714 -0
  164. canvas_sdk/value_set/v2026/immunization.py +297 -0
  165. canvas_sdk/value_set/v2026/individual_characteristic.py +339 -0
  166. canvas_sdk/value_set/v2026/intervention.py +1703 -0
  167. canvas_sdk/value_set/v2026/laboratory_test.py +1831 -0
  168. canvas_sdk/value_set/v2026/medication.py +8218 -0
  169. canvas_sdk/value_set/v2026/no_qdm_category_assigned.py +26493 -0
  170. canvas_sdk/value_set/v2026/physical_exam.py +342 -0
  171. canvas_sdk/value_set/v2026/procedure.py +27869 -0
  172. canvas_sdk/value_set/v2026/symptom.py +625 -0
  173. logger/logger.py +30 -31
  174. logger/logstash.py +282 -0
  175. logger/pubsub.py +26 -0
  176. plugin_runner/allowed-module-imports.json +940 -9
  177. plugin_runner/generate_allowed_imports.py +1 -0
  178. plugin_runner/installation.py +2 -2
  179. plugin_runner/plugin_runner.py +21 -24
  180. plugin_runner/sandbox.py +34 -0
  181. protobufs/canvas_generated/messages/effects.proto +65 -0
  182. protobufs/canvas_generated/messages/events.proto +150 -51
  183. settings.py +27 -11
  184. canvas_sdk/effects/calendar/create_event.py +0 -43
  185. {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,77 @@
1
+ from abc import ABC
2
+ from typing import Any, Literal
3
+ from uuid import UUID
4
+
5
+ from pydantic_core import InitErrorDetails
6
+
7
+ from canvas_sdk.effects import EffectType, _BaseEffect
8
+ from canvas_sdk.v1.data import PracticeLocation
9
+
10
+
11
+ class BaseFaxEffect(_BaseEffect, ABC):
12
+ """Base class for fax effects."""
13
+
14
+ class Meta:
15
+ effect_type = EffectType.UNKNOWN_EFFECT
16
+
17
+ recipient_name: str
18
+ recipient_fax_number: str
19
+ include_coversheet: bool = False
20
+ subject: str | None = None
21
+ comment: str | None = None
22
+ location_id: str | UUID | None = None
23
+
24
+ def _get_error_details(self, method: Literal["apply"]) -> list[InitErrorDetails]:
25
+ errors = super()._get_error_details(method)
26
+
27
+ if self.include_coversheet:
28
+ if not self.subject:
29
+ errors.append(
30
+ self._create_error_detail(
31
+ "value",
32
+ "subject is required when include_coversheet is True",
33
+ self.subject,
34
+ )
35
+ )
36
+ if not self.comment:
37
+ errors.append(
38
+ self._create_error_detail(
39
+ "value",
40
+ "comment is required when include_coversheet is True",
41
+ self.comment,
42
+ )
43
+ )
44
+ if self.location_id:
45
+ if not PracticeLocation.objects.filter(id=self.location_id).exists():
46
+ errors.append(
47
+ self._create_error_detail(
48
+ "value",
49
+ f"Practice Location {self.location_id} does not exist",
50
+ self.location_id,
51
+ )
52
+ )
53
+ else:
54
+ errors.append(
55
+ self._create_error_detail(
56
+ "value",
57
+ "location_id is required when include_coversheet is True",
58
+ self.location_id,
59
+ )
60
+ )
61
+
62
+ return errors
63
+
64
+ @property
65
+ def values(self) -> dict[str, Any]:
66
+ """Return the values of the fax effect."""
67
+ return {
68
+ "recipient_name": self.recipient_name,
69
+ "recipient_fax_number": self.recipient_fax_number,
70
+ "include_coversheet": self.include_coversheet,
71
+ "subject": self.subject,
72
+ "comment": self.comment,
73
+ "location_id": str(self.location_id) if self.location_id else None,
74
+ }
75
+
76
+
77
+ __exports__ = ()
@@ -0,0 +1,42 @@
1
+ from typing import Literal
2
+ from uuid import UUID
3
+
4
+ from pydantic_core import InitErrorDetails
5
+
6
+ from canvas_generated.messages.effects_pb2 import EffectType
7
+ from canvas_sdk.effects.fax.base import BaseFaxEffect
8
+ from canvas_sdk.v1.data import Note
9
+
10
+
11
+ class FaxNoteEffect(BaseFaxEffect):
12
+ """Fax note effect."""
13
+
14
+ class Meta:
15
+ effect_type = EffectType.FAX_NOTE
16
+
17
+ note_id: str | UUID
18
+
19
+ def _get_error_details(self, method: Literal["apply"]) -> list[InitErrorDetails]:
20
+ errors = super()._get_error_details(method)
21
+ if self.note_id and not Note.objects.filter(id=self.note_id).exists():
22
+ errors.append(
23
+ self._create_error_detail(
24
+ "value",
25
+ f"Note with ID {self.note_id} does not exist.",
26
+ self.note_id,
27
+ )
28
+ )
29
+
30
+ return errors
31
+
32
+ @property
33
+ def values(self) -> dict[str, str]:
34
+ """Return the values of the note fax effect."""
35
+ values = super().values
36
+ return {
37
+ **values,
38
+ "note_id": str(self.note_id),
39
+ }
40
+
41
+
42
+ __exports__ = ()
@@ -1,9 +1,23 @@
1
1
  import json
2
+ from dataclasses import dataclass
3
+ from typing import Any
2
4
 
3
5
  from canvas_generated.messages.effects_pb2 import Effect
4
6
  from canvas_sdk.base import TrackableFieldsModel
5
7
 
6
8
 
9
+ @dataclass
10
+ class Metadata:
11
+ """A class representing a metadata."""
12
+
13
+ key: str
14
+ value: str
15
+
16
+ def to_dict(self) -> dict[str, Any]:
17
+ """Convert the metadata to a dictionary."""
18
+ return {"key": self.key, "value": self.value}
19
+
20
+
7
21
  class BaseMetadata(TrackableFieldsModel):
8
22
  """Base class for metadata effects."""
9
23
 
@@ -23,4 +37,4 @@ class BaseMetadata(TrackableFieldsModel):
23
37
  )
24
38
 
25
39
 
26
- __exports__ = ()
40
+ __exports__ = ("Metadata",)
@@ -1,4 +1,9 @@
1
- from canvas_sdk.effects.note.appointment import Appointment, ScheduleEvent
1
+ from canvas_sdk.effects.note.appointment import (
2
+ AddAppointmentLabel,
3
+ Appointment,
4
+ RemoveAppointmentLabel,
5
+ ScheduleEvent,
6
+ )
2
7
  from canvas_sdk.effects.note.base import AppointmentIdentifier
3
8
  from canvas_sdk.effects.note.note import Note
4
9
 
@@ -7,4 +12,6 @@ __all__ = __exports__ = (
7
12
  "Note",
8
13
  "Appointment",
9
14
  "ScheduleEvent",
15
+ "AddAppointmentLabel",
16
+ "RemoveAppointmentLabel",
10
17
  )
@@ -1,12 +1,17 @@
1
1
  import json
2
- from typing import Any
2
+ from typing import Annotated, Any
3
3
  from uuid import UUID
4
4
 
5
+ from django.db.models import Count
6
+ from pydantic import Field
5
7
  from pydantic_core import InitErrorDetails
6
8
 
7
- from canvas_sdk.effects import Effect
9
+ from canvas_sdk.base import TrackableFieldsModel
10
+ from canvas_sdk.effects import Effect, EffectType
11
+ from canvas_sdk.effects.base import _BaseEffect
8
12
  from canvas_sdk.effects.note.base import AppointmentABC
9
- from canvas_sdk.v1.data import NoteType, Patient
13
+ from canvas_sdk.v1.data import Appointment as AppointmentDataModel
14
+ from canvas_sdk.v1.data import AppointmentLabel, NoteType, Patient
10
15
  from canvas_sdk.v1.data.note import NoteTypeCategories
11
16
 
12
17
 
@@ -127,6 +132,7 @@ class Appointment(AppointmentABC):
127
132
  appointment_note_type_id (UUID | str | None): The ID of the appointment note type.
128
133
  meeting_link (str | None): The meeting link for the appointment, if any.
129
134
  patient_id (str | None): The ID of the patient.
135
+ labels (conset[str] | None): A set of label names to apply to the appointment.
130
136
  """
131
137
 
132
138
  class Meta:
@@ -135,6 +141,13 @@ class Appointment(AppointmentABC):
135
141
  appointment_note_type_id: UUID | str | None = None
136
142
  meeting_link: str | None = None
137
143
  patient_id: str | None = None
144
+ labels: (
145
+ Annotated[
146
+ set[Annotated[str, Field(min_length=1, max_length=50)]],
147
+ Field(min_length=1, max_length=3),
148
+ ]
149
+ | None
150
+ ) = None
138
151
 
139
152
  def _get_error_details(self, method: Any) -> list[InitErrorDetails]:
140
153
  """
@@ -186,9 +199,11 @@ class Appointment(AppointmentABC):
186
199
 
187
200
  if self.appointment_note_type_id:
188
201
  try:
189
- category, is_scheduleable = NoteType.objects.values_list(
190
- "category", "is_scheduleable"
191
- ).get(id=self.appointment_note_type_id)
202
+ category, is_scheduleable = (
203
+ NoteType.objects.values_list("category", "is_scheduleable")
204
+ .filter(is_active=True)
205
+ .get(id=self.appointment_note_type_id)
206
+ )
192
207
  if category != NoteTypeCategories.ENCOUNTER:
193
208
  errors.append(
194
209
  self._create_error_detail(
@@ -215,8 +230,39 @@ class Appointment(AppointmentABC):
215
230
  )
216
231
  )
217
232
 
233
+ if method == "update" and self.labels and self.instance_id:
234
+ existing_count = AppointmentLabel.objects.filter(
235
+ appointment__id=self.instance_id
236
+ ).count()
237
+ if existing_count + len(self.labels) > 3:
238
+ errors.append(
239
+ self._create_error_detail(
240
+ "value",
241
+ f"Limit reached: Only 3 appointment labels allowed. Attempted to add {len(self.labels)} label(s) to appointment with {existing_count} existing label(s).",
242
+ sorted(self.labels),
243
+ )
244
+ )
245
+
218
246
  return errors
219
247
 
248
+ @property
249
+ def values(self) -> dict:
250
+ """
251
+ Returns a dictionary of modified attributes with type-specific transformations.
252
+ """
253
+ values = super().values
254
+ # Convert labels set to list for JSON serialization
255
+ # This is necessary because:
256
+ # 1. The labels field is defined as a conset (constrained set) for validation
257
+ # 2. JSON cannot serialize Python sets directly - it only supports lists, dicts, strings, numbers, booleans, and None
258
+ # 3. When the effect payload is serialized to JSON in the base Effect class, it would fail with:
259
+ # "TypeError: Object of type set is not JSON serializable"
260
+ # 4. Converting to list maintains the same data while making it JSON-compatible
261
+ # 5. Sort the labels to ensure consistent ordering for tests and API responses
262
+ if self.labels is not None:
263
+ values["labels"] = sorted(self.labels)
264
+ return values
265
+
220
266
  def cancel(self) -> Effect:
221
267
  """Send a CANCEL effect for the appointment."""
222
268
  self._validate_before_effect("cancel")
@@ -230,4 +276,86 @@ class Appointment(AppointmentABC):
230
276
  )
231
277
 
232
278
 
233
- __exports__ = ("ScheduleEvent", "Appointment")
279
+ class _AppointmentLabelBase(_BaseEffect, TrackableFieldsModel):
280
+ """
281
+ Base class for appointment label effects.
282
+
283
+ Attributes:
284
+ appointment_id (UUID | str): The ID of the appointment.
285
+ labels (conset[str]): A set of label names (1-3 labels allowed).
286
+ """
287
+
288
+ appointment_id: str
289
+ labels: Annotated[set[str], Field(min_length=1, max_length=3)]
290
+
291
+ @property
292
+ def values(self) -> dict:
293
+ """The effect's values."""
294
+ result = {
295
+ "appointment_id": str(self.appointment_id),
296
+ "labels": sorted(self.labels),
297
+ }
298
+ return result
299
+
300
+
301
+ class AddAppointmentLabel(_AppointmentLabelBase):
302
+ """
303
+ Effect to add one or more labels to an appointment.
304
+ """
305
+
306
+ class Meta:
307
+ effect_type = EffectType.ADD_APPOINTMENT_LABEL
308
+
309
+ def _get_error_details(self, method: Any) -> list[InitErrorDetails]:
310
+ """Validate that the appointment does not exceed the 3-label limit."""
311
+ errors = super()._get_error_details(method)
312
+
313
+ appointment_label_count = (
314
+ AppointmentDataModel.objects.filter(id=self.appointment_id)
315
+ .annotate(label_count=Count("labels"))
316
+ .values_list("label_count", flat=True)
317
+ .first()
318
+ )
319
+
320
+ # note that appointment_label_count will be None if the appointment doesn't exist
321
+ # and appointment_label_count will be 0 if the appointment exists but has no labels
322
+ if appointment_label_count is None:
323
+ errors.append(
324
+ self._create_error_detail(
325
+ "value",
326
+ f"Appointment {self.appointment_id} does not exist",
327
+ self.appointment_id,
328
+ )
329
+ )
330
+ elif appointment_label_count + len(self.labels) > 3:
331
+ errors.append(
332
+ self._create_error_detail(
333
+ "value",
334
+ f"Limit reached: Only 3 appointment labels allowed. "
335
+ f"Attempted to add {len(self.labels)} label(s) to appointment with "
336
+ f"{appointment_label_count} existing label(s).",
337
+ sorted(self.labels),
338
+ )
339
+ )
340
+ return errors
341
+
342
+
343
+ class RemoveAppointmentLabel(_AppointmentLabelBase):
344
+ """
345
+ Effect to remove one or more labels from an appointment.
346
+
347
+ Attributes:
348
+ appointment_id (UUID | str): The ID of the appointment to remove labels from.
349
+ labels (list[str]): A list of label names to remove.
350
+ """
351
+
352
+ class Meta:
353
+ effect_type = EffectType.REMOVE_APPOINTMENT_LABEL
354
+
355
+
356
+ __exports__ = (
357
+ "ScheduleEvent",
358
+ "Appointment",
359
+ "AddAppointmentLabel",
360
+ "RemoveAppointmentLabel",
361
+ )
@@ -239,5 +239,22 @@ class AppointmentABC(NoteOrAppointmentABC, ABC):
239
239
 
240
240
  return values
241
241
 
242
+ def reschedule(self) -> Effect:
243
+ """Send a RESCHEDULE effect for the appointment."""
244
+ self._validate_before_effect("update")
245
+
246
+ # Check if any fields were actually modified
247
+ if self._dirty_keys == {"instance_id"}:
248
+ raise ValueError("No fields have been modified. Nothing to update.")
249
+
250
+ return Effect(
251
+ type=f"RESCHEDULE_{self.Meta.effect_type}",
252
+ payload=json.dumps(
253
+ {
254
+ "data": self.values,
255
+ }
256
+ ),
257
+ )
258
+
242
259
 
243
260
  __exports__ = ("AppointmentIdentifier", "NoteOrAppointmentABC", "AppointmentABC")
@@ -1,4 +1,5 @@
1
1
  import json
2
+ from datetime import datetime
2
3
  from typing import Any
3
4
  from uuid import UUID
4
5
 
@@ -19,9 +20,10 @@ class Message(TrackableFieldsModel):
19
20
  effect_type = "MESSAGE"
20
21
 
21
22
  message_id: str | UUID | None = None
22
- content: str
23
+ content: str | None
23
24
  sender_id: str | UUID
24
25
  recipient_id: str | UUID
26
+ read: datetime | None = None
25
27
 
26
28
  def _get_error_details(self, method: Any) -> list[InitErrorDetails]:
27
29
  errors = super()._get_error_details(method)
@@ -68,21 +70,27 @@ class Message(TrackableFieldsModel):
68
70
  self.message_id,
69
71
  )
70
72
  )
71
- elif (
72
- method
73
- in (
74
- "create",
75
- "create_and_send",
76
- )
77
- and self.message_id
73
+ elif method in (
74
+ "create",
75
+ "create_and_send",
78
76
  ):
79
- errors.append(
80
- self._create_error_detail(
81
- "value",
82
- "Can't set message ID when creating a message.",
83
- self.message_id,
77
+ if not self.content or self.content.strip() == "":
78
+ errors.append(
79
+ self._create_error_detail(
80
+ "value",
81
+ "Message content cannot be empty.",
82
+ self.content,
83
+ )
84
+ )
85
+
86
+ if self.message_id:
87
+ errors.append(
88
+ self._create_error_detail(
89
+ "value",
90
+ "Can't set message ID when creating a message.",
91
+ self.message_id,
92
+ )
84
93
  )
85
- )
86
94
 
87
95
  return errors
88
96
 
@@ -1,13 +1,33 @@
1
1
  import datetime
2
+ import json
2
3
  from typing import Any
3
4
  from uuid import UUID
4
5
 
5
6
  from pydantic_core import InitErrorDetails
6
7
 
8
+ from canvas_generated.messages.effects_pb2 import EffectType
9
+ from canvas_sdk.effects import Effect
7
10
  from canvas_sdk.effects.note.base import NoteOrAppointmentABC
8
11
  from canvas_sdk.v1.data import Note as NoteModel
9
12
  from canvas_sdk.v1.data import NoteType, Patient
10
- from canvas_sdk.v1.data.note import NoteTypeCategories
13
+ from canvas_sdk.v1.data.note import NoteStates, NoteTypeCategories
14
+
15
+ TRANSITION_STATE_MATRIX = {
16
+ NoteStates.NEW: [NoteStates.LOCKED, NoteStates.PUSHED],
17
+ NoteStates.LOCKED: [NoteStates.SIGNED, NoteStates.UNLOCKED],
18
+ NoteStates.UNLOCKED: [NoteStates.LOCKED, NoteStates.PUSHED],
19
+ NoteStates.SIGNED: [NoteStates.UNLOCKED, NoteStates.SIGNED],
20
+ NoteStates.PUSHED: [NoteStates.LOCKED, NoteStates.PUSHED],
21
+ }
22
+
23
+ ACTION_STATE_MATRIX = {
24
+ "lock": NoteStates.LOCKED,
25
+ "unlock": NoteStates.UNLOCKED,
26
+ "sign": NoteStates.SIGNED,
27
+ "push_charges": NoteStates.PUSHED,
28
+ "check_in": NoteStates.CONVERTED,
29
+ "no_show": NoteStates.NOSHOW,
30
+ }
11
31
 
12
32
 
13
33
  class Note(NoteOrAppointmentABC):
@@ -28,6 +48,82 @@ class Note(NoteOrAppointmentABC):
28
48
  patient_id: str | None = None
29
49
  title: str | None = None
30
50
 
51
+ def push_charges(self) -> Effect:
52
+ """Pushes BillingLineItems from the Note to the associated Claim. Identicial to clicking the Push Charges button in the note footer."""
53
+ self._validate_before_effect("push_charges")
54
+ return Effect(
55
+ type=EffectType.PUSH_NOTE_CHARGES,
56
+ payload=json.dumps({"data": {"note": str(self.instance_id)}}),
57
+ )
58
+
59
+ def unlock(self) -> Effect:
60
+ """Unlocks the note to allow further edits."""
61
+ self._validate_before_effect("unlock")
62
+ return Effect(
63
+ type=EffectType.UNLOCK_NOTE,
64
+ payload=json.dumps({"data": {"note": str(self.instance_id)}}),
65
+ )
66
+
67
+ def lock(self) -> Effect:
68
+ """Locks the note to prevent further edits."""
69
+ self._validate_before_effect("lock")
70
+ return Effect(
71
+ type=EffectType.LOCK_NOTE,
72
+ payload=json.dumps({"data": {"note": str(self.instance_id)}}),
73
+ )
74
+
75
+ def sign(self) -> Effect:
76
+ """Signs the note."""
77
+ self._validate_before_effect("sign")
78
+ return Effect(
79
+ type=EffectType.SIGN_NOTE,
80
+ payload=json.dumps({"data": {"note": str(self.instance_id)}}),
81
+ )
82
+
83
+ def check_in(self) -> Effect:
84
+ """Mark the note as checked-in."""
85
+ self._validate_before_effect("check_in")
86
+ return Effect(
87
+ type=EffectType.CHECK_IN_NOTE,
88
+ payload=json.dumps({"data": {"note": str(self.instance_id)}}),
89
+ )
90
+
91
+ def no_show(self) -> Effect:
92
+ """Mark the note as no-show."""
93
+ self._validate_before_effect("no_show")
94
+ return Effect(
95
+ type=EffectType.NO_SHOW_NOTE,
96
+ payload=json.dumps({"data": {"note": str(self.instance_id)}}),
97
+ )
98
+
99
+ def _validate_state_transition(
100
+ self, note: NoteModel, next_state: NoteStates
101
+ ) -> tuple[bool, InitErrorDetails | None]:
102
+ """Validates state transitions for the note."""
103
+ current_state = note.current_state.state if note.current_state else None
104
+
105
+ if not current_state:
106
+ return False, self._create_error_detail(
107
+ "value", "Unsupported state transitions", next_state
108
+ )
109
+
110
+ is_sig_required = note.note_type_version.is_sig_required
111
+ if next_state == NoteStates.SIGNED and not is_sig_required:
112
+ return False, self._create_error_detail(
113
+ "value", "Cannot sign a note that does not require a signature.", next_state
114
+ )
115
+
116
+ if (
117
+ next_state == NoteStates.CONVERTED or next_state == NoteStates.NOSHOW
118
+ ) and note.note_type_version.category != NoteTypeCategories.APPOINTMENT:
119
+ return False, self._create_error_detail(
120
+ "value",
121
+ "Only appointments can be checked in or marked as no-show.",
122
+ next_state,
123
+ )
124
+
125
+ return True, None
126
+
31
127
  def _get_error_details(self, method: Any) -> list[InitErrorDetails]:
32
128
  """
33
129
  Validates the note type category and returns a list of error details if validation fails.
@@ -40,6 +136,59 @@ class Note(NoteOrAppointmentABC):
40
136
  """
41
137
  errors = super()._get_error_details(method)
42
138
 
139
+ if method in ACTION_STATE_MATRIX:
140
+ if not self.instance_id:
141
+ errors.append(
142
+ self._create_error_detail(
143
+ "missing",
144
+ "Field 'instance_id' is required.",
145
+ None,
146
+ )
147
+ )
148
+ return errors
149
+ elif not (note := NoteModel.objects.filter(id=self.instance_id).first()):
150
+ errors.append(
151
+ self._create_error_detail(
152
+ "value",
153
+ f"Note with ID {self.instance_id} does not exist.",
154
+ self.instance_id,
155
+ )
156
+ )
157
+ return errors
158
+
159
+ if note.note_type_version.category in (
160
+ NoteTypeCategories.LETTER,
161
+ NoteTypeCategories.MESSAGE,
162
+ ):
163
+ errors.append(
164
+ self._create_error_detail(
165
+ "value",
166
+ f"Note with note type {note.note_type_version.name} cannot perform action '{method}'.",
167
+ note.note_type_version,
168
+ )
169
+ )
170
+ return errors
171
+
172
+ if method == "push_charges" and (
173
+ not note.note_type_version or not note.note_type_version.is_billable
174
+ ):
175
+ errors.append(
176
+ self._create_error_detail(
177
+ "value",
178
+ f"Note with note type {note.note_type_version} is not billable and has no associated claim.",
179
+ note.note_type_version,
180
+ )
181
+ )
182
+ return errors
183
+
184
+ if method in ACTION_STATE_MATRIX:
185
+ _, error = self._validate_state_transition(note, ACTION_STATE_MATRIX[method])
186
+
187
+ if error:
188
+ errors.append(error)
189
+
190
+ return errors
191
+
43
192
  if method == "create":
44
193
  if not self.note_type_id:
45
194
  errors.append(
@@ -0,0 +1,11 @@
1
+ from canvas_sdk.effects.observation.base import (
2
+ CodingData,
3
+ Observation,
4
+ ObservationComponentData,
5
+ )
6
+
7
+ __all__ = __exports__ = (
8
+ "Observation",
9
+ "CodingData",
10
+ "ObservationComponentData",
11
+ )