from datetime import datetime, timedelta from typing import Literal, TypedDict from layercake.dateutils import now, ttl from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from layercake.extra_types import CpfStr, NameStr from layercake.strutils import md5_hash from pydantic import ( UUID4, BaseModel, ConfigDict, EmailStr, Field, ) from typing_extensions import NotRequired from config import DEDUP_WINDOW_OFFSET_DAYS, ORDER_TABLE, USER_TABLE class User(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) id: UUID4 | str name: NameStr email: EmailStr cpf: CpfStr | None = None class Course(BaseModel): id: UUID4 | str name: str access_period: int = 90 # 3 months class Enrollment(BaseModel): id: UUID4 | str user: User course: Course progress: int = Field(default=0, ge=0, le=100) status: Literal['PENDING'] = 'PENDING' Org = TypedDict('Org', {'org_id': str, 'name': str}) CreatedBy = TypedDict('CreatedBy', {'id': str, 'name': str}) Seat = TypedDict('Seat', {'order_id': str, 'enrollment_id': NotRequired[str]}) DeduplicationWindow = TypedDict('DeduplicationWindow', {'offset_days': int}) Subscription = TypedDict( 'Subscription', { 'org_id': str, 'billing_day': int, 'billing_period': NotRequired[str], }, ) class DeduplicationConflictError(Exception): def __init__(self, *args): super().__init__('Enrollment already exists') class SubscriptionRequiredError(Exception): def __init__(self, msg: str | dict): super().__init__('Subscription required') class SubscriptionFrozenError(Exception): def __init__(self, msg: str | dict): super().__init__('Subscription is frozen') class SeatNotFoundError(Exception): def __init__(self, msg: str | dict): super().__init__('Seat required') class OrderNotFoundError(Exception): def __init__(self, msg: str | dict): super().__init__('Order not found') def enroll( enrollment: Enrollment, *, org: Org | None = None, cancel_policy: bool = False, subscription: Subscription | None = None, created_by: CreatedBy | None = None, scheduled_at: datetime | None = None, seat: Seat | None = None, parent_entity: str | None = None, deduplication_window: DeduplicationWindow | None = None, persistence_layer: DynamoDBPersistenceLayer, ) -> bool: now_ = now() user = enrollment.user course = enrollment.course lock_hash = md5_hash(f'{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(), } | ({'org_id': org['org_id']} if org else {}) | ({'subscription_covered': True} if subscription else {}) | ({'scheduled_at': scheduled_at} if scheduled_at else {}) ) if cancel_policy: transact.put( item={ 'id': enrollment.id, 'sk': 'CANCEL_POLICY', 'created_at': now_, } # | ({'seat': seat} if seat else {}) ) if seat: order_id = seat['order_id'] transact.condition( key=KeyPair(order_id, '0'), cond_expr='attribute_exists(sk)', exc_cls=OrderNotFoundError, table_name=ORDER_TABLE, ) transact.put( item={ 'id': order_id, 'sk': f'ENROLLMENT#{enrollment.id}', 'course': course.model_dump(), 'user': user.model_dump(exclude={'cpf'}), 'status': 'EXECUTED', 'executed_at': now_, 'created_at': now_, }, table_name=ORDER_TABLE, ) # Enrollment should know where it comes from transact.put( item={ 'id': enrollment.id, 'sk': f'LINKED_ENTITY#PARENT#ORDER#{order_id}', 'created_at': now_, }, cond_expr='attribute_not_exists(sk)', ) if parent_entity: # Parent knows the child transact.put( item={ 'id': parent_entity, 'sk': f'LINKED_ENTITY#CHILD#ENROLLMENT#{enrollment.id}', 'created_at': now_, }, cond_expr='attribute_not_exists(sk)', ) # Child knows the parent transact.put( item={ 'id': enrollment.id, 'sk': f'LINKED_ENTITY#PARENT#ENROLLMENT#{parent_entity}', '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: org_id = subscription['org_id'] transact.put( item={ 'id': enrollment.id, 'sk': 'METADATA#SUBSCRIPTION_COVERED', 'billing_day': subscription['billing_day'], 'created_at': now_, } | subscription, ) transact.condition( key=KeyPair( pk='SUBSCRIPTION', sk=f'ORG#{org_id}', ), cond_expr='attribute_exists(sk)', exc_cls=SubscriptionRequiredError, table_name=USER_TABLE, ) transact.condition( key=KeyPair( pk='SUBSCRIPTION#FROZEN', sk=f'ORG#{org_id}', ), cond_expr='attribute_not_exists(sk)', exc_cls=SubscriptionFrozenError, table_name=USER_TABLE, ) if created_by: transact.put( item={ 'id': enrollment.id, 'sk': 'CREATED_BY', 'name': created_by['name'], 'user_id': created_by['id'], 'created_at': now_, } ) # 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 settings. if deduplication_window: transact.put( item={ 'id': enrollment.id, 'sk': 'METADATA#DEDUPLICATION_WINDOW', 'offset_days': offset_days, 'created_at': now_, }, ) return True