add test mode to enrollments
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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(','))
|
||||
|
||||
@@ -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(','))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
"""
|
||||
Stopgap events. Everything here is a quick fix and should be replaced with
|
||||
proper solutions.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user