from datetime import timedelta from enum import Enum from typing import TypedDict from uuid import uuid4 from layercake.dateutils import now, ttl from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from layercake.strutils import md5_hash from config import ORDER_TABLE from models import Course, Enrollment class Tenant(TypedDict): id: str name: str class Author(TypedDict): id: str name: str class Vacancy(TypedDict): ... class DeduplicationWindow(TypedDict): offset_days: int class LifecycleEvents(str, Enum): """Lifecycle events related to scheduling actions.""" # Reminder if the user does not access within 3 days REMINDER_NO_ACCESS_3_DAYS = 'schedules#reminder_no_access_3_days' # When there is no activity 7 days after the first access NO_ACTIVITY_7_DAYS = 'schedules#no_activity_7_days' # Reminder 30 days before the access period expires ACCESS_PERIOD_REMINDER_30_DAYS = 'schedules#access_period_reminder_30_days' # Reminder for certificate expiration set to 30 days from now CERT_EXPIRATION_REMINDER_30_DAYS = 'schedules#cert_expiration_reminder_30_days' # Archive the course after the certificate expires COURSE_ARCHIVED = 'schedules#course_archived' # When the access period ends for a course without a certificate COURSE_EXPIRED = 'schedules#course_expired' def enroll( enrollment: Enrollment, *, tenant: Tenant, vacancy: Vacancy | None = None, 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 tenant_id = tenant['id'] with persistence_layer.transact_writer() as transact: transact.put( item={ 'sk': '0', 'create_date': now_, 'metadata__tenant_id': tenant_id, 'metadata__related_ids': {tenant_id, user.id}, **enrollment.model_dump(), }, ) transact.put( item={ 'id': enrollment.id, 'sk': 'metadata#tenant', 'tenant_id': f'ORG#{tenant_id}', 'name': tenant['name'], 'create_date': now_, }, ) transact.put( item={ 'id': enrollment.id, 'sk': LifecycleEvents.REMINDER_NO_ACCESS_3_DAYS, 'name': user.name, 'email': user.email, 'course': course.name, 'create_date': now_, 'ttl': ttl(days=3, start_dt=now_), }, ) transact.put( item={ 'id': enrollment.id, 'sk': LifecycleEvents.ACCESS_PERIOD_REMINDER_30_DAYS, 'name': user.name, 'email': user.email, 'course': course.name, 'create_date': now_, 'ttl': ttl(start_dt=now_ + timedelta(days=course.access_period - 30)), }, ) transact.put( item={ 'id': enrollment.id, 'sk': LifecycleEvents.COURSE_EXPIRED, 'name': user.name, 'email': user.email, 'course': course.name, 'create_date': now_, 'ttl': ttl(start_dt=now_ + timedelta(days=course.access_period)), }, ) # Prevents the user from enrolling in the same course again until # the deduplication window expires or is removed if deduplication_window: lock_hash = md5_hash('%s%s' % (user.id, course.id)) offset_days = deduplication_window['offset_days'] ttl_expiration = ttl( start_dt=now_ + timedelta(days=course.access_period - offset_days) ) class DeduplicationConflictError(Exception): def __init__(self, *args): super().__init__('Enrollment already exists') transact.put( item={ 'id': 'lock', 'sk': lock_hash, 'enrollment_id': enrollment.id, 'create_date': now_, 'ttl': ttl_expiration, }, cond_expr='attribute_not_exists(sk)', exc_cls=DeduplicationConflictError, ) transact.put( item={ 'id': enrollment.id, 'sk': 'metadata#lock', 'hash': lock_hash, 'create_date': now_, 'ttl': ttl_expiration, }, ) # Deduplication window can be recalculated if needed transact.put( item={ 'id': enrollment.id, 'sk': 'metadata#deduplication_window', 'offset_days': offset_days, 'create_date': now_, }, ) return True def set_status_as_canceled( id: str, *, lock_hash: str, author: Author, course: Course | None = None, vacancy_key: KeyPair | None = None, persistence_layer: DynamoDBPersistenceLayer, ): """Cancel the enrollment if there's a `cancel_policy` and put its vacancy back if `vacancy_key` is provided.""" now_ = now() with persistence_layer.transact_writer() as transact: transact.update( key=KeyPair(id, '0'), update_expr='SET #status = :canceled, update_date = :update', expr_attr_names={ '#status': 'status', }, expr_attr_values={ ':canceled': 'CANCELED', ':update': now_, }, ) transact.put( item={ 'id': id, 'sk': 'canceled_date', 'author': author, 'create_date': now_, }, ) transact.delete( key=KeyPair(id, 'cancel_policy'), cond_expr='attribute_exists(sk)', ) # Remove schedules lifecycle events, referencies and locks transact.delete(key=KeyPair(id, 'schedules#archive_it')) transact.delete(key=KeyPair(id, 'schedules#no_activity')) transact.delete(key=KeyPair(id, 'schedules#access_period_ends')) transact.delete(key=KeyPair(id, 'schedules#does_not_access')) transact.delete(key=KeyPair(id, 'parent_vacancy')) transact.delete(key=KeyPair(id, 'lock')) transact.delete(key=KeyPair('lock', lock_hash)) if vacancy_key and course: vacancy_pk, vacancy_sk = vacancy_key.values() org_id = vacancy_pk.removeprefix('vacancies#') order_id, enrollment_id = vacancy_sk.split('#') transact.condition( key=KeyPair(order_id, '0'), cond_expr='attribute_exists(id)', table_name=ORDER_TABLE, ) # Put the vacancy back and assign a new ID transact.put( item={ 'id': f'vacancies#{org_id}', 'sk': f'{order_id}#{uuid4()}', 'course': course, 'create_date': now_, }, cond_expr='attribute_not_exists(sk)', ) # Set the status of `generated_items` to `ROLLBACK` to know # which vacancy is available for reuse transact.update( key=KeyPair(order_id, f'generated_items#{enrollment_id}'), update_expr='SET #status = :status, update_date = :update', expr_attr_names={ '#status': 'status', }, expr_attr_values={ ':status': 'ROLLBACK', ':update': now_, }, cond_expr='attribute_exists(sk)', table_name=ORDER_TABLE, ) return True