This commit is contained in:
2025-06-03 20:13:07 -03:00
parent 957f9c4a72
commit c3e6ed4a50
13 changed files with 312 additions and 75 deletions

View File

@@ -40,6 +40,7 @@ def update_course(
persistence_layer: DynamoDBPersistenceLayer,
):
now_ = now()
with persistence_layer.transact_writer() as transact:
transact.update(
key=KeyPair(id, '0'),

View File

@@ -1,6 +1,7 @@
from dataclasses import asdict, dataclass
from datetime import timedelta
from enum import Enum
from typing import TypedDict
from typing import Self, TypedDict
from uuid import uuid4
from layercake.dateutils import now, ttl
@@ -10,22 +11,31 @@ from layercake.strutils import md5_hash
from config import ORDER_TABLE
from models import Course, Enrollment
Tenant = TypedDict('Tenant', {'id': str, 'name': str})
Author = TypedDict('Author', {'id': str, 'name': str})
DeduplicationWindow = TypedDict('DeduplicationWindow', {'offset_days': int})
class Tenant(TypedDict):
class RelatedId(str):
def __new__(cls, id: str, kind: str) -> Self:
return super().__new__(cls, id)
def __init__(self, id: str, kind: str) -> None:
# __init__ is used to store the parameters for later reference.
# For immutable types like str, __init__ cannot change the instance's value.
self.id = id
self.kind = kind
@dataclass(frozen=True)
class Vacancy:
id: str
name: str
sk: str
class Author(TypedDict):
id: str
name: str
class Vacancy(TypedDict): ...
class DeduplicationWindow(TypedDict):
offset_days: int
@property
def order_id(self) -> RelatedId:
idx, _ = self.sk.split('#')
return RelatedId(idx, 'order')
class LifecycleEvents(str, Enum):
@@ -55,6 +65,8 @@ def enroll(
*,
tenant: Tenant,
vacancy: Vacancy | None = None,
author: Author | None = None,
related_ids: frozenset[RelatedId] = frozenset(),
deduplication_window: DeduplicationWindow | None = None,
persistence_layer: DynamoDBPersistenceLayer,
) -> bool:
@@ -66,12 +78,15 @@ def enroll(
lock_hash = md5_hash('%s%s' % (user.id, course.id))
with persistence_layer.transact_writer() as transact:
if vacancy:
related_ids = frozenset({vacancy.order_id}) | related_ids
transact.put(
item={
'sk': '0',
'create_date': now_,
'metadata__tenant_id': tenant_id,
'metadata__related_ids': {tenant_id, user.id},
'metadata__related_ids': {tenant_id, user.id} | related_ids,
**enrollment.model_dump(),
},
)
@@ -95,17 +110,9 @@ def enroll(
'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)),
},
)
# Enrollment expires by default when the access period ends.
# When the course is finished, it is automatically removed,
# and the `schedules#course_archived` event is created.
transact.put(
item={
'id': enrollment.id,
@@ -117,25 +124,78 @@ def enroll(
'ttl': ttl(start_dt=now_ + timedelta(days=course.access_period)),
},
)
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)),
},
)
for related_id in related_ids:
kind = related_id.kind.lower()
transact.put(
item={
'id': enrollment.id,
'sk': f'related_ids#{kind}',
'create_date': now_,
f'{kind}_id': related_id,
}
)
if vacancy:
transact.put(
item={
'id': enrollment.id,
'sk': 'parent_vacancy',
'vacancy': asdict(vacancy),
'create_date': now_,
}
)
class VacancyDoesNotExistError(Exception):
def __init__(self, *args):
super().__init__('Vacancy does not exist')
transact.delete(
key=KeyPair(vacancy.id, vacancy.sk),
cond_expr='attribute_exists(sk)',
exc_cls=VacancyDoesNotExistError,
)
transact.put(
item={
'id': enrollment.id,
'sk': 'metadata#cancel_policy',
'create_date': now_,
}
)
if author:
transact.put(
item={
'id': enrollment.id,
'sk': 'metadata#author',
'user_id': author['id'],
'name': author['name'],
'create_date': now_,
},
)
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,
)
# the deduplication window expires or is removed.
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',
@@ -144,6 +204,8 @@ def enroll(
'create_date': now_,
'ttl': ttl_expiration,
},
cond_expr='attribute_not_exists(sk)',
exc_cls=DeduplicationConflictError,
)
transact.put(
item={
@@ -163,6 +225,12 @@ def enroll(
'create_date': now_,
},
)
else:
transact.condition(
key=KeyPair('lock', lock_hash),
cond_expr='attribute_not_exists(sk)',
exc_cls=DeduplicationConflictError,
)
return True

View File

@@ -1,3 +1,4 @@
from datetime import timedelta
from types import SimpleNamespace
from typing import TypedDict
@@ -9,6 +10,7 @@ from layercake.dynamodb import (
ComposeKey,
DynamoDBPersistenceLayer,
KeyPair,
SortKey,
)
User = TypedDict('User', {'id': str, 'name': str, 'cpf': str})
@@ -23,7 +25,12 @@ def update_user(
now_ = now()
user = SimpleNamespace(**data)
# Get the user's CPF, if it exists.
old_cpf = persistence_layer.get_item(KeyPair(user.id, '0')).get('cpf', None)
old_cpf = persistence_layer.collection.get_item(
KeyPair(
pk=user.id,
sk=SortKey('0', path_spec='cpf'),
)
)
with persistence_layer.transact_writer() as transact:
transact.update(
@@ -128,9 +135,7 @@ def del_email(
) -> bool:
"""Delete any email except the primary email."""
with persistence_layer.transact_writer() as transact:
transact.delete(
key=KeyPair('email', email),
)
transact.delete(key=KeyPair('email', email))
transact.delete(
key=KeyPair(id, ComposeKey(email, prefix='emails')),
cond_expr='email_primary <> :primary',