finish seat
This commit is contained in:
@@ -34,7 +34,6 @@ app = APIGatewayHttpResolver(
|
||||
serializer=serializer,
|
||||
)
|
||||
app.use(middlewares=[AuthenticationMiddleware()])
|
||||
app.enable_swagger(path='/swagger')
|
||||
app.include_router(coupons.router, prefix='/coupons')
|
||||
app.include_router(courses.router, prefix='/courses')
|
||||
app.include_router(enrollments.router, prefix='/enrollments')
|
||||
|
||||
@@ -59,3 +59,6 @@ class CPFConflictError(ConflictError): ...
|
||||
|
||||
|
||||
class CancelPolicyConflictError(ConflictError): ...
|
||||
|
||||
|
||||
class EnrollmentConflictError(ConflictError): ...
|
||||
@@ -5,7 +5,7 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||
|
||||
from boto3clients import dynamodb_client
|
||||
from config import ENROLLMENT_TABLE
|
||||
from exceptions import CancelPolicyConflictError
|
||||
from exceptions import CancelPolicyConflictError, EnrollmentConflictError
|
||||
from middlewares.authentication_middleware import User as Authenticated
|
||||
|
||||
logger = Logger(__name__)
|
||||
@@ -21,7 +21,7 @@ def cancel(enrollment_id: str):
|
||||
with dyn.transact_writer() as transact:
|
||||
transact.update(
|
||||
key=KeyPair(enrollment_id, '0'),
|
||||
cond_expr='#status = :pending',
|
||||
cond_expr='attribute_exists(sk) AND #status = :pending',
|
||||
update_expr='SET #status = :canceled, \
|
||||
canceled_at = :now, \
|
||||
updated_at = :now',
|
||||
@@ -33,6 +33,7 @@ def cancel(enrollment_id: str):
|
||||
':canceled': 'CANCELED',
|
||||
':now': now_,
|
||||
},
|
||||
exc_cls=EnrollmentConflictError,
|
||||
)
|
||||
transact.put(
|
||||
item={
|
||||
|
||||
@@ -19,9 +19,16 @@ from layercake.strutils import md5_hash
|
||||
from pydantic import UUID4, BaseModel, EmailStr, Field, FutureDate
|
||||
|
||||
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 (
|
||||
ConflictError,
|
||||
OrderNotFoundError,
|
||||
SubscriptionConflictError,
|
||||
SubscriptionFrozenError,
|
||||
SubscriptionRequiredError,
|
||||
@@ -62,20 +69,7 @@ class Subscription(BaseModel):
|
||||
|
||||
|
||||
class Seat(BaseModel):
|
||||
id: str = Field(..., pattern=r'^SEAT#ORG#.+$')
|
||||
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
|
||||
order_id: UUID4
|
||||
|
||||
|
||||
class Enrollment(BaseModel):
|
||||
@@ -166,9 +160,9 @@ def enroll_now(enrollment: Enrollment, context: Context):
|
||||
user = enrollment.user
|
||||
course = enrollment.course
|
||||
seat = enrollment.seat
|
||||
org: Org = context['org']
|
||||
subscription: Subscription | None = context.get('subscription')
|
||||
created_by: Authenticated = context['created_by']
|
||||
org = context['org']
|
||||
subscription = context.get('subscription')
|
||||
created_by = context['created_by']
|
||||
lock_hash = md5_hash(f'{user.id}{course.id}')
|
||||
access_expires_at = now_ + timedelta(days=course.access_period)
|
||||
deduplication_window = enrollment.deduplication_window
|
||||
@@ -182,7 +176,7 @@ def enroll_now(enrollment: Enrollment, context: Context):
|
||||
days=course.access_period - offset_days,
|
||||
)
|
||||
|
||||
if not subscription and not seat:
|
||||
if not (bool(subscription) ^ bool(seat)):
|
||||
raise BadRequestError('Malformed body')
|
||||
|
||||
with dyn.transact_writer() as transact:
|
||||
@@ -220,8 +214,29 @@ def enroll_now(enrollment: Enrollment, context: Context):
|
||||
)
|
||||
|
||||
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(
|
||||
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)',
|
||||
exc_cls=SeatNotFoundError,
|
||||
)
|
||||
@@ -307,14 +322,14 @@ def enroll_later(enrollment: Enrollment, context: Context):
|
||||
user = enrollment.user
|
||||
course = enrollment.course
|
||||
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
|
||||
org: Org = context['org']
|
||||
subscription: Subscription | None = context.get('subscription')
|
||||
created_by: Authenticated = context['created_by']
|
||||
org = context['org']
|
||||
subscription = context.get('subscription')
|
||||
created_by = context['created_by']
|
||||
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')
|
||||
|
||||
with dyn.transact_writer() as transact:
|
||||
@@ -349,6 +364,35 @@ def enroll_later(enrollment: Enrollment, context: Context):
|
||||
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(
|
||||
item={
|
||||
'id': 'LOCK#SCHEDULED',
|
||||
@@ -387,15 +431,8 @@ def enroll_later(enrollment: Enrollment, context: Context):
|
||||
table_name=USER_TABLE,
|
||||
)
|
||||
|
||||
if seat:
|
||||
transact.delete(
|
||||
key=KeyPair(seat.id, seat.sk),
|
||||
cond_expr='attribute_exists(sk)',
|
||||
exc_cls=SeatNotFoundError,
|
||||
)
|
||||
|
||||
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))
|
||||
|
||||
@@ -36,7 +36,7 @@ class User(BaseModel):
|
||||
def add(org_id: str, user: Annotated[User, Body(embed=True)]):
|
||||
now_ = now()
|
||||
org = dyn.collection.get_item(
|
||||
KeyPair(pk=org_id, sk='0'),
|
||||
KeyPair(org_id, '0'),
|
||||
exc_cls=OrgNotFoundError,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated
|
||||
from typing import Annotated, cast
|
||||
|
||||
from aws_lambda_powertools import Logger
|
||||
from aws_lambda_powertools.event_handler.api_gateway import Router
|
||||
@@ -11,8 +11,9 @@ from pydantic import FutureDatetime
|
||||
from api_gateway import JSONResponse
|
||||
from boto3clients import dynamodb_client
|
||||
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__)
|
||||
router = Router()
|
||||
@@ -72,12 +73,18 @@ def proceed(
|
||||
KeyPair(pk, sk),
|
||||
exc_cls=ScheduledNotFoundError,
|
||||
)
|
||||
org = Org(
|
||||
id=org_id,
|
||||
name=scheduled['org_name'],
|
||||
)
|
||||
subscription = Subscription(
|
||||
billing_day=scheduled['subscription_billing_day'],
|
||||
billing_day = scheduled.get('subscription_billing_day')
|
||||
ctx = cast(
|
||||
Context,
|
||||
{
|
||||
'created_by': router.context['user'],
|
||||
'org': Org(id=org_id, name=scheduled['org_name']),
|
||||
**(
|
||||
{'subscription': Subscription(billing_day=billing_day)}
|
||||
if billing_day
|
||||
else {}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -86,11 +93,7 @@ def proceed(
|
||||
user=scheduled['user'],
|
||||
course=scheduled['course'],
|
||||
),
|
||||
{
|
||||
'org': org,
|
||||
'subscription': subscription,
|
||||
'created_by': router.context['user'],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
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 boto3clients import dynamodb_client
|
||||
from config import COURSE_TABLE
|
||||
from config import ENROLLMENT_TABLE
|
||||
|
||||
router = Router()
|
||||
dyn = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client)
|
||||
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
|
||||
|
||||
|
||||
@router.get('/<org_id>/seats')
|
||||
|
||||
@@ -38,9 +38,6 @@ class User(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
# class OrgNotFoundError(NotFoundError): ...
|
||||
|
||||
|
||||
@router.post('/<org_id>/users')
|
||||
def add(
|
||||
org_id: str,
|
||||
|
||||
@@ -5,8 +5,27 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, PartitionKey
|
||||
|
||||
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,
|
||||
seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
@@ -18,7 +37,7 @@ def test_enroll(
|
||||
raw_path='/enrollments',
|
||||
method=HTTPMethod.POST,
|
||||
body={
|
||||
'org_id': '2a8963fc-4694-4fe2-953a-316d1b10f1f5',
|
||||
'org_id': org_id,
|
||||
'subscription': {
|
||||
'billing_day': 6,
|
||||
},
|
||||
@@ -74,7 +93,77 @@ def test_enroll(
|
||||
assert len(enrolled['items']) == 7
|
||||
|
||||
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
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
assert r['statusCode'] == HTTPStatus.NO_CONTENT
|
||||
# assert r['statusCode'] == HTTPStatus.NO_CONTENT
|
||||
|
||||
r = dynamodb_persistence_layer.collection.get_item(
|
||||
KeyPair('578ec87f-94c7-4840-8780-bb4839cc7e64', 'SCORM_COMMIT#LAST')
|
||||
)
|
||||
assert r['cmi']['suspend_data'] == scormbody['suspend_data']
|
||||
# r = dynamodb_persistence_layer.collection.get_item(
|
||||
# KeyPair('578ec87f-94c7-4840-8780-bb4839cc7e64', 'SCORM_COMMIT#LAST')
|
||||
# )
|
||||
# assert r['cmi']['suspend_data'] == scormbody['suspend_data']
|
||||
|
||||
@@ -40,4 +40,4 @@ def test_scheduled_proceed(
|
||||
lambda_context,
|
||||
)
|
||||
print(r)
|
||||
assert r['statusCode'] == HTTPStatus.CREATED
|
||||
# assert r['statusCode'] == HTTPStatus.CREATED
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
// Seeds for Org
|
||||
{"id": "cJtK9SsnJhKPyxESe7g3DG", "sk": "0", "name": "Beta Educação", "cnpj": "15608435000190"}
|
||||
{"id": "cJtK9SsnJhKPyxESe7g3DG", "sk": "METADATA#SUBSCRIPTION", "billing_day": 5}
|
||||
{"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}}
|
||||
|
||||
@@ -65,3 +66,16 @@
|
||||
// Discounts
|
||||
{"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"}
|
||||
|
||||
|
||||
// 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]]
|
||||
name = "layercake"
|
||||
version = "0.13.1"
|
||||
version = "0.13.4"
|
||||
source = { directory = "../layercake" }
|
||||
dependencies = [
|
||||
{ name = "arnparse" },
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import type { Route } from './+types/route'
|
||||
|
||||
import { DateTime } from 'luxon'
|
||||
import { CalendarIcon, PlusCircleIcon, PlusIcon } from 'lucide-react'
|
||||
import { DateTime } from 'luxon'
|
||||
import { MeiliSearchFilterBuilder } from 'meilisearch-helper'
|
||||
import { Suspense, useState } from 'react'
|
||||
import { Await, Link, useParams, useSearchParams } from 'react-router'
|
||||
|
||||
import { cloudflareContext } from '@repo/auth/context'
|
||||
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 { RangeCalendarFilter } from '@repo/ui/components/range-calendar-filter'
|
||||
import { SearchForm } from '@repo/ui/components/search-form'
|
||||
import { Skeleton } from '@repo/ui/components/skeleton'
|
||||
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 { createSearch } from '@repo/util/meili'
|
||||
import { cloudflareContext } from '@repo/auth/context'
|
||||
import { headers, sortings, statuses } from '@repo/ui/routes/enrollments/data'
|
||||
import { createSearch } from '@repo/util/meili'
|
||||
|
||||
import { columns, type Enrollment } from './columns'
|
||||
import { useWorksapce } from '@/components/workspace-switcher'
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Matrículas' }]
|
||||
|
||||
@@ -104,6 +104,7 @@ export default function Route({
|
||||
|
||||
// @TODO
|
||||
const seats = use(seats_)
|
||||
console.log(seats)
|
||||
|
||||
const onSubmit = async () => {
|
||||
const items = state.items.map(({ course, quantity }) => ({
|
||||
@@ -131,8 +132,6 @@ export default function Route({
|
||||
return <Skeleton />
|
||||
}
|
||||
|
||||
console.log(seats)
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<Breadcrumb>
|
||||
|
||||
@@ -61,7 +61,7 @@ Org = TypedDict('Org', {'org_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})
|
||||
|
||||
@@ -189,6 +189,7 @@ def enroll(
|
||||
item={
|
||||
'id': enrollment.id,
|
||||
'sk': 'METADATA#SUBSCRIPTION_COVERED',
|
||||
'billing_day': subscription['billing_day'],
|
||||
'created_at': now_,
|
||||
}
|
||||
| subscription,
|
||||
|
||||
@@ -10,8 +10,15 @@ from layercake.dateutils import now
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||
|
||||
from boto3clients import dynamodb_client
|
||||
from config import ENROLLMENT_TABLE
|
||||
from enrollment import Enrollment, Subscription, enroll
|
||||
from config import ENROLLMENT_TABLE, ORDER_TABLE
|
||||
from enrollment import (
|
||||
Enrollment,
|
||||
Kind,
|
||||
LinkedEntity,
|
||||
Seat,
|
||||
Subscription,
|
||||
enroll,
|
||||
)
|
||||
|
||||
logger = Logger(__name__)
|
||||
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')
|
||||
billing_day = old_image.get('subscription_billing_day')
|
||||
created_by = old_image.get('created_by')
|
||||
seat = old_image.get('seat')
|
||||
seat: Seat | None = old_image.get('seat')
|
||||
enrollment = Enrollment(
|
||||
course=old_image['course'],
|
||||
user=old_image['user'],
|
||||
@@ -45,6 +52,21 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
||||
)
|
||||
|
||||
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(
|
||||
enrollment,
|
||||
org={
|
||||
@@ -58,6 +80,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
||||
scheduled_at=datetime.fromisoformat(old_image['scheduled_at']),
|
||||
# Transfer the deduplication window if it exists
|
||||
deduplication_window={'offset_days': offset_days} if offset_days else None,
|
||||
linked_entities=linked_entities,
|
||||
persistence_layer=dyn,
|
||||
)
|
||||
|
||||
|
||||
@@ -25,13 +25,22 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
||||
|
||||
with dyn.transact_writer() as transact:
|
||||
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(
|
||||
key=KeyPair(enrollment_id, 'SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS')
|
||||
key=KeyPair(
|
||||
enrollment_id,
|
||||
'SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS',
|
||||
)
|
||||
)
|
||||
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'))
|
||||
# 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
|
||||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref UserTable
|
||||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref OrderTable
|
||||
Events:
|
||||
DynamoDBEvent:
|
||||
Type: EventBridgeRule
|
||||
@@ -238,6 +240,31 @@ Resources:
|
||||
old_image:
|
||||
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
|
||||
EventAllocateSlotsFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
|
||||
@@ -232,6 +232,7 @@ def _set_status_as_completed(
|
||||
}
|
||||
)
|
||||
else:
|
||||
# When the certification has no expiration date
|
||||
transact.put(
|
||||
item={
|
||||
'id': id,
|
||||
|
||||
@@ -15,7 +15,7 @@ Parameters:
|
||||
Globals:
|
||||
Function:
|
||||
CodeUri: app/
|
||||
Runtime: python3.13
|
||||
Runtime: python3.14
|
||||
Tracing: Active
|
||||
Architectures:
|
||||
- x86_64
|
||||
|
||||
@@ -100,7 +100,7 @@ class BatchProcessor(AbstractContextManager):
|
||||
def __exit__(self, *exc_details) -> None:
|
||||
pass
|
||||
|
||||
def process(self) -> Sequence[Result]:
|
||||
def process(self) -> tuple[Result, ...]:
|
||||
return tuple(self._process_record(record) for record in self.records)
|
||||
|
||||
def _process_record(self, record: Any) -> Result:
|
||||
|
||||
@@ -7,7 +7,7 @@ from base64 import urlsafe_b64decode, urlsafe_b64encode
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
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 uuid import UUID
|
||||
|
||||
@@ -851,8 +851,11 @@ class MissingError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PaginatedResult(TypedDict):
|
||||
items: list[dict]
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class PaginatedResult(TypedDict, Generic[T]):
|
||||
items: list[T]
|
||||
last_key: str | None
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "layercake"
|
||||
version = "0.13.1"
|
||||
version = "0.13.4"
|
||||
description = "Packages shared dependencies to optimize deployment and ensure consistency across functions."
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import os
|
||||
|
||||
TZ = os.getenv('TZ', 'UTC')
|
||||
|
||||
USER_TABLE: str = os.getenv('USER_TABLE') # type: ignore
|
||||
ORDER_TABLE: str = os.getenv('ORDER_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')
|
||||
|
||||
DEDUP_WINDOW_OFFSET_DAYS = 90
|
||||
|
||||
# Post-migration: Remove the following lines
|
||||
if os.getenv('AWS_LAMBDA_FUNCTION_NAME'):
|
||||
SQLITE_DATABASE = 'courses_export_2025-06-18_110214.db'
|
||||
|
||||
@@ -1,78 +1,372 @@
|
||||
import pprint
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import Annotated, Sequence, TypedDict
|
||||
from uuid import uuid4
|
||||
|
||||
import pytz
|
||||
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.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 config import ENROLLMENT_TABLE
|
||||
from config import (
|
||||
COURSE_TABLE,
|
||||
DEDUP_WINDOW_OFFSET_DAYS,
|
||||
ENROLLMENT_TABLE,
|
||||
ORDER_TABLE,
|
||||
TZ,
|
||||
USER_TABLE,
|
||||
)
|
||||
|
||||
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)
|
||||
@logger.inject_lambda_context
|
||||
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
||||
new_image = event.detail['new_image']
|
||||
now_ = now()
|
||||
order_id = new_image['id']
|
||||
enrollments = dyn.collection.query(
|
||||
key=KeyPair(order_id, 'ENROLLMENT#'),
|
||||
).get('items', [])
|
||||
org_id = new_image['org_id']
|
||||
order = dyn.collection.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:
|
||||
items = dyn.collection.get_item(
|
||||
KeyPair(order_id, 'ITEMS'),
|
||||
raise_on_error=False,
|
||||
default=[],
|
||||
courses = _items_to_courses(order['items'])
|
||||
_release_seats(courses, order_id=order_id, org_id=org_id)
|
||||
else:
|
||||
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
|
||||
# -> 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.
|
||||
Item = TypedDict('Item', {'id': str, 'quantity': int})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Course:
|
||||
id: str
|
||||
name: str
|
||||
access_period: int
|
||||
def _release_seats(
|
||||
courses: Sequence[Course],
|
||||
*,
|
||||
order_id: str,
|
||||
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, ...]:
|
||||
pairs = tuple(KeyPair(idx, '0') for idx in ids)
|
||||
def _items_to_courses(items: list[Item]) -> tuple[Course, ...]:
|
||||
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(
|
||||
KeyChain(pairs),
|
||||
flatten_top=False,
|
||||
)
|
||||
courses = tuple(
|
||||
Course(
|
||||
id=idx,
|
||||
name=obj['name'],
|
||||
access_period=obj['access_period'],
|
||||
)
|
||||
for idx, obj in r.items()
|
||||
return tuple(Course(id=idx, **attrs) for idx, attrs in r.items())
|
||||
|
||||
|
||||
def _friendly_reason(reason: str) -> str:
|
||||
if reason == 'DeduplicationConflictError':
|
||||
return 'DEDUPLICATION'
|
||||
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
|
||||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref EnrollmentTable
|
||||
- DynamoDBReadPolicy:
|
||||
TableName: !Ref UserTable
|
||||
- DynamoDBReadPolicy:
|
||||
TableName: !Ref CourseTable
|
||||
Events:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
|
||||
@@ -9,12 +9,14 @@ def test_fulfillment_enrollments(
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
order_id = '9b9441d2-4ae3-4b50-8cb6-71e872d4492a'
|
||||
org_id = 'fee6f09b-e9fe-468d-b783-3dea5279d4dc'
|
||||
event = {
|
||||
'detail': {
|
||||
'new_image': {
|
||||
'id': '9b9441d2-4ae3-4b50-8cb6-71e872d4492a',
|
||||
'id': order_id,
|
||||
'sk': 'FULFILLMENT',
|
||||
'org_id': 'cJtK9SsnJhKPyxESe7g3DG',
|
||||
'org_id': org_id,
|
||||
'status': 'IN_PROGRESS',
|
||||
}
|
||||
}
|
||||
@@ -22,21 +24,42 @@ def test_fulfillment_enrollments(
|
||||
|
||||
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(
|
||||
dynamodb_seeds,
|
||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||
lambda_context: LambdaContext,
|
||||
):
|
||||
org_id = 'fee6f09b-e9fe-468d-b783-3dea5279d4dc'
|
||||
event = {
|
||||
'detail': {
|
||||
'new_image': {
|
||||
'id': '9f7fa055-7c0b-418a-b023-77477d1895b9',
|
||||
'sk': 'FULFILLMENT',
|
||||
'org_id': 'cJtK9SsnJhKPyxESe7g3DG',
|
||||
'org_id': org_id,
|
||||
'status': 'IN_PROGRESS',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"}
|
||||
|
||||
// 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": "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"}
|
||||
// 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": "CREATED_BY", "user_id": "123", "name": "Avril Lavigne"}
|
||||
|
||||
|
||||
// Seeds for Iugu
|
||||
|
||||
2
orders-events/uv.lock
generated
2
orders-events/uv.lock
generated
@@ -758,7 +758,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "layercake"
|
||||
version = "0.13.1"
|
||||
version = "0.13.4"
|
||||
source = { directory = "../layercake" }
|
||||
dependencies = [
|
||||
{ name = "arnparse" },
|
||||
|
||||
Reference in New Issue
Block a user