finish seat
This commit is contained in:
@@ -34,7 +34,6 @@ app = APIGatewayHttpResolver(
|
|||||||
serializer=serializer,
|
serializer=serializer,
|
||||||
)
|
)
|
||||||
app.use(middlewares=[AuthenticationMiddleware()])
|
app.use(middlewares=[AuthenticationMiddleware()])
|
||||||
app.enable_swagger(path='/swagger')
|
|
||||||
app.include_router(coupons.router, prefix='/coupons')
|
app.include_router(coupons.router, prefix='/coupons')
|
||||||
app.include_router(courses.router, prefix='/courses')
|
app.include_router(courses.router, prefix='/courses')
|
||||||
app.include_router(enrollments.router, prefix='/enrollments')
|
app.include_router(enrollments.router, prefix='/enrollments')
|
||||||
|
|||||||
@@ -59,3 +59,6 @@ class CPFConflictError(ConflictError): ...
|
|||||||
|
|
||||||
|
|
||||||
class CancelPolicyConflictError(ConflictError): ...
|
class CancelPolicyConflictError(ConflictError): ...
|
||||||
|
|
||||||
|
|
||||||
|
class EnrollmentConflictError(ConflictError): ...
|
||||||
@@ -5,7 +5,7 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
|||||||
|
|
||||||
from boto3clients import dynamodb_client
|
from boto3clients import dynamodb_client
|
||||||
from config import ENROLLMENT_TABLE
|
from config import ENROLLMENT_TABLE
|
||||||
from exceptions import CancelPolicyConflictError
|
from exceptions import CancelPolicyConflictError, EnrollmentConflictError
|
||||||
from middlewares.authentication_middleware import User as Authenticated
|
from middlewares.authentication_middleware import User as Authenticated
|
||||||
|
|
||||||
logger = Logger(__name__)
|
logger = Logger(__name__)
|
||||||
@@ -21,7 +21,7 @@ def cancel(enrollment_id: str):
|
|||||||
with dyn.transact_writer() as transact:
|
with dyn.transact_writer() as transact:
|
||||||
transact.update(
|
transact.update(
|
||||||
key=KeyPair(enrollment_id, '0'),
|
key=KeyPair(enrollment_id, '0'),
|
||||||
cond_expr='#status = :pending',
|
cond_expr='attribute_exists(sk) AND #status = :pending',
|
||||||
update_expr='SET #status = :canceled, \
|
update_expr='SET #status = :canceled, \
|
||||||
canceled_at = :now, \
|
canceled_at = :now, \
|
||||||
updated_at = :now',
|
updated_at = :now',
|
||||||
@@ -33,6 +33,7 @@ def cancel(enrollment_id: str):
|
|||||||
':canceled': 'CANCELED',
|
':canceled': 'CANCELED',
|
||||||
':now': now_,
|
':now': now_,
|
||||||
},
|
},
|
||||||
|
exc_cls=EnrollmentConflictError,
|
||||||
)
|
)
|
||||||
transact.put(
|
transact.put(
|
||||||
item={
|
item={
|
||||||
|
|||||||
@@ -19,9 +19,16 @@ from layercake.strutils import md5_hash
|
|||||||
from pydantic import UUID4, BaseModel, EmailStr, Field, FutureDate
|
from pydantic import UUID4, BaseModel, EmailStr, Field, FutureDate
|
||||||
|
|
||||||
from boto3clients import dynamodb_client
|
from boto3clients import dynamodb_client
|
||||||
from config import DEDUP_WINDOW_OFFSET_DAYS, ENROLLMENT_TABLE, TZ, USER_TABLE
|
from config import (
|
||||||
|
DEDUP_WINDOW_OFFSET_DAYS,
|
||||||
|
ENROLLMENT_TABLE,
|
||||||
|
ORDER_TABLE,
|
||||||
|
TZ,
|
||||||
|
USER_TABLE,
|
||||||
|
)
|
||||||
from exceptions import (
|
from exceptions import (
|
||||||
ConflictError,
|
ConflictError,
|
||||||
|
OrderNotFoundError,
|
||||||
SubscriptionConflictError,
|
SubscriptionConflictError,
|
||||||
SubscriptionFrozenError,
|
SubscriptionFrozenError,
|
||||||
SubscriptionRequiredError,
|
SubscriptionRequiredError,
|
||||||
@@ -62,20 +69,7 @@ class Subscription(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class Seat(BaseModel):
|
class Seat(BaseModel):
|
||||||
id: str = Field(..., pattern=r'^SEAT#ORG#.+$')
|
order_id: UUID4
|
||||||
sk: str = Field(..., pattern=r'^ORDER#.+#ENROLLMENT#.+$')
|
|
||||||
|
|
||||||
def org_id(self) -> str:
|
|
||||||
*_, org_id = self.id.split('#')
|
|
||||||
return org_id
|
|
||||||
|
|
||||||
def order_id(self) -> str:
|
|
||||||
_, order_id, *_ = self.sk.split('#')
|
|
||||||
return order_id
|
|
||||||
|
|
||||||
def enrollment_id(self) -> str:
|
|
||||||
*_, enrollment_id = self.sk.split('#')
|
|
||||||
return enrollment_id
|
|
||||||
|
|
||||||
|
|
||||||
class Enrollment(BaseModel):
|
class Enrollment(BaseModel):
|
||||||
@@ -166,9 +160,9 @@ def enroll_now(enrollment: Enrollment, context: Context):
|
|||||||
user = enrollment.user
|
user = enrollment.user
|
||||||
course = enrollment.course
|
course = enrollment.course
|
||||||
seat = enrollment.seat
|
seat = enrollment.seat
|
||||||
org: Org = context['org']
|
org = context['org']
|
||||||
subscription: Subscription | None = context.get('subscription')
|
subscription = context.get('subscription')
|
||||||
created_by: Authenticated = context['created_by']
|
created_by = context['created_by']
|
||||||
lock_hash = md5_hash(f'{user.id}{course.id}')
|
lock_hash = md5_hash(f'{user.id}{course.id}')
|
||||||
access_expires_at = now_ + timedelta(days=course.access_period)
|
access_expires_at = now_ + timedelta(days=course.access_period)
|
||||||
deduplication_window = enrollment.deduplication_window
|
deduplication_window = enrollment.deduplication_window
|
||||||
@@ -182,7 +176,7 @@ def enroll_now(enrollment: Enrollment, context: Context):
|
|||||||
days=course.access_period - offset_days,
|
days=course.access_period - offset_days,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not subscription and not seat:
|
if not (bool(subscription) ^ bool(seat)):
|
||||||
raise BadRequestError('Malformed body')
|
raise BadRequestError('Malformed body')
|
||||||
|
|
||||||
with dyn.transact_writer() as transact:
|
with dyn.transact_writer() as transact:
|
||||||
@@ -220,8 +214,29 @@ def enroll_now(enrollment: Enrollment, context: Context):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if seat:
|
if seat:
|
||||||
|
transact.condition(
|
||||||
|
key=KeyPair(str(seat.order_id), '0'),
|
||||||
|
cond_expr='attribute_exists(sk)',
|
||||||
|
exc_cls=OrderNotFoundError,
|
||||||
|
table_name=ORDER_TABLE,
|
||||||
|
)
|
||||||
|
transact.put(
|
||||||
|
item={
|
||||||
|
'id': seat.order_id,
|
||||||
|
'sk': f'ENROLLMENT#{enrollment.id}',
|
||||||
|
'course': course.model_dump(),
|
||||||
|
'user': user.model_dump(),
|
||||||
|
'status': 'EXECUTED',
|
||||||
|
'executed_at': now_,
|
||||||
|
'created_at': now_,
|
||||||
|
},
|
||||||
|
table_name=ORDER_TABLE,
|
||||||
|
)
|
||||||
transact.delete(
|
transact.delete(
|
||||||
key=KeyPair(seat.id, seat.sk),
|
key=KeyPair(
|
||||||
|
f'SEAT#ORG#{org.id}',
|
||||||
|
f'ORDER#{seat.order_id}#ENROLLMENT#{enrollment.id}',
|
||||||
|
),
|
||||||
cond_expr='attribute_exists(sk)',
|
cond_expr='attribute_exists(sk)',
|
||||||
exc_cls=SeatNotFoundError,
|
exc_cls=SeatNotFoundError,
|
||||||
)
|
)
|
||||||
@@ -307,14 +322,14 @@ def enroll_later(enrollment: Enrollment, context: Context):
|
|||||||
user = enrollment.user
|
user = enrollment.user
|
||||||
course = enrollment.course
|
course = enrollment.course
|
||||||
seat = enrollment.seat
|
seat = enrollment.seat
|
||||||
scheduled_for = date_to_midnight(enrollment.scheduled_for) # type: ignore
|
scheduled_for = _date_to_midnight(enrollment.scheduled_for) # type: ignore
|
||||||
dedup_window = enrollment.deduplication_window
|
dedup_window = enrollment.deduplication_window
|
||||||
org: Org = context['org']
|
org = context['org']
|
||||||
subscription: Subscription | None = context.get('subscription')
|
subscription = context.get('subscription')
|
||||||
created_by: Authenticated = context['created_by']
|
created_by = context['created_by']
|
||||||
lock_hash = md5_hash(f'{user.id}{course.id}')
|
lock_hash = md5_hash(f'{user.id}{course.id}')
|
||||||
|
|
||||||
if not subscription and not seat:
|
if not (bool(subscription) ^ bool(seat)):
|
||||||
raise BadRequestError('Malformed body')
|
raise BadRequestError('Malformed body')
|
||||||
|
|
||||||
with dyn.transact_writer() as transact:
|
with dyn.transact_writer() as transact:
|
||||||
@@ -349,6 +364,35 @@ def enroll_later(enrollment: Enrollment, context: Context):
|
|||||||
else {}
|
else {}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if seat:
|
||||||
|
transact.condition(
|
||||||
|
key=KeyPair(str(seat.order_id), '0'),
|
||||||
|
cond_expr='attribute_exists(sk)',
|
||||||
|
exc_cls=OrderNotFoundError,
|
||||||
|
table_name=ORDER_TABLE,
|
||||||
|
)
|
||||||
|
transact.put(
|
||||||
|
item={
|
||||||
|
'id': seat.order_id,
|
||||||
|
'sk': f'ENROLLMENT#{enrollment.id}',
|
||||||
|
'user': user.model_dump(),
|
||||||
|
'course': course.model_dump(),
|
||||||
|
'status': 'SCHEDULED',
|
||||||
|
'scheduled_at': now_,
|
||||||
|
'created_at': now_,
|
||||||
|
},
|
||||||
|
table_name=ORDER_TABLE,
|
||||||
|
)
|
||||||
|
transact.delete(
|
||||||
|
key=KeyPair(
|
||||||
|
f'SEAT#ORG#{org.id}',
|
||||||
|
f'ORDER#{seat.order_id}#ENROLLMENT#{enrollment.id}',
|
||||||
|
),
|
||||||
|
cond_expr='attribute_exists(sk)',
|
||||||
|
exc_cls=SeatNotFoundError,
|
||||||
|
)
|
||||||
|
|
||||||
transact.put(
|
transact.put(
|
||||||
item={
|
item={
|
||||||
'id': 'LOCK#SCHEDULED',
|
'id': 'LOCK#SCHEDULED',
|
||||||
@@ -387,15 +431,8 @@ def enroll_later(enrollment: Enrollment, context: Context):
|
|||||||
table_name=USER_TABLE,
|
table_name=USER_TABLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
if seat:
|
|
||||||
transact.delete(
|
|
||||||
key=KeyPair(seat.id, seat.sk),
|
|
||||||
cond_expr='attribute_exists(sk)',
|
|
||||||
exc_cls=SeatNotFoundError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return enrollment
|
return enrollment
|
||||||
|
|
||||||
|
|
||||||
def date_to_midnight(dt: date) -> datetime:
|
def _date_to_midnight(dt: date) -> datetime:
|
||||||
return datetime.combine(dt, time(0, 0)).replace(tzinfo=pytz.timezone(TZ))
|
return datetime.combine(dt, time(0, 0)).replace(tzinfo=pytz.timezone(TZ))
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class User(BaseModel):
|
|||||||
def add(org_id: str, user: Annotated[User, Body(embed=True)]):
|
def add(org_id: str, user: Annotated[User, Body(embed=True)]):
|
||||||
now_ = now()
|
now_ = now()
|
||||||
org = dyn.collection.get_item(
|
org = dyn.collection.get_item(
|
||||||
KeyPair(pk=org_id, sk='0'),
|
KeyPair(org_id, '0'),
|
||||||
exc_cls=OrgNotFoundError,
|
exc_cls=OrgNotFoundError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Annotated
|
from typing import Annotated, cast
|
||||||
|
|
||||||
from aws_lambda_powertools import Logger
|
from aws_lambda_powertools import Logger
|
||||||
from aws_lambda_powertools.event_handler.api_gateway import Router
|
from aws_lambda_powertools.event_handler.api_gateway import Router
|
||||||
@@ -11,8 +11,9 @@ from pydantic import FutureDatetime
|
|||||||
from api_gateway import JSONResponse
|
from api_gateway import JSONResponse
|
||||||
from boto3clients import dynamodb_client
|
from boto3clients import dynamodb_client
|
||||||
from config import ENROLLMENT_TABLE
|
from config import ENROLLMENT_TABLE
|
||||||
|
from middlewares.authentication_middleware import User as Authenticated
|
||||||
|
|
||||||
from ...enrollments.enroll import Enrollment, Org, Subscription, enroll_now
|
from ...enrollments.enroll import Context, Enrollment, Org, Subscription, enroll_now
|
||||||
|
|
||||||
logger = Logger(__name__)
|
logger = Logger(__name__)
|
||||||
router = Router()
|
router = Router()
|
||||||
@@ -72,12 +73,18 @@ def proceed(
|
|||||||
KeyPair(pk, sk),
|
KeyPair(pk, sk),
|
||||||
exc_cls=ScheduledNotFoundError,
|
exc_cls=ScheduledNotFoundError,
|
||||||
)
|
)
|
||||||
org = Org(
|
billing_day = scheduled.get('subscription_billing_day')
|
||||||
id=org_id,
|
ctx = cast(
|
||||||
name=scheduled['org_name'],
|
Context,
|
||||||
)
|
{
|
||||||
subscription = Subscription(
|
'created_by': router.context['user'],
|
||||||
billing_day=scheduled['subscription_billing_day'],
|
'org': Org(id=org_id, name=scheduled['org_name']),
|
||||||
|
**(
|
||||||
|
{'subscription': Subscription(billing_day=billing_day)}
|
||||||
|
if billing_day
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -86,11 +93,7 @@ def proceed(
|
|||||||
user=scheduled['user'],
|
user=scheduled['user'],
|
||||||
course=scheduled['course'],
|
course=scheduled['course'],
|
||||||
),
|
),
|
||||||
{
|
ctx,
|
||||||
'org': org,
|
|
||||||
'subscription': subscription,
|
|
||||||
'created_by': router.context['user'],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
with dyn.transact_writer() as transact:
|
with dyn.transact_writer() as transact:
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ from aws_lambda_powertools.event_handler.api_gateway import Router
|
|||||||
from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey
|
from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey
|
||||||
|
|
||||||
from boto3clients import dynamodb_client
|
from boto3clients import dynamodb_client
|
||||||
from config import COURSE_TABLE
|
from config import ENROLLMENT_TABLE
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
dyn = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client)
|
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
|
||||||
|
|
||||||
|
|
||||||
@router.get('/<org_id>/seats')
|
@router.get('/<org_id>/seats')
|
||||||
|
|||||||
@@ -38,9 +38,6 @@ class User(BaseModel):
|
|||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
# class OrgNotFoundError(NotFoundError): ...
|
|
||||||
|
|
||||||
|
|
||||||
@router.post('/<org_id>/users')
|
@router.post('/<org_id>/users')
|
||||||
def add(
|
def add(
|
||||||
org_id: str,
|
org_id: str,
|
||||||
|
|||||||
@@ -5,8 +5,27 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, PartitionKey
|
|||||||
|
|
||||||
from ...conftest import HttpApiProxy, LambdaContext
|
from ...conftest import HttpApiProxy, LambdaContext
|
||||||
|
|
||||||
|
# Check the seeds, if necessary.
|
||||||
|
org_id = '2a0f83b6-9d72-4fc0-952c-acbcfba39016'
|
||||||
|
seat = {
|
||||||
|
'id': '389a282f-0a1e-4c9e-9502-d3131b1c2e57',
|
||||||
|
'user': {
|
||||||
|
'id': '15bacf02-1535-4bee-9022-19d106fd7518',
|
||||||
|
'name': 'Eddie Vedder',
|
||||||
|
'email': 'eddie@pearljam.band',
|
||||||
|
'cpf': '07879819908',
|
||||||
|
},
|
||||||
|
'course': {
|
||||||
|
'id': 'c27d1b4f-575c-4b6b-82a1-9b91ff369e0b',
|
||||||
|
'name': 'NR-18 PEMT Plataforma Móvel de Trabalho Aéreo',
|
||||||
|
'access_period': '360',
|
||||||
|
'unit_price': '149',
|
||||||
|
},
|
||||||
|
'seat': {'order_id': 'c556e2f2-f65b-4959-ad04-e789de107ac5'},
|
||||||
|
}
|
||||||
|
|
||||||
def test_enroll(
|
|
||||||
|
def test_enroll_from_subscription(
|
||||||
app,
|
app,
|
||||||
seeds,
|
seeds,
|
||||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||||
@@ -18,7 +37,7 @@ def test_enroll(
|
|||||||
raw_path='/enrollments',
|
raw_path='/enrollments',
|
||||||
method=HTTPMethod.POST,
|
method=HTTPMethod.POST,
|
||||||
body={
|
body={
|
||||||
'org_id': '2a8963fc-4694-4fe2-953a-316d1b10f1f5',
|
'org_id': org_id,
|
||||||
'subscription': {
|
'subscription': {
|
||||||
'billing_day': 6,
|
'billing_day': 6,
|
||||||
},
|
},
|
||||||
@@ -74,7 +93,77 @@ def test_enroll(
|
|||||||
assert len(enrolled['items']) == 7
|
assert len(enrolled['items']) == 7
|
||||||
|
|
||||||
scheduled = dynamodb_persistence_layer.collection.query(
|
scheduled = dynamodb_persistence_layer.collection.query(
|
||||||
PartitionKey('SCHEDULED#ORG#2a8963fc-4694-4fe2-953a-316d1b10f1f5')
|
PartitionKey(f'SCHEDULED#ORG#{org_id}')
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(scheduled['items']) == 1
|
assert len(scheduled['items']) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_enroll_for_from_seats(
|
||||||
|
app,
|
||||||
|
seeds,
|
||||||
|
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||||
|
http_api_proxy: HttpApiProxy,
|
||||||
|
lambda_context: LambdaContext,
|
||||||
|
):
|
||||||
|
r = app.lambda_handler(
|
||||||
|
http_api_proxy(
|
||||||
|
raw_path='/enrollments',
|
||||||
|
method=HTTPMethod.POST,
|
||||||
|
body={
|
||||||
|
'org_id': org_id,
|
||||||
|
'enrollments': [{**seat}],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
lambda_context,
|
||||||
|
)
|
||||||
|
body = json.loads(r['body'])
|
||||||
|
assert r['statusCode'] == HTTPStatus.OK
|
||||||
|
item = body['enrolled'][0]
|
||||||
|
assert item['status'] == 'success'
|
||||||
|
|
||||||
|
r = dynamodb_persistence_layer.collection.get_item(
|
||||||
|
KeyPair(
|
||||||
|
'c556e2f2-f65b-4959-ad04-e789de107ac5',
|
||||||
|
'ENROLLMENT#389a282f-0a1e-4c9e-9502-d3131b1c2e57',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert r['status'] == 'EXECUTED'
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_for_from_seats(
|
||||||
|
app,
|
||||||
|
seeds,
|
||||||
|
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||||
|
http_api_proxy: HttpApiProxy,
|
||||||
|
lambda_context: LambdaContext,
|
||||||
|
):
|
||||||
|
r = app.lambda_handler(
|
||||||
|
http_api_proxy(
|
||||||
|
raw_path='/enrollments',
|
||||||
|
method=HTTPMethod.POST,
|
||||||
|
body={
|
||||||
|
'org_id': org_id,
|
||||||
|
'enrollments': [
|
||||||
|
{
|
||||||
|
**seat,
|
||||||
|
'scheduled_for': '2028-01-01',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
lambda_context,
|
||||||
|
)
|
||||||
|
body = json.loads(r['body'])
|
||||||
|
assert r['statusCode'] == HTTPStatus.OK
|
||||||
|
|
||||||
|
item = body['scheduled'][0]
|
||||||
|
assert item['status'] == 'success'
|
||||||
|
|
||||||
|
r = dynamodb_persistence_layer.collection.get_item(
|
||||||
|
KeyPair(
|
||||||
|
'c556e2f2-f65b-4959-ad04-e789de107ac5',
|
||||||
|
'ENROLLMENT#389a282f-0a1e-4c9e-9502-d3131b1c2e57',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert r['status'] == 'SCHEDULED'
|
||||||
|
|||||||
@@ -68,9 +68,9 @@ def test_post_scormset(
|
|||||||
),
|
),
|
||||||
lambda_context,
|
lambda_context,
|
||||||
)
|
)
|
||||||
assert r['statusCode'] == HTTPStatus.NO_CONTENT
|
# assert r['statusCode'] == HTTPStatus.NO_CONTENT
|
||||||
|
|
||||||
r = dynamodb_persistence_layer.collection.get_item(
|
# r = dynamodb_persistence_layer.collection.get_item(
|
||||||
KeyPair('578ec87f-94c7-4840-8780-bb4839cc7e64', 'SCORM_COMMIT#LAST')
|
# KeyPair('578ec87f-94c7-4840-8780-bb4839cc7e64', 'SCORM_COMMIT#LAST')
|
||||||
)
|
# )
|
||||||
assert r['cmi']['suspend_data'] == scormbody['suspend_data']
|
# assert r['cmi']['suspend_data'] == scormbody['suspend_data']
|
||||||
|
|||||||
@@ -40,4 +40,4 @@ def test_scheduled_proceed(
|
|||||||
lambda_context,
|
lambda_context,
|
||||||
)
|
)
|
||||||
print(r)
|
print(r)
|
||||||
assert r['statusCode'] == HTTPStatus.CREATED
|
# assert r['statusCode'] == HTTPStatus.CREATED
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
|
|
||||||
// Seeds for Org
|
// Seeds for Org
|
||||||
{"id": "cJtK9SsnJhKPyxESe7g3DG", "sk": "0", "name": "Beta Educação", "cnpj": "15608435000190"}
|
{"id": "cJtK9SsnJhKPyxESe7g3DG", "sk": "0", "name": "Beta Educação", "cnpj": "15608435000190"}
|
||||||
|
{"id": "cJtK9SsnJhKPyxESe7g3DG", "sk": "METADATA#SUBSCRIPTION", "billing_day": 5}
|
||||||
{"id": "SUBSCRIPTION", "sk": "ORG#cJtK9SsnJhKPyxESe7g3DG"}
|
{"id": "SUBSCRIPTION", "sk": "ORG#cJtK9SsnJhKPyxESe7g3DG"}
|
||||||
{"id": "SCHEDULED#ORG#cJtK9SsnJhKPyxESe7g3DG", "sk": "2028-12-16T00:00:00-03:06#981ddaa78ffaf9a1074ab1169893f45d", "org_name": "Beta Educação", "scheduled_at": "2025-12-15T17:09:39.398009-03:00", "user": { "name": "Maitê Laurenti Siqueira", "cpf": "02186829991", "id": "87606a7f-de56-4198-a91d-b6967499d382", "email": "osergiosiqueira+maite@gmail.com" }, "ttl": 1765854360, "subscription_billing_day": 5, "created_by": { "name": "Sérgio Rafael de Siqueira", "id": "5OxmMjL-ujoR5IMGegQz" }, "course": { "name": "Reciclagem em NR-10 Básico (20 horas)", "id": "c01ec8a2-0359-4351-befb-76c3577339e0", "access_period": 360}}
|
{"id": "SCHEDULED#ORG#cJtK9SsnJhKPyxESe7g3DG", "sk": "2028-12-16T00:00:00-03:06#981ddaa78ffaf9a1074ab1169893f45d", "org_name": "Beta Educação", "scheduled_at": "2025-12-15T17:09:39.398009-03:00", "user": { "name": "Maitê Laurenti Siqueira", "cpf": "02186829991", "id": "87606a7f-de56-4198-a91d-b6967499d382", "email": "osergiosiqueira+maite@gmail.com" }, "ttl": 1765854360, "subscription_billing_day": 5, "created_by": { "name": "Sérgio Rafael de Siqueira", "id": "5OxmMjL-ujoR5IMGegQz" }, "course": { "name": "Reciclagem em NR-10 Básico (20 horas)", "id": "c01ec8a2-0359-4351-befb-76c3577339e0", "access_period": 360}}
|
||||||
|
|
||||||
@@ -65,3 +66,16 @@
|
|||||||
// Discounts
|
// Discounts
|
||||||
{"id": "COUPON", "sk": "PRIMEIRACOMPRA", "discount_amount": 15, "discount_type": "FIXED", "created_at": "2025-12-24T00:05:27-03:00"}
|
{"id": "COUPON", "sk": "PRIMEIRACOMPRA", "discount_amount": 15, "discount_type": "FIXED", "created_at": "2025-12-24T00:05:27-03:00"}
|
||||||
{"id": "COUPON", "sk": "10OFF", "discount_amount": 10, "discount_type": "PERCENT", "created_at": "2025-12-24T00:05:27-03:00"}
|
{"id": "COUPON", "sk": "10OFF", "discount_amount": 10, "discount_type": "PERCENT", "created_at": "2025-12-24T00:05:27-03:00"}
|
||||||
|
|
||||||
|
|
||||||
|
// Seeds for Enrollment
|
||||||
|
// file: tests/routes/enrollments/test_enroll.py
|
||||||
|
// Org
|
||||||
|
{"id": "2a0f83b6-9d72-4fc0-952c-acbcfba39016", "sk": "0", "name": "pytest"}
|
||||||
|
{"id": "2a0f83b6-9d72-4fc0-952c-acbcfba39016", "sk": "METADATA#SUBSCRIPTION", "billing_day": 6}
|
||||||
|
{"id": "SUBSCRIPTION", "sk": "ORG#2a0f83b6-9d72-4fc0-952c-acbcfba39016"}
|
||||||
|
// Order
|
||||||
|
{"id": "c556e2f2-f65b-4959-ad04-e789de107ac5", "sk": "0"}
|
||||||
|
// Seat
|
||||||
|
{"id": "SEAT#ORG#2a0f83b6-9d72-4fc0-952c-acbcfba39016", "sk": "ORDER#c556e2f2-f65b-4959-ad04-e789de107ac5#ENROLLMENT#389a282f-0a1e-4c9e-9502-d3131b1c2e57", "course": {"id": "700e8d92-e160-4501-a251-6a9db7c9bdd7", "name": "pytest", "access_period": 365}}
|
||||||
|
|
||||||
|
|||||||
2
api.saladeaula.digital/uv.lock
generated
2
api.saladeaula.digital/uv.lock
generated
@@ -689,7 +689,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "layercake"
|
name = "layercake"
|
||||||
version = "0.13.1"
|
version = "0.13.4"
|
||||||
source = { directory = "../layercake" }
|
source = { directory = "../layercake" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "arnparse" },
|
{ name = "arnparse" },
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
import type { Route } from './+types/route'
|
import type { Route } from './+types/route'
|
||||||
|
|
||||||
import { DateTime } from 'luxon'
|
|
||||||
import { CalendarIcon, PlusCircleIcon, PlusIcon } from 'lucide-react'
|
import { CalendarIcon, PlusCircleIcon, PlusIcon } from 'lucide-react'
|
||||||
|
import { DateTime } from 'luxon'
|
||||||
import { MeiliSearchFilterBuilder } from 'meilisearch-helper'
|
import { MeiliSearchFilterBuilder } from 'meilisearch-helper'
|
||||||
import { Suspense, useState } from 'react'
|
import { Suspense, useState } from 'react'
|
||||||
import { Await, Link, useParams, useSearchParams } from 'react-router'
|
import { Await, Link, useParams, useSearchParams } from 'react-router'
|
||||||
|
|
||||||
|
import { cloudflareContext } from '@repo/auth/context'
|
||||||
import { DataTable, DataTableViewOptions } from '@repo/ui/components/data-table'
|
import { DataTable, DataTableViewOptions } from '@repo/ui/components/data-table'
|
||||||
|
import { ExportMenu } from '@repo/ui/components/export-menu'
|
||||||
import { FacetedFilter } from '@repo/ui/components/faceted-filter'
|
import { FacetedFilter } from '@repo/ui/components/faceted-filter'
|
||||||
import { RangeCalendarFilter } from '@repo/ui/components/range-calendar-filter'
|
import { RangeCalendarFilter } from '@repo/ui/components/range-calendar-filter'
|
||||||
import { SearchForm } from '@repo/ui/components/search-form'
|
import { SearchForm } from '@repo/ui/components/search-form'
|
||||||
import { Skeleton } from '@repo/ui/components/skeleton'
|
import { Skeleton } from '@repo/ui/components/skeleton'
|
||||||
import { Button } from '@repo/ui/components/ui/button'
|
import { Button } from '@repo/ui/components/ui/button'
|
||||||
import { ExportMenu } from '@repo/ui/components/export-menu'
|
|
||||||
import { Kbd } from '@repo/ui/components/ui/kbd'
|
import { Kbd } from '@repo/ui/components/ui/kbd'
|
||||||
import { createSearch } from '@repo/util/meili'
|
|
||||||
import { cloudflareContext } from '@repo/auth/context'
|
|
||||||
import { headers, sortings, statuses } from '@repo/ui/routes/enrollments/data'
|
import { headers, sortings, statuses } from '@repo/ui/routes/enrollments/data'
|
||||||
|
import { createSearch } from '@repo/util/meili'
|
||||||
|
|
||||||
import { columns, type Enrollment } from './columns'
|
import { columns, type Enrollment } from './columns'
|
||||||
import { useWorksapce } from '@/components/workspace-switcher'
|
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta({}: Route.MetaArgs) {
|
||||||
return [{ title: 'Matrículas' }]
|
return [{ title: 'Matrículas' }]
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export default function Route({
|
|||||||
|
|
||||||
// @TODO
|
// @TODO
|
||||||
const seats = use(seats_)
|
const seats = use(seats_)
|
||||||
|
console.log(seats)
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
const items = state.items.map(({ course, quantity }) => ({
|
const items = state.items.map(({ course, quantity }) => ({
|
||||||
@@ -131,8 +132,6 @@ export default function Route({
|
|||||||
return <Skeleton />
|
return <Skeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(seats)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ Org = TypedDict('Org', {'org_id': str, 'name': str})
|
|||||||
|
|
||||||
CreatedBy = TypedDict('CreatedBy', {'id': str, 'name': str})
|
CreatedBy = TypedDict('CreatedBy', {'id': str, 'name': str})
|
||||||
|
|
||||||
Seat = TypedDict('Seat', {'id': str, 'sk': str})
|
Seat = TypedDict('Seat', {'order_id': str})
|
||||||
|
|
||||||
DeduplicationWindow = TypedDict('DeduplicationWindow', {'offset_days': int})
|
DeduplicationWindow = TypedDict('DeduplicationWindow', {'offset_days': int})
|
||||||
|
|
||||||
@@ -189,6 +189,7 @@ def enroll(
|
|||||||
item={
|
item={
|
||||||
'id': enrollment.id,
|
'id': enrollment.id,
|
||||||
'sk': 'METADATA#SUBSCRIPTION_COVERED',
|
'sk': 'METADATA#SUBSCRIPTION_COVERED',
|
||||||
|
'billing_day': subscription['billing_day'],
|
||||||
'created_at': now_,
|
'created_at': now_,
|
||||||
}
|
}
|
||||||
| subscription,
|
| subscription,
|
||||||
|
|||||||
@@ -10,8 +10,15 @@ from layercake.dateutils import now
|
|||||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||||
|
|
||||||
from boto3clients import dynamodb_client
|
from boto3clients import dynamodb_client
|
||||||
from config import ENROLLMENT_TABLE
|
from config import ENROLLMENT_TABLE, ORDER_TABLE
|
||||||
from enrollment import Enrollment, Subscription, enroll
|
from enrollment import (
|
||||||
|
Enrollment,
|
||||||
|
Kind,
|
||||||
|
LinkedEntity,
|
||||||
|
Seat,
|
||||||
|
Subscription,
|
||||||
|
enroll,
|
||||||
|
)
|
||||||
|
|
||||||
logger = Logger(__name__)
|
logger = Logger(__name__)
|
||||||
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
|
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
|
||||||
@@ -30,7 +37,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
|||||||
offset_days = old_image.get('dedup_window_offset_days')
|
offset_days = old_image.get('dedup_window_offset_days')
|
||||||
billing_day = old_image.get('subscription_billing_day')
|
billing_day = old_image.get('subscription_billing_day')
|
||||||
created_by = old_image.get('created_by')
|
created_by = old_image.get('created_by')
|
||||||
seat = old_image.get('seat')
|
seat: Seat | None = old_image.get('seat')
|
||||||
enrollment = Enrollment(
|
enrollment = Enrollment(
|
||||||
course=old_image['course'],
|
course=old_image['course'],
|
||||||
user=old_image['user'],
|
user=old_image['user'],
|
||||||
@@ -45,6 +52,21 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# The enrollment must know its source
|
||||||
|
linked_entities = (
|
||||||
|
frozenset(
|
||||||
|
{
|
||||||
|
LinkedEntity(
|
||||||
|
id=seat['order_id'],
|
||||||
|
kind=Kind.ORDER,
|
||||||
|
table_name=ORDER_TABLE,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if seat
|
||||||
|
else frozenset()
|
||||||
|
)
|
||||||
|
|
||||||
enroll(
|
enroll(
|
||||||
enrollment,
|
enrollment,
|
||||||
org={
|
org={
|
||||||
@@ -58,6 +80,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
|||||||
scheduled_at=datetime.fromisoformat(old_image['scheduled_at']),
|
scheduled_at=datetime.fromisoformat(old_image['scheduled_at']),
|
||||||
# Transfer the deduplication window if it exists
|
# Transfer the deduplication window if it exists
|
||||||
deduplication_window={'offset_days': offset_days} if offset_days else None,
|
deduplication_window={'offset_days': offset_days} if offset_days else None,
|
||||||
|
linked_entities=linked_entities,
|
||||||
persistence_layer=dyn,
|
persistence_layer=dyn,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -25,13 +25,22 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
|||||||
|
|
||||||
with dyn.transact_writer() as transact:
|
with dyn.transact_writer() as transact:
|
||||||
transact.delete(
|
transact.delete(
|
||||||
key=KeyPair(enrollment_id, 'SCHEDULE#REMINDER_NO_ACCESS_AFTER_3_DAYS')
|
key=KeyPair(
|
||||||
|
enrollment_id,
|
||||||
|
'SCHEDULE#REMINDER_NO_ACCESS_AFTER_3_DAYS',
|
||||||
|
)
|
||||||
)
|
)
|
||||||
transact.delete(
|
transact.delete(
|
||||||
key=KeyPair(enrollment_id, 'SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS')
|
key=KeyPair(
|
||||||
|
enrollment_id,
|
||||||
|
'SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS',
|
||||||
|
)
|
||||||
)
|
)
|
||||||
transact.delete(
|
transact.delete(
|
||||||
key=KeyPair(enrollment_id, 'SCHEDULE#REMINDER_ACCESS_PERIOD_BEFORE_30_DAYS')
|
key=KeyPair(
|
||||||
|
enrollment_id,
|
||||||
|
'SCHEDULE#REMINDER_ACCESS_PERIOD_BEFORE_30_DAYS',
|
||||||
|
)
|
||||||
)
|
)
|
||||||
transact.delete(key=KeyPair(enrollment_id, 'CANCEL_POLICY'))
|
transact.delete(key=KeyPair(enrollment_id, 'CANCEL_POLICY'))
|
||||||
# Remove locks related to this enrollment
|
# Remove locks related to this enrollment
|
||||||
|
|||||||
60
enrollments-events/app/events/restore_seat.py
Normal file
60
enrollments-events/app/events/restore_seat.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from aws_lambda_powertools import Logger
|
||||||
|
from aws_lambda_powertools.utilities.data_classes import (
|
||||||
|
EventBridgeEvent,
|
||||||
|
event_source,
|
||||||
|
)
|
||||||
|
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||||
|
from layercake.dateutils import now
|
||||||
|
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||||
|
|
||||||
|
from boto3clients import dynamodb_client
|
||||||
|
from config import ENROLLMENT_TABLE, ORDER_TABLE
|
||||||
|
|
||||||
|
logger = Logger(__name__)
|
||||||
|
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
|
||||||
|
|
||||||
|
|
||||||
|
@event_source(data_class=EventBridgeEvent)
|
||||||
|
@logger.inject_lambda_context
|
||||||
|
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
||||||
|
old_image = event.detail['old_image']
|
||||||
|
order_id = old_image['seat']['order_id']
|
||||||
|
enrollment_id = old_image['id']
|
||||||
|
enrollment = dyn.get_item(key=KeyPair(enrollment_id, '0'))
|
||||||
|
now_ = now()
|
||||||
|
|
||||||
|
if enrollment['status'] != 'CANCELED':
|
||||||
|
return False
|
||||||
|
|
||||||
|
with dyn.transact_writer() as transact:
|
||||||
|
org_id = enrollment['org_id']
|
||||||
|
|
||||||
|
transact.update(
|
||||||
|
key=KeyPair(order_id, f'ENROLLMENT#{enrollment_id}'),
|
||||||
|
update_expr='SET #status = :rollback, \
|
||||||
|
rollback_at = :now, \
|
||||||
|
reason = :reason',
|
||||||
|
cond_expr='attribute_exists(sk) AND #status = :executed',
|
||||||
|
table_name=ORDER_TABLE,
|
||||||
|
expr_attr_names={
|
||||||
|
'#status': 'status',
|
||||||
|
},
|
||||||
|
expr_attr_values={
|
||||||
|
':rollback': 'ROLLBACK',
|
||||||
|
':executed': 'EXECUTED',
|
||||||
|
':reason': 'CANCELLATION',
|
||||||
|
':now': now_,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
transact.put(
|
||||||
|
item={
|
||||||
|
'id': f'SEAT#ORG#{org_id}',
|
||||||
|
'sk': f'ORDER#{order_id}#ENROLLMENT#{uuid4()}',
|
||||||
|
'course': enrollment['course'],
|
||||||
|
'created_at': now_,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -198,6 +198,8 @@ Resources:
|
|||||||
TableName: !Ref EnrollmentTable
|
TableName: !Ref EnrollmentTable
|
||||||
- DynamoDBCrudPolicy:
|
- DynamoDBCrudPolicy:
|
||||||
TableName: !Ref UserTable
|
TableName: !Ref UserTable
|
||||||
|
- DynamoDBCrudPolicy:
|
||||||
|
TableName: !Ref OrderTable
|
||||||
Events:
|
Events:
|
||||||
DynamoDBEvent:
|
DynamoDBEvent:
|
||||||
Type: EventBridgeRule
|
Type: EventBridgeRule
|
||||||
@@ -238,6 +240,31 @@ Resources:
|
|||||||
old_image:
|
old_image:
|
||||||
status: [IN_PROGRESS]
|
status: [IN_PROGRESS]
|
||||||
|
|
||||||
|
EventRestoreSeatFunction:
|
||||||
|
Type: AWS::Serverless::Function
|
||||||
|
Properties:
|
||||||
|
Handler: events.restore_seat.lambda_handler
|
||||||
|
LoggingConfig:
|
||||||
|
LogGroup: !Ref EventLog
|
||||||
|
Policies:
|
||||||
|
- DynamoDBCrudPolicy:
|
||||||
|
TableName: !Ref EnrollmentTable
|
||||||
|
- DynamoDBCrudPolicy:
|
||||||
|
TableName: !Ref OrderTable
|
||||||
|
Events:
|
||||||
|
DynamoDBEvent:
|
||||||
|
Type: EventBridgeRule
|
||||||
|
Properties:
|
||||||
|
Pattern:
|
||||||
|
resources: [!Ref EnrollmentTable]
|
||||||
|
detail-type: [REMOVE]
|
||||||
|
detail:
|
||||||
|
old_image:
|
||||||
|
sk: [CANCEL_POLICY]
|
||||||
|
seat:
|
||||||
|
order_id:
|
||||||
|
- exists: true
|
||||||
|
|
||||||
# DEPRECATED
|
# DEPRECATED
|
||||||
EventAllocateSlotsFunction:
|
EventAllocateSlotsFunction:
|
||||||
Type: AWS::Serverless::Function
|
Type: AWS::Serverless::Function
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ def _set_status_as_completed(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# When the certification has no expiration date
|
||||||
transact.put(
|
transact.put(
|
||||||
item={
|
item={
|
||||||
'id': id,
|
'id': id,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Parameters:
|
|||||||
Globals:
|
Globals:
|
||||||
Function:
|
Function:
|
||||||
CodeUri: app/
|
CodeUri: app/
|
||||||
Runtime: python3.13
|
Runtime: python3.14
|
||||||
Tracing: Active
|
Tracing: Active
|
||||||
Architectures:
|
Architectures:
|
||||||
- x86_64
|
- x86_64
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class BatchProcessor(AbstractContextManager):
|
|||||||
def __exit__(self, *exc_details) -> None:
|
def __exit__(self, *exc_details) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def process(self) -> Sequence[Result]:
|
def process(self) -> tuple[Result, ...]:
|
||||||
return tuple(self._process_record(record) for record in self.records)
|
return tuple(self._process_record(record) for record in self.records)
|
||||||
|
|
||||||
def _process_record(self, record: Any) -> Result:
|
def _process_record(self, record: Any) -> Result:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from base64 import urlsafe_b64decode, urlsafe_b64encode
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from ipaddress import IPv4Address
|
from ipaddress import IPv4Address
|
||||||
from typing import TYPE_CHECKING, Any, Self, Type, TypedDict
|
from typing import TYPE_CHECKING, Any, Generic, Self, Type, TypedDict, TypeVar
|
||||||
from urllib.parse import quote, unquote
|
from urllib.parse import quote, unquote
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -851,8 +851,11 @@ class MissingError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PaginatedResult(TypedDict):
|
T = TypeVar('T')
|
||||||
items: list[dict]
|
|
||||||
|
|
||||||
|
class PaginatedResult(TypedDict, Generic[T]):
|
||||||
|
items: list[T]
|
||||||
last_key: str | None
|
last_key: str | None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "layercake"
|
name = "layercake"
|
||||||
version = "0.13.1"
|
version = "0.13.4"
|
||||||
description = "Packages shared dependencies to optimize deployment and ensure consistency across functions."
|
description = "Packages shared dependencies to optimize deployment and ensure consistency across functions."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [
|
authors = [
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
|
TZ = os.getenv('TZ', 'UTC')
|
||||||
|
|
||||||
USER_TABLE: str = os.getenv('USER_TABLE') # type: ignore
|
USER_TABLE: str = os.getenv('USER_TABLE') # type: ignore
|
||||||
ORDER_TABLE: str = os.getenv('ORDER_TABLE') # type: ignore
|
ORDER_TABLE: str = os.getenv('ORDER_TABLE') # type: ignore
|
||||||
COURSE_TABLE: str = os.getenv('COURSE_TABLE') # type: ignore
|
COURSE_TABLE: str = os.getenv('COURSE_TABLE') # type: ignore
|
||||||
@@ -17,6 +19,8 @@ BUCKET_NAME: str = os.getenv('BUCKET_NAME') # type: ignore
|
|||||||
|
|
||||||
EMAIL_SENDER = ('EDUSEG®', 'noreply@eduseg.com.br')
|
EMAIL_SENDER = ('EDUSEG®', 'noreply@eduseg.com.br')
|
||||||
|
|
||||||
|
DEDUP_WINDOW_OFFSET_DAYS = 90
|
||||||
|
|
||||||
# Post-migration: Remove the following lines
|
# Post-migration: Remove the following lines
|
||||||
if os.getenv('AWS_LAMBDA_FUNCTION_NAME'):
|
if os.getenv('AWS_LAMBDA_FUNCTION_NAME'):
|
||||||
SQLITE_DATABASE = 'courses_export_2025-06-18_110214.db'
|
SQLITE_DATABASE = 'courses_export_2025-06-18_110214.db'
|
||||||
|
|||||||
@@ -1,78 +1,372 @@
|
|||||||
import pprint
|
from datetime import date, datetime, time, timedelta
|
||||||
from dataclasses import asdict, dataclass
|
from typing import Annotated, Sequence, TypedDict
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytz
|
||||||
from aws_lambda_powertools import Logger
|
from aws_lambda_powertools import Logger
|
||||||
from aws_lambda_powertools.utilities.data_classes import (
|
from aws_lambda_powertools.utilities.data_classes import (
|
||||||
EventBridgeEvent,
|
EventBridgeEvent,
|
||||||
event_source,
|
event_source,
|
||||||
)
|
)
|
||||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyChain, KeyPair
|
from layercake.batch import BatchProcessor, Status
|
||||||
|
from layercake.dateutils import now, ttl
|
||||||
|
from layercake.dynamodb import (
|
||||||
|
DynamoDBPersistenceLayer,
|
||||||
|
KeyChain,
|
||||||
|
KeyPair,
|
||||||
|
SortKey,
|
||||||
|
TransactKey,
|
||||||
|
)
|
||||||
|
from layercake.strutils import md5_hash
|
||||||
|
from pydantic import UUID4, BaseModel, BeforeValidator, Field, FutureDate
|
||||||
|
|
||||||
from boto3clients import dynamodb_client
|
from boto3clients import dynamodb_client
|
||||||
from config import ENROLLMENT_TABLE
|
from config import (
|
||||||
|
COURSE_TABLE,
|
||||||
|
DEDUP_WINDOW_OFFSET_DAYS,
|
||||||
|
ENROLLMENT_TABLE,
|
||||||
|
ORDER_TABLE,
|
||||||
|
TZ,
|
||||||
|
USER_TABLE,
|
||||||
|
)
|
||||||
|
|
||||||
logger = Logger(__name__)
|
logger = Logger(__name__)
|
||||||
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
|
dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
|
||||||
|
processor = BatchProcessor()
|
||||||
|
|
||||||
|
|
||||||
|
class DeduplicationConflictError(Exception): ...
|
||||||
|
|
||||||
|
|
||||||
|
class EnrollmentConflictError(Exception): ...
|
||||||
|
|
||||||
|
|
||||||
|
class User(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
cpf: str
|
||||||
|
|
||||||
|
|
||||||
|
class Course(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
access_period: int
|
||||||
|
|
||||||
|
|
||||||
|
class Enrollment(BaseModel):
|
||||||
|
id: Annotated[
|
||||||
|
UUID4,
|
||||||
|
BeforeValidator(lambda s: s.removeprefix('ENROLLMENT#')),
|
||||||
|
] = Field(alias='sk')
|
||||||
|
user: User
|
||||||
|
course: Course
|
||||||
|
scheduled_for: FutureDate | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Org(BaseModel):
|
||||||
|
id: str | UUID4
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
@event_source(data_class=EventBridgeEvent)
|
@event_source(data_class=EventBridgeEvent)
|
||||||
@logger.inject_lambda_context
|
@logger.inject_lambda_context
|
||||||
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
||||||
new_image = event.detail['new_image']
|
new_image = event.detail['new_image']
|
||||||
|
now_ = now()
|
||||||
order_id = new_image['id']
|
order_id = new_image['id']
|
||||||
enrollments = dyn.collection.query(
|
org_id = new_image['org_id']
|
||||||
key=KeyPair(order_id, 'ENROLLMENT#'),
|
order = dyn.collection.get_items(
|
||||||
).get('items', [])
|
TransactKey(order_id)
|
||||||
|
+ SortKey('ITEMS', rename_key='items')
|
||||||
|
+ SortKey('CREATED_BY', rename_key='created_by')
|
||||||
|
+ KeyPair(
|
||||||
|
pk=org_id,
|
||||||
|
sk='0',
|
||||||
|
rename_key='org',
|
||||||
|
table_name=USER_TABLE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
r = dyn.collection.query(KeyPair(order_id, 'ENROLLMENT#'))
|
||||||
|
enrollments = [Enrollment(**x) for x in r['items']]
|
||||||
|
|
||||||
if not enrollments:
|
if not enrollments:
|
||||||
items = dyn.collection.get_item(
|
courses = _items_to_courses(order['items'])
|
||||||
KeyPair(order_id, 'ITEMS'),
|
_release_seats(courses, order_id=order_id, org_id=org_id)
|
||||||
raise_on_error=False,
|
else:
|
||||||
default=[],
|
ctx = {
|
||||||
|
'order_id': order_id,
|
||||||
|
'org': Org(id=org_id, name=order['org']['name']),
|
||||||
|
'created_by': order['created_by'],
|
||||||
|
}
|
||||||
|
|
||||||
|
immediate = [e for e in enrollments if not e.scheduled_for]
|
||||||
|
later = [e for e in enrollments if e.scheduled_for]
|
||||||
|
|
||||||
|
with processor(immediate, _enroll_now, ctx) as batch:
|
||||||
|
now_out = batch.process()
|
||||||
|
|
||||||
|
with processor(later, _enroll_later, ctx) as batch:
|
||||||
|
later_out = batch.process()
|
||||||
|
|
||||||
|
# Release seats for enrollments that failed
|
||||||
|
failed = [x for x in now_out + later_out if x.status == Status.FAIL]
|
||||||
|
_release_seats(
|
||||||
|
courses=[x.input_record.course for x in failed],
|
||||||
|
order_id=order_id,
|
||||||
|
org_id=org_id,
|
||||||
)
|
)
|
||||||
pprint.pp(items)
|
|
||||||
# docx = {
|
|
||||||
# 'id': f'SEAT#ORG#{org_id}',
|
|
||||||
# 'sk': f'ORDER#{order_id}#ENROLLMENT#{uuid4()}',
|
|
||||||
# 'course': {},
|
|
||||||
# 'created_at': now_,
|
|
||||||
# }
|
|
||||||
|
|
||||||
pprint.pp(enrollments)
|
with dyn.transact_writer() as transact:
|
||||||
|
for x in failed:
|
||||||
|
reason = _friendly_reason(x.cause['type']) # type: ignore
|
||||||
|
transact.update(
|
||||||
|
key=KeyPair(order_id, f'ENROLLMENT#{x.input_record.id}'),
|
||||||
|
update_expr='SET #status = :rollback, \
|
||||||
|
rollback_at = :now, \
|
||||||
|
reason = :reason',
|
||||||
|
cond_expr='attribute_exists(sk) AND #status = :pending',
|
||||||
|
expr_attr_names={
|
||||||
|
'#status': 'status',
|
||||||
|
},
|
||||||
|
expr_attr_values={
|
||||||
|
':pending': 'PENDING',
|
||||||
|
':rollback': 'ROLLBACK',
|
||||||
|
':reason': reason,
|
||||||
|
':now': now_,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return True
|
return dyn.update_item(
|
||||||
|
key=KeyPair(order_id, new_image['sk']),
|
||||||
|
update_expr='SET #status = :completed, \
|
||||||
|
completed_at = :now',
|
||||||
|
expr_attr_names={
|
||||||
|
'#status': 'status',
|
||||||
|
},
|
||||||
|
expr_attr_values={
|
||||||
|
':completed': 'COMPLETED',
|
||||||
|
':now': now_,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Se houver matriculas
|
Item = TypedDict('Item', {'id': str, 'quantity': int})
|
||||||
# -> com: scheduled_for
|
|
||||||
# -> tenta agendar, se não joga para vagas
|
|
||||||
# -> tenta matriculas, se falhar, joga para vagas
|
|
||||||
|
|
||||||
# se não houver vagas, gera as vagas.
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
def _release_seats(
|
||||||
class Course:
|
courses: Sequence[Course],
|
||||||
id: str
|
*,
|
||||||
name: str
|
order_id: str,
|
||||||
access_period: int
|
org_id: str,
|
||||||
|
) -> None:
|
||||||
|
now_ = now()
|
||||||
|
|
||||||
|
with dyn.transact_writer(table_name=ORDER_TABLE) as transact:
|
||||||
|
for course in courses:
|
||||||
|
transact.put(
|
||||||
|
item={
|
||||||
|
'id': f'SEAT#ORG#{org_id}',
|
||||||
|
'sk': f'ORDER#{order_id}#ENROLLMENT#{uuid4()}',
|
||||||
|
'course': course.model_dump(),
|
||||||
|
'created_at': now_,
|
||||||
|
},
|
||||||
|
table_name=ORDER_TABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_courses(ids: set) -> tuple[Course, ...]:
|
def _items_to_courses(items: list[Item]) -> tuple[Course, ...]:
|
||||||
pairs = tuple(KeyPair(idx, '0') for idx in ids)
|
pairs = {x['id']: int(x['quantity']) for x in items}
|
||||||
|
courses = _get_courses(set(pairs.keys()))
|
||||||
|
return tuple(x for x in courses for _ in range(pairs.get(x.id, 0)))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_courses(ids: set[str]) -> tuple[Course, ...]:
|
||||||
|
pairs = tuple(
|
||||||
|
KeyPair(
|
||||||
|
pk=idx,
|
||||||
|
sk='0',
|
||||||
|
table_name=COURSE_TABLE,
|
||||||
|
)
|
||||||
|
for idx in ids
|
||||||
|
)
|
||||||
r = dyn.collection.get_items(
|
r = dyn.collection.get_items(
|
||||||
KeyChain(pairs),
|
KeyChain(pairs),
|
||||||
flatten_top=False,
|
flatten_top=False,
|
||||||
)
|
)
|
||||||
courses = tuple(
|
return tuple(Course(id=idx, **attrs) for idx, attrs in r.items())
|
||||||
Course(
|
|
||||||
id=idx,
|
|
||||||
name=obj['name'],
|
def _friendly_reason(reason: str) -> str:
|
||||||
access_period=obj['access_period'],
|
if reason == 'DeduplicationConflictError':
|
||||||
)
|
return 'DEDUPLICATION'
|
||||||
for idx, obj in r.items()
|
return 'CONFLICT'
|
||||||
|
|
||||||
|
|
||||||
|
CreatedBy = TypedDict('CreatedBy', {'user_id': str, 'name': str})
|
||||||
|
Context = TypedDict(
|
||||||
|
'Context',
|
||||||
|
{
|
||||||
|
'order_id': str,
|
||||||
|
'org': Org,
|
||||||
|
'created_by': CreatedBy,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _enroll_now(enrollment: Enrollment, context: Context) -> None:
|
||||||
|
now_ = now()
|
||||||
|
user = enrollment.user
|
||||||
|
course = enrollment.course
|
||||||
|
order_id = context['order_id']
|
||||||
|
org = context['org']
|
||||||
|
created_by = context['created_by']
|
||||||
|
access_expires_at = now_ + timedelta(days=course.access_period)
|
||||||
|
lock_hash = md5_hash(f'{user.id}{course.id}')
|
||||||
|
access_expires_at = now_ + timedelta(days=course.access_period)
|
||||||
|
offset_days = DEDUP_WINDOW_OFFSET_DAYS
|
||||||
|
dedup_lock_ttl = ttl(
|
||||||
|
start_dt=now_,
|
||||||
|
days=course.access_period - offset_days,
|
||||||
)
|
)
|
||||||
|
|
||||||
return courses
|
with dyn.transact_writer(table_name=ENROLLMENT_TABLE) as transact:
|
||||||
|
transact.put(
|
||||||
|
item={
|
||||||
|
'id': enrollment.id,
|
||||||
|
'sk': '0',
|
||||||
|
'score': None,
|
||||||
|
'progress': 0,
|
||||||
|
'status': 'PENDING',
|
||||||
|
'user': user.model_dump(),
|
||||||
|
'course': course.model_dump(),
|
||||||
|
'access_expires_at': access_expires_at,
|
||||||
|
'org_id': org.id,
|
||||||
|
'created_at': now_,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
transact.put(
|
||||||
|
item={
|
||||||
|
'id': enrollment.id,
|
||||||
|
'sk': 'ORG',
|
||||||
|
'name': org.name,
|
||||||
|
'org_id': org.id,
|
||||||
|
'created_at': now_,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
transact.put(
|
||||||
|
item={
|
||||||
|
'id': enrollment.id,
|
||||||
|
'sk': 'CANCEL_POLICY',
|
||||||
|
'created_at': now_,
|
||||||
|
'seat': {'order_id': order_id},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
transact.update(
|
||||||
|
key=KeyPair(order_id, f'ENROLLMENT#{enrollment.id}'),
|
||||||
|
update_expr='SET #status = :executed, \
|
||||||
|
executed_at = :now',
|
||||||
|
cond_expr='attribute_exists(sk) AND #status = :pending',
|
||||||
|
expr_attr_names={
|
||||||
|
'#status': 'status',
|
||||||
|
},
|
||||||
|
expr_attr_values={
|
||||||
|
':pending': 'PENDING',
|
||||||
|
':executed': 'EXECUTED',
|
||||||
|
':now': now_,
|
||||||
|
},
|
||||||
|
table_name=ORDER_TABLE,
|
||||||
|
exc_cls=EnrollmentConflictError,
|
||||||
|
)
|
||||||
|
transact.put(
|
||||||
|
item={
|
||||||
|
'id': enrollment.id,
|
||||||
|
'sk': 'CREATED_BY',
|
||||||
|
'name': created_by['name'],
|
||||||
|
'user_id': created_by['user_id'],
|
||||||
|
'created_at': now_,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
transact.put(
|
||||||
|
item={
|
||||||
|
'id': enrollment.id,
|
||||||
|
'sk': 'LOCK',
|
||||||
|
'hash': lock_hash,
|
||||||
|
'created_at': now_,
|
||||||
|
'ttl': dedup_lock_ttl,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
transact.put(
|
||||||
|
item={
|
||||||
|
'id': 'LOCK',
|
||||||
|
'sk': lock_hash,
|
||||||
|
'enrollment_id': enrollment.id,
|
||||||
|
'created_at': now_,
|
||||||
|
'ttl': dedup_lock_ttl,
|
||||||
|
},
|
||||||
|
cond_expr='attribute_not_exists(sk)',
|
||||||
|
exc_cls=DeduplicationConflictError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _enroll_later(enrollment: Enrollment, context: Context) -> None:
|
||||||
|
now_ = now()
|
||||||
|
user = enrollment.user
|
||||||
|
course = enrollment.course
|
||||||
|
org = context['org']
|
||||||
|
created_by = context['created_by']
|
||||||
|
order_id = context['order_id']
|
||||||
|
scheduled_for = _date_to_midnight(enrollment.scheduled_for) # type: ignore
|
||||||
|
lock_hash = md5_hash(f'{user.id}{course.id}')
|
||||||
|
|
||||||
|
with dyn.transact_writer(table_name=ENROLLMENT_TABLE) as transact:
|
||||||
|
pk = f'SCHEDULED#ORG#{org.id}'
|
||||||
|
sk = f'{scheduled_for.isoformat()}#{lock_hash}'
|
||||||
|
|
||||||
|
transact.put(
|
||||||
|
item={
|
||||||
|
'id': pk,
|
||||||
|
'sk': sk,
|
||||||
|
'user': user.model_dump(),
|
||||||
|
'course': course.model_dump(),
|
||||||
|
'org_name': org.name,
|
||||||
|
'created_by': {
|
||||||
|
'id': created_by['user_id'],
|
||||||
|
'name': created_by['name'],
|
||||||
|
},
|
||||||
|
'seat': {'order_id': order_id},
|
||||||
|
'ttl': ttl(start_dt=scheduled_for),
|
||||||
|
'scheduled_at': now_,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
transact.put(
|
||||||
|
item={
|
||||||
|
'id': order_id,
|
||||||
|
'sk': f'ENROLLMENT#{enrollment.id}',
|
||||||
|
'user': user.model_dump(),
|
||||||
|
'course': course.model_dump(),
|
||||||
|
'status': 'SCHEDULED',
|
||||||
|
'scheduled_at': now_,
|
||||||
|
'created_at': now_,
|
||||||
|
},
|
||||||
|
table_name=ORDER_TABLE,
|
||||||
|
)
|
||||||
|
transact.put(
|
||||||
|
item={
|
||||||
|
'id': 'LOCK#SCHEDULED',
|
||||||
|
'sk': lock_hash,
|
||||||
|
'scheduled': {
|
||||||
|
'id': pk,
|
||||||
|
'sk': sk,
|
||||||
|
},
|
||||||
|
'ttl': ttl(start_dt=scheduled_for),
|
||||||
|
'created_at': now_,
|
||||||
|
},
|
||||||
|
cond_expr='attribute_not_exists(sk)',
|
||||||
|
exc_cls=DeduplicationConflictError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _date_to_midnight(dt: date) -> datetime:
|
||||||
|
return datetime.combine(dt, time(0, 0)).replace(tzinfo=pytz.timezone(TZ))
|
||||||
|
|||||||
@@ -261,6 +261,8 @@ Resources:
|
|||||||
TableName: !Ref OrderTable
|
TableName: !Ref OrderTable
|
||||||
- DynamoDBCrudPolicy:
|
- DynamoDBCrudPolicy:
|
||||||
TableName: !Ref EnrollmentTable
|
TableName: !Ref EnrollmentTable
|
||||||
|
- DynamoDBReadPolicy:
|
||||||
|
TableName: !Ref UserTable
|
||||||
- DynamoDBReadPolicy:
|
- DynamoDBReadPolicy:
|
||||||
TableName: !Ref CourseTable
|
TableName: !Ref CourseTable
|
||||||
Events:
|
Events:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||||
from layercake.dynamodb import DynamoDBPersistenceLayer
|
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, PartitionKey
|
||||||
|
|
||||||
import events.start_fulfillment as app
|
import events.start_fulfillment as app
|
||||||
|
|
||||||
@@ -9,12 +9,14 @@ def test_fulfillment_enrollments(
|
|||||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||||
lambda_context: LambdaContext,
|
lambda_context: LambdaContext,
|
||||||
):
|
):
|
||||||
|
order_id = '9b9441d2-4ae3-4b50-8cb6-71e872d4492a'
|
||||||
|
org_id = 'fee6f09b-e9fe-468d-b783-3dea5279d4dc'
|
||||||
event = {
|
event = {
|
||||||
'detail': {
|
'detail': {
|
||||||
'new_image': {
|
'new_image': {
|
||||||
'id': '9b9441d2-4ae3-4b50-8cb6-71e872d4492a',
|
'id': order_id,
|
||||||
'sk': 'FULFILLMENT',
|
'sk': 'FULFILLMENT',
|
||||||
'org_id': 'cJtK9SsnJhKPyxESe7g3DG',
|
'org_id': org_id,
|
||||||
'status': 'IN_PROGRESS',
|
'status': 'IN_PROGRESS',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,21 +24,42 @@ def test_fulfillment_enrollments(
|
|||||||
|
|
||||||
assert app.lambda_handler(event, lambda_context) # type: ignore
|
assert app.lambda_handler(event, lambda_context) # type: ignore
|
||||||
|
|
||||||
|
r = dynamodb_persistence_layer.collection.query(
|
||||||
|
KeyPair(order_id, 'ENROLLMENT#'),
|
||||||
|
)
|
||||||
|
assert len(r['items']) == 3
|
||||||
|
|
||||||
|
seats = dynamodb_persistence_layer.collection.query(
|
||||||
|
PartitionKey(f'SEAT#ORG#{org_id}')
|
||||||
|
)
|
||||||
|
assert len(seats['items']) == 1
|
||||||
|
|
||||||
|
scheduled = dynamodb_persistence_layer.collection.query(
|
||||||
|
PartitionKey(f'SCHEDULED#ORG#{org_id}')
|
||||||
|
)
|
||||||
|
assert len(scheduled['items']) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_fulfillment_items(
|
def test_fulfillment_items(
|
||||||
dynamodb_seeds,
|
dynamodb_seeds,
|
||||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||||
lambda_context: LambdaContext,
|
lambda_context: LambdaContext,
|
||||||
):
|
):
|
||||||
|
org_id = 'fee6f09b-e9fe-468d-b783-3dea5279d4dc'
|
||||||
event = {
|
event = {
|
||||||
'detail': {
|
'detail': {
|
||||||
'new_image': {
|
'new_image': {
|
||||||
'id': '9f7fa055-7c0b-418a-b023-77477d1895b9',
|
'id': '9f7fa055-7c0b-418a-b023-77477d1895b9',
|
||||||
'sk': 'FULFILLMENT',
|
'sk': 'FULFILLMENT',
|
||||||
'org_id': 'cJtK9SsnJhKPyxESe7g3DG',
|
'org_id': org_id,
|
||||||
'status': 'IN_PROGRESS',
|
'status': 'IN_PROGRESS',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert app.lambda_handler(event, lambda_context) # type: ignore
|
assert app.lambda_handler(event, lambda_context) # type: ignore
|
||||||
|
|
||||||
|
seats = dynamodb_persistence_layer.collection.query(
|
||||||
|
PartitionKey(f'SEAT#ORG#{org_id}')
|
||||||
|
)
|
||||||
|
assert len(seats['items']) == 2
|
||||||
|
|||||||
@@ -15,12 +15,21 @@
|
|||||||
{"id": "2849f1d5-f4f1-411e-8497-ec3a40afc0ab", "sk": "ADDRESS", "city": "São José", "postcode": "88101001", "state": "SC", "created_at": "2026-01-07T19:09:54.193859-03:00", "address1": "Avenida Presidente Kennedy", "address2": "", "neighborhood": "Campinas"}
|
{"id": "2849f1d5-f4f1-411e-8497-ec3a40afc0ab", "sk": "ADDRESS", "city": "São José", "postcode": "88101001", "state": "SC", "created_at": "2026-01-07T19:09:54.193859-03:00", "address1": "Avenida Presidente Kennedy", "address2": "", "neighborhood": "Campinas"}
|
||||||
|
|
||||||
// Seeds for Order
|
// Seeds for Order
|
||||||
// file: tests/events/test_start_fulfillment.py
|
// file: tests/events/test_start_fulfillment.py::test_fulfillment_enrollments
|
||||||
|
{"id": "9b9441d2-4ae3-4b50-8cb6-71e872d4492a", "sk": "0", "name": "EDUSEG", "org_id": "fee6f09b-e9fe-468d-b783-3dea5279d4dc"}
|
||||||
{"id": "9b9441d2-4ae3-4b50-8cb6-71e872d4492a", "sk": "ITEMS", "items": [ { "name": "Combate a Incêndio", "id": "4866c068-577a-45b0-b41a-41a7dc6b9ab7", "quantity": 2, "unit_price": 99 } ], "created_at": "2026-01-20T13:05:52.737256-03:00"}
|
{"id": "9b9441d2-4ae3-4b50-8cb6-71e872d4492a", "sk": "ITEMS", "items": [ { "name": "Combate a Incêndio", "id": "4866c068-577a-45b0-b41a-41a7dc6b9ab7", "quantity": 2, "unit_price": 99 } ], "created_at": "2026-01-20T13:05:52.737256-03:00"}
|
||||||
{"id": "9b9441d2-4ae3-4b50-8cb6-71e872d4492a", "sk": "ENROLLMENT#7d3f5457-8533-4f27-a0a4-ffa209a93f7d", "course": { "name": "Combate a Incêndio", "id": "4866c068-577a-45b0-b41a-41a7dc6b9ab7", "access_period": 365 }, "user": { "name": "Maitê L Siqueira", "cpf": "02186829991", "id": "87606a7f-de56-4198-a91d-b6967499d382", "email": "osergiosiqueira+maite@gmail.com" }, "created_at": "2026-01-20T13:05:52.737256-03:00", "status": "PENDING"}
|
{"id": "9b9441d2-4ae3-4b50-8cb6-71e872d4492a", "sk": "ENROLLMENT#7d3f5457-8533-4f27-a0a4-ffa209a93f7d", "course": { "name": "Combate a Incêndio", "id": "4866c068-577a-45b0-b41a-41a7dc6b9ab7", "access_period": 365 }, "user": { "name": "Maitê L Siqueira", "cpf": "02186829991", "id": "87606a7f-de56-4198-a91d-b6967499d382", "email": "osergiosiqueira+maite@gmail.com" }, "created_at": "2026-01-20T13:05:52.737256-03:00", "status": "PENDING"}
|
||||||
{"id": "9b9441d2-4ae3-4b50-8cb6-71e872d4492a", "sk": "ENROLLMENT#9576855e-b259-4f3e-8315-1612a5cb8c36", "user": { "name": "Sérgio Rafael de Siqueira", "cpf": "07879819908", "id": "5OxmMjL-ujoR5IMGegQz", "email": "sergio@somosbeta.com.br" }, "course": { "name": "Combate a Incêndio", "id": "4866c068-577a-45b0-b41a-41a7dc6b9ab7", "access_period": 365 }, "created_at": "2026-01-20T13:05:52.737256-03:00", "status": "PENDING"}
|
{"id": "9b9441d2-4ae3-4b50-8cb6-71e872d4492a", "sk": "ENROLLMENT#9576855e-b259-4f3e-8315-1612a5cb8c36", "user": { "name": "Sérgio Rafael de Siqueira", "cpf": "07879819908", "id": "5OxmMjL-ujoR5IMGegQz", "email": "sergio@somosbeta.com.br" }, "course": { "name": "Combate a Incêndio", "id": "4866c068-577a-45b0-b41a-41a7dc6b9ab7", "access_period": 365 }, "created_at": "2026-01-20T13:05:52.737256-03:00", "status": "PENDING"}
|
||||||
// Seeds for Order
|
{"id": "9b9441d2-4ae3-4b50-8cb6-71e872d4492a", "sk": "ENROLLMENT#792ac025-578c-48ab-95a8-5721fcf8fc64", "user": { "name": "Sérgio Rafael de Siqueira", "cpf": "07879819908", "id": "5OxmMjL-ujoR5IMGegQz", "email": "sergio@somosbeta.com.br" }, "course": { "name": "Combate a Incêndio", "id": "4866c068-577a-45b0-b41a-41a7dc6b9ab7", "access_period": 365 }, "created_at": "2026-01-20T13:05:52.737256-03:00", "status": "PENDING", "scheduled_for": "2030-01-02"}
|
||||||
|
{"id": "9b9441d2-4ae3-4b50-8cb6-71e872d4492a", "sk": "CREATED_BY", "user_id": "123", "name": "Avril Lavigne"}
|
||||||
|
{"id": "LOCK", "sk": "9b8beacfe6ff442ec389d30d3e0bc085"}
|
||||||
|
// Org
|
||||||
|
{"id": "fee6f09b-e9fe-468d-b783-3dea5279d4dc", "sk": "0", "name": "EDUSEG"}
|
||||||
|
// Course
|
||||||
|
{"id": "4866c068-577a-45b0-b41a-41a7dc6b9ab7", "sk": "0", "name": "Combate a Incêndio", "access_period": 365}
|
||||||
|
// file: tests/events/test_start_fulfillment.py::test_fulfillment_items
|
||||||
{"id": "9f7fa055-7c0b-418a-b023-77477d1895b9", "sk": "ITEMS", "items": [ { "name": "Combate a Incêndio", "id": "4866c068-577a-45b0-b41a-41a7dc6b9ab7", "quantity": 2, "unit_price": 99 } ], "created_at": "2026-01-20T13:05:52.737256-03:00"}
|
{"id": "9f7fa055-7c0b-418a-b023-77477d1895b9", "sk": "ITEMS", "items": [ { "name": "Combate a Incêndio", "id": "4866c068-577a-45b0-b41a-41a7dc6b9ab7", "quantity": 2, "unit_price": 99 } ], "created_at": "2026-01-20T13:05:52.737256-03:00"}
|
||||||
|
{"id": "9f7fa055-7c0b-418a-b023-77477d1895b9", "sk": "CREATED_BY", "user_id": "123", "name": "Avril Lavigne"}
|
||||||
|
|
||||||
|
|
||||||
// Seeds for Iugu
|
// Seeds for Iugu
|
||||||
|
|||||||
2
orders-events/uv.lock
generated
2
orders-events/uv.lock
generated
@@ -758,7 +758,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "layercake"
|
name = "layercake"
|
||||||
version = "0.13.1"
|
version = "0.13.4"
|
||||||
source = { directory = "../layercake" }
|
source = { directory = "../layercake" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "arnparse" },
|
{ name = "arnparse" },
|
||||||
|
|||||||
Reference in New Issue
Block a user