add enroll to subscribed

This commit is contained in:
2025-12-08 16:48:31 -03:00
parent 1ff2634bc0
commit 93d96486ff
11 changed files with 148 additions and 108 deletions

View File

@@ -40,8 +40,8 @@ class DeduplicationConflictError(ConflictError): ...
class User(BaseModel):
id: str | UUID4
name: NameStr
cpf: CpfStr
email: EmailStr
cpf: CpfStr
class Course(BaseModel):
@@ -123,9 +123,9 @@ def enroll(
Context = TypedDict(
'Context',
{
'created_by': Authenticated,
'org': Org,
'terms': SubscriptionTerms,
'created_by': Authenticated,
},
)
@@ -251,14 +251,12 @@ def enroll_later(enrollment: Enrollment, context: Context):
'sk': f'{scheduled_for.isoformat()}#{lock_hash}',
'user': user.model_dump(),
'course': course.model_dump(),
'org': org.model_dump(),
'org_name': org.name,
'created_by': {
'id': created_by.id,
'name': created_by.name,
},
'subscription_covered': {
'billing_day': subscription_terms.billing_day,
},
'subscription_billing_day': subscription_terms.billing_day,
'ttl': ttl(start_dt=scheduled_for),
'created_at': now_,
}

View File

@@ -16,8 +16,7 @@ dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
@router.get('/<org_id>/enrollments/scheduled')
def scheduled(org_id: str, start_key: Annotated[str | None, Query] = None):
return dyn.collection.query(
# Post-migration: rename `scheduled_items` to `SCHEDULED#ORG#{org_id}`
key=PartitionKey(f'scheduled_items#{org_id}'),
key=PartitionKey(f'SCHEDULED#ORG#{org_id}'),
start_key=start_key,
limit=150,
)

View File

@@ -1,5 +1,3 @@
import json
import pprint
from http import HTTPMethod
from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey
@@ -26,14 +24,14 @@ def test_enroll(
'user': {
'id': '15bacf02-1535-4bee-9022-19d106fd7518',
'name': 'Eddie Vedder',
'email': 'sergio@somosbeta.com.br',
'email': 'eddie@pearljam.band',
'cpf': '07879819908',
},
'course': {
'id': 'c27d1b4f-575c-4b6b-82a1-9b91ff369e0b',
'name': 'NR-10',
'name': 'NR-18 PEMT Plataforma Móvel de Trabalho Aéreo',
'access_period': '360',
'unit_price': '100.30',
'unit_price': '149',
},
'scheduled_for': '2028-01-01',
},
@@ -61,11 +59,14 @@ def test_enroll(
lambda_context,
)
body = json.loads(r['body'])
pprint.pp(body)
enrolled = dynamodb_persistence_layer.collection.query(
PartitionKey('d0349bbe-cef3-44f7-b20e-3cb4476ab4c5')
)
pprint.pp(enrolled)
assert len(enrolled['items']) == 7
scheduled = dynamodb_persistence_layer.collection.query(
PartitionKey('SCHEDULED#ORG#2a8963fc-4694-4fe2-953a-316d1b10f1f5')
)
assert len(scheduled['items']) == 1

View File

@@ -1,6 +1,8 @@
import type { Route } from './+types/route'
import { isValidCNPJ } from '@brazilian-utils/brazilian-utils'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { PatternFormat } from 'react-number-format'
import {
@@ -13,9 +15,22 @@ import {
} from '@repo/ui/components/ui/form'
import { Input } from '@repo/ui/components/ui/input'
import { Button } from '@repo/ui/components/ui/button'
import z from 'zod'
const formSchema = z.object({
cnpj: z
.string('CNPJ obrigatório')
.refine(isValidCNPJ, { message: 'CNPJ inválido' })
})
export type Schema = z.infer<typeof formSchema>
export default function Route({}: Route.ComponentProps) {
const form = useForm()
const form = useForm({ resolver: zodResolver(formSchema) })
const { handleSubmit, control } = form
const onSubmit = async (data: Schema) => {
console.log(data)
}
return (
<>
@@ -26,17 +41,18 @@ export default function Route({}: Route.ComponentProps) {
</div>
<Form {...form}>
<form className="space-y-6">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
control={control}
name="cnpj"
render={({ field: { onChange, ref, ...props } }) => (
render={({ field: { onChange, ref } }) => (
<FormItem className="grid gap-3">
<FormLabel>CNPJ</FormLabel>
<FormControl>
<PatternFormat
format="##.###.###/####-##"
mask="_"
autoFocus={true}
placeholder="__.___.__/____-__"
customInput={Input}
getInputRef={ref}
@@ -50,7 +66,7 @@ export default function Route({}: Route.ComponentProps) {
)}
/>
<Button type="submit" className="w-full">
<Button type="submit" className="w-full cursor-pointer">
Continuar
</Button>
</form>

View File

@@ -15,15 +15,15 @@ export function meta({}: Route.MetaArgs) {
export default function Route({}: Route.ComponentProps) {
return (
<>
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10 relative">
<div className="min-h-dvh flex items-center justify-center overflow-auto">
<Link
to="/"
className="flex items-center gap-0.5 absolute top-5 left-5 text-sm z-2"
className="absolute left-4 top-6 flex items-center gap-1 z-10 hover:underline"
>
<ChevronLeftIcon className="size-5" /> Voltar
</Link>
<div className="w-full max-w-xs relative max-sm:mt-10 z-1 space-y-6">
<div className="w-full max-w-xs pt-8 relative z-10 space-y-6 px-4">
<div className="flex justify-center">
<div className="border border-white/15 bg-white/5 px-2.5 py-3 rounded-xl">
<img src={logo} alt="EDUSEG®" className="block size-12" />

View File

@@ -1,37 +1,74 @@
from abc import ABC
from dataclasses import dataclass
from datetime import timedelta
from datetime import datetime, timedelta
from enum import Enum
from typing import TypedDict
from typing import Any, Literal, TypedDict
from uuid import uuid4
from layercake.dateutils import now, ttl
from layercake.dynamodb import DynamoDBPersistenceLayer
from layercake.extra_types import CpfStr, NameStr
from layercake.strutils import md5_hash
from pydantic import (
UUID4,
BaseModel,
ConfigDict,
EmailStr,
Field,
)
from typing_extensions import NotRequired
from config import DEDUP_WINDOW_OFFSET_DAYS
from schemas import Enrollment
Org = TypedDict(
'Org',
{
'org_id': str,
'name': str,
},
)
DeduplicationWindow = TypedDict(
'DeduplicationWindow',
{
'offset_days': int,
},
)
class User(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
id: UUID4 | str
name: NameStr
email: EmailStr
email_verified: bool = False
cpf: CpfStr | None = None
class Course(BaseModel):
id: UUID4 | str
name: str
access_period: int = 90 # 3 months
class Enrollment(BaseModel):
id: UUID4 | str = Field(default_factory=uuid4)
user: User
course: Course
progress: int = Field(default=0, ge=0, le=100)
status: Literal['PENDING'] = 'PENDING'
def model_dump(
self,
exclude=None,
*args,
**kwargs,
) -> dict[str, Any]:
return super().model_dump(
exclude={'user': {'email_verified'}},
*args,
**kwargs,
)
Org = TypedDict('Org', {'org_id': str, 'name': str})
CreatedBy = TypedDict('CreatedBy', {'id': str, 'name': str})
DeduplicationWindow = TypedDict('DeduplicationWindow', {'offset_days': int})
Subscription = TypedDict(
'Subscription',
{
'org_id': str,
'billing_day': int,
'billing_period': str,
'billing_period': NotRequired[str],
},
)
@@ -58,6 +95,8 @@ def enroll(
*,
org: Org | None = None,
subscription: Subscription | None = None,
created_by: CreatedBy | None = None,
scheduled_at: datetime | None = None,
linked_entities: frozenset[LinkedEntity] = frozenset(),
deduplication_window: DeduplicationWindow | None = None,
persistence_layer: DynamoDBPersistenceLayer,
@@ -65,7 +104,7 @@ def enroll(
now_ = now()
user = enrollment.user
course = enrollment.course
lock_hash = md5_hash('%s%s' % (user.id, course.id))
lock_hash = md5_hash(f'{user.id}{course.id}')
access_expires_at = now_ + timedelta(days=course.access_period)
with persistence_layer.transact_writer() as transact:
@@ -76,8 +115,9 @@ def enroll(
'access_expires_at': access_expires_at,
**enrollment.model_dump(),
}
| ({'org_id': org['org_id']} if org else {})
| ({'subscription_covered': True} if subscription else {})
| ({'org_id': org['org_id']} if org else {}),
| ({'scheduled_at': scheduled_at} if scheduled_at else {})
)
# Relationships between this enrollment and its related entities
@@ -123,6 +163,16 @@ def enroll(
| subscription,
)
if created_by:
transact.put(
item={
'id': enrollment.id,
'sk': 'CREATED_BY',
'created_by': created_by,
'created_at': now_,
}
)
# Prevents the user from enrolling in the same course again until
# the deduplication window expires or is removed.
offset_days = (

View File

@@ -1,5 +1,3 @@
from uuid import uuid4
from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.data_classes import (
EventBridgeEvent,
@@ -19,11 +17,13 @@ from layercake.dynamodb import (
from boto3clients import dynamodb_client
from config import COURSE_TABLE, ENROLLMENT_TABLE, ORDER_TABLE
from enrollment import (
Course,
Enrollment,
Kind,
LinkedEntity,
User,
enroll,
)
from schemas import Course, Enrollment, User
logger = Logger(__name__)
order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
@@ -82,11 +82,10 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
)
def _handler(record: Course, context: dict) -> Enrollment:
def _handler(course: Course, context: dict) -> Enrollment:
enrollment = Enrollment(
id=uuid4(),
user=context['user'],
course=record,
course=course,
)
enroll(

View File

@@ -1,3 +1,5 @@
from datetime import datetime
from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.data_classes import (
EventBridgeEvent,
@@ -7,9 +9,8 @@ from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import DynamoDBPersistenceLayer
from boto3clients import dynamodb_client
from config import (
ENROLLMENT_TABLE,
)
from config import ENROLLMENT_TABLE
from enrollment import Enrollment, enroll
logger = Logger(__name__)
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
@@ -21,5 +22,29 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
old_image = event.detail['old_image']
# Key pattern `SCHEDULED#ORG#{org_id}`
*_, org_id = old_image['id'].split('#')
offset_days = old_image.get('dedup_window_offset_days')
billing_day = old_image.get('subscription_billing_day')
enrollment = Enrollment(
course=old_image['course'],
user=old_image['user'],
)
return True
return enroll(
enrollment,
org={
'org_id': org_id,
'name': old_image['org_name'],
},
subscription=(
{
'org_id': org_id,
'billing_day': int(billing_day),
}
if billing_day
else None
),
scheduled_at=datetime.fromisoformat(old_image['created_at']),
# Transfer the deduplication window if it exists
deduplication_window={'offset_days': offset_days} if offset_days else None,
persistence_layer=dyn,
)

View File

@@ -10,8 +10,7 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, SortKey, TransactKey
from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE
from enrollment import Kind, LinkedEntity, enroll
from schemas import Course, Enrollment, User
from enrollment import Course, Enrollment, Kind, LinkedEntity, User, enroll
logger = Logger(__name__)
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
@@ -24,16 +23,16 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
metadata = dyn.collection.get_items(
TransactKey(new_image['id'])
+ SortKey(
'METADATA#SUBSCRIPTION_COVERED',
sk='METADATA#SUBSCRIPTION_COVERED',
rename_key='subscription',
)
+ SortKey(
'METADATA#DEDUPLICATION_WINDOW',
sk='METADATA#DEDUPLICATION_WINDOW',
path_spec='offset_days',
rename_key='dedup_window_offset_days',
)
+ SortKey(
'ORG',
sk='ORG',
rename_key='org',
),
flatten_top=False,

View File

@@ -1,47 +0,0 @@
from typing import Any, Literal
from uuid import uuid4
from layercake.extra_types import CpfStr, NameStr
from pydantic import (
UUID4,
BaseModel,
ConfigDict,
EmailStr,
Field,
)
class User(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
id: UUID4 | str = Field(default_factory=uuid4)
name: NameStr
email: EmailStr
email_verified: bool = False
cpf: CpfStr | None = None
class Course(BaseModel):
id: UUID4 | str = Field(default_factory=uuid4)
name: str
access_period: int = 90 # 3 months
class Enrollment(BaseModel):
id: UUID4 | str = Field(default_factory=uuid4)
user: User
course: Course
progress: int = Field(default=0, ge=0, le=100)
status: Literal['PENDING'] = 'PENDING'
def model_dump(
self,
exclude=None,
*args,
**kwargs,
) -> dict[str, Any]:
return super().model_dump(
exclude={'user': {'email_verified'}},
*args,
**kwargs,
)

View File

@@ -29,7 +29,7 @@ def test_append_cert(
'cert_expires_at': cert_expires_at.isoformat(),
'user': {
'id': '1234',
'name': 'Tobias Summit',
'name': 'Tobias Sammit',
},
'org_id': '1e2eaf0e-e319-49eb-ab33-1ddec156dc94',
'created_at': '2025-01-01T00:00:00-03:06',
@@ -81,7 +81,7 @@ def test_report_exists(
'cert_expires_at': '2025-07-02T00:00:00-03:06',
'user': {
'id': '1234',
'name': 'Tobias Summit',
'name': 'Tobias Sammit',
},
'org_id': '00237409-9384-4692-9be5-b4443a41e1c4',
'created_at': '2025-01-01T00:00:00-03:06',