finish seat

This commit is contained in:
2026-01-25 04:52:44 -03:00
parent 5fac7888a8
commit 3719842ae9
31 changed files with 731 additions and 134 deletions

View File

@@ -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')

View File

@@ -59,3 +59,6 @@ class CPFConflictError(ConflictError): ...
class CancelPolicyConflictError(ConflictError): ... class CancelPolicyConflictError(ConflictError): ...
class EnrollmentConflictError(ConflictError): ...

View File

@@ -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={

View File

@@ -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))

View File

@@ -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,
) )

View File

@@ -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:

View File

@@ -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')

View File

@@ -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,

View File

@@ -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'

View File

@@ -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']

View File

@@ -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

View File

@@ -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}}

View File

@@ -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" },

View File

@@ -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' }]

View File

@@ -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>

View File

@@ -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,

View File

@@ -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,
) )

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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 = [

View File

@@ -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'

View File

@@ -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))

View File

@@ -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:

View File

@@ -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

View File

@@ -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
View File

@@ -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" },