from abc import ABC from dataclasses import dataclass from datetime import timedelta from enum import Enum from typing import NotRequired, TypedDict from layercake.dateutils import now, ttl from layercake.dynamodb import DynamoDBPersistenceLayer from layercake.strutils import md5_hash from config import DEDUP_WINDOW_OFFSET_DAYS from schemas import Enrollment Org = TypedDict( 'Org', { 'org_id': str, 'name': str, }, ) DeduplicationWindow = TypedDict( 'DeduplicationWindow', { 'offset_days': int, }, ) Subscription = TypedDict( 'Subscription', { 'org_id': str, 'billing_day': int, 'billing_period': NotRequired[str], }, ) class Kind(str, Enum): ORDER = 'ORDER' ENROLLMENT = 'ENROLLMENT' @dataclass(frozen=True) class LinkedEntity(ABC): id: str kind: Kind table_name: str | None = None class DeduplicationConflictError(Exception): def __init__(self, *args): super().__init__('Enrollment already exists') def enroll( enrollment: Enrollment, *, org: Org | None = None, subscription: Subscription | None = None, linked_entities: frozenset[LinkedEntity] = frozenset(), deduplication_window: DeduplicationWindow | None = None, persistence_layer: DynamoDBPersistenceLayer, ) -> bool: now_ = now() user = enrollment.user course = enrollment.course lock_hash = md5_hash('%s%s' % (user.id, course.id)) access_expires_at = now_ + timedelta(days=course.access_period) with persistence_layer.transact_writer() as transact: transact.put( item={ 'sk': '0', 'created_at': now_, 'access_expires_at': access_expires_at, **enrollment.model_dump(), } | ({'subscription_covered': True} if subscription else {}) | ({'org_id': org['org_id']} if org else {}), ) # Relationships between this enrollment and its related entities for entity in linked_entities: # Parent knows the child transact.put( item={ 'id': entity.id, 'sk': f'LINKED_ENTITIES#CHILD#ENROLLMENT#{enrollment.id}', 'created_at': now_, }, cond_expr='attribute_not_exists(sk)', table_name=entity.table_name, ) # Child knows the parent transact.put( item={ 'id': enrollment.id, 'sk': f'LINKED_ENTITIES#PARENT#{entity.kind.value}#{entity.id}', 'created_at': now_, }, cond_expr='attribute_not_exists(sk)', ) if org: transact.put( item={ 'id': enrollment.id, 'sk': 'ORG', 'created_at': now_, } | org ) if subscription: transact.put( item={ 'id': enrollment.id, 'sk': 'METADATA#SUBSCRIPTION_COVERED', 'created_at': now_, } | subscription, ) # Prevents the user from enrolling in the same course again until # the deduplication window expires or is removed. offset_days = ( int(deduplication_window['offset_days']) if deduplication_window else DEDUP_WINDOW_OFFSET_DAYS ) dedup_lock_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': dedup_lock_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': dedup_lock_ttl, }, ) # The deduplication window can be recalculated based on user settings. if deduplication_window: transact.put( item={ 'id': enrollment.id, 'sk': 'METADATA#DEDUPLICATION_WINDOW', 'offset_days': offset_days, 'created_at': now_, }, ) return True