from datetime import timedelta from enum import Enum from typing import Literal, 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 conf 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 Rel(TypedDict): id: str scope: Literal['ORG', 'USER', 'ENROLLMENT'] class LifecycleEvents(str, Enum): """Schedules lifecycle events.""" # 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' # When the access period expires ACCESS_PERIOD_EXPIRED = 'schedules#access_period_expired' # When the course certificate expires CERTIFICATE_EXPIRATION = 'schedules#certificate_expiration' # Archive the course after the certificate expires COURSE_ARCHIVED = 'schedules#course_archived' def enroll( enrollment: Enrollment, *, tenant: Tenant, rel: tuple[Rel, ...] | Rel = (), author: Author | None = None, vacancy: Vacancy | None = None, ensure_vacancy: bool = True, persistence_layer: DynamoDBPersistenceLayer, ) -> bool: """Enrolls a user into a course and schedules lifecycle events.""" now_ = now() user = enrollment.user course = enrollment.course exp_interval = course.exp_interval lock_hash = md5_hash('%s%s' % (user.id, course.id)) ttl_date = now_ + timedelta(days=exp_interval - 30) transact = TransactItems(persistence_layer.table_name) transact.put( item={ 'sk': '0', 'create_date': now_, 'tenant__org_id': tenant['id'], **enrollment.model_dump(), }, ) transact.put( item={ 'id': enrollment.id, 'sk': 'metadata#tenant', 'org_id': tenant['id'], 'name': tenant['name'], 'create_date': now_, }, ) transact.put( item={ 'id': enrollment.id, 'sk': LifecycleEvents.COURSE_ARCHIVED, 'create_date': now_, 'ttl': ttl(days=exp_interval, start_dt=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_EXPIRED, 'name': user.name, 'email': user.email, 'course': course.name, 'create_date': now_, 'ttl': ttl(start_dt=now_ + timedelta(days=course.access_period - 30)), }, ) for r in rel: print(r['id']) transact.put( item={ 'id': enrollment.id, # 'sk': 'rel#{}' % r['id'], 'create_date': now_, }, ) if author: transact.put( item={ 'id': enrollment.id, 'sk': 'metadata#author', 'user_id': author['id'], 'name': author['name'], 'create_date': now_, }, ) if vacancy: transact.put( item={ 'id': enrollment.id, 'sk': 'parent_vacancy', # 'vacancy': vacancy.model_dump(), } ) if ensure_vacancy: # Ensures that there's a vacancy transact.delete( key=vacancy.model_dump(), cond_expr='attribute_exists(sk)', ) # Add cancel policy if there is a vacancy if vacancy: transact.put( item={ 'id': enrollment.id, 'sk': 'metadata#cancel_policy', 'create_date': now_, } ) # To ensure that the user does not enroll in the same course again until # the certificate expires. transact.put( item={ 'id': 'metadata#lock', 'sk': lock_hash, 'enrollment_id': enrollment.id, 'create_date': vacancy, 'ttl': ttl(start_dt=ttl_date), }, cond_expr='attribute_not_exists(sk)', ) transact.put( item={ 'id': enrollment.id, 'sk': 'lock', 'hash': lock_hash, 'create_date': vacancy, 'ttl': ttl(start_dt=ttl_date), }, ) 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)