add filter

This commit is contained in:
2025-12-16 13:02:34 -03:00
parent 62b5340b20
commit ab5bf50900
7 changed files with 230 additions and 79 deletions

View File

@@ -1,5 +1,4 @@
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
from decimal import Decimal
from http import HTTPStatus from http import HTTPStatus
from typing import Annotated, TypedDict from typing import Annotated, TypedDict
from uuid import uuid4 from uuid import uuid4
@@ -49,7 +48,6 @@ class Course(BaseModel):
id: UUID4 id: UUID4
name: str name: str
access_period: int access_period: int
unit_price: Decimal = Field(exclude=True)
class DeduplicationWindow(BaseModel): class DeduplicationWindow(BaseModel):
@@ -177,6 +175,14 @@ def enroll_now(enrollment: Enrollment, context: Context):
) )
with dyn.transact_writer() as transact: with dyn.transact_writer() as transact:
transact.condition(
key=KeyPair(
pk='SUBSCRIPTION',
sk=f'ORG#{org.id}',
),
cond_expr='attribute_exists(sk)',
exc_cls=SubscriptionRequiredError,
)
transact.put( transact.put(
item={ item={
'id': enrollment.id, 'id': enrollment.id,
@@ -276,6 +282,14 @@ def enroll_later(enrollment: Enrollment, context: Context):
pk = f'SCHEDULED#ORG#{org.id}' pk = f'SCHEDULED#ORG#{org.id}'
sk = f'{scheduled_for.isoformat()}#{lock_hash}' sk = f'{scheduled_for.isoformat()}#{lock_hash}'
transact.condition(
key=KeyPair(
pk='SUBSCRIPTION',
sk=f'ORG#{org.id}',
),
cond_expr='attribute_exists(sk)',
exc_cls=SubscriptionRequiredError,
)
transact.put( transact.put(
item={ item={
'id': pk, 'id': pk,

View File

@@ -11,12 +11,18 @@ from pydantic import FutureDatetime
from api_gateway import JSONResponse from api_gateway import JSONResponse
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE from config import ENROLLMENT_TABLE
from middlewares.authentication_middleware import User as Authenticated
from ...enrollments.enroll import Enrollment, Org, Subscription, enroll_now
logger = Logger(__name__) logger = Logger(__name__)
router = Router() router = Router()
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
class ScheduledNotFoundError(NotFoundError): ...
@router.get('/<org_id>/enrollments/scheduled') @router.get('/<org_id>/enrollments/scheduled')
def scheduled( def scheduled(
org_id: str, org_id: str,
@@ -29,9 +35,6 @@ def scheduled(
) )
class ScheduledNotFoundError(NotFoundError): ...
@router.delete('/<org_id>/enrollments/scheduled') @router.delete('/<org_id>/enrollments/scheduled')
def cancel( def cancel(
org_id: str, org_id: str,
@@ -52,8 +55,52 @@ def cancel(
pk='LOCK#SCHEDULED', pk='LOCK#SCHEDULED',
sk=lock_hash, sk=lock_hash,
), ),
cond_expr='attribute_exists(sk)',
exc_cls=ScheduledNotFoundError,
) )
return JSONResponse(status_code=HTTPStatus.NO_CONTENT) return JSONResponse(status_code=HTTPStatus.NO_CONTENT)
@router.post('/<org_id>/enrollments/scheduled/proceed')
def proceed(
org_id: str,
scheduled_for: Annotated[FutureDatetime, Body(embed=True)],
lock_hash: Annotated[str, Body(embed=True)],
):
pk = f'SCHEDULED#ORG#{org_id}'
sk = f'{scheduled_for.isoformat()}#{lock_hash}'
scheduled = dyn.collection.get_item(
KeyPair(pk, sk),
exc_cls=ScheduledNotFoundError,
)
org = Org(
id=org_id,
name=scheduled['org_name'],
)
subscription = Subscription(
billing_day=scheduled['subscription_billing_day'],
)
try:
enrollment = enroll_now(
Enrollment(
user=scheduled['user'],
course=scheduled['course'],
),
{
'org': org,
'subscription': subscription,
'created_by': router.context['user'],
},
)
with dyn.transact_writer() as transact:
transact.delete(key=KeyPair(pk, sk))
transact.delete(key=KeyPair('LOCK#SCHEDULED', lock_hash))
except Exception:
raise
else:
return JSONResponse(
status_code=HTTPStatus.CREATED,
body=enrollment,
)

View File

@@ -0,0 +1,27 @@
from http import HTTPMethod, HTTPStatus
from layercake.dynamodb import DynamoDBPersistenceLayer
from ...conftest import HttpApiProxy, LambdaContext
def test_scheduled_proceed(
app,
seeds,
http_api_proxy: HttpApiProxy,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext,
):
r = app.lambda_handler(
http_api_proxy(
raw_path='/orgs/cJtK9SsnJhKPyxESe7g3DG/enrollments/scheduled/proceed',
method=HTTPMethod.POST,
body={
'scheduled_for': '2028-12-16T00:00:00-03:06',
'lock_hash': '981ddaa78ffaf9a1074ab1169893f45d',
},
),
lambda_context,
)
print(r)
assert r['statusCode'] == HTTPStatus.CREATED

View File

@@ -12,9 +12,12 @@
{"id": "578ec87f-94c7-4840-8780-bb4839cc7e64", "sk": "0", "course": {"id": "af3258f0-bccf-4781-aec6-d4c618d234a7", "name": "pytest", "access_period": 180}, "user": {"id": "068b4600-cc36-4b55-b832-bb620021705a", "name": "Benjamin Burnley", "email": "burnley@breakingbenjamin.com"}} {"id": "578ec87f-94c7-4840-8780-bb4839cc7e64", "sk": "0", "course": {"id": "af3258f0-bccf-4781-aec6-d4c618d234a7", "name": "pytest", "access_period": 180}, "user": {"id": "068b4600-cc36-4b55-b832-bb620021705a", "name": "Benjamin Burnley", "email": "burnley@breakingbenjamin.com"}}
{"id": "9c166c5e-890f-4e77-9855-769c29aaeb2e", "sk": "0", "course": {"id": "c27d1b4f-575c-4b6b-82a1-9b91ff369e0b", "name": "pytest", "access_period": 180, "scormset": "76c75561-d972-43ef-9818-497d8fc6edbe"}, "user": {"id": "068b4600-cc36-4b55-b832-bb620021705a", "name": "Layne Staley", "email": "layne@aliceinchains.com"}} {"id": "9c166c5e-890f-4e77-9855-769c29aaeb2e", "sk": "0", "course": {"id": "c27d1b4f-575c-4b6b-82a1-9b91ff369e0b", "name": "pytest", "access_period": 180, "scormset": "76c75561-d972-43ef-9818-497d8fc6edbe"}, "user": {"id": "068b4600-cc36-4b55-b832-bb620021705a", "name": "Layne Staley", "email": "layne@aliceinchains.com"}}
// Scheduled
{"id": "SCHEDULED#ORG#cJtK9SsnJhKPyxESe7g3DG", "sk": "2028-12-16T00:00:00-03:06#981ddaa78ffaf9a1074ab1169893f45d", "org_name": "Beta Educação", "scheduled_at": "2025-12-15T17:09:39.398009-03:00", "user": { "name": "Maitê Laurenti Siqueira", "cpf": "02186829991", "id": "87606a7f-de56-4198-a91d-b6967499d382", "email": "osergiosiqueira+maite@gmail.com" }, "ttl": 1765854360, "subscription_billing_day": 5, "created_by": { "name": "Sérgio Rafael de Siqueira", "id": "5OxmMjL-ujoR5IMGegQz" }, "course": { "name": "Reciclagem em NR-10 Básico (20 horas)", "id": "c01ec8a2-0359-4351-befb-76c3577339e0", "access_period": 360}}
// Orgs // Orgs
{"id": "2a8963fc-4694-4fe2-953a-316d1b10f1f5", "sk": "0", "name": "pytest", "cnpj": "04978826000180"} {"id": "2a8963fc-4694-4fe2-953a-316d1b10f1f5", "sk": "0", "name": "pytest", "cnpj": "04978826000180"}
{"id": "2a8963fc-4694-4fe2-953a-316d1b10f1f5", "sk": "METADATA#SUBSCRIPTION_TERMS", "billing_day": 6} {"id": "2a8963fc-4694-4fe2-953a-316d1b10f1f5", "sk": "METADATA#SUBSCRIPTION", "billing_day": 6}
{"id": "f6000f79-6e5c-49a0-952f-3bda330ef278", "sk": "0", "name": "Banco do Brasil", "cnpj": "00000000000191"} {"id": "f6000f79-6e5c-49a0-952f-3bda330ef278", "sk": "0", "name": "Banco do Brasil", "cnpj": "00000000000191"}
// Org admins // Org admins
{"id": "f6000f79-6e5c-49a0-952f-3bda330ef278", "sk": "admins#15bacf02-1535-4bee-9022-19d106fd7518", "name": "Chester Bennington", "email": "chester@linkinpark.com"} {"id": "f6000f79-6e5c-49a0-952f-3bda330ef278", "sk": "admins#15bacf02-1535-4bee-9022-19d106fd7518", "name": "Chester Bennington", "email": "chester@linkinpark.com"}
@@ -26,6 +29,7 @@
{"id": "cnpj", "sk": "04978826000180", "org_id": "2a8963fc-4694-4fe2-953a-316d1b10f1f5"} {"id": "cnpj", "sk": "04978826000180", "org_id": "2a8963fc-4694-4fe2-953a-316d1b10f1f5"}
{"id": "cnpj", "sk": "00000000000191", "org_id": "6000f79-6e5c-49a0-952f-3bda330ef278"} {"id": "cnpj", "sk": "00000000000191", "org_id": "6000f79-6e5c-49a0-952f-3bda330ef278"}
{"id": "SUBSCRIPTION", "sk": "ORG#2a8963fc-4694-4fe2-953a-316d1b10f1f5"} {"id": "SUBSCRIPTION", "sk": "ORG#2a8963fc-4694-4fe2-953a-316d1b10f1f5"}
{"id": "SUBSCRIPTION", "sk": "ORG#cJtK9SsnJhKPyxESe7g3DG"}
// CPFs // CPFs
{"id": "cpf", "sk": "07879819908", "user_id": "15bacf02-1535-4bee-9022-19d106fd7518"} {"id": "cpf", "sk": "07879819908", "user_id": "15bacf02-1535-4bee-9022-19d106fd7518"}

View File

@@ -104,7 +104,8 @@ export default function Route({
defaultValue={search || ''} defaultValue={search || ''}
placeholder={ placeholder={
<> <>
Digite <Kbd>/</Kbd> para pesquisar Digite <Kbd className="border font-mono">/</Kbd>{' '}
para pesquisar
</> </>
} }
onChange={(value) => onChange={(value) =>

View File

@@ -1,6 +1,6 @@
import type { Route } from './+types/route' import type { Route } from './+types/route'
import type { MouseEvent } from 'react' import type { MouseEvent, ReactNode } from 'react'
import { useRequest, useToggle } from 'ahooks' import { useRequest, useToggle } from 'ahooks'
import { import {
BanIcon, BanIcon,
@@ -63,6 +63,12 @@ import {
import { Spinner } from '@repo/ui/components/ui/spinner' import { Spinner } from '@repo/ui/components/ui/spinner'
import { useParams } from 'react-router' import { useParams } from 'react-router'
import { useRevalidator } from 'react-router' import { useRevalidator } from 'react-router'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger
} from '@repo/ui/components/ui/tabs'
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [{ title: 'Matrículas agendadas' }] return [{ title: 'Matrículas agendadas' }]
@@ -97,9 +103,7 @@ export default function Route({
<Await resolve={scheduled}> <Await resolve={scheduled}>
{({ items }) => { {({ items }) => {
const scheduled = grouping(items) if (items.length === 0) {
if (scheduled.length === 0) {
return ( return (
<Empty className="border border-dashed"> <Empty className="border border-dashed">
<EmptyHeader> <EmptyHeader>
@@ -123,29 +127,86 @@ export default function Route({
) )
} }
const scheduled = grouping(filtering(items, undefined))
const executed = grouping(filtering(items, 'EXECUTED'))
const failed = grouping(filtering(items, 'FAILED'))
return ( return (
<div className="space-y-5 lg:max-w-4xl mx-auto"> <div className="space-y-5 lg:max-w-4xl mx-auto">
{scheduled.map(([run_at, items], index) => ( <Tabs defaultValue="pending" className="space-y-5">
<div <div className="flex justify-between">
className="grid grid-cols-1 lg:grid-cols-5 gap-2.5" <TabsList className="*:cursor-pointer">
key={index} <TabsTrigger value="pending">Aguardando</TabsTrigger>
> <TabsTrigger value="executed">Executada</TabsTrigger>
<TabsTrigger value="failed">Falhou</TabsTrigger>
</TabsList>
<Button asChild>
<Link to="../enrollments/add">
<PlusIcon />{' '}
<span className="hidden xl:block">Agendar</span>
</Link>
</Button>
</div>
<TabsContent value="pending" className="space-y-5">
<Timeline events={scheduled}>
{({ items }) => (
<Scheduled items={items} className="col-span-4" />
)}
</Timeline>
</TabsContent>
<TabsContent value="executed" className="space-y-5">
<Timeline events={executed}>
{({ items }) => <>...</>}
</Timeline>
</TabsContent>
<TabsContent value="failed" className="space-y-5">
<Timeline events={failed}>{({ items }) => <>...</>}</Timeline>
</TabsContent>
</Tabs>
</div>
)
}}
</Await>
</Suspense>
)
}
function Timeline({
events = [],
children
}: {
events: any[]
children: (props: any) => ReactNode
}) {
return (
<>
{events.map(([run_at, items], index) => (
<div className="grid grid-cols-1 lg:grid-cols-5 gap-2.5" key={index}>
<div> <div>
{DateTime.fromISO(run_at) {DateTime.fromISO(run_at)
.setLocale('pt-BR') .setLocale('pt-BR')
.toFormat('cccc, dd LLL yyyy')} .toFormat('cccc, dd LLL yyyy')}
</div> </div>
<Card className="col-span-4"> {children({ items })}
</div>
))}
</>
)
}
function Scheduled({ items, className }) {
return (
<Card className={className}>
<CardContent> <CardContent>
<ItemGroup> <ItemGroup>
{items.map( {items.map(
( ({ sk, user, course, created_by, scheduled_at }, index) => (
{ sk, user, course, created_by, scheduled_at },
index
) => (
<Fragment key={index}> <Fragment key={index}>
<Item> <Item className="max-lg:px-0 max-lg:first:pt-0 max-lg:last:pb-0">
<ItemMedia className="hidden lg:block"> <ItemMedia className="hidden lg:block">
<Avatar className="size-10 "> <Avatar className="size-10 ">
<AvatarFallback className="border"> <AvatarFallback className="border">
@@ -153,30 +214,27 @@ export default function Route({
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>{course.name}</ItemTitle> <ItemTitle>{course.name}</ItemTitle>
<ItemDescription className="flex flex-col"> <ItemDescription className="flex flex-col">
<Abbr>{user.name}</Abbr> <Abbr>{user.name}</Abbr>
<Abbr>{user.email}</Abbr> <Abbr>{user.email}</Abbr>
</ItemDescription> </ItemDescription>
<div className="mt-1"> <div className="mt-1">
<ul <ul className="lg:flex gap-2.5 text-muted-foreground text-sm *:flex *:gap-1 *:items-center">
className="lg:flex gap-2.5 text-muted-foreground text-sm
*:flex *:gap-1 *:items-center"
>
<li> <li>
<CalendarIcon className="size-3.5" />{' '} <CalendarIcon className="size-3.5" />{' '}
{datetime.format( {datetime.format(new Date(scheduled_at))}
new Date(scheduled_at)
)}
</li> </li>
<li> <li>
<UserIcon className="size-3.5" />{' '} <UserIcon className="size-3.5" /> {created_by.name}
{created_by.name}
</li> </li>
</ul> </ul>
</div> </div>
</ItemContent> </ItemContent>
<ItemActions className="self-start"> <ItemActions className="self-start">
<ActionMenu sk={sk} /> <ActionMenu sk={sk} />
</ItemActions> </ItemActions>
@@ -189,13 +247,6 @@ export default function Route({
</ItemGroup> </ItemGroup>
</CardContent> </CardContent>
</Card> </Card>
</div>
))}
</div>
)
}}
</Await>
</Suspense>
) )
} }
@@ -286,6 +337,13 @@ function CancelItem({ sk, onSuccess }: { sk: string; onSuccess?: () => void }) {
) )
} }
function filtering(items, status) {
return items.filter(({ sk }: { sk: string }) => {
const [, , s] = sk.split('#')
return s == status
})
}
function grouping(items) { function grouping(items) {
const newItems = Object.entries( const newItems = Object.entries(
items.reduce((acc, item) => { items.reduce((acc, item) => {

View File

@@ -1,4 +1,4 @@
import type { Route } from './+types' import type { Route } from './+types/route'
import { redirect } from 'react-router' import { redirect } from 'react-router'