From 1e1a0ae24cd5e886cefae9f4429325da766c1b0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Thu, 11 Dec 2025 20:46:34 -0300 Subject: [PATCH] add report submission --- api.saladeaula.digital/app/app.py | 1 + .../app/routes/enrollments/cancel.py | 18 +- .../app/routes/enrollments/enroll.py | 54 ++++-- .../app/routes/orgs/__init__.py | 11 +- .../app/routes/orgs/enrollments/submission.py | 22 +++ .../tests/routes/enrollments/test_enroll.py | 11 +- .../route.tsx | 160 ++++++++++++++++++ .../_.$orgid.enrollments._index/columns.tsx | 10 +- .../routes/_.$orgid.enrollments.add/data.ts | 8 +- .../routes/_.$orgid.enrollments.add/route.tsx | 40 +++-- 10 files changed, 281 insertions(+), 54 deletions(-) create mode 100644 api.saladeaula.digital/app/routes/orgs/enrollments/submission.py create mode 100644 apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.$id.submitted/route.tsx diff --git a/api.saladeaula.digital/app/app.py b/api.saladeaula.digital/app/app.py index c73cb83..dc8bfd9 100644 --- a/api.saladeaula.digital/app/app.py +++ b/api.saladeaula.digital/app/app.py @@ -51,6 +51,7 @@ app.include_router(orgs.add, prefix='/orgs') app.include_router(orgs.admins, prefix='/orgs') app.include_router(orgs.custom_pricing, prefix='/orgs') app.include_router(orgs.scheduled, prefix='/orgs') +app.include_router(orgs.submission, prefix='/orgs') app.include_router(orgs.users, prefix='/orgs') app.include_router(orgs.batch_jobs, prefix='/orgs') diff --git a/api.saladeaula.digital/app/routes/enrollments/cancel.py b/api.saladeaula.digital/app/routes/enrollments/cancel.py index 21a401f..1b4e189 100644 --- a/api.saladeaula.digital/app/routes/enrollments/cancel.py +++ b/api.saladeaula.digital/app/routes/enrollments/cancel.py @@ -18,17 +18,10 @@ router = Router() dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) -class CancelPolicyConflictError(BadRequestError): - def __init__(self, *_): - super().__init__('Cancellation policy not found') +class CancelPolicyConflictError(BadRequestError): ... -class SlotConflictError(BadRequestError): - def __init__(self, *_): - super().__init__('Slot not found') - - -@router.post('//cancel') +@router.patch('//cancel') def cancel( enrollment_id: str, lock_hash: Annotated[str | None, Body(embed=True)] = None, @@ -44,12 +37,11 @@ def cancel( canceled_at = :now, \ updated_at = :now', expr_attr_names={ - ':status': 'status', + '#status': 'status', }, expr_attr_values={ ':pending': 'PENDING', ':canceled': 'CANCELED', - ':true': True, ':now': now_, }, ) @@ -89,9 +81,7 @@ def cancel( key=KeyPair( pk=enrollment_id, sk='METADATA#PARENT_SLOT', - ), - cond_expr='attribute_exists(sk)', - exc_cls=SlotConflictError, + ) ) if lock_hash: diff --git a/api.saladeaula.digital/app/routes/enrollments/enroll.py b/api.saladeaula.digital/app/routes/enrollments/enroll.py index a2bd8d6..8bcf545 100644 --- a/api.saladeaula.digital/app/routes/enrollments/enroll.py +++ b/api.saladeaula.digital/app/routes/enrollments/enroll.py @@ -8,9 +8,10 @@ import pytz from aws_lambda_powertools import Logger from aws_lambda_powertools.event_handler.api_gateway import Router from aws_lambda_powertools.event_handler.exceptions import ( - NotFoundError, + ServiceError, ) from aws_lambda_powertools.event_handler.openapi.params import Body +from aws_lambda_powertools.shared.functions import extract_event_from_common_models from layercake.batch import BatchProcessor from layercake.dateutils import now, ttl from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair @@ -19,9 +20,8 @@ from layercake.strutils import md5_hash from pydantic import UUID4, BaseModel, EmailStr, Field, FutureDate from typing_extensions import TypedDict -from api_gateway import JSONResponse from boto3clients import dynamodb_client -from config import DEDUP_WINDOW_OFFSET_DAYS, ENROLLMENT_TABLE, TZ +from config import DEDUP_WINDOW_OFFSET_DAYS, ENROLLMENT_TABLE, TZ, USER_TABLE from exceptions import ConflictError from middlewares.authentication_middleware import User as Authenticated @@ -31,7 +31,9 @@ dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) processor = BatchProcessor() -class SubscriptionNotFoundError(NotFoundError): ... +class SubscriptionRequiredError(ServiceError): + def __init__(self, msg: str | dict): + super().__init__(HTTPStatus.NOT_ACCEPTABLE, msg) class DeduplicationConflictError(ConflictError): ... @@ -74,30 +76,32 @@ class Org(BaseModel): @router.post('/') def enroll( - org_id: Annotated[UUID4 | str, Body(embed=True)], + org_id: Annotated[str | UUID4, Body(embed=True)], enrollments: Annotated[tuple[Enrollment, ...], Body(embed=True)], ): + now_ = now() org = dyn.collection.get_items( KeyPair( pk=str(org_id), sk='0', + table_name=USER_TABLE, ) + KeyPair( pk=str(org_id), sk='METADATA#SUBSCRIPTION_TERMS', rename_key='terms', + table_name=USER_TABLE, ) + KeyPair( pk='SUBSCRIPTION', sk=f'ORG#{org_id}', rename_key='subscribed', + table_name=USER_TABLE, ) ) if 'subscribed' not in org: - return JSONResponse( - status_code=HTTPStatus.NOT_ACCEPTABLE, - ) + raise SubscriptionRequiredError('Organization not subscribed') ctx = { 'org': Org.model_validate(org), @@ -105,20 +109,38 @@ def enroll( 'terms': SubscriptionTerms.model_validate(org['terms']), } - now = [e for e in enrollments if not e.scheduled_for] + immediate = [e for e in enrollments if not e.scheduled_for] later = [e for e in enrollments if e.scheduled_for] - with processor(now, enroll_now, ctx) as batch: + with processor(immediate, enroll_now, ctx) as batch: now_out = batch.process() with processor(later, enroll_later, ctx) as batch: later_out = batch.process() - return { - 'enrolled': now_out, - 'scheduled': later_out, + def fmt(r): + return { + 'status': r.status.value, + 'input_record': extract_event_from_common_models(r.input_record), + 'output': extract_event_from_common_models(r.output), + 'cause': extract_event_from_common_models(r.cause), + } + + 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 * 6), } + try: + dyn.put_item(item=item) + except Exception as exc: + logger.exception(exc) + finally: + return item + Context = TypedDict( 'Context', @@ -155,6 +177,9 @@ def enroll_now(enrollment: Enrollment, context: Context): item={ 'id': enrollment.id, 'sk': '0', + 'score': None, + 'progress': 0, + 'status': 'PENDING', 'user': user.model_dump(), 'course': course.model_dump(), 'access_expires_at': access_expires_at, @@ -230,7 +255,8 @@ def enroll_now(enrollment: Enrollment, context: Context): 'created_at': now_, }, ) - return True + + return enrollment def enroll_later(enrollment: Enrollment, context: Context): diff --git a/api.saladeaula.digital/app/routes/orgs/__init__.py b/api.saladeaula.digital/app/routes/orgs/__init__.py index 3825aab..ec2346c 100644 --- a/api.saladeaula.digital/app/routes/orgs/__init__.py +++ b/api.saladeaula.digital/app/routes/orgs/__init__.py @@ -2,7 +2,16 @@ from .add import router as add from .admins import router as admins from .custom_pricing import router as custom_pricing from .enrollments.scheduled import router as scheduled +from .enrollments.submission import router as submission from .users.add import router as users from .users.batch_jobs import router as batch_jobs -__all__ = ['add', 'admins', 'custom_pricing', 'scheduled', 'users', 'batch_jobs'] +__all__ = [ + 'add', + 'admins', + 'custom_pricing', + 'scheduled', + 'submission', + 'users', + 'batch_jobs', +] diff --git a/api.saladeaula.digital/app/routes/orgs/enrollments/submission.py b/api.saladeaula.digital/app/routes/orgs/enrollments/submission.py new file mode 100644 index 0000000..cd74239 --- /dev/null +++ b/api.saladeaula.digital/app/routes/orgs/enrollments/submission.py @@ -0,0 +1,22 @@ +from aws_lambda_powertools import Logger +from aws_lambda_powertools.event_handler.api_gateway import Router +from aws_lambda_powertools.event_handler.exceptions import NotFoundError +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair + +from boto3clients import dynamodb_client +from config import ENROLLMENT_TABLE + +logger = Logger(__name__) +router = Router() +dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) + + +@router.get('//enrollments//submitted') +def submitted(org_id: str, submission_id: str): + return dyn.collection.get_item( + KeyPair( + pk=f'SUBMISSION#ORG#{org_id}', + sk=submission_id, + ), + exc_cls=NotFoundError, + ) diff --git a/api.saladeaula.digital/tests/routes/enrollments/test_enroll.py b/api.saladeaula.digital/tests/routes/enrollments/test_enroll.py index 9e45fdf..da58cd3 100644 --- a/api.saladeaula.digital/tests/routes/enrollments/test_enroll.py +++ b/api.saladeaula.digital/tests/routes/enrollments/test_enroll.py @@ -1,6 +1,7 @@ -from http import HTTPMethod +import json +from http import HTTPMethod, HTTPStatus -from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, PartitionKey from ...conftest import HttpApiProxy, LambdaContext @@ -58,6 +59,12 @@ def test_enroll( ), lambda_context, ) + assert r['statusCode'] == HTTPStatus.OK + body = json.loads(r['body']) + submission = dynamodb_persistence_layer.get_item(KeyPair(body['id'], body['sk'])) + assert submission['sk'] == body['sk'] + + print(body) enrolled = dynamodb_persistence_layer.collection.query( PartitionKey('d0349bbe-cef3-44f7-b20e-3cb4476ab4c5') diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.$id.submitted/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.$id.submitted/route.tsx new file mode 100644 index 0000000..5bae784 --- /dev/null +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.$id.submitted/route.tsx @@ -0,0 +1,160 @@ +import type { Route } from './+types/route' + +import { AlertCircleIcon, CheckCircle2Icon, ClockIcon } from 'lucide-react' +import { Link } from 'react-router' +import { Suspense } from 'react' + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@repo/ui/components/ui/card' +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from '@repo/ui/components/ui/breadcrumb' +import { + Alert, + AlertDescription, + AlertTitle +} from '@repo/ui/components/ui/alert' +import { request as req } from '@repo/util/request' +import { Skeleton } from '@repo/ui/components/skeleton' +import { Await } from 'react-router' +import { Abbr } from '@repo/ui/components/abbr' + +export function meta({}: Route.MetaArgs) { + return [{ title: 'Relatório de matrículas' }] +} + +export async function loader({ context, request, params }: Route.LoaderArgs) { + const { orgid, id } = params + const submission = req({ + url: `/orgs/${orgid}/enrollments/${id}/submitted`, + context, + request + }).then((r) => r.json()) + + return { + data: submission + } +} + +export default function Route({ loaderData: { data } }: Route.ComponentProps) { + return ( + }> +
+ + + + + Matrículas + + + + + + Adicionar matrículas + + + + + Relatório de matrículas + + + + +
+ + + + Relatório de matrículas + + + Resumo detalhado do processamento das matrículas enviadas. + + + + + + {({ enrolled, scheduled }) => { + const succeed = enrolled.filter( + ({ status }) => status === 'success' + ) + const failed = enrolled.filter( + ({ status }) => status === 'fail' + ) + + // console.log(succeed) + return ( + <> + {succeed?.length > 0 && ( + + + + Matrículas adicionadas com sucesso. + + +
    + {succeed.map(({ output }) => ( +
  • + {output.user.name} + + {output.course.name} +
  • + ))} +
+
+
+ )} + + {failed?.length > 0 && ( + + + Matrículas não processadas. + +
    + {failed.map(({ input_record }) => ( +
  • + {input_record.user.name} + + {input_record.course.name} +
  • + ))} +
+
+
+ )} + + {scheduled?.length && ( + + + + Matrículas agendadas. Serão processadas na data + definida. + + +
    +
  • ...
  • +
  • ...
  • +
+
+
+ )} + + ) + }} +
+
+
+
+
+
+ ) +} diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx index 4859d69..634d4ce 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx @@ -123,6 +123,7 @@ function ActionMenu({ row }: { row: any }) { @@ -180,7 +181,7 @@ function RemoveDedupItem({ const [loading, { set }] = useToggle(false) const [open, { set: setOpen }] = useToggle(false) - const cancel = async (e: MouseEvent) => { + const remove = async (e: MouseEvent) => { e.preventDefault() set(true) @@ -231,7 +232,7 @@ function RemoveDedupItem({ - @@ -272,8 +273,9 @@ function CancelItem({ set(true) const r = await fetch(`/~/api/enrollments/${id}/cancel`, { - method: 'POST', - headers: new Headers({ 'Content-Type': 'application/json' }) + method: 'PATCH', + headers: new Headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ lock_hash: lock?.hash }) }) if (r.ok) { diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/data.ts b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/data.ts index 178f6ce..075f230 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/data.ts +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/data.ts @@ -1,5 +1,7 @@ import { z } from 'zod' +export const MAX_ITEMS = 50 + export const enrollment = z.object({ user: z .object( @@ -7,7 +9,7 @@ export const enrollment = z.object({ id: z.string(), name: z.string(), email: z.string(), - cpf: z.string() + cpf: z.string('a') }, { error: 'Escolhe um colaborador' } ) @@ -32,9 +34,7 @@ export const enrollment = z.object({ }) export const formSchema = z.object({ - enrollments: z.array(enrollment).min(1).max(100) + enrollments: z.array(enrollment).min(1).max(MAX_ITEMS) }) export type Schema = z.infer - -export const MAX_ITEMS = 100 diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/route.tsx index 90186c3..5d260fb 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/route.tsx @@ -13,7 +13,7 @@ import { CheckIcon, BookIcon } from 'lucide-react' -import { Link, useParams } from 'react-router' +import { Link, useParams, useFetcher } from 'react-router' import { Controller, useFieldArray, useForm } from 'react-hook-form' import { Fragment, use, useEffect, useMemo, useState } from 'react' import { format } from 'date-fns' @@ -62,10 +62,12 @@ import { Label } from '@repo/ui/components/ui/label' import { Calendar } from '@repo/ui/components/ui/calendar' import { createSearch } from '@repo/util/meili' import { cn } from '@repo/ui/lib/utils' +import { HttpMethod, request as req } from '@repo/util/request' import { useIsMobile } from '@repo/ui/hooks/use-mobile' import { formSchema, type Schema, MAX_ITEMS } from './data' import { AsyncCombobox } from './async-combobox' +import { redirect } from 'react-router' export function meta({}: Route.MetaArgs) { return [{ title: 'Adicionar matrícula' }] @@ -83,8 +85,19 @@ export async function loader({ context }: Route.LoaderArgs) { return { courses } } -export async function action({}: Route.ActionArgs) { - return {} +export async function action({ params, request, context }: Route.ActionArgs) { + const body = (await request.json()) as object + const r = await req({ + url: `enrollments`, + headers: new Headers({ 'Content-Type': 'application/json' }), + method: HttpMethod.POST, + body: JSON.stringify({ org_id: params.orgid, ...body }), + request, + context + }) + + const result = (await r.json()) as { sk: string } + return redirect(`/${params.orgid}/enrollments/${result.sk}/submitted`) } const emptyRow = { @@ -97,19 +110,22 @@ export default function Route({ loaderData: { courses } }: Route.ComponentProps) { const { orgid } = useParams() - const [data, setData] = useState({}) + const fetcher = useFetcher() const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { enrollments: [emptyRow] } }) - const { formState, control, handleSubmit, getValues } = form + const { formState, control, handleSubmit, getValues, reset } = form const { fields, insert, remove, append } = useFieldArray({ control, name: 'enrollments' }) const onSubmit = async (data: Schema) => { - setData(data) + await fetcher.submit(JSON.stringify(data), { + method: 'post', + encType: 'application/json' + }) } const onSearch = async (search: string) => { @@ -142,7 +158,7 @@ export default function Route({ - Adicionar matrícula + Adicionar matrículas @@ -156,7 +172,7 @@ export default function Route({ > - Adicionar matrícula + Adicionar matrículas Siga os passos abaixo para adicionar novas matrículas. @@ -306,12 +322,6 @@ export default function Route({ - - {data && ( -
-          {JSON.stringify(data, null, 2)}
-        
- )} ) } @@ -540,7 +550,7 @@ function DuplicateRowMultipleTimes({ type="number" defaultValue="2" min="2" - max="100" + max={String(MAX_ITEMS - 1)} className="col-span-2" />