add report submission

This commit is contained in:
2025-12-11 20:46:34 -03:00
parent 3fb8488074
commit 1e1a0ae24c
10 changed files with 281 additions and 54 deletions

View File

@@ -51,6 +51,7 @@ app.include_router(orgs.add, prefix='/orgs')
app.include_router(orgs.admins, prefix='/orgs') app.include_router(orgs.admins, prefix='/orgs')
app.include_router(orgs.custom_pricing, prefix='/orgs') app.include_router(orgs.custom_pricing, prefix='/orgs')
app.include_router(orgs.scheduled, 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.users, prefix='/orgs')
app.include_router(orgs.batch_jobs, prefix='/orgs') app.include_router(orgs.batch_jobs, prefix='/orgs')

View File

@@ -18,17 +18,10 @@ router = Router()
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
class CancelPolicyConflictError(BadRequestError): class CancelPolicyConflictError(BadRequestError): ...
def __init__(self, *_):
super().__init__('Cancellation policy not found')
class SlotConflictError(BadRequestError): @router.patch('/<enrollment_id>/cancel')
def __init__(self, *_):
super().__init__('Slot not found')
@router.post('/<enrollment_id>/cancel')
def cancel( def cancel(
enrollment_id: str, enrollment_id: str,
lock_hash: Annotated[str | None, Body(embed=True)] = None, lock_hash: Annotated[str | None, Body(embed=True)] = None,
@@ -44,12 +37,11 @@ def cancel(
canceled_at = :now, \ canceled_at = :now, \
updated_at = :now', updated_at = :now',
expr_attr_names={ expr_attr_names={
':status': 'status', '#status': 'status',
}, },
expr_attr_values={ expr_attr_values={
':pending': 'PENDING', ':pending': 'PENDING',
':canceled': 'CANCELED', ':canceled': 'CANCELED',
':true': True,
':now': now_, ':now': now_,
}, },
) )
@@ -89,9 +81,7 @@ def cancel(
key=KeyPair( key=KeyPair(
pk=enrollment_id, pk=enrollment_id,
sk='METADATA#PARENT_SLOT', sk='METADATA#PARENT_SLOT',
), )
cond_expr='attribute_exists(sk)',
exc_cls=SlotConflictError,
) )
if lock_hash: if lock_hash:

View File

@@ -8,9 +8,10 @@ import pytz
from aws_lambda_powertools import Logger from aws_lambda_powertools import Logger
from aws_lambda_powertools.event_handler.api_gateway import Router from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.exceptions import ( from aws_lambda_powertools.event_handler.exceptions import (
NotFoundError, ServiceError,
) )
from aws_lambda_powertools.event_handler.openapi.params import Body 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.batch import BatchProcessor
from layercake.dateutils import now, ttl from layercake.dateutils import now, ttl
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair 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 pydantic import UUID4, BaseModel, EmailStr, Field, FutureDate
from typing_extensions import TypedDict from typing_extensions import TypedDict
from api_gateway import JSONResponse
from boto3clients import dynamodb_client 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 exceptions import ConflictError
from middlewares.authentication_middleware import User as Authenticated from middlewares.authentication_middleware import User as Authenticated
@@ -31,7 +31,9 @@ dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
processor = BatchProcessor() processor = BatchProcessor()
class SubscriptionNotFoundError(NotFoundError): ... class SubscriptionRequiredError(ServiceError):
def __init__(self, msg: str | dict):
super().__init__(HTTPStatus.NOT_ACCEPTABLE, msg)
class DeduplicationConflictError(ConflictError): ... class DeduplicationConflictError(ConflictError): ...
@@ -74,30 +76,32 @@ class Org(BaseModel):
@router.post('/') @router.post('/')
def enroll( 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)], enrollments: Annotated[tuple[Enrollment, ...], Body(embed=True)],
): ):
now_ = now()
org = dyn.collection.get_items( org = dyn.collection.get_items(
KeyPair( KeyPair(
pk=str(org_id), pk=str(org_id),
sk='0', sk='0',
table_name=USER_TABLE,
) )
+ KeyPair( + KeyPair(
pk=str(org_id), pk=str(org_id),
sk='METADATA#SUBSCRIPTION_TERMS', sk='METADATA#SUBSCRIPTION_TERMS',
rename_key='terms', rename_key='terms',
table_name=USER_TABLE,
) )
+ KeyPair( + KeyPair(
pk='SUBSCRIPTION', pk='SUBSCRIPTION',
sk=f'ORG#{org_id}', sk=f'ORG#{org_id}',
rename_key='subscribed', rename_key='subscribed',
table_name=USER_TABLE,
) )
) )
if 'subscribed' not in org: if 'subscribed' not in org:
return JSONResponse( raise SubscriptionRequiredError('Organization not subscribed')
status_code=HTTPStatus.NOT_ACCEPTABLE,
)
ctx = { ctx = {
'org': Org.model_validate(org), 'org': Org.model_validate(org),
@@ -105,20 +109,38 @@ def enroll(
'terms': SubscriptionTerms.model_validate(org['terms']), '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] 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() now_out = batch.process()
with processor(later, enroll_later, ctx) as batch: with processor(later, enroll_later, ctx) as batch:
later_out = batch.process() later_out = batch.process()
def fmt(r):
return { return {
'enrolled': now_out, 'status': r.status.value,
'scheduled': later_out, '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 = TypedDict(
'Context', 'Context',
@@ -155,6 +177,9 @@ def enroll_now(enrollment: Enrollment, context: Context):
item={ item={
'id': enrollment.id, 'id': enrollment.id,
'sk': '0', 'sk': '0',
'score': None,
'progress': 0,
'status': 'PENDING',
'user': user.model_dump(), 'user': user.model_dump(),
'course': course.model_dump(), 'course': course.model_dump(),
'access_expires_at': access_expires_at, 'access_expires_at': access_expires_at,
@@ -230,7 +255,8 @@ def enroll_now(enrollment: Enrollment, context: Context):
'created_at': now_, 'created_at': now_,
}, },
) )
return True
return enrollment
def enroll_later(enrollment: Enrollment, context: Context): def enroll_later(enrollment: Enrollment, context: Context):

View File

@@ -2,7 +2,16 @@ from .add import router as add
from .admins import router as admins from .admins import router as admins
from .custom_pricing import router as custom_pricing from .custom_pricing import router as custom_pricing
from .enrollments.scheduled import router as scheduled from .enrollments.scheduled import router as scheduled
from .enrollments.submission import router as submission
from .users.add import router as users from .users.add import router as users
from .users.batch_jobs import router as batch_jobs 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',
]

View File

@@ -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('/<org_id>/enrollments/<submission_id>/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,
)

View File

@@ -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 from ...conftest import HttpApiProxy, LambdaContext
@@ -58,6 +59,12 @@ def test_enroll(
), ),
lambda_context, 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( enrolled = dynamodb_persistence_layer.collection.query(
PartitionKey('d0349bbe-cef3-44f7-b20e-3cb4476ab4c5') PartitionKey('d0349bbe-cef3-44f7-b20e-3cb4476ab4c5')

View File

@@ -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 (
<Suspense fallback={<Skeleton />}>
<div className="space-y-2.5">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="../enrollments">Matrículas</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="../enrollments/add">Adicionar matrículas</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Relatório de matrículas</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="lg:max-w-4xl mx-auto space-y-2.5">
<Card>
<CardHeader>
<CardTitle className="text-2xl">
Relatório de matrículas
</CardTitle>
<CardDescription>
Resumo detalhado do processamento das matrículas enviadas.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Await resolve={data}>
{({ enrolled, scheduled }) => {
const succeed = enrolled.filter(
({ status }) => status === 'success'
)
const failed = enrolled.filter(
({ status }) => status === 'fail'
)
// console.log(succeed)
return (
<>
{succeed?.length > 0 && (
<Alert className="text-green-500 *:data-[slot=alert-description]:text-green-500/90">
<CheckCircle2Icon />
<AlertTitle>
Matrículas adicionadas com sucesso.
</AlertTitle>
<AlertDescription>
<ul className="list-decimal list-inside">
{succeed.map(({ output }) => (
<li className="space-x-1">
<Abbr>{output.user.name}</Abbr>
<span>&mdash;</span>
<Abbr>{output.course.name}</Abbr>
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{failed?.length > 0 && (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>Matrículas não processadas.</AlertTitle>
<AlertDescription>
<ul className="list-decimal list-inside">
{failed.map(({ input_record }) => (
<li className="space-x-1">
<Abbr>{input_record.user.name}</Abbr>
<span>&mdash;</span>
<Abbr>{input_record.course.name}</Abbr>
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{scheduled?.length && (
<Alert>
<ClockIcon />
<AlertTitle>
Matrículas agendadas. Serão processadas na data
definida.
</AlertTitle>
<AlertDescription>
<ul className="list-decimal list-inside">
<li>...</li>
<li>...</li>
</ul>
</AlertDescription>
</Alert>
)}
</>
)
}}
</Await>
</CardContent>
</Card>
</div>
</div>
</Suspense>
)
}

View File

@@ -123,6 +123,7 @@ function ActionMenu({ row }: { row: any }) {
<CancelItem <CancelItem
id={row.id} id={row.id}
cancelPolicy={data?.cancel_policy} cancelPolicy={data?.cancel_policy}
lock={data?.lock}
onSuccess={onSuccess} onSuccess={onSuccess}
/> />
</> </>
@@ -180,7 +181,7 @@ function RemoveDedupItem({
const [loading, { set }] = useToggle(false) const [loading, { set }] = useToggle(false)
const [open, { set: setOpen }] = useToggle(false) const [open, { set: setOpen }] = useToggle(false)
const cancel = async (e: MouseEvent<HTMLButtonElement>) => { const remove = async (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault() e.preventDefault()
set(true) set(true)
@@ -231,7 +232,7 @@ function RemoveDedupItem({
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter className="*:cursor-pointer"> <AlertDialogFooter className="*:cursor-pointer">
<AlertDialogAction asChild> <AlertDialogAction asChild>
<Button onClick={cancel} disabled={loading} variant="destructive"> <Button onClick={remove} disabled={loading} variant="destructive">
{loading ? <Spinner /> : null} Continuar {loading ? <Spinner /> : null} Continuar
</Button> </Button>
</AlertDialogAction> </AlertDialogAction>
@@ -272,8 +273,9 @@ function CancelItem({
set(true) set(true)
const r = await fetch(`/~/api/enrollments/${id}/cancel`, { const r = await fetch(`/~/api/enrollments/${id}/cancel`, {
method: 'POST', method: 'PATCH',
headers: new Headers({ 'Content-Type': 'application/json' }) headers: new Headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ lock_hash: lock?.hash })
}) })
if (r.ok) { if (r.ok) {

View File

@@ -1,5 +1,7 @@
import { z } from 'zod' import { z } from 'zod'
export const MAX_ITEMS = 50
export const enrollment = z.object({ export const enrollment = z.object({
user: z user: z
.object( .object(
@@ -7,7 +9,7 @@ export const enrollment = z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
email: z.string(), email: z.string(),
cpf: z.string() cpf: z.string('a')
}, },
{ error: 'Escolhe um colaborador' } { error: 'Escolhe um colaborador' }
) )
@@ -32,9 +34,7 @@ export const enrollment = z.object({
}) })
export const formSchema = 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<typeof formSchema> export type Schema = z.infer<typeof formSchema>
export const MAX_ITEMS = 100

View File

@@ -13,7 +13,7 @@ import {
CheckIcon, CheckIcon,
BookIcon BookIcon
} from 'lucide-react' } 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 { Controller, useFieldArray, useForm } from 'react-hook-form'
import { Fragment, use, useEffect, useMemo, useState } from 'react' import { Fragment, use, useEffect, useMemo, useState } from 'react'
import { format } from 'date-fns' 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 { Calendar } from '@repo/ui/components/ui/calendar'
import { createSearch } from '@repo/util/meili' import { createSearch } from '@repo/util/meili'
import { cn } from '@repo/ui/lib/utils' 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 { useIsMobile } from '@repo/ui/hooks/use-mobile'
import { formSchema, type Schema, MAX_ITEMS } from './data' import { formSchema, type Schema, MAX_ITEMS } from './data'
import { AsyncCombobox } from './async-combobox' import { AsyncCombobox } from './async-combobox'
import { redirect } from 'react-router'
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [{ title: 'Adicionar matrícula' }] return [{ title: 'Adicionar matrícula' }]
@@ -83,8 +85,19 @@ export async function loader({ context }: Route.LoaderArgs) {
return { courses } return { courses }
} }
export async function action({}: Route.ActionArgs) { export async function action({ params, request, context }: Route.ActionArgs) {
return {} 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 = { const emptyRow = {
@@ -97,19 +110,22 @@ export default function Route({
loaderData: { courses } loaderData: { courses }
}: Route.ComponentProps) { }: Route.ComponentProps) {
const { orgid } = useParams() const { orgid } = useParams()
const [data, setData] = useState({}) const fetcher = useFetcher()
const form = useForm({ const form = useForm({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { enrollments: [emptyRow] } defaultValues: { enrollments: [emptyRow] }
}) })
const { formState, control, handleSubmit, getValues } = form const { formState, control, handleSubmit, getValues, reset } = form
const { fields, insert, remove, append } = useFieldArray({ const { fields, insert, remove, append } = useFieldArray({
control, control,
name: 'enrollments' name: 'enrollments'
}) })
const onSubmit = async (data: Schema) => { const onSubmit = async (data: Schema) => {
setData(data) await fetcher.submit(JSON.stringify(data), {
method: 'post',
encType: 'application/json'
})
} }
const onSearch = async (search: string) => { const onSearch = async (search: string) => {
@@ -142,7 +158,7 @@ export default function Route({
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbSeparator /> <BreadcrumbSeparator />
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbPage>Adicionar matrícula</BreadcrumbPage> <BreadcrumbPage>Adicionar matrículas</BreadcrumbPage>
</BreadcrumbItem> </BreadcrumbItem>
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
@@ -156,7 +172,7 @@ export default function Route({
> >
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-2xl">Adicionar matrícula</CardTitle> <CardTitle className="text-2xl">Adicionar matrículas</CardTitle>
<CardDescription> <CardDescription>
Siga os passos abaixo para adicionar novas matrículas. Siga os passos abaixo para adicionar novas matrículas.
</CardDescription> </CardDescription>
@@ -306,12 +322,6 @@ export default function Route({
</CardContent> </CardContent>
</Card> </Card>
</form> </form>
{data && (
<pre className="whitespace-pre-wrap text-xs bg-muted p-2 rounded">
{JSON.stringify(data, null, 2)}
</pre>
)}
</div> </div>
) )
} }
@@ -540,7 +550,7 @@ function DuplicateRowMultipleTimes({
type="number" type="number"
defaultValue="2" defaultValue="2"
min="2" min="2"
max="100" max={String(MAX_ITEMS - 1)}
className="col-span-2" className="col-span-2"
/> />
</div> </div>