from dataclasses import asdict, dataclass from typing import Self, TypedDict from layercake.dateutils import now, ttl from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from layercake.strutils import md5_hash from schemas import Enrollment Tenant = TypedDict('Tenant', {'id': str, 'name': str}) Author = TypedDict('Author', {'id': str, 'name': str}) DeduplicationWindow = TypedDict('DeduplicationWindow', {'offset_days': int}) class LinkedEntity(str): def __new__(cls, id: str, type: str) -> Self: return super().__new__(cls, '#'.join([type.lower(), id])) def __init__(self, id: str, type: str) -> None: # __init__ is used to store the parameters for later reference. # For immutable types like str, __init__ cannot change the instance's value. self.id = id self.type = type @dataclass(frozen=True) class Slot: id: str sk: str @property def order_id(self) -> LinkedEntity: idx, _ = self.sk.split('#') return LinkedEntity(idx, 'ORDER') class DeduplicationConflictError(Exception): def __init__(self, *args): super().__init__('Enrollment already exists') class SlotDoesNotExistError(Exception): def __init__(self, *args): super().__init__('Slot does not exist') def enroll( enrollment: Enrollment, *, slot: Slot | None = None, author: Author | None = None, linked_entities: frozenset[LinkedEntity] = frozenset(), deduplication_window: DeduplicationWindow | None = None, persistence_layer: DynamoDBPersistenceLayer, ) -> bool: """Enrolls a user into a course and schedules lifecycle events.""" now_ = now() user = enrollment.user course = enrollment.course lock_hash = md5_hash('%s%s' % (user.id, course.id)) with persistence_layer.transact_writer() as transact: if slot: linked_entities = frozenset({slot.order_id}) | linked_entities transact.put( item={ 'sk': '0', 'created_at': now_, **enrollment.model_dump(), }, ) transact.put( item={ 'id': enrollment.id, 'sk': 'METADATA#COURSE', 'created_at': now_, **course.model_dump(include={'cert', 'access_period'}), } ) for entity in linked_entities: keyprefix = entity.type.lower() transact.put( item={ 'id': enrollment.id, 'sk': f'LINKED_ENTITIES#{entity.type}', 'created_at': now_, f'{keyprefix}_id': entity.id, } ) if slot: transact.put( item={ 'id': enrollment.id, # Post-migration: uncomment the following line # 'sk': 'METADATA#SOURCE_SLOT', 'sk': 'parent_vacancy', 'vacancy': asdict(slot), 'created_at': now_, } ) transact.delete( key=KeyPair(slot.id, slot.sk), cond_expr='attribute_exists(sk)', exc_cls=SlotDoesNotExistError, ) transact.put( item={ 'id': enrollment.id, 'sk': 'CANCEL_POLICY', 'created_at': now_, } ) if author: transact.put( item={ 'id': enrollment.id, 'sk': 'author', 'user_id': author['id'], 'name': author['name'], 'created_at': now_, }, ) # Prevents the user from enrolling in the same course again until # the deduplication window expires or is removed. if deduplication_window: offset_days = deduplication_window['offset_days'] ttl_ = ttl( start_dt=now_, days=course.access_period - offset_days, ) transact.put( item={ 'id': 'LOCK', 'sk': lock_hash, 'enrollment_id': enrollment.id, 'created_at': now_, 'ttl': ttl_, }, cond_expr='attribute_not_exists(sk)', exc_cls=DeduplicationConflictError, ) transact.put( item={ 'id': enrollment.id, 'sk': 'LOCK', 'hash': lock_hash, 'created_at': now_, 'ttl': ttl_, }, ) # Deduplication window can be recalculated if needed transact.put( item={ 'id': enrollment.id, 'sk': 'METADATA#DEDUPLICATION_WINDOW', 'offset_days': offset_days, 'created_at': now_, }, ) else: transact.condition( key=KeyPair('LOCK', lock_hash), cond_expr='attribute_not_exists(sk)', exc_cls=DeduplicationConflictError, ) return True