wip
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
@@ -17,7 +18,7 @@ from config import (
|
||||
)
|
||||
from middlewares import Tenant, TenantMiddleware
|
||||
from models import Course, Enrollment, User
|
||||
from rules.enrollment import enroll
|
||||
from rules.enrollment import DeduplicationWindow, Vacancy, enroll
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -31,9 +32,18 @@ processor = BatchProcessor()
|
||||
class Item(BaseModel):
|
||||
user: User
|
||||
course: Course
|
||||
deduplication_window: dict = {}
|
||||
vacancy: Vacancy | None = None
|
||||
deduplication_window: DeduplicationWindow | None = None
|
||||
schedule_date: datetime | None = None
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
if not self.vacancy:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
_, idx = self.vacancy.sk.split('#')
|
||||
return idx
|
||||
|
||||
|
||||
class Payload(BaseModel):
|
||||
items: tuple[Item, ...]
|
||||
@@ -59,6 +69,7 @@ def enroll_(payload: Payload):
|
||||
def handler(record: Item, context: dict):
|
||||
tenant: Tenant = context['tenant']
|
||||
enrollment = Enrollment(
|
||||
id=record.id,
|
||||
user=record.user,
|
||||
course=record.course,
|
||||
)
|
||||
@@ -69,7 +80,8 @@ def handler(record: Item, context: dict):
|
||||
'id': str(tenant.id),
|
||||
'name': tenant.name,
|
||||
},
|
||||
deduplication_window=record.deduplication_window, # type: ignore
|
||||
deduplication_window=record.deduplication_window,
|
||||
vacancy=record.vacancy,
|
||||
persistence_layer=enrollment_layer,
|
||||
)
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3,10 +3,11 @@ from http import HTTPMethod, HTTPStatus
|
||||
|
||||
from layercake.dynamodb import (
|
||||
ComposeKey,
|
||||
DynamoDBCollection,
|
||||
DynamoDBPersistenceLayer,
|
||||
KeyPair,
|
||||
PartitionKey,
|
||||
SortKey,
|
||||
TransactKey,
|
||||
)
|
||||
|
||||
from ..conftest import HttpApiProxy, LambdaContext
|
||||
@@ -53,6 +54,10 @@ def test_enroll(
|
||||
'deduplication_window': {
|
||||
'offset_days': 60,
|
||||
},
|
||||
'vacancy': {
|
||||
'id': 'vacancies#cJtK9SsnJhKPyxESe7g3DG',
|
||||
'sk': '3CNrFB9dy2RLit2pdeUWy4#8c9b55ef-e988-43ee-b2da-8594850605d7',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -62,12 +67,75 @@ def test_enroll(
|
||||
|
||||
assert r['statusCode'] == HTTPStatus.OK
|
||||
|
||||
fail, _ = json.loads(r['body'])
|
||||
fail, succ = json.loads(r['body'])
|
||||
assert fail['cause'] == {
|
||||
'type': 'DeduplicationConflictError',
|
||||
'message': 'Enrollment already exists',
|
||||
}
|
||||
|
||||
enrollment = dynamodb_persistence_layer.collection.get_items(
|
||||
TransactKey(
|
||||
'8c9b55ef-e988-43ee-b2da-8594850605d7',
|
||||
)
|
||||
+ SortKey('0')
|
||||
+ SortKey('parent_vacancy', path_spec='vacancy')
|
||||
+ SortKey('related_ids#order', path_spec='order_id'),
|
||||
)
|
||||
|
||||
assert succ['output']['id'] == '8c9b55ef-e988-43ee-b2da-8594850605d7'
|
||||
assert enrollment['related_ids#order'] == '3CNrFB9dy2RLit2pdeUWy4'
|
||||
assert enrollment['parent_vacancy'] == {
|
||||
'sk': '3CNrFB9dy2RLit2pdeUWy4#8c9b55ef-e988-43ee-b2da-8594850605d7',
|
||||
'id': 'vacancies#cJtK9SsnJhKPyxESe7g3DG',
|
||||
}
|
||||
|
||||
|
||||
def test_enroll_vacancy(
|
||||
mock_app,
|
||||
dynamodb_seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
http_api_proxy: HttpApiProxy,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
r = mock_app.lambda_handler(
|
||||
http_api_proxy(
|
||||
raw_path='/enrollments',
|
||||
method=HTTPMethod.POST,
|
||||
headers={'X-Tenant': 'cJtK9SsnJhKPyxESe7g3DG'},
|
||||
body={
|
||||
'items': [
|
||||
{
|
||||
'user': {
|
||||
'id': '9a41e867-55e1-4573-bd27-7b5d1d1bcfde',
|
||||
'name': 'Tiago Maciel',
|
||||
'email': 'tiago@somosbeta.com.br',
|
||||
'cpf': '08679004901',
|
||||
},
|
||||
'course': {
|
||||
'id': '6d69a34a-cefd-40aa-a89b-dceb694c3e61',
|
||||
'name': 'pytest',
|
||||
},
|
||||
'deduplication_window': {
|
||||
'offset_days': 60,
|
||||
},
|
||||
'vacancy': {
|
||||
'id': 'vacancies#cJtK9SsnJhKPyxESe7g3DG',
|
||||
'sk': '3CNrFB9dy2RLit2pdeUWy4#does_not_exist',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
lambda_context,
|
||||
)
|
||||
|
||||
assert r['statusCode'] == HTTPStatus.OK
|
||||
fail, *_ = json.loads(r['body'])
|
||||
assert fail['cause'] == {
|
||||
'type': 'VacancyDoesNotExistError',
|
||||
'message': 'Vacancy does not exist',
|
||||
}
|
||||
|
||||
|
||||
def test_vacancies(
|
||||
mock_app,
|
||||
@@ -119,11 +187,12 @@ def test_cancel_enrollment(
|
||||
|
||||
assert r['statusCode'] == HTTPStatus.OK
|
||||
|
||||
collect = DynamoDBCollection(dynamodb_persistence_layer)
|
||||
enrollment = collect.get_item(KeyPair('43ea4475-c369-4f90-b576-135b7df5106b', '0'))
|
||||
enrollment = dynamodb_persistence_layer.collection.get_item(
|
||||
KeyPair('43ea4475-c369-4f90-b576-135b7df5106b', '0')
|
||||
)
|
||||
assert enrollment['status'] == 'CANCELED'
|
||||
|
||||
vacancies = collect.query(
|
||||
vacancies = dynamodb_persistence_layer.collection.query(
|
||||
PartitionKey(ComposeKey('cJtK9SsnJhKPyxESe7g3DG', 'vacancies'))
|
||||
)
|
||||
assert len(vacancies['items']) == 1
|
||||
assert len(vacancies['items']) == 2
|
||||
|
||||
@@ -247,12 +247,10 @@ def test_patch_email(
|
||||
|
||||
assert r['statusCode'] == HTTPStatus.OK
|
||||
|
||||
collect = DynamoDBCollection(dynamodb_persistence_layer)
|
||||
user = collect.get_item(KeyPair('5OxmMjL-ujoR5IMGegQz', '0'))
|
||||
print(user)
|
||||
# assert user['emails'] == {
|
||||
# 'sergio@somosbeta.com.br',
|
||||
# }
|
||||
user = dynamodb_persistence_layer.collection.get_item(
|
||||
KeyPair('5OxmMjL-ujoR5IMGegQz', '0')
|
||||
)
|
||||
assert user['email'] == 'osergiosiqueira@gmail.com'
|
||||
|
||||
|
||||
def test_delete_email(
|
||||
|
||||
@@ -21,4 +21,5 @@
|
||||
{"id": {"S": "email"}, "sk": {"S": "sergio@somosbeta.com.br"}}
|
||||
{"id": {"S": "cpf"}, "sk": {"S": "07879819908"}}
|
||||
{"id": {"S": "cpf"}, "sk": {"S": "08679004901"}}
|
||||
{"id": {"S": "lock"}, "sk": {"S": "c2116a43f8f1aed659a10c83dab17ed3"}}
|
||||
{"id": {"S": "lock"}, "sk": {"S": "c2116a43f8f1aed659a10c83dab17ed3"}}
|
||||
{"id": {"S": "vacancies#cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "3CNrFB9dy2RLit2pdeUWy4#8c9b55ef-e988-43ee-b2da-8594850605d7"}}
|
||||
Reference in New Issue
Block a user