kalbio 0.2.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.
kalbio/activities.py ADDED
@@ -0,0 +1,1202 @@
1
+ """Activities Module for Kaleidoscope API Client.
2
+
3
+ This module provides comprehensive functionality for managing activities (tasks, experiments,
4
+ projects, stages, milestones, and design cycles) within the Kaleidoscope platform. It includes
5
+ models for activities, activity definitions, and properties, as well as service classes for
6
+ performing CRUD operations and managing activity workflows.
7
+
8
+ The module manages:
9
+
10
+ - Activity creation, updates, and status transitions
11
+ - Activity definitions
12
+ - Properties
13
+ - Records of activities
14
+ - User and group assignments
15
+ - Labels of activities
16
+ - Related programs
17
+ - Parent-child activity relationships
18
+ - Activity dependencies and scheduling
19
+
20
+ Classes and types:
21
+ ActivityStatusEnum: Enumeration of possible activity statuses used across activity workflows.
22
+ ActivityType: Type alias for supported activity categories (task, experiment, project, stage, milestone, cycle).
23
+ Property: Model representing a property (field) attached to entities, with update and file upload helpers.
24
+ ActivityDefinition: Template/definition for activities (templates for programs, users, groups, labels, and properties).
25
+ Activity: Core activity model (task/experiment/project) with cached relations, record accessors, and update helpers.
26
+ ActivitiesService: Service class exposing CRUD and retrieval operations for activities and activity definitions.
27
+ ActivityIdentifier: Identifier union for activities (instance, title, or UUID).
28
+ DefinitionIdentifier: Identifier union for activity definitions (instance, title, or UUID).
29
+
30
+ Example:
31
+ ```python
32
+ # Create a new activity
33
+ activity = client.activities.create_activity(
34
+ title="Synthesis Experiment",
35
+ activity_type="experiment",
36
+ program_ids=["program-uuid", ...],
37
+ assigned_user_ids=["user-uuid", ...]
38
+ )
39
+
40
+ # Update activity status
41
+ activity.update(status=ActivityStatusEnum.IN_PROGRESS)
42
+
43
+ # Add records to activity
44
+ activity.add_records(["record-uuid"])
45
+
46
+ # Get activity data
47
+ record_data = activity.get_record_data()
48
+ ```
49
+
50
+ Note:
51
+ This module uses Pydantic for data validation and serialization. All datetime
52
+ objects are timezone-aware and follow ISO 8601 format.
53
+ """
54
+
55
+ from __future__ import annotations
56
+ import logging
57
+ from datetime import datetime
58
+ from enum import Enum
59
+ from functools import cached_property, lru_cache
60
+
61
+ import cachetools.func
62
+ from kalbio._kaleidoscope_model import _KaleidoscopeBaseModel
63
+ from kalbio.client import KaleidoscopeClient
64
+ from kalbio.entity_fields import DataFieldTypeEnum
65
+ from kalbio.programs import Program
66
+ from kalbio.labels import Label
67
+ from kalbio.workspace import WorkspaceUser, WorkspaceGroup
68
+ from typing import Any, BinaryIO, List, Literal, Optional, Union
69
+ from typing import TYPE_CHECKING
70
+
71
+ if TYPE_CHECKING:
72
+ from kalbio.records import Record, RecordIdentifier
73
+
74
+
75
+ _logger = logging.getLogger(__name__)
76
+
77
+
78
+ class ActivityStatusEnum(str, Enum):
79
+ """Enumeration of possible activity status values.
80
+
81
+ This enum defines all possible states that an activity can be in during its lifecycle,
82
+ including general workflow states, review states, and domain-specific states for
83
+ design, synthesis, testing, and compound selection processes.
84
+
85
+ Attributes:
86
+ REQUESTED (str): Activity has been requested but not yet started.
87
+ TODO (str): Activity is queued to be worked on.
88
+ IN_PROGRESS (str): Activity is currently being worked on.
89
+ NEEDS_REVIEW (str): Activity requires review.
90
+ BLOCKED (str): Activity is blocked by dependencies or issues.
91
+ PAUSED (str): Activity has been temporarily paused.
92
+ CANCELLED (str): Activity has been cancelled.
93
+ IN_REVIEW (str): Activity is currently under review.
94
+ LOCKED (str): Activity is locked from modifications.
95
+ TO_REVIEW (str): Activity is ready to be reviewed.
96
+ UPLOAD_COMPLETE (str): Upload process for the activity is complete.
97
+ NEW (str): Newly created activity.
98
+ IN_DESIGN (str): Activity is in the design phase.
99
+ READY_FOR_MAKE (str): Activity is ready for manufacturing/creation.
100
+ IN_SYNTHESIS (str): Activity is in the synthesis phase.
101
+ IN_TEST (str): Activity is in the testing phase.
102
+ IN_ANALYSIS (str): Activity is in the analysis phase.
103
+ PARKED (str): Activity has been parked for later consideration.
104
+ COMPLETE (str): Activity has been completed.
105
+ IDEATION (str): Activity is in the ideation phase.
106
+ TWO_D_SELECTION (str): Activity is in 2D selection phase.
107
+ COMPUTATION (str): Activity is in the computation phase.
108
+ COMPOUND_SELECTION (str): Activity is in the compound selection phase.
109
+ SELECTED (str): Activity or compound has been selected.
110
+ QUEUE_FOR_SYNTHESIS (str): Activity is queued for synthesis.
111
+ DATA_REVIEW (str): Activity is in the data review phase.
112
+ DONE (str): Activity is done.
113
+
114
+ Example:
115
+ ```python
116
+ from kalbio.activities import ActivityStatusEnum
117
+
118
+ status = ActivityStatusEnum.IN_PROGRESS
119
+ print(status.value)
120
+ ```
121
+ """
122
+
123
+ REQUESTED = "requested"
124
+ TODO = "to do"
125
+ IN_PROGRESS = "in progress"
126
+ NEEDS_REVIEW = "needs review"
127
+ BLOCKED = "blocked"
128
+ PAUSED = "paused"
129
+ CANCELLED = "cancelled"
130
+ IN_REVIEW = "in review"
131
+ LOCKED = "locked"
132
+
133
+ TO_REVIEW = "to review"
134
+ UPLOAD_COMPLETE = "upload complete"
135
+
136
+ NEW = "new"
137
+ IN_DESIGN = "in design"
138
+ READY_FOR_MAKE = "ready for make"
139
+ IN_SYNTHESIS = "in synthesis"
140
+ IN_TEST = "in test"
141
+ IN_ANALYSIS = "in analysis"
142
+ PARKED = "parked"
143
+ COMPLETE = "complete"
144
+
145
+ IDEATION = "ideation"
146
+ TWO_D_SELECTION = "2D selection"
147
+ COMPUTATION = "computation"
148
+ COMPOUND_SELECTION = "compound selection"
149
+ SELECTED = "selected"
150
+ QUEUE_FOR_SYNTHESIS = "queue for synthesis"
151
+ DATA_REVIEW = "data review"
152
+
153
+ DONE = "done"
154
+
155
+
156
+ type ActivityType = Union[
157
+ Literal["task"],
158
+ Literal["experiment"],
159
+ Literal["project"],
160
+ Literal["stage"],
161
+ Literal["milestone"],
162
+ Literal["cycle"],
163
+ ]
164
+ """Type alias representing the valid types of activities in the system.
165
+
166
+ This type defines the allowed string values for the `activity_type` field
167
+ in Activity and ActivityDefinition models.
168
+ """
169
+
170
+ ACTIVITY_TYPE_TO_LABEL: dict[ActivityType, str] = {
171
+ "task": "Task",
172
+ "experiment": "Experiment",
173
+ "project": "Project",
174
+ "stage": "Stage",
175
+ "milestone": "Milestone",
176
+ "cycle": "Design cycle",
177
+ }
178
+ """Dictionary mapping activity type keys to their human-readable labels.
179
+
180
+ This mapping is used to convert the internal `activity_type` identifiers
181
+ into display-friendly strings for UI and reporting purposes.
182
+ """
183
+
184
+
185
+ class Property(_KaleidoscopeBaseModel):
186
+ """Represents a property in the Kaleidoscope system.
187
+
188
+ A Property is a data field associated with an entity that contains a value of a specific type.
189
+ It includes metadata about when and by whom it was created/updated, and provides methods
190
+ to update its content.
191
+
192
+ Attributes:
193
+ id (str): UUID of the property.
194
+ property_field_id (str): UUID to the property field that defines this
195
+ property's schema.
196
+ content (Any): The actual value/content stored in this property.
197
+ created_at (datetime): Timestamp when the property was created.
198
+ last_updated_by (str): UUID of the user who last updated this property.
199
+ created_by (str): UUID of the user who created this property.
200
+ property_name (str): Human-readable name of the property.
201
+ field_type (DataFieldTypeEnum): The data type of this property's content.
202
+
203
+ Example:
204
+ ```python
205
+ from kalbio.activities import Property
206
+
207
+ prop = Property(
208
+ id="prop_uuid",
209
+ property_field_id="field_uuid",
210
+ content="In progress",
211
+ created_at=datetime.utcnow(),
212
+ last_updated_by="user_uuid",
213
+ created_by="user_uuid",
214
+ property_name="Status",
215
+ field_type=DataFieldTypeEnum.TEXT,
216
+ )
217
+ print(prop.property_name, prop.content)
218
+ ```
219
+ """
220
+
221
+ property_field_id: str
222
+ content: Any
223
+ created_at: datetime
224
+ last_updated_by: str
225
+ created_by: str
226
+ property_name: str
227
+ field_type: DataFieldTypeEnum
228
+
229
+ def __str__(self):
230
+ return f"Property({self.property_name}:{self.content})"
231
+
232
+ def update_property(self, property_value: Any) -> None:
233
+ """Update the property with a new value.
234
+
235
+ Args:
236
+ property_value: The new value to set for the property.
237
+
238
+ Example:
239
+ ```python
240
+ prop.update_property("Reviewed")
241
+ ```
242
+ """
243
+ try:
244
+ resp = self._client._put(
245
+ "/properties/" + self.id, {"content": property_value}
246
+ )
247
+ if resp:
248
+ for key, value in resp.items():
249
+ if hasattr(self, key):
250
+ setattr(self, key, value)
251
+ except Exception as e:
252
+ _logger.error(f"Error updating property {self.id}: {e}")
253
+ return None
254
+
255
+ def update_property_file(
256
+ self,
257
+ file_name: str,
258
+ file_data: BinaryIO,
259
+ file_type: str,
260
+ ) -> dict | None:
261
+ """Update a property by uploading a file.
262
+
263
+ Args:
264
+ file_name: The name of the file to be updated.
265
+ file_data: The binary data of the file to be updated.
266
+ file_type: The MIME type of the file to be updated.
267
+
268
+ Returns:
269
+ A dict of response JSON data (contains reference to the
270
+ uploaded file) if request successful, otherwise None.
271
+
272
+ Example:
273
+ ```python
274
+ with open("report.pdf", "rb") as file_data:
275
+ upload_info = prop.update_property_file(
276
+ file_name="report.pdf",
277
+ file_data=file_data,
278
+ file_type="application/pdf",
279
+ )
280
+ ```
281
+ """
282
+ try:
283
+ resp = self._client._post_file(
284
+ "/properties/" + self.id + "/file",
285
+ (file_name, file_data, file_type),
286
+ )
287
+ if resp is None or len(resp) == 0:
288
+ return None
289
+
290
+ return resp
291
+ except Exception as e:
292
+ _logger.error(f"Error adding file to property {self.id}: {e}")
293
+ return None
294
+
295
+
296
+ class ActivityDefinition(_KaleidoscopeBaseModel):
297
+ """Represents the definition of an activity in the Kaleidoscope system.
298
+
299
+ An ActivityDefinition contains a template for the metadata about a task or activity,
300
+ including associated programs, users, groups, labels, and properties.
301
+
302
+ Attributes:
303
+ id (str): UUID of the Activity Definition.
304
+ program_ids (List[str]): List of program UUIDs associated with this activity.
305
+ title (str): The title of the activity.
306
+ activity_type (ActivityType): The type/category of the activity.
307
+ status (Optional[ActivityStatusEnum]): The current status of the activity.
308
+ Defaults to None if not specified.
309
+ assigned_user_ids (List[str]): List of user IDs assigned to this activity.
310
+ assigned_group_ids (List[str]): List of group IDs assigned to this activity.
311
+ label_ids (List[str]): List of label identifiers associated with this activity.
312
+ properties (List[Property]): List of properties that define additional
313
+ characteristics of the activity.
314
+ external_id (Optional[str]): The id of the activity definition if it was imported from an external source
315
+
316
+ Example:
317
+ ```python
318
+ definition = client.activities.get_definition_by_id("definition_uuid")
319
+ if definition:
320
+ print(definition.title, definition.activity_type)
321
+ ```
322
+ """
323
+
324
+ program_ids: List[str]
325
+ title: str
326
+ activity_type: ActivityType
327
+ status: Optional[ActivityStatusEnum] = None
328
+ assigned_user_ids: List[str]
329
+ assigned_group_ids: List[str]
330
+ label_ids: List[str]
331
+ properties: List[Property]
332
+ external_id: Optional[str] = None
333
+
334
+ def __str__(self):
335
+ return f"{self.id}:{self.title}"
336
+
337
+ @cached_property
338
+ def activities(self) -> List[Activity]:
339
+ """Get the activities for this activity definition.
340
+
341
+ Returns:
342
+ The activities associated with this
343
+ activity definition.
344
+
345
+ Note:
346
+ This is a cached property.
347
+
348
+ Example:
349
+ ```python
350
+ definition = client.activities.get_definition_by_id("definition_uuid")
351
+ related = definition.activities if definition else []
352
+ ```
353
+ """
354
+ return [
355
+ a
356
+ for a in self._client.activities.get_activities()
357
+ if a.definition_id == self.id
358
+ ]
359
+
360
+
361
+ class Activity(_KaleidoscopeBaseModel):
362
+ """Represents an activity (e.g. task or experiment) within the Kaleidoscope system.
363
+
364
+ An Activity is a unit of work that can be assigned to users or groups, have dependencies,
365
+ and contain associated records and properties. Activities can be organized hierarchically
366
+ with parent-child relationships and linked to programs.
367
+
368
+ Attributes:
369
+ id (str): Unique identifier for the model instance.
370
+ created_at (datetime): The timestamp when the activity was created.
371
+ parent_id (Optional[str]): The ID of the parent activity, if this is a child activity.
372
+ child_ids (List[str]): List of child activity IDs.
373
+ definition_id (Optional[str]): The ID of the activity definition template.
374
+ program_ids (List[str]): List of program IDs this activity belongs to.
375
+ activity_type (ActivityType): The type/category of the activity.
376
+ title (str): The title of the activity.
377
+ description (Any): Detailed description of the activity.
378
+ status (ActivityStatusEnum): Current status of the activity.
379
+ assigned_user_ids (List[str]): List of user IDs assigned to this activity.
380
+ assigned_group_ids (List[str]): List of group IDs assigned to this activity.
381
+ due_date (Optional[datetime]): The deadline for completing the activity.
382
+ start_date (Optional[datetime]): The scheduled start date for the activity.
383
+ duration (Optional[int]): Expected duration of the activity.
384
+ completed_at_date (Optional[datetime]): The timestamp when the activity was completed.
385
+ dependencies (List[str]): List of activity IDs that this activity depends on.
386
+ label_ids (List[str]): List of label IDs associated with this activity.
387
+ is_draft (bool): Whether the activity is in draft status.
388
+ properties (List[Property]): List of custom properties associated with the activity.
389
+ external_id (Optional[str]): The id of the activity if it was imported from an external source
390
+ all_record_ids (List[str]): All record IDs associated with the activity across operations.
391
+
392
+ Example:
393
+ ```python
394
+ activity = client.activities.get_activity_by_id("activity_uuid")
395
+ if activity:
396
+ print(activity.title, activity.status)
397
+ first_record = activity.records[0] if activity.records else None
398
+ ```
399
+ """
400
+
401
+ created_at: datetime
402
+ parent_id: Optional[str] = None
403
+ child_ids: List[str]
404
+ definition_id: Optional[str] = None
405
+ program_ids: List[str]
406
+ activity_type: ActivityType
407
+ title: str
408
+ description: Any
409
+ status: ActivityStatusEnum
410
+ assigned_user_ids: List[str]
411
+ assigned_group_ids: List[str]
412
+ due_date: Optional[datetime] = None
413
+ start_date: Optional[datetime] = None
414
+ duration: Optional[int] = None
415
+ completed_at_date: Optional[datetime] = None
416
+ dependencies: List[str]
417
+ label_ids: List[str]
418
+ is_draft: bool
419
+ properties: List[Property]
420
+ external_id: Optional[str] = None
421
+
422
+ # operation fields
423
+ all_record_ids: List[str]
424
+
425
+ def __str__(self):
426
+ return f'Activity("{self.title}")'
427
+
428
+ @cached_property
429
+ def activity_definition(self) -> ActivityDefinition | None:
430
+ """Get the activity definition for this activity.
431
+
432
+ Returns:
433
+ The activity definition associated with this
434
+ activity. If the activity has no definition, returns None.
435
+
436
+ Note:
437
+ This is a cached property.
438
+
439
+ Example:
440
+ ```python
441
+ definition = activity.activity_definition
442
+ print(definition.title if definition else "No template")
443
+ ```
444
+ """
445
+ if self.definition_id:
446
+ return self._client.activities.get_definition_by_id(self.definition_id)
447
+ else:
448
+ return None
449
+
450
+ @cached_property
451
+ def assigned_users(self) -> List[WorkspaceUser]:
452
+ """Get the assigned users for this activity.
453
+
454
+ Returns:
455
+ The users assigned to this activity.
456
+
457
+ Note:
458
+ This is a cached property.
459
+ """
460
+ return self._client.workspace.get_members_by_ids(self.assigned_user_ids)
461
+
462
+ @cached_property
463
+ def assigned_groups(self) -> List[WorkspaceGroup]:
464
+ """Get the assigned groups for this activity.
465
+
466
+ Returns:
467
+ The groups assigned to this activity.
468
+
469
+ Note:
470
+ This is a cached property.
471
+ """
472
+ return self._client.workspace.get_groups_by_ids(self.assigned_group_ids)
473
+
474
+ @cached_property
475
+ def labels(self) -> List[Label]:
476
+ """Get the labels for this activity.
477
+
478
+ Returns:
479
+ The labels associated with this activity.
480
+
481
+ Note:
482
+ This is a cached property.
483
+
484
+ Example:
485
+ ```python
486
+ label_names = [label.name for label in activity.labels]
487
+ ```
488
+ """
489
+ return self._client.labels.get_labels_by_ids(self.label_ids)
490
+
491
+ @cached_property
492
+ def programs(self) -> List[Program]:
493
+ """Retrieve the programs associated with this activity.
494
+
495
+ Returns:
496
+ A list of Program instances fetched by their IDs.
497
+
498
+ Note:
499
+ This is a cached property.
500
+
501
+ Example:
502
+ ```python
503
+ program_titles = [program.title for program in activity.programs]
504
+ ```
505
+ """
506
+ return self._client.programs.get_programs_by_ids(self.program_ids)
507
+
508
+ @cached_property
509
+ def child_activities(self) -> List[Activity]:
510
+ """Retrieve the child activities associated with this activity.
511
+
512
+ Returns:
513
+ A list of Activity objects representing the child activities.
514
+
515
+ Note:
516
+ This is a cached property.
517
+ """
518
+ try:
519
+ resp = self._client._get("/activities/" + self.id + "/activities")
520
+ return self._client.activities._create_activity_list(resp)
521
+ except Exception as e:
522
+ _logger.error(f"Error fetching child activities: {e}")
523
+ return []
524
+
525
+ @property
526
+ def records(self) -> List["Record"]:
527
+ """Retrieve the records associated with this activity.
528
+
529
+ Returns:
530
+ A list of Record objects corresponding to the activity.
531
+
532
+ Note:
533
+ This is a cached property.
534
+ """
535
+ try:
536
+ resp = self._client._get("/operations/" + self.id + "/records")
537
+ return [
538
+ rec for rec in self._client.records._create_record_list(resp) if rec
539
+ ]
540
+ except Exception as e:
541
+ _logger.error(f"Error fetching records: {e}")
542
+ return []
543
+
544
+ def get_record(self, identifier: RecordIdentifier) -> Record | None:
545
+ """Retrieves the record with the given identifier if it is in the operation.
546
+
547
+ Args:
548
+ identifier: An identifier for a Record.
549
+
550
+ This method will accept and resolve any type of RecordIdentifier.
551
+
552
+ Returns:
553
+ The record if it is in the operation, otherwise None
554
+
555
+ Example:
556
+ ```python
557
+ record = activity.get_record("record_uuid")
558
+ ```
559
+ """
560
+ idx = self._client.records._resolve_to_record_id(identifier)
561
+
562
+ if idx is None:
563
+ return None
564
+
565
+ return next(
566
+ (r for r in self.records if r.id == idx),
567
+ None,
568
+ )
569
+
570
+ def has_record(self, identifier: RecordIdentifier) -> bool:
571
+ """Retrieve whether a record with the given identifier is in the operation
572
+
573
+ Args:
574
+ identifier: An identifier for a Record.
575
+
576
+ This method will accept and resolve any type of RecordIdentifier.
577
+
578
+ Returns:
579
+ Whether the record is in the operation
580
+
581
+ Example:
582
+ ```python
583
+ has_link = activity.has_record("record_uuid")
584
+ ```
585
+ """
586
+ return self.get_record(identifier) is not None
587
+
588
+ def update(self, **kwargs: Any) -> None:
589
+ """Update the activity with the provided keyword arguments.
590
+
591
+ Args:
592
+ **kwargs: Arbitrary keyword arguments representing fields to update
593
+ for the activity.
594
+
595
+ Note:
596
+ After calling update(), cached properties may be stale. Re-fetch the activity if needed.
597
+
598
+ Example:
599
+ ```python
600
+ activity.update(status=ActivityStatusEnum.IN_PROGRESS)
601
+ ```
602
+ """
603
+ try:
604
+ resp = self._client._put("/activities/" + self.id, kwargs)
605
+ if resp:
606
+ for key, value in resp.items():
607
+ if hasattr(self, key):
608
+ setattr(self, key, value)
609
+ except Exception as e:
610
+ _logger.error(f"Error updating activity: {e}")
611
+ return None
612
+
613
+ def add_records(self, record_ids: List[str]) -> None:
614
+ """Add a list of record IDs to the activity.
615
+
616
+ Args:
617
+ record_ids: A list of record IDs to be added to the activity.
618
+
619
+ Example:
620
+ ```python
621
+ activity.add_records(["record_uuid_1", "record_uuid_2"])
622
+ ```
623
+ """
624
+ try:
625
+ self._client._put(
626
+ "/operations/" + self.id + "/records", {"record_ids": record_ids}
627
+ )
628
+ except Exception as e:
629
+ _logger.error(f"Error adding record: {e}")
630
+ return None
631
+
632
+ def get_record_data(self) -> List[dict]:
633
+ """Retrieve data from all this activity's associated records.
634
+
635
+ Returns:
636
+ A list containing the activity data for each record,
637
+ obtained by calling get_activity_data with the current activity's UUID.
638
+
639
+ Example:
640
+ ```python
641
+ data = activity.get_record_data()
642
+ ```
643
+ """
644
+ data = []
645
+ for record in self.records:
646
+ data.append(record.get_activity_data(self.id))
647
+ return data
648
+
649
+ def refetch(self):
650
+ """Refreshes all the data of the current activity instance.
651
+
652
+ The activity is also removed from all local caches of its associated client.
653
+
654
+ Automatically called by mutating methods of this activity, but can also be called manually.
655
+
656
+ Example:
657
+ ```python
658
+ activity.refetch()
659
+ up_to_date_records = activity.records
660
+ ```
661
+ """
662
+ self._client.activities._clear_activity_caches()
663
+
664
+ new = self._client.activities.get_activity_by_id(self.id)
665
+
666
+ if new is None:
667
+ _logger.error(f"Unable to refresh Activity({self.id})")
668
+ return None
669
+
670
+ for k, v in new.__dict__.items():
671
+ setattr(self, k, v)
672
+
673
+
674
+ type ActivityIdentifier = Union[Activity, str]
675
+ """Identifier class for Activity
676
+
677
+ Activities are able to be identified by:
678
+
679
+ * an object instance of an Activity
680
+ * title
681
+ * UUID
682
+ """
683
+
684
+ type DefinitionIdentifier = Union[ActivityDefinition, str]
685
+ """Identifier class for ActivityDefinition
686
+
687
+ ActivityDefinitions are able to be identified by:
688
+
689
+ * an object instance of an ActivityDefinition
690
+ * title
691
+ * UUID
692
+ """
693
+
694
+
695
+ class ActivitiesService:
696
+ """Service class for managing activities in the Kaleidoscope platform.
697
+
698
+ This service provides methods to create, retrieve, and manage activities
699
+ (tasks/experiments) and their definitions within a Kaleidoscope workspace.
700
+ It handles activity lifecycle operations including creation, retrieval by
701
+ ID or associated records, and batch operations.
702
+
703
+ Note:
704
+ Some methods use LRU caching to improve performance. Cache is cleared on errors.
705
+ """
706
+
707
+ def __init__(self, client: KaleidoscopeClient):
708
+ self._client = client
709
+
710
+ #########################
711
+ # Public Methods #
712
+ #########################
713
+
714
+ ##### for Activities #####
715
+
716
+ @lru_cache
717
+ def get_activities(self) -> List[Activity]:
718
+ """Retrieve all activities in the workspace, including experiments.
719
+
720
+ Returns:
721
+ A list of Activity objects representing the activities
722
+ in the workspace.
723
+
724
+ Note:
725
+ This method caches its results. If an exception occurs, logs the error,
726
+ clears the cache, and returns an empty list.
727
+
728
+ Example:
729
+ ```python
730
+ activities = client.activities.get_activities()
731
+ ```
732
+ """
733
+ try:
734
+ resp = self._client._get("/activities")
735
+ return self._create_activity_list(resp)
736
+ except Exception as e:
737
+ _logger.error(f"Error fetching activities: {e}")
738
+ self._clear_activity_caches()
739
+ return []
740
+
741
+ def get_activity_by_type(self, activity_type: ActivityType) -> List[Activity]:
742
+ """Retrieve all activities of a certain type in the workspace.
743
+
744
+ Args:
745
+ activity_type: The type of `Activity` to retrieve.
746
+
747
+ Returns:
748
+ A list of Activity objects with the type of `activity_type`
749
+
750
+ Example:
751
+ ```python
752
+ experiments = client.activities.get_activity_by_type("experiment")
753
+ tasks = client.activities.get_activity_by_type("task")
754
+ ```
755
+ """
756
+
757
+ return [
758
+ act for act in self.get_activities() if act.activity_type == activity_type
759
+ ]
760
+
761
+ def get_activity_by_id(self, activity_id: ActivityIdentifier) -> Activity | None:
762
+ """Retrieve an activity by its identifier.
763
+
764
+ Args:
765
+ activity_id: An identifier of the activity to retrieve.
766
+
767
+ This method will accept and resolve any type of ActivityIdentifier.
768
+
769
+ Returns:
770
+ The Activity object if found, otherwise None.
771
+
772
+ Example:
773
+ ```python
774
+ activity = client.activities.get_activity_by_id("activity_uuid")
775
+ ```
776
+ """
777
+ id_to_activity = self._get_activity_id_map()
778
+ identifier = self._resolve_activity_id(activity_id)
779
+
780
+ if identifier is None:
781
+ return None
782
+
783
+ return id_to_activity.get(identifier, None)
784
+
785
+ def get_activities_by_ids(self, ids: List[ActivityIdentifier]) -> List[Activity]:
786
+ """Fetch multiple activities by their identifiers.
787
+
788
+ Args:
789
+ ids: A list of activity identifier strings to fetch.
790
+
791
+ This method will accept and resolve any type of ActivityIdentifier inside the `ids`.
792
+
793
+ Returns:
794
+ A list of Activity objects corresponding to the provided IDs.
795
+
796
+ Note:
797
+ ids that are invalid and return None are not included in the returned list of Activities
798
+
799
+ Example:
800
+ ```python
801
+ selected = client.activities.get_activities_by_ids([
802
+ "activity_uuid_1",
803
+ "activity_uuid_2",
804
+ ])
805
+ ```
806
+ """
807
+ activities = []
808
+
809
+ for activity_id in ids:
810
+ res = self.get_activity_by_id(activity_id)
811
+ if res:
812
+ activities.append(res)
813
+
814
+ return activities
815
+
816
+ def get_activity_by_external_id(self, external_id: str) -> Activity | None:
817
+ """Retrieve an activity by its external identifier.
818
+
819
+ Args:
820
+ external_id: The external identifier of the activity to retrieve.
821
+
822
+ Returns:
823
+ The Activity object if found, otherwise None.
824
+
825
+ Example:
826
+ ```python
827
+ ext_activity = client.activities.get_activity_by_external_id("jira-123")
828
+ ```
829
+ """
830
+ activities = self.get_activities()
831
+ return next(
832
+ (a for a in activities if a.external_id == external_id),
833
+ None,
834
+ )
835
+
836
+ def create_activity(
837
+ self,
838
+ title: str,
839
+ activity_type: ActivityType,
840
+ program_ids: Optional[list[str]] = None,
841
+ activity_definition_id: Optional[DefinitionIdentifier] = None,
842
+ assigned_user_ids: Optional[List[str]] = None,
843
+ start_date: Optional[datetime] = None,
844
+ duration: Optional[int] = None,
845
+ ) -> Activity | None:
846
+ """Create a new activity.
847
+
848
+ Args:
849
+ title: The title/name of the activity.
850
+ activity_type: The type of activity (e.g. task, experiment, etc.).
851
+ program_ids: List of program IDs to associate with
852
+ the activity. Defaults to None.
853
+ activity_definition_id: Identifier for an activity definition to create the activity with.
854
+ Defaults to None.
855
+
856
+ The identifier will resolve any type of DefinitionIdentifier.
857
+ assigned_user_ids: List of user IDs to assign to
858
+ the activity. Defaults to None.
859
+ start_date: Start date for the activity. Defaults to None.
860
+ duration: Duration in days for the activity. Defaults to None.
861
+
862
+ Returns:
863
+ The newly created activity instance or None if activity
864
+ creation was not successful.
865
+
866
+ Example:
867
+ ```python
868
+ new_activity = client.activities.create_activity(
869
+ title="Synthesis",
870
+ activity_type="experiment",
871
+ program_ids=["program_uuid"],
872
+ )
873
+ ```
874
+ """
875
+ self._clear_activity_caches()
876
+
877
+ try:
878
+ payload = {
879
+ "program_ids": program_ids,
880
+ "title": title,
881
+ "activity_type": activity_type,
882
+ "definition_id": self._resolve_definition_id(activity_definition_id),
883
+ "record_ids": [],
884
+ "assigned_user_ids": assigned_user_ids,
885
+ "start_date": start_date.isoformat() if start_date else None,
886
+ "duration": duration,
887
+ }
888
+ resp = self._client._post("/activities", payload)
889
+ return self._create_activity(resp[0])
890
+ except Exception as e:
891
+ _logger.error(f"Error creating activity {title}: {e}")
892
+ return None
893
+
894
+ @cachetools.func.ttl_cache(maxsize=128, ttl=10)
895
+ def get_activities_with_record(self, record_id: RecordIdentifier) -> List[Activity]:
896
+ """Retrieve all activities that contain a specific record.
897
+
898
+ Args:
899
+ record_id: Identifier for the record.
900
+
901
+ Any type of RecordIdentifier will be accepted.
902
+
903
+ Returns:
904
+ Activities that include the specified record.
905
+
906
+ Note:
907
+ If an exception occurs, logs the error and returns an empty list.
908
+
909
+ Example:
910
+ ```python
911
+ activities = client.activities.get_activities_with_record("record_uuid")
912
+ ```
913
+ """
914
+ record_uuid = self._client.records._resolve_to_record_id(record_id)
915
+ if record_uuid is None:
916
+ return []
917
+
918
+ try:
919
+ resp = self._client._get("/records/" + record_uuid + "/operations")
920
+ return self._create_activity_list(resp)
921
+ except Exception as e:
922
+ _logger.error(f"Error fetching activities with record {record_id}: {e}")
923
+ self.get_activities_with_record.cache_clear()
924
+ return []
925
+
926
+ ##### for ActivityDefinitions #####
927
+ @lru_cache
928
+ def get_definitions(self) -> List[ActivityDefinition]:
929
+ """Retrieve all available activity definitions.
930
+
931
+ Returns:
932
+ All activity definitions in the workspace.
933
+
934
+ Raises:
935
+ ValidationError: If the data could not be validated as an ActivityDefinition.
936
+
937
+ Note:
938
+ This method caches its results. If an exception occurs, logs the error,
939
+ clears the cache, and returns an empty list.
940
+
941
+ Example:
942
+ ```python
943
+ definitions = client.activities.get_definitions()
944
+ ```
945
+ """
946
+ try:
947
+ resp = self._client._get("/activity_definitions")
948
+ return [self._create_activity_definition(data) for data in resp]
949
+
950
+ except Exception as e:
951
+ _logger.error(f"Error fetching activity definitions: {e}")
952
+ self._clear_definition_caches()
953
+ return []
954
+
955
+ def get_definition_by_id(
956
+ self, definition_id: DefinitionIdentifier
957
+ ) -> ActivityDefinition | None:
958
+ """Retrieve an activity definition by ID (UUID or name)
959
+
960
+ Args:
961
+ definition_id: Identifier for the activity definition.
962
+
963
+ This method will accept and resolve any type of DefinitionIdentifier.
964
+
965
+ Returns:
966
+ The activity definition if found, None otherwise.
967
+
968
+ Example:
969
+ ```python
970
+ definition = client.activities.get_definition_by_id("definition_uuid")
971
+ ```
972
+ """
973
+ id_map = self._get_definition_id_map()
974
+ identifier = self._resolve_definition_id(definition_id)
975
+
976
+ if identifier is None:
977
+ return None
978
+ else:
979
+ return id_map.get(identifier, None)
980
+
981
+ def get_definitions_by_ids(
982
+ self, ids: List[DefinitionIdentifier]
983
+ ) -> List[ActivityDefinition]:
984
+ """Retrieve activity definitions by their identifiers
985
+
986
+ Args:
987
+ ids: List of definition identifiers to retrieve.
988
+
989
+ This method will accept and resolve all types of DefinitionIdentifier.
990
+
991
+ Returns:
992
+ List of found activity definitions.
993
+
994
+ Example:
995
+ ```python
996
+ defs = client.activities.get_definitions_by_ids(["def1", "def2"])
997
+ ```
998
+ """
999
+ definitions = []
1000
+
1001
+ for definition_id in ids:
1002
+ res = self.get_definition_by_id(definition_id)
1003
+ if res:
1004
+ definitions.append(res)
1005
+
1006
+ return definitions
1007
+
1008
+ def get_activity_definition_by_external_id(
1009
+ self, external_id: str
1010
+ ) -> ActivityDefinition | None:
1011
+ """Retrieve an activity definition by its external identifier.
1012
+
1013
+ Args:
1014
+ external_id: The external identifier of the activity definition to retrieve.
1015
+
1016
+ Returns:
1017
+ The ActivityDefinition object if found, otherwise None.
1018
+
1019
+ Example:
1020
+ ```python
1021
+ definition = client.activities.get_activity_definition_by_external_id("jira-def-7")
1022
+ ```
1023
+ """
1024
+ definitions = self.get_definitions()
1025
+ return next(
1026
+ (d for d in definitions if d.external_id == external_id),
1027
+ None,
1028
+ )
1029
+
1030
+ #########################
1031
+ # Private Methods #
1032
+ #########################
1033
+
1034
+ ##### for Activities #####
1035
+
1036
+ def _create_activity(self, data: dict) -> Activity:
1037
+ """Convert a dictionary of activity data into a validated Activity object.
1038
+
1039
+ Args:
1040
+ data: A dictionary containing the activity information.
1041
+
1042
+ Returns:
1043
+ An activity object created from the provided data, with the
1044
+ client set.
1045
+
1046
+ Raises:
1047
+ ValidationError: If the data could not be validated as an Activity.
1048
+ """
1049
+ activity = Activity.model_validate(data)
1050
+ activity._set_client(self._client)
1051
+
1052
+ return activity
1053
+
1054
+ def _create_activity_list(self, data: list[dict]) -> List[Activity]:
1055
+ """Convert input data into a list of Activity objects.
1056
+
1057
+ Args:
1058
+ data: The input data to be converted into Activity objects.
1059
+
1060
+ Returns:
1061
+ A list of Activity objects with clients set.
1062
+
1063
+ Raises:
1064
+ ValidationError: If the data could not be validated as a list of
1065
+ Activity objects.
1066
+ """
1067
+ return [self._create_activity(d) for d in data]
1068
+
1069
+ @lru_cache
1070
+ def _get_activity_id_map(self) -> dict[str, Activity]:
1071
+ """gets a dict that maps uuids to their corresponding Activity
1072
+
1073
+ Returns:
1074
+ a map of uuid to Activity
1075
+ """
1076
+ return {activity.id: activity for activity in self.get_activities()}
1077
+
1078
+ @lru_cache
1079
+ def _get_activity_title_map(self) -> dict[str, Activity]:
1080
+ """gets a dict that maps an activity's title to its object instance
1081
+
1082
+ Returns:
1083
+ str-to-Activity dict that maps titles to Activity
1084
+ """
1085
+ return {activity.title: activity for activity in self.get_activities()}
1086
+
1087
+ def _resolve_activity_id(self, identifier: ActivityIdentifier | None) -> str | None:
1088
+ """Resolves an ActivityIdentifier.
1089
+
1090
+ Will get the corresponding uuid of Activity based on the identifier.
1091
+
1092
+ Identifiers will be resolved, while `None` will always return `None`.
1093
+
1094
+ Args:
1095
+ identifier: Identifier for an Activity or None.
1096
+
1097
+ Returns:
1098
+ Returns an Activity's UUID for a valid ActivityIdentifier, else returns None
1099
+ """
1100
+ if identifier is None:
1101
+ return None
1102
+
1103
+ if isinstance(identifier, Activity):
1104
+ return identifier.id
1105
+
1106
+ id_map = self._get_activity_id_map()
1107
+ if identifier in id_map:
1108
+ return identifier
1109
+
1110
+ name_map = self._get_activity_title_map()
1111
+ activity = name_map.get(identifier)
1112
+ if activity:
1113
+ return activity.id
1114
+
1115
+ _logger.error(f"Activity not found: {identifier}")
1116
+ return None
1117
+
1118
+ def _clear_activity_caches(self):
1119
+ """Clears all caches of Activity objects
1120
+
1121
+ Call when any activity is created, removed, or updated
1122
+ """
1123
+ self.get_activities.cache_clear()
1124
+ self._get_activity_id_map.cache_clear()
1125
+ self._get_activity_title_map.cache_clear()
1126
+
1127
+ ##### for ActivityDefinitions #####
1128
+
1129
+ def _create_activity_definition(self, data: dict) -> ActivityDefinition:
1130
+ """Creates an ActivityDefinition based on API data
1131
+
1132
+ Args:
1133
+ data: dict of json data
1134
+
1135
+ Returns:
1136
+ validated ActivityDefinition
1137
+
1138
+ Raises:
1139
+ ValidationError: if data can not be validated
1140
+ """
1141
+ activity_definition = ActivityDefinition.model_validate(data)
1142
+ activity_definition._set_client(self._client)
1143
+
1144
+ return activity_definition
1145
+
1146
+ @lru_cache
1147
+ def _get_definition_id_map(self) -> dict[str, ActivityDefinition]:
1148
+ """get a map of uuids to their respective activity definition.
1149
+
1150
+ Returns:
1151
+ A mapping of uuid-to-ActivityDefinition
1152
+ """
1153
+ return {definition.id: definition for definition in self.get_definitions()}
1154
+
1155
+ @lru_cache
1156
+ def _get_definition_title_map(self) -> dict[str, ActivityDefinition]:
1157
+ """get a map of an ActivityDefinition's title to their respective ActivityDefinition
1158
+
1159
+ Returns:
1160
+ A mapping of title-to-Activity-Definition
1161
+ """
1162
+ return {definition.title: definition for definition in self.get_definitions()}
1163
+
1164
+ def _resolve_definition_id(
1165
+ self, identifier: DefinitionIdentifier | None
1166
+ ) -> str | None:
1167
+ """Resolve an ActivityDefinitionIdentifier to its corresponding uuid.
1168
+
1169
+ Will return the corresponding UUID of given identifiers, and will always return `None` if the identifier is `None`.
1170
+
1171
+ Args:
1172
+ identifier: An identifier for ActivityDefinition.
1173
+
1174
+ Returns:
1175
+ Return the corresponding UUID if the identifier is valid, else returns None
1176
+ """
1177
+ if identifier is None:
1178
+ return None
1179
+
1180
+ if isinstance(identifier, ActivityDefinition):
1181
+ return identifier.id
1182
+
1183
+ id_map = self._get_definition_id_map()
1184
+ if identifier in id_map: # check by uuid
1185
+ return identifier
1186
+
1187
+ name_map = self._get_definition_title_map()
1188
+ definition = name_map.get(identifier)
1189
+ if definition: # check by title
1190
+ return definition.id
1191
+
1192
+ _logger.error(f"Definition not found: {identifier}")
1193
+ return None
1194
+
1195
+ def _clear_definition_caches(self):
1196
+ """Clears all caches of ActivityDefinition objects
1197
+
1198
+ Call when any activity definition is created, removed, or updated
1199
+ """
1200
+ self.get_definitions.cache_clear()
1201
+ self._get_definition_id_map.cache_clear()
1202
+ self._get_definition_title_map.cache_clear()