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

View File

@@ -277,6 +277,14 @@ def checkout(payload: Checkout):
'created_at': now_, '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( return JSONResponse(
body={'id': order_id}, 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 { headers, sortings, statuses } from '@repo/ui/routes/enrollments/data'
import { createSearch } from '@repo/util/meili' import { createSearch } from '@repo/util/meili'
import { workspaceContext } from '@/middleware/workspace'
import { columns, type Enrollment } from './columns' import { columns, type Enrollment } from './columns'
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
@@ -26,6 +27,7 @@ export function meta({}: Route.MetaArgs) {
export async function loader({ params, context, request }: Route.LoaderArgs) { export async function loader({ params, context, request }: Route.LoaderArgs) {
const cloudflare = context.get(cloudflareContext) const cloudflare = context.get(cloudflareContext)
const { test_mode } = context.get(workspaceContext)
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const { orgid } = params const { orgid } = params
const query = searchParams.get('q') || '' 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 page = Number(searchParams.get('p')) + 1
const hitsPerPage = Number(searchParams.get('perPage')) || 25 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) { if (status) {
builder = builder.where('status', 'in', status.split(',')) builder = builder.where('status', 'in', status.split(','))

View File

@@ -1,36 +1,36 @@
import type { Route } from './+types/route' import type { Route } from './+types/route'
import { pick } from 'ramda' import { formatCNPJ } from '@brazilian-utils/brazilian-utils'
import { import {
CalendarIcon,
PlusCircleIcon,
BuildingIcon, BuildingIcon,
CheckIcon CalendarIcon,
CheckIcon,
PlusCircleIcon
} from 'lucide-react' } from 'lucide-react'
import { MeiliSearchFilterBuilder } from 'meilisearch-helper' import { MeiliSearchFilterBuilder } from 'meilisearch-helper'
import { pick } from 'ramda'
import { Suspense, useState } from 'react' import { Suspense, useState } from 'react'
import { Await, Outlet, useSearchParams } from 'react-router' import { Await, Outlet, useSearchParams } from 'react-router'
import { formatCNPJ } from '@brazilian-utils/brazilian-utils'
import { cloudflareContext } from '@repo/auth/context' import { cloudflareContext } from '@repo/auth/context'
import { Abbr } from '@repo/ui/components/abbr'
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 { SearchFilter } from '@repo/ui/components/search-filter' import { SearchFilter } from '@repo/ui/components/search-filter'
import { SearchForm } from '@repo/ui/components/search-form'
import { Skeleton } from '@repo/ui/components/skeleton' 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 { 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' 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 page = Number(searchParams.get('p')) + 1
const hitsPerPage = Number(searchParams.get('perPage')) || 25 const hitsPerPage = Number(searchParams.get('perPage')) || 25
let builder = new MeiliSearchFilterBuilder() let builder = new MeiliSearchFilterBuilder().where('is_test', 'exists', false)
if (status) { if (status) {
builder = builder.where('status', 'in', status.split(',')) 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'] new_image = event.detail['new_image']
# Key pattern `BILLING#ORG#{org_id}` # Key pattern `BILLING#ORG#{org_id}`
*_, org_id = new_image['id'].split('#') *_, 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('#') _, start_period, _, end_period, *_ = new_image['sk'].split('#')
emailmsg = Message( emailmsg = Message(

View File

@@ -102,16 +102,6 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
transact.delete( transact.delete(
key=KeyPair(order_id, 'CREDIT_CARD#PAYMENT_INTENT'), 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: except Exception:
pass 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: org_id:
- exists: true - exists: true
EventRunSelfDestructionFunction: EventRunAutoCleanupFunction:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
Properties: Properties:
Handler: events.run_self_destruction.lambda_handler Handler: events.run_auto_cleanup.lambda_handler
Timeout: 30 Timeout: 30
LoggingConfig: LoggingConfig:
LogGroup: !Ref EventLog LogGroup: !Ref EventLog
@@ -299,7 +299,9 @@ Resources:
detail-type: [EXPIRE] detail-type: [EXPIRE]
detail: detail:
keys: keys:
sk: ['SCHEDULED#SELF_DESTRUCTION'] sk:
- SCHEDULED#AUTO_CLEANUP
- SCHEDULED#SELF_DESTRUCTION
# DEPRECATED # DEPRECATED
EventAppendOrgIdFunction: EventAppendOrgIdFunction:
@@ -325,7 +327,6 @@ Resources:
sk: ['0'] sk: ['0']
cnpj: cnpj:
- exists: true - exists: true
# Post-migration: rename `tenant_id` to `org_id`
tenant_id: tenant_id:
- exists: false - exists: false
@@ -382,55 +383,6 @@ Resources:
- exists: true - exists: true
status: [CANCELED, EXPIRED] 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: Outputs:
HttpApiUrl: HttpApiUrl:
Description: URL of your API endpoint 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'