from decimal import Decimal from aws_lambda_powertools import Logger from aws_lambda_powertools.event_handler.exceptions import ( BadRequestError, NotFoundError, ) from layercake.dateutils import now, ttl from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey, TransactKey from layercake.strutils import md5_hash from config import COURSE_TABLE, DEDUP_WINDOW_OFFSET_DAYS logger = Logger(__name__) def update_progress( id: str, progress: Decimal, *, dynamodb_persistence_layer: DynamoDBPersistenceLayer, ): now_ = now() reminder_ttl = ttl(start_dt=now_, days=7) try: with dynamodb_persistence_layer.transact_writer() as transact: # Update progress only if the enrollment status is `IN_PROGRESS` transact.update( key=KeyPair(id, '0'), update_expr='SET progress = :progress, \ updated_at = :now', cond_expr='#status = :in_progress', expr_attr_names={ '#status': 'status', }, expr_attr_values={ ':in_progress': 'IN_PROGRESS', ':progress': progress, ':now': now_, }, exc_cls=EnrollmentConflictError, ) # Schedule a reminder if there is no activity after 7 days transact.put( item={ 'id': id, 'sk': 'SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS', 'ttl': reminder_ttl, 'created_at': now_, } ) except EnrollmentConflictError: with dynamodb_persistence_layer.transact_writer() as transact: # If the enrollment status is `PENDING`, set it to `IN_PROGRESS` # and update `progress` and `updated_at` transact.update( key=KeyPair(id, '0'), update_expr='SET progress = :progress, \ #status = :in_progress, \ started_at = if_not_exists(started_at, :now), \ updated_at = :now', cond_expr='#status = :pending', expr_attr_names={ '#status': 'status', }, expr_attr_values={ ':in_progress': 'IN_PROGRESS', ':pending': 'PENDING', ':progress': progress, ':now': now_, }, exc_cls=EnrollmentConflictError, ) # Schedule a reminder for inactivity transact.put( item={ 'id': id, 'sk': 'SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS', 'ttl': reminder_ttl, 'created_at': now_, } ) # Remove reminders and policies that no longer apply transact.delete( key=KeyPair( pk=id, sk='CANCEL_POLICY', ), ) transact.delete( key=KeyPair( pk=id, sk='SCHEDULE#REMINDER_NO_ACCESS_AFTER_3_DAYS', ) ) return True def set_score( id: str, /, score: Decimal, progress: Decimal, *, dynamodb_persistence_layer: DynamoDBPersistenceLayer, ): enrollment = dynamodb_persistence_layer.collection.get_items( TransactKey(id) + SortKey('0') + SortKey( sk='METADATA#DEDUPLICATION_WINDOW', path_spec='offset_days', rename_key='dedup_window_offset_days', ), ) user_id = enrollment['user']['id'] course_id = enrollment['course']['id'] dedup_window_offset_days = int( enrollment.get('dedup_window_offset_days', DEDUP_WINDOW_OFFSET_DAYS) ) try: if score >= 70: # Got a score of 70 or higher return _set_status_as_completed( id, score, progress=progress, user_id=user_id, course_id=course_id, dedup_window_offset_days=dedup_window_offset_days, dynamodb_persistence_layer=dynamodb_persistence_layer, ) # Got a score below 70 return _set_status_as_failed( id, score, progress=progress, user_id=user_id, course_id=course_id, dynamodb_persistence_layer=dynamodb_persistence_layer, ) except EnrollmentConflictError as err: logger.exception(err) raise def _set_status_as_completed( id: str, /, score: Decimal, progress: Decimal, *, user_id: str, course_id: str, dedup_window_offset_days: int, dynamodb_persistence_layer: DynamoDBPersistenceLayer, ) -> bool: now_ = now() lock_hash = md5_hash(f'{user_id}{course_id}') cert_exp_interval = int( dynamodb_persistence_layer.collection.get_item( KeyPair( pk=course_id, sk=SortKey('0', path_spec='cert.exp_interval'), table_name=COURSE_TABLE, ), raise_on_error=False, default=0, ) ) with dynamodb_persistence_layer.transact_writer() as transact: transact.update( key=KeyPair(pk=id, sk='0'), update_expr='SET #status = :completed, \ progress = :progress, \ score = :score, \ completed_at = if_not_exists(completed_at, :now), \ updated_at = :now', cond_expr='#status = :in_progress', expr_attr_names={'#status': 'status'}, expr_attr_values={ ':completed': 'COMPLETED', ':in_progress': 'IN_PROGRESS', ':score': score, ':progress': progress, ':now': now_, }, exc_cls=EnrollmentConflictError, ) if cert_exp_interval: dedup_lock_ttl = ttl( start_dt=now_, days=cert_exp_interval - dedup_window_offset_days, ) transact.put( item={ 'id': id, 'sk': 'SCHEDULE#REMINDER_CERT_EXPIRED', 'ttl': ttl(start_dt=now_, days=cert_exp_interval), 'created_at': now_, } ) transact.put( item={ 'id': id, 'sk': 'SCHEDULE#REMINDER_CERT_EXPIRATION_BEFORE_30_DAYS', 'ttl': ttl(start_dt=now_, days=cert_exp_interval - 30), 'created_at': now_, } ) transact.put( item={ 'id': id, 'sk': 'LOCK', 'ttl': dedup_lock_ttl, 'hash': lock_hash, 'created_at': now_, } ) transact.put( item={ 'id': 'LOCK', 'sk': lock_hash, 'enrollment_id': id, 'ttl': dedup_lock_ttl, 'created_at': now_, } ) else: transact.put( item={ 'id': id, 'sk': 'LOCK', 'hash': lock_hash, 'created_at': now_, } ) transact.put( item={ 'id': 'LOCK', 'sk': lock_hash, 'enrollment_id': id, 'created_at': now_, } ) # Remove reminders and policies that no longer apply transact.delete( key=KeyPair( pk=id, sk='CANCEL_POLICY', ) ) transact.delete( key=KeyPair( pk=id, sk='SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS', ) ) transact.delete( key=KeyPair( pk=id, sk='SCHEDULE#REMINDER_ACCESS_PERIOD_BEFORE_30_DAYS', ) ) return True def _set_status_as_failed( id: str, /, score: Decimal, progress: Decimal, user_id: str, course_id: str, *, dynamodb_persistence_layer: DynamoDBPersistenceLayer, ) -> bool: now_ = now() lock_hash = md5_hash(f'{user_id}{course_id}') with dynamodb_persistence_layer.transact_writer() as transact: transact.update( key=KeyPair(pk=id, sk='0'), update_expr='SET #status = :failed, \ progress = :progress, \ score = :score, \ access_expired = :true, \ failed_at = if_not_exists(failed_at, :now), \ updated_at = :now', cond_expr='#status = :in_progress', expr_attr_names={'#status': 'status'}, expr_attr_values={ ':failed': 'FAILED', ':in_progress': 'IN_PROGRESS', ':score': score, ':progress': progress, ':true': True, ':now': now_, }, exc_cls=EnrollmentConflictError, ) transact.put( item={ 'id': id, 'sk': 'FAILED', 'created_at': now_, }, cond_expr='attribute_not_exists(sk)', ) # Remove reminders and events that no longer apply transact.delete( key=KeyPair( pk=id, sk='CANCEL_POLICY', ) ) transact.delete( key=KeyPair( pk=id, sk='SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS', ) ) transact.delete( key=KeyPair( pk=id, sk='SCHEDULE#REMINDER_ACCESS_PERIOD_BEFORE_30_DAYS', ) ) # Remove locks related to this enrollment transact.delete( key=KeyPair(pk=id, sk='LOCK'), ) transact.delete( key=KeyPair(pk='LOCK', sk=lock_hash), ) return True class EnrollmentNotFoundError(NotFoundError): def __init__(self, *_): super().__init__('Enrollment not found') class EnrollmentConflictError(BadRequestError): def __init__(self, *_): super().__init__('Enrollment status conflict')