add test mode to enrollments

This commit is contained in:
2026-01-28 19:11:52 -03:00
parent fc14d425f2
commit 2b34caa3be
14 changed files with 65 additions and 260 deletions

View File

@@ -28,6 +28,7 @@ from config import (
)
from exceptions import (
ConflictError,
NotAcceptableError,
OrderNotFoundError,
SubscriptionConflictError,
SubscriptionFrozenError,
@@ -47,6 +48,9 @@ class DeduplicationConflictError(ConflictError): ...
class SeatNotFoundError(NotFoundError): ...
class TestModeRequiredError(NotAcceptableError): ...
class User(BaseModel):
id: str | UUID4
name: NameStr
@@ -91,6 +95,7 @@ def enroll(
org_id: Annotated[str | UUID4, Body(embed=True)],
enrollments: Annotated[tuple[Enrollment, ...], Body(embed=True)],
subscription: Annotated[Subscription | None, Body(embed=True)] = None,
test_mode: Annotated[bool, Body(embed=True)] = False,
):
now_ = now()
created_by: Authenticated = router.context['user']
@@ -106,6 +111,7 @@ def enroll(
'org': Org.model_validate(org),
'created_by': created_by,
'subscription': subscription,
'test_mode': test_mode,
}
immediate = [e for e in enrollments if not e.scheduled_for]
@@ -125,12 +131,13 @@ def enroll(
'cause': r.cause,
}
expires_after_days = 7 if test_mode else 30 * 3
item = {
'id': f'SUBMISSION#ORG#{org_id}',
'sk': now_,
'enrolled': list(map(fmt, now_out)) if now_out else None,
'scheduled': list(map(fmt, later_out)) if later_out else None,
'ttl': ttl(start_dt=now_, days=30 * 3),
'ttl': ttl(start_dt=now_, days=expires_after_days),
'created_by': {
'id': created_by.id,
'name': created_by.name,
@@ -151,6 +158,7 @@ Context = TypedDict(
'org': Org,
'created_by': Authenticated,
'subscription': NotRequired[Subscription],
'test_mode': NotRequired[bool],
},
)
@@ -161,6 +169,7 @@ def enroll_now(enrollment: Enrollment, context: Context):
course = enrollment.course
seat = enrollment.seat
org = context['org']
test_mode = context.get('test_mode')
subscription = context.get('subscription')
created_by = context['created_by']
lock_hash = md5_hash(f'{user.id}{course.id}')
@@ -194,7 +203,16 @@ def enroll_now(enrollment: Enrollment, context: Context):
'created_at': now_,
}
| ({'subscription_covered': True} if subscription else {})
| ({'is_test': True} if test_mode else {})
)
if test_mode:
transact.condition(
key=KeyPair(str(org.id), 'METADATA#TEST_MODE'),
cond_expr='attribute_exists(sk)',
exc_cls=TestModeRequiredError,
)
transact.put(
item={
'id': enrollment.id,
@@ -344,6 +362,7 @@ def _enroll_later(enrollment: Enrollment, context: Context):
scheduled_for = _date_to_midnight(enrollment.scheduled_for) # type: ignore
dedup_window = enrollment.deduplication_window
org = context['org']
test_mode = context.get('test_mode')
subscription = context.get('subscription')
created_by = context['created_by']
lock_hash = md5_hash(f'{user.id}{course.id}')
@@ -371,6 +390,7 @@ def _enroll_later(enrollment: Enrollment, context: Context):
'scheduled_at': now_,
}
| ({'seat': seat.model_dump()} if seat else {})
| ({'is_test': True} if test_mode else {})
| (
{'dedup_window_offset_days': dedup_window.offset_days}
if dedup_window
@@ -385,6 +405,13 @@ def _enroll_later(enrollment: Enrollment, context: Context):
),
)
if test_mode:
transact.condition(
key=KeyPair(str(org.id), 'METADATA#TEST_MODE'),
cond_expr='attribute_exists(sk)',
exc_cls=TestModeRequiredError,
)
if seat:
transact.condition(
key=KeyPair(str(seat.order_id), '0'),

View File

@@ -277,6 +277,14 @@ def checkout(payload: Checkout):
'created_at': now_,
}
)
transact.put(
item={
'id': order_id,
'sk': 'SCHEDULED#AUTO_CLEANUP',
'ttl': ttl(start_dt=now_, days=7),
'created_at': now_,
}
)
return JSONResponse(
body={'id': order_id},

View File

@@ -18,6 +18,7 @@ import { Kbd } from '@repo/ui/components/ui/kbd'
import { headers, sortings, statuses } from '@repo/ui/routes/enrollments/data'
import { createSearch } from '@repo/util/meili'
import { workspaceContext } from '@/middleware/workspace'
import { columns, type Enrollment } from './columns'
export function meta({}: Route.MetaArgs) {
@@ -26,6 +27,7 @@ export function meta({}: Route.MetaArgs) {
export async function loader({ params, context, request }: Route.LoaderArgs) {
const cloudflare = context.get(cloudflareContext)
const { test_mode } = context.get(workspaceContext)
const { searchParams } = new URL(request.url)
const { orgid } = params
const query = searchParams.get('q') || ''
@@ -36,7 +38,9 @@ export async function loader({ params, context, request }: Route.LoaderArgs) {
const page = Number(searchParams.get('p')) + 1
const hitsPerPage = Number(searchParams.get('perPage')) || 25
let builder = new MeiliSearchFilterBuilder().where('org_id', '=', orgid)
let builder = new MeiliSearchFilterBuilder()
.where('org_id', '=', orgid)
.where('is_test', 'exists', test_mode)
if (status) {
builder = builder.where('status', 'in', status.split(','))

View File

@@ -1,36 +1,36 @@
import type { Route } from './+types/route'
import { pick } from 'ramda'
import { formatCNPJ } from '@brazilian-utils/brazilian-utils'
import {
CalendarIcon,
PlusCircleIcon,
BuildingIcon,
CheckIcon
CalendarIcon,
CheckIcon,
PlusCircleIcon
} from 'lucide-react'
import { MeiliSearchFilterBuilder } from 'meilisearch-helper'
import { pick } from 'ramda'
import { Suspense, useState } from 'react'
import { Await, Outlet, useSearchParams } from 'react-router'
import { formatCNPJ } from '@brazilian-utils/brazilian-utils'
import { cloudflareContext } from '@repo/auth/context'
import { Abbr } from '@repo/ui/components/abbr'
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 { SearchFilter } from '@repo/ui/components/search-filter'
import { SearchForm } from '@repo/ui/components/search-form'
import { Skeleton } from '@repo/ui/components/skeleton'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { ExportMenu } from '@repo/ui/components/export-menu'
import { createSearch } from '@repo/util/meili'
import { headers, sortings, statuses } from '@repo/ui/routes/enrollments/data'
import { cn, initials } from '@repo/ui/lib/utils'
import { CommandItem } from '@repo/ui/components/ui/command'
import { Button } from '@repo/ui/components/ui/button'
import { Separator } from '@repo/ui/components/ui/separator'
import { Badge } from '@repo/ui/components/ui/badge'
import { Abbr } from '@repo/ui/components/abbr'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
import { Badge } from '@repo/ui/components/ui/badge'
import { Button } from '@repo/ui/components/ui/button'
import { CommandItem } from '@repo/ui/components/ui/command'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { Separator } from '@repo/ui/components/ui/separator'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { cn, initials } from '@repo/ui/lib/utils'
import { headers, sortings, statuses } from '@repo/ui/routes/enrollments/data'
import { createSearch } from '@repo/util/meili'
import { columns, type Enrollment } from './columns'
@@ -50,7 +50,7 @@ export async function loader({ context, request }: Route.LoaderArgs) {
const page = Number(searchParams.get('p')) + 1
const hitsPerPage = Number(searchParams.get('perPage')) || 25
let builder = new MeiliSearchFilterBuilder()
let builder = new MeiliSearchFilterBuilder().where('is_test', 'exists', false)
if (status) {
builder = builder.where('status', 'in', status.split(','))

View File

@@ -43,7 +43,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
new_image = event.detail['new_image']
# Key pattern `BILLING#ORG#{org_id}`
*_, org_id = new_image['id'].split('#')
# Key pattern `START#{start_period}#END#{v}
# Key pattern `START#{start_period}#END#{end_period}
_, start_period, _, end_period, *_ = new_image['sk'].split('#')
emailmsg = Message(

View File

@@ -102,16 +102,6 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
transact.delete(
key=KeyPair(order_id, 'CREDIT_CARD#PAYMENT_INTENT'),
)
if test_mode:
transact.put(
item={
'id': order_id,
'sk': 'SCHEDULED#SELF_DESTRUCTION',
'ttl': ttl(start_dt=now_, days=7),
'created_at': now_,
}
)
except Exception:
pass

View File

@@ -1,4 +0,0 @@
"""
Stopgap events. Everything here is a quick fix and should be replaced with
proper solutions.
"""

View File

@@ -1,71 +0,0 @@
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,
KeyPair,
)
from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE, ORDER_TABLE, USER_TABLE
logger = Logger(__name__)
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
@event_source(data_class=EventBridgeEvent)
@logger.inject_lambda_context
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
"""Remove slots if the tenant has a `metadata#billing_policy` and
the total is greater than zero."""
new_image = event.detail['new_image']
data = order_layer.get_item(KeyPair(new_image['id'], '0'))
org_id = data.get('tenant_id')
if not org_id:
return False
policy = user_layer.collection.get_item(
KeyPair(pk=org_id, sk='metadata#billing_policy'),
raise_on_error=False,
default=False,
)
# Skip if billing policy is missing or order is less than or equal to zero
if not policy or data['total'] <= 0:
logger.info('Missing billing policy')
return False
logger.info(f'Billing policy from Org ID "{org_id}" found', policy=policy)
result = enrollment_layer.collection.query(
KeyPair(
f'vacancies#{org_id}',
new_image['id'],
),
limit=150,
)
logger.info(
'Slots found',
total_items=len(result['items']),
slots=result['items'],
)
with enrollment_layer.batch_writer() as batch:
for pair in result['items']:
batch.delete_item(
Key={
'id': {'S': pair['id']},
'sk': {'S': pair['sk']},
}
)
logger.info('Deleted all slots')
return True

View File

@@ -1,47 +0,0 @@
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 ORDER_TABLE
logger = Logger(__name__)
order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
@event_source(data_class=EventBridgeEvent)
@logger.inject_lambda_context
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
"""Set to `PAID` if the status is `PENDING` and the total is zero."""
new_image = event.detail['new_image']
now_ = now()
with order_layer.transact_writer() as transact:
transact.update(
key=KeyPair(new_image['id'], '0'),
update_expr='SET #status = :status, updated_at = :updated_at',
expr_attr_names={
'#status': 'status',
},
expr_attr_values={
':status': 'PAID',
':updated_at': now_,
},
)
transact.put(
item={
'id': new_image['id'],
'sk': 'paid_at',
'paid_at': now_,
}
)
return True

View File

@@ -280,10 +280,10 @@ Resources:
org_id:
- exists: true
EventRunSelfDestructionFunction:
EventRunAutoCleanupFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.run_self_destruction.lambda_handler
Handler: events.run_auto_cleanup.lambda_handler
Timeout: 30
LoggingConfig:
LogGroup: !Ref EventLog
@@ -299,7 +299,9 @@ Resources:
detail-type: [EXPIRE]
detail:
keys:
sk: ['SCHEDULED#SELF_DESTRUCTION']
sk:
- SCHEDULED#AUTO_CLEANUP
- SCHEDULED#SELF_DESTRUCTION
# DEPRECATED
EventAppendOrgIdFunction:
@@ -325,7 +327,6 @@ Resources:
sk: ['0']
cnpj:
- exists: true
# Post-migration: rename `tenant_id` to `org_id`
tenant_id:
- exists: false
@@ -382,55 +383,6 @@ Resources:
- exists: true
status: [CANCELED, EXPIRED]
EventStopgapSetAsPaidFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.stopgap.set_as_paid.lambda_handler
LoggingConfig:
LogGroup: !Ref EventLog
Policies:
- DynamoDBWritePolicy:
TableName: !Ref OrderTable
Events:
Event:
Type: EventBridgeRule
Properties:
Pattern:
resources: [!Ref OrderTable]
detail-type: [INSERT]
detail:
new_image:
sk: ['0']
cnpj:
- exists: true
total: [0]
status: [CREATING, PENDING]
payment_method: [MANUAL]
EventStopgapRemoveSlotsFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.stopgap.remove_slots.lambda_handler
LoggingConfig:
LogGroup: !Ref EventLog
Policies:
- DynamoDBReadPolicy:
TableName: !Ref UserTable
- DynamoDBReadPolicy:
TableName: !Ref OrderTable
- DynamoDBCrudPolicy:
TableName: !Ref EnrollmentTable
Events:
DynamoDBEvent:
Type: EventBridgeRule
Properties:
Pattern:
resources: [!Ref OrderTable]
detail:
new_image:
sk: [generated_items]
status: [SUCCESS]
Outputs:
HttpApiUrl:
Description: URL of your API endpoint

View File

@@ -1,30 +0,0 @@
from layercake.dynamodb import PartitionKey
import events.stopgap.remove_slots as app
from ...conftest import LambdaContext
def test_remove_slots(
dynamodb_seeds,
dynamodb_persistence_layer,
lambda_context: LambdaContext,
):
event = {
'detail': {
'new_image': {
'id': '9omWNKymwU5U4aeun6mWzZ',
'sk': 'generated_items',
'create_date': '2024-07-23T20:43:37.303418-03:00',
'status': 'SUCCESS',
'scope': 'MILTI_USER',
}
},
}
assert app.lambda_handler(event, lambda_context) # type: ignore
result = dynamodb_persistence_layer.collection.query(
PartitionKey('vacancies#cJtK9SsnJhKPyxESe7g3DG')
)
assert len(result['items']) == 0

View File

@@ -1,24 +0,0 @@
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
import events.stopgap.set_as_paid as app
def test_set_as_paid(
dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext,
):
event = {
'detail': {
'new_image': {
'id': '9omWNKymwU5U4aeun6mWzZ',
}
}
}
assert app.lambda_handler(event, lambda_context) # type: ignore
doc = dynamodb_persistence_layer.get_item(
key=KeyPair('9omWNKymwU5U4aeun6mWzZ', '0'),
)
assert doc['status'] == 'PAID'