From 93d96486ff7cfe5d86aab732a342b8abf13c1eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Mon, 8 Dec 2025 16:48:31 -0300 Subject: [PATCH] add enroll to subscribed --- .../app/routes/enrollments/enroll.py | 10 +-- .../app/routes/orgs/enrollments/scheduled.py | 3 +- .../tests/routes/enrollments/test_enroll.py | 19 ++-- .../app/routes/_.setup._index/route.tsx | 26 ++++-- .../app/routes/_.setup/route.tsx | 6 +- enrollments-events/app/enrollment.py | 88 +++++++++++++++---- enrollments-events/app/events/enroll.py | 11 ++- .../app/events/enroll_scheduled.py | 33 ++++++- .../app/events/reenroll_if_failed.py | 9 +- enrollments-events/app/schemas.py | 47 ---------- .../events/reporting/test_append_cert.py | 4 +- 11 files changed, 148 insertions(+), 108 deletions(-) delete mode 100644 enrollments-events/app/schemas.py diff --git a/api.saladeaula.digital/app/routes/enrollments/enroll.py b/api.saladeaula.digital/app/routes/enrollments/enroll.py index fe088c3..a2bd8d6 100644 --- a/api.saladeaula.digital/app/routes/enrollments/enroll.py +++ b/api.saladeaula.digital/app/routes/enrollments/enroll.py @@ -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_, } diff --git a/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py b/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py index 7f92185..7930906 100644 --- a/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py +++ b/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py @@ -16,8 +16,7 @@ dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) @router.get('//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, ) diff --git a/api.saladeaula.digital/tests/routes/enrollments/test_enroll.py b/api.saladeaula.digital/tests/routes/enrollments/test_enroll.py index f97b2a4..9e45fdf 100644 --- a/api.saladeaula.digital/tests/routes/enrollments/test_enroll.py +++ b/api.saladeaula.digital/tests/routes/enrollments/test_enroll.py @@ -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 diff --git a/apps/admin.saladeaula.digital/app/routes/_.setup._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.setup._index/route.tsx index b85d85a..f4c66ed 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.setup._index/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.setup._index/route.tsx @@ -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 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) {
- + ( + render={({ field: { onChange, ref } }) => ( CNPJ - diff --git a/apps/admin.saladeaula.digital/app/routes/_.setup/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.setup/route.tsx index 9589c71..25d0c28 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.setup/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.setup/route.tsx @@ -15,15 +15,15 @@ export function meta({}: Route.MetaArgs) { export default function Route({}: Route.ComponentProps) { return ( <> -
+
Voltar -
+
EDUSEG® diff --git a/enrollments-events/app/enrollment.py b/enrollments-events/app/enrollment.py index ed5f765..0689e66 100644 --- a/enrollments-events/app/enrollment.py +++ b/enrollments-events/app/enrollment.py @@ -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 = ( diff --git a/enrollments-events/app/events/enroll.py b/enrollments-events/app/events/enroll.py index 40d8c5d..6de9450 100644 --- a/enrollments-events/app/events/enroll.py +++ b/enrollments-events/app/events/enroll.py @@ -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( diff --git a/enrollments-events/app/events/enroll_scheduled.py b/enrollments-events/app/events/enroll_scheduled.py index 3134691..d797e32 100644 --- a/enrollments-events/app/events/enroll_scheduled.py +++ b/enrollments-events/app/events/enroll_scheduled.py @@ -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, + ) diff --git a/enrollments-events/app/events/reenroll_if_failed.py b/enrollments-events/app/events/reenroll_if_failed.py index 96d6b9d..1281760 100644 --- a/enrollments-events/app/events/reenroll_if_failed.py +++ b/enrollments-events/app/events/reenroll_if_failed.py @@ -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, diff --git a/enrollments-events/app/schemas.py b/enrollments-events/app/schemas.py deleted file mode 100644 index 2a5e471..0000000 --- a/enrollments-events/app/schemas.py +++ /dev/null @@ -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, - ) diff --git a/enrollments-events/tests/events/reporting/test_append_cert.py b/enrollments-events/tests/events/reporting/test_append_cert.py index dd1d17f..da1dd42 100644 --- a/enrollments-events/tests/events/reporting/test_append_cert.py +++ b/enrollments-events/tests/events/reporting/test_append_cert.py @@ -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',