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, TransactItems 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'] transact = TransactItems(persistence_layer.table_name) 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) ) transact.put( item={ 'id': 'lock', 'sk': lock_hash, 'enrollment_id': enrollment.id, 'create_date': now_, 'ttl': ttl_expiration, }, cond_expr='attribute_not_exists(sk)', ) 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 persistence_layer.transact_write_items(transact) 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() transact = TransactItems(persistence_layer.table_name) 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 persistence_layer.transact_write_items(transact)