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,74 +127,45 @@ 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>
<div> <TabsTrigger value="failed">Falhou</TabsTrigger>
{DateTime.fromISO(run_at) </TabsList>
.setLocale('pt-BR') <Button asChild>
.toFormat('cccc, dd LLL yyyy')} <Link to="../enrollments/add">
</div> <PlusIcon />{' '}
<span className="hidden xl:block">Agendar</span>
<Card className="col-span-4"> </Link>
<CardContent> </Button>
<ItemGroup>
{items.map(
(
{ sk, user, course, created_by, scheduled_at },
index
) => (
<Fragment key={index}>
<Item>
<ItemMedia className="hidden lg:block">
<Avatar className="size-10 ">
<AvatarFallback className="border">
{initials(user.name)}
</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent>
<ItemTitle>{course.name}</ItemTitle>
<ItemDescription className="flex flex-col">
<Abbr>{user.name}</Abbr>
<Abbr>{user.email}</Abbr>
</ItemDescription>
<div className="mt-1">
<ul
className="lg:flex gap-2.5 text-muted-foreground text-sm
*:flex *:gap-1 *:items-center"
>
<li>
<CalendarIcon className="size-3.5" />{' '}
{datetime.format(
new Date(scheduled_at)
)}
</li>
<li>
<UserIcon className="size-3.5" />{' '}
{created_by.name}
</li>
</ul>
</div>
</ItemContent>
<ItemActions className="self-start">
<ActionMenu sk={sk} />
</ItemActions>
</Item>
{index !== items.length - 1 && <ItemSeparator />}
</Fragment>
)
)}
</ItemGroup>
</CardContent>
</Card>
</div> </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> </div>
) )
}} }}
@@ -199,6 +174,82 @@ export default function Route({
) )
} }
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>
{DateTime.fromISO(run_at)
.setLocale('pt-BR')
.toFormat('cccc, dd LLL yyyy')}
</div>
{children({ items })}
</div>
))}
</>
)
}
function Scheduled({ items, className }) {
return (
<Card className={className}>
<CardContent>
<ItemGroup>
{items.map(
({ sk, user, course, created_by, scheduled_at }, index) => (
<Fragment key={index}>
<Item className="max-lg:px-0 max-lg:first:pt-0 max-lg:last:pb-0">
<ItemMedia className="hidden lg:block">
<Avatar className="size-10 ">
<AvatarFallback className="border">
{initials(user.name)}
</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent>
<ItemTitle>{course.name}</ItemTitle>
<ItemDescription className="flex flex-col">
<Abbr>{user.name}</Abbr>
<Abbr>{user.email}</Abbr>
</ItemDescription>
<div className="mt-1">
<ul className="lg:flex gap-2.5 text-muted-foreground text-sm *:flex *:gap-1 *:items-center">
<li>
<CalendarIcon className="size-3.5" />{' '}
{datetime.format(new Date(scheduled_at))}
</li>
<li>
<UserIcon className="size-3.5" /> {created_by.name}
</li>
</ul>
</div>
</ItemContent>
<ItemActions className="self-start">
<ActionMenu sk={sk} />
</ItemActions>
</Item>
{index !== items.length - 1 && <ItemSeparator />}
</Fragment>
)
)}
</ItemGroup>
</CardContent>
</Card>
)
}
function ActionMenu({ sk }: { sk: string }) { function ActionMenu({ sk }: { sk: string }) {
const { revalidate } = useRevalidator() const { revalidate } = useRevalidator()
@@ -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'