Files
saladeaula.digital/http-api/app/rules/enrollment.py

253 lines
8.0 KiB
Python

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']
lock_hash = md5_hash('%s%s' % (user.id, course.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)),
},
)
class DeduplicationConflictError(Exception):
def __init__(self, *args):
super().__init__('Enrollment already exists')
# Prevents the user from enrolling in the same course again until
# the deduplication window expires or is removed
transact.condition(
key=KeyPair('lock', lock_hash),
cond_expr='attribute_not_exists(sk)',
exc_cls=DeduplicationConflictError,
)
if deduplication_window:
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,
},
)
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