add lookup

This commit is contained in:
2025-12-01 22:27:14 -03:00
parent f3e3d9f8c2
commit 8eb5427af4
22 changed files with 548 additions and 133 deletions

View File

@@ -5,8 +5,6 @@ import { AwardIcon, BanIcon, LaptopIcon } from 'lucide-react'
import { Suspense, useMemo } from 'react' import { Suspense, useMemo } from 'react'
import { Await, useSearchParams } from 'react-router' import { Await, useSearchParams } from 'react-router'
import placeholder from '@/assets/placeholder.webp'
import { SearchForm } from '@repo/ui/components/search-form' import { SearchForm } from '@repo/ui/components/search-form'
import { Skeleton } from '@repo/ui/components/skeleton' import { Skeleton } from '@repo/ui/components/skeleton'
import { import {
@@ -27,6 +25,8 @@ import { cn } from '@repo/ui/lib/utils'
import { createSearch } from '@repo/util/meili' import { createSearch } from '@repo/util/meili'
import { request as req } from '@repo/util/request' import { request as req } from '@repo/util/request'
import placeholder from '@/assets/placeholder.webp'
type Cert = { type Cert = {
exp_interval: number exp_interval: number
} }
@@ -61,7 +61,7 @@ export async function loader({ context, request, params }: Route.LoaderArgs) {
url: `/orgs/${params.orgid}/custom-pricing`, url: `/orgs/${params.orgid}/custom-pricing`,
context, context,
request request
}).then((r) => r.json()) }).then((r) => r.json() as Promise<{ items: CustomPricing[] }>)
return { return {
data: Promise.all([courses, customPricing]) data: Promise.all([courses, customPricing])
@@ -70,7 +70,7 @@ export async function loader({ context, request, params }: Route.LoaderArgs) {
export default function Route({ loaderData: { data } }: Route.ComponentProps) { export default function Route({ loaderData: { data } }: Route.ComponentProps) {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const term = searchParams.get('term') as string const s = searchParams.get('s') as string
return ( return (
<Suspense fallback={<Skeleton />}> <Suspense fallback={<Skeleton />}>
@@ -96,17 +96,15 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
pesquisar pesquisar
</> </>
} }
defaultValue={term} defaultValue={s}
onChange={(term) => { onChange={(s) => {
setSearchParams({ term }) setSearchParams({ s: String(s) })
}} }}
/> />
</div> </div>
</div> </div>
<div className="grid lg:grid-cols-3 xl:grid-cols-4 gap-5"> <List s={s} hits={hits as Course[]} customPricing={items} />
<List term={term} hits={hits} customPricing={items} />
</div>
</> </>
) )
}} }}
@@ -116,11 +114,11 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
} }
function List({ function List({
term, s,
hits = [], hits = [],
customPricing = [] customPricing = []
}: { }: {
term: string s: string
hits: Course[] hits: Course[]
customPricing: CustomPricing[] customPricing: CustomPricing[]
}) { }) {
@@ -133,12 +131,12 @@ function List({
}, [hits]) }, [hits])
const hits_ = useMemo(() => { const hits_ = useMemo(() => {
if (!term) { if (!s) {
return hits return hits
} }
return fuse.search(term).map(({ item }) => item) return fuse.search(s).map(({ item }) => item)
}, [term, fuse, hits]) }, [s, fuse, hits])
const customPricingMap = new Map( const customPricingMap = new Map(
customPricing.map((x) => { customPricing.map((x) => {
@@ -149,29 +147,35 @@ function List({
if (hits_.length === 0) { if (hits_.length === 0) {
return ( return (
<Empty> <Empty className="border border-dashed">
<EmptyHeader> <EmptyHeader>
<EmptyMedia variant="icon"> <EmptyMedia variant="icon">
<BanIcon /> <BanIcon />
</EmptyMedia> </EmptyMedia>
<EmptyTitle>Nada encontrado</EmptyTitle> <EmptyTitle>Nada encontrado</EmptyTitle>
<EmptyDescription> <EmptyDescription>
Nenhum resultado para <mark>{term}</mark>. Nenhum resultado para <mark>{s}</mark>.
</EmptyDescription> </EmptyDescription>
</EmptyHeader> </EmptyHeader>
</Empty> </Empty>
) )
} }
return hits_.map((props: Course, idx) => { return (
return ( <div className="grid lg:grid-cols-3 xl:grid-cols-4 gap-5">
<Course {hits_
key={idx} .filter(({ metadata__unit_price = 0 }) => metadata__unit_price > 0)
custom_pricing={customPricingMap.get(props.id)} .map((props: Course, idx) => {
{...props} return (
/> <Course
) key={idx}
}) custom_pricing={customPricingMap.get(props.id)}
{...props}
/>
)
})}
</div>
)
} }
function Course({ function Course({

View File

@@ -177,7 +177,7 @@ export default function Route({}: Route.ComponentProps) {
/> />
</FormControl> </FormControl>
<FormLabel className="cursor-pointer"> <FormLabel className="cursor-pointer">
Usar um email fornecido pela plataforma. Usar email gerado pela plataforma
</FormLabel> </FormLabel>
</FormItem> </FormItem>
)} )}

View File

@@ -8,7 +8,9 @@ import {
export default [ export default [
layout('routes/layout.tsx', [ layout('routes/layout.tsx', [
index('routes/index.tsx'), index('routes/index.tsx'),
route('/signup', 'routes/signup.tsx'), layout('routes/register/layout.tsx', [
route('/register', 'routes/register/index.tsx')
]),
route('/forgot', 'routes/forgot.tsx'), route('/forgot', 'routes/forgot.tsx'),
route('/deny', 'routes/deny.tsx') route('/deny', 'routes/deny.tsx')
]), ]),

View File

@@ -136,7 +136,7 @@ export default function Index({}: Route.ComponentProps) {
<p className="text-white/50 text-sm"> <p className="text-white/50 text-sm">
Não tem uma senha?{' '} Não tem uma senha?{' '}
<Link <Link
to="/signup" to="/register"
className="font-medium text-white hover:underline" className="font-medium text-white hover:underline"
> >
Criar senha Criar senha
@@ -183,6 +183,7 @@ export default function Index({}: Route.ComponentProps) {
<FormControl> <FormControl>
<Input <Input
type={show ? 'text' : 'password'} type={show ? 'text' : 'password'}
autoComplete="false"
placeholder="••••••••" placeholder="••••••••"
{...field} {...field}
/> />

View File

@@ -0,0 +1,97 @@
import { PatternFormat } from 'react-number-format'
import { useRequest } from 'ahooks'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { Button } from '@repo/ui/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@repo/ui/components/ui/form'
import { Input } from '@repo/ui/components/ui/input'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { cpf, type RegisterContextProps, type User } from './data'
import { RegisterContext } from './data'
import { use } from 'react'
const formSchema = z.object({
cpf: cpf
})
type Schema = z.infer<typeof formSchema>
export function Cpf() {
const { setUser } = use(RegisterContext) as RegisterContextProps
const form = useForm({
resolver: zodResolver(formSchema)
})
const { control, handleSubmit, formState } = form
const { runAsync } = useRequest(
async ({ cpf }) => {
return await fetch(`/lookup?cpf=${cpf}`, {
method: 'GET',
headers: new Headers({ 'Content-Type': 'application/json' })
})
},
{ manual: true }
)
const onSubmit = async ({ cpf }: Schema) => {
const r = await runAsync({ cpf })
const user = (await r.json()) as any
setUser({ cpf, ...user })
}
return (
<>
<Form {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<fieldset disabled={formState.isSubmitting} className="grid gap-6">
<FormField
control={control}
name="cpf"
defaultValue=""
render={({ field: { ref, onChange, ...props } }) => (
<FormItem>
<FormLabel>CPF</FormLabel>
<FormControl>
<PatternFormat
format="###.###.###-##"
mask="_"
placeholder="___.___.___-__"
customInput={Input}
autoFocus={true}
getInputRef={ref}
onValueChange={({ value }) => {
onChange(value)
}}
{...props}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full cursor-pointer relative overflow-hidden"
>
{formState.isSubmitting && (
<div className="absolute bg-lime-500 inset-0 flex items-center justify-center">
<Spinner />
</div>
)}
Continuar
</Button>
</fieldset>
</form>
</Form>
</>
)
}

View File

@@ -0,0 +1,39 @@
import { z } from 'zod'
import { isValidCPF } from '@brazilian-utils/brazilian-utils'
import { createContext } from 'react'
const isName = (name: string) => name && name.includes(' ')
export type User = {
id?: string | null
cpf: string
name: string
email: string
}
export const cpf = z
.string()
.nonempty('Digite seu CPF')
.refine(isValidCPF, 'Deve ser um CPF válido')
export const formSchema = z.object({
name: z
.string()
.trim()
.nonempty('Digite seu nome')
.refine(isName, { message: 'Nome inválido' }),
email: z.email('Digite seu email'),
password: z
.string()
.nonempty('Digite sua senha')
.min(6, 'Deve ter no mínimo 6 caracteres'),
cpf: cpf
})
export type Schema = z.infer<typeof formSchema>
export type RegisterContextProps = {
user: User | null
setUser: (user: User) => void
}
export const RegisterContext = createContext<RegisterContextProps | null>(null)

View File

@@ -1,13 +1,10 @@
import type { Route } from './+types' import type { Route } from '../+types'
import { isValidCPF } from '@brazilian-utils/brazilian-utils' import { PatternFormat } from 'react-number-format'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { useState } from 'react' import { useState, createContext, type ReactNode, use } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { Link } from 'react-router'
import { z } from 'zod'
import logo from '@repo/ui/components/logo2.svg'
import { Button } from '@repo/ui/components/ui/button' import { Button } from '@repo/ui/components/ui/button'
import { Checkbox } from '@repo/ui/components/ui/checkbox' import { Checkbox } from '@repo/ui/components/ui/checkbox'
import { import {
@@ -21,20 +18,8 @@ import {
import { Input } from '@repo/ui/components/ui/input' import { Input } from '@repo/ui/components/ui/input'
import { Label } from '@repo/ui/components/ui/label' import { Label } from '@repo/ui/components/ui/label'
const schema = z.object({ import { Cpf } from './cpf'
name: z.string().trim().nonempty('Digite seu nome'), import { formSchema, type Schema, RegisterContext, type User } from './data'
email: z.email('Digite seu email'),
password: z
.string()
.nonempty('Digite sua senha')
.min(6, 'Deve ter no mínimo 6 caracteres'),
cpf: z
.string()
.nonempty('Digite seu CPF')
.refine(isValidCPF, 'Deve ser um CPF válido')
})
type Schema = z.infer<typeof schema>
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [{ title: 'Criar conta · EDUSEG®' }] return [{ title: 'Criar conta · EDUSEG®' }]
@@ -42,8 +27,9 @@ export function meta({}: Route.MetaArgs) {
export default function Signup({}: Route.ComponentProps) { export default function Signup({}: Route.ComponentProps) {
const [show, setShow] = useState(false) const [show, setShow] = useState(false)
const [user, setUser] = useState<User | null>(null)
const form = useForm({ const form = useForm({
resolver: zodResolver(schema) resolver: zodResolver(formSchema)
}) })
const { control, handleSubmit, formState } = form const { control, handleSubmit, formState } = form
@@ -51,34 +37,17 @@ export default function Signup({}: Route.ComponentProps) {
console.log(data) console.log(data)
} }
console.log(user)
return ( return (
<> <RegisterContext value={{ user, setUser }}>
<div className="space-y-6"> {user ? (
<div className="flex justify-center">
<div className="border border-white/15 bg-white/5 px-2.5 py-3 rounded-xl">
<img src={logo} alt="EDUSEG®" className="block size-12" />
</div>
</div>
<div className="text-center space-y-1.5">
<h1 className="text-2xl font-semibold font-display text-balance">
Criar conta
</h1>
<p className="text-white/50 text-sm">
tem uma conta?{' '}
<Link to="/" className="font-medium text-white hover:underline">
Faça login
</Link>
.
</p>
</div>
<Form {...form}> <Form {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="grid gap-6"> <form onSubmit={handleSubmit(onSubmit)} className="grid gap-6">
<FormField <FormField
control={control} control={control}
name="name" name="name"
defaultValue="" defaultValue={user?.name}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Nome</FormLabel> <FormLabel>Nome</FormLabel>
@@ -93,6 +62,7 @@ export default function Signup({}: Route.ComponentProps) {
<FormField <FormField
control={control} control={control}
name="email" name="email"
defaultValue={user?.email}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Email</FormLabel> <FormLabel>Email</FormLabel>
@@ -107,12 +77,22 @@ export default function Signup({}: Route.ComponentProps) {
<FormField <FormField
control={control} control={control}
name="cpf" name="cpf"
defaultValue="" defaultValue={user.cpf}
render={({ field }) => ( render={({ field: { ref, onChange, ...props } }) => (
<FormItem> <FormItem>
<FormLabel>CPF</FormLabel> <FormLabel>CPF</FormLabel>
<FormControl> <FormControl>
<Input placeholder="___.___.___-__" {...field} /> <PatternFormat
format="###.###.###-##"
mask="_"
placeholder="___.___.___-__"
customInput={Input}
getInputRef={ref}
onValueChange={({ value }) => {
onChange(value)
}}
{...props}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -130,6 +110,7 @@ export default function Signup({}: Route.ComponentProps) {
<Input <Input
type={show ? 'text' : 'password'} type={show ? 'text' : 'password'}
placeholder="••••••••" placeholder="••••••••"
autoComplete="false"
{...field} {...field}
/> />
</FormControl> </FormControl>
@@ -155,19 +136,9 @@ export default function Signup({}: Route.ComponentProps) {
</Button> </Button>
</form> </form>
</Form> </Form>
) : (
<p className="text-white/50 text-xs text-center"> <Cpf />
Ao fazer login, você concorda com nossa{' '} )}
<a </RegisterContext>
href="//eduseg.com.br/politica"
target="_blank"
className="underline hover:no-underline"
>
política de privacidade
</a>
.
</p>
</div>
</>
) )
} }

View File

@@ -0,0 +1,50 @@
import type { Route } from './+types/index'
import { Outlet, Link } from 'react-router'
import logo from '@repo/ui/components/logo2.svg'
export function meta({}: Route.MetaArgs) {
return [{ title: 'Criar conta · EDUSEG®' }]
}
export default function Layout() {
return (
<>
<div className="space-y-6">
<div className="flex justify-center">
<div className="border border-white/15 bg-white/5 px-2.5 py-3 rounded-xl">
<img src={logo} alt="EDUSEG®" className="block size-12" />
</div>
</div>
<div className="text-center space-y-1.5">
<h1 className="text-2xl font-semibold font-display text-balance">
Criar conta
</h1>
<p className="text-white/50 text-sm">
tem uma conta?{' '}
<Link to="/" className="font-medium text-white hover:underline">
Faça login
</Link>
.
</p>
</div>
<Outlet />
<p className="text-white/50 text-xs text-center">
Ao cadastrar, você concorda com nossa{' '}
<a
href="//eduseg.com.br/politica"
target="_blank"
className="underline hover:no-underline"
>
política de privacidade
</a>
.
</p>
</div>
</>
)
}

View File

@@ -9,8 +9,8 @@ async function proxy({
request, request,
context context
}: Route.ActionArgs): Promise<Response> { }: Route.ActionArgs): Promise<Response> {
const pathname = new URL(request.url).pathname const { pathname, search } = new URL(request.url)
const url = new URL(pathname, context.cloudflare.env.ISSUER_URL) const url = new URL(pathname + search, context.cloudflare.env.ISSUER_URL)
const headers = new Headers(request.headers) const headers = new Headers(request.headers)
const shouldCache = const shouldCache =

View File

@@ -87,7 +87,7 @@ export default function Component({
loaderData: { data } loaderData: { data }
}: Route.ComponentProps) { }: Route.ComponentProps) {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const term = searchParams.get('term') as string const s = searchParams.get('s') as string
return ( return (
<Container className="space-y-4"> <Container className="space-y-4">
@@ -104,7 +104,7 @@ export default function Component({
<div className="flex gap-2.5"> <div className="flex gap-2.5">
<div className="w-full xl:w-1/3"> <div className="w-full xl:w-1/3">
<SearchForm <SearchForm
defaultValue={term || ''} defaultValue={s || ''}
placeholder={ placeholder={
<> <>
Digite <Kbd>/</Kbd> para pesquisar Digite <Kbd>/</Kbd> para pesquisar
@@ -112,7 +112,7 @@ export default function Component({
} }
onChange={(value) => onChange={(value) =>
setSearchParams((searchParams) => { setSearchParams((searchParams) => {
searchParams.set('term', String(value)) searchParams.set('s', String(value))
return searchParams return searchParams
}) })
} }
@@ -145,14 +145,14 @@ export default function Component({
</div> </div>
<Await resolve={data}> <Await resolve={data}>
{({ hits = [] }) => <List term={term} hits={hits as Enrollment[]} />} {({ hits = [] }) => <List s={s} hits={hits as Enrollment[]} />}
</Await> </Await>
</Suspense> </Suspense>
</Container> </Container>
) )
} }
function List({ term, hits = [] }: { term: string; hits: Enrollment[] }) { function List({ s, hits = [] }: { s: string; hits: Enrollment[] }) {
const fuse = useMemo(() => { const fuse = useMemo(() => {
return new Fuse(hits, { return new Fuse(hits, {
keys: ['course.name'], keys: ['course.name'],
@@ -162,12 +162,12 @@ function List({ term, hits = [] }: { term: string; hits: Enrollment[] }) {
}, [hits]) }, [hits])
const hits_ = useMemo(() => { const hits_ = useMemo(() => {
if (!term) { if (!s) {
return hits return hits
} }
return fuse.search(term).map(({ item }) => item) return fuse.search(s).map(({ item }) => item)
}, [term, fuse, hits]) }, [s, fuse, hits])
if (hits_.length === 0) { if (hits_.length === 0) {
return ( return (
@@ -178,7 +178,7 @@ function List({ term, hits = [] }: { term: string; hits: Enrollment[] }) {
</EmptyMedia> </EmptyMedia>
<EmptyTitle>Nada encontrado</EmptyTitle> <EmptyTitle>Nada encontrado</EmptyTitle>
<EmptyDescription> <EmptyDescription>
Nenhum resultado para <mark>{term}</mark>. Nenhum resultado para <mark>{s}</mark>.
</EmptyDescription> </EmptyDescription>
</EmptyHeader> </EmptyHeader>
</Empty> </Empty>

View File

@@ -69,11 +69,15 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
update_expr = 'SET cert = :cert, updated_at = :now' update_expr = 'SET cert = :cert, updated_at = :now'
expr_attr_values = { expr_attr_values = {
':now': now_, ':now': now_,
':cert': {'issued_at': now_} | ({'s3_uri': s3_uri} if s3_uri else {}), ':cert': {'issued_at': now_}
| ({'expires_at': expires_at} if expires_at else {})
| ({'s3_uri': s3_uri} if s3_uri else {}),
} }
# Post-migration: remove the following lines
if expires_at: if expires_at:
update_expr = 'SET cert = :cert, cert_expires_at = :cert_expires_at, \ update_expr = 'SET cert = :cert, \
cert_expires_at = :cert_expires_at, \
updated_at = :now' updated_at = :now'
expr_attr_values[':cert_expires_at'] = expires_at expr_attr_values[':cert_expires_at'] = expires_at

View File

@@ -41,9 +41,9 @@ Globals:
POSTGRES_DB: saladeaula.digital POSTGRES_DB: saladeaula.digital
POSTGRES_HOST: sp-node01.saladeaula.digital POSTGRES_HOST: sp-node01.saladeaula.digital
POSTGRES_PORT: 5432 POSTGRES_PORT: 5432
POSTGRES_USER: "{{resolve:ssm:/saladeaula/postgres_user}}" POSTGRES_USER: '{{resolve:ssm:/saladeaula/postgres_user}}'
POSTGRES_PASSWORD: "{{resolve:ssm:/saladeaula/postgres_password}}" POSTGRES_PASSWORD: '{{resolve:ssm:/saladeaula/postgres_password}}'
DOCUSEAL_KEY: "{{resolve:ssm:/saladeaula/docuseal_key}}" DOCUSEAL_KEY: '{{resolve:ssm:/saladeaula/docuseal_key}}'
Resources: Resources:
EventLog: EventLog:
@@ -60,7 +60,7 @@ Resources:
Type: AWS::Serverless::HttpApi Type: AWS::Serverless::HttpApi
Properties: Properties:
CorsConfiguration: CorsConfiguration:
AllowOrigins: ["*"] AllowOrigins: ['*']
AllowMethods: [POST, OPTIONS] AllowMethods: [POST, OPTIONS]
AllowHeaders: [Content-Type, X-Requested-With] AllowHeaders: [Content-Type, X-Requested-With]
@@ -104,7 +104,7 @@ Resources:
detail-type: [INSERT] detail-type: [INSERT]
detail: detail:
new_image: new_image:
sk: ["0"] sk: ['0']
org_id: org_id:
- exists: true - exists: true
@@ -128,7 +128,7 @@ Resources:
detail-type: [INSERT] detail-type: [INSERT]
detail: detail:
new_image: new_image:
sk: ["0"] sk: ['0']
access_expires_at: access_expires_at:
- exists: false - exists: false
@@ -154,7 +154,7 @@ Resources:
detail-type: [INSERT] detail-type: [INSERT]
detail: detail:
new_image: new_image:
sk: ["0"] sk: ['0']
EventEnrollFunction: EventEnrollFunction:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
@@ -185,6 +185,27 @@ Resources:
scope: [SINGLE_USER] scope: [SINGLE_USER]
status: [PENDING] status: [PENDING]
EventEnrollScheduledFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.enroll_scheduled.lambda_handler
LoggingConfig:
LogGroup: !Ref EventLog
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref EnrollmentTable
Events:
DynamoDBEvent:
Type: EventBridgeRule
Properties:
Pattern:
resources: [!Ref EnrollmentTable]
detail-type: [EXPIRE]
detail:
keys:
id:
- prefix: SCHEDULED#ORG#
EventReenrollIfFailedFunction: EventReenrollIfFailedFunction:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
Properties: Properties:
@@ -204,10 +225,10 @@ Resources:
detail: detail:
changes: [status] changes: [status]
new_image: new_image:
sk: ["0"] sk: ['0']
status: [FAILED] status: [FAILED]
score: score:
- numeric: ["<", 70] - numeric: ['<', 70]
old_image: old_image:
status: [IN_PROGRESS] status: [IN_PROGRESS]
@@ -298,9 +319,10 @@ Resources:
detail-type: [INSERT] detail-type: [INSERT]
detail: detail:
new_image: new_image:
sk: ["0"] sk: ['0']
status: [PENDING] status: [PENDING]
# Deprecated
EventSetAccessExpiredFunction: EventSetAccessExpiredFunction:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
Properties: Properties:
@@ -321,6 +343,7 @@ Resources:
keys: keys:
sk: [SCHEDULE#SET_ACCESS_EXPIRED, SCHEDULE#SET_AS_EXPIRED] sk: [SCHEDULE#SET_ACCESS_EXPIRED, SCHEDULE#SET_AS_EXPIRED]
# Deprecated
EventSetCertExpiredFunction: EventSetCertExpiredFunction:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
Properties: Properties:
@@ -364,7 +387,7 @@ Resources:
resources: [!Ref EnrollmentTable] resources: [!Ref EnrollmentTable]
detail: detail:
keys: keys:
sk: ["0"] sk: ['0']
new_image: new_image:
status: [COMPLETED] status: [COMPLETED]
old_image: old_image:
@@ -389,7 +412,7 @@ Resources:
resources: [!Ref EnrollmentTable] resources: [!Ref EnrollmentTable]
detail: detail:
keys: keys:
sk: ["0"] sk: ['0']
new_image: new_image:
status: [COMPLETED] status: [COMPLETED]
cert: cert:
@@ -417,7 +440,7 @@ Resources:
detail-type: [MODIFY] detail-type: [MODIFY]
detail: detail:
keys: keys:
sk: ["0"] sk: ['0']
new_image: new_image:
status: [COMPLETED] status: [COMPLETED]
cert_expires_at: cert_expires_at:
@@ -463,7 +486,7 @@ Outputs:
HttpApiUrl: HttpApiUrl:
Description: URL of your API endpoint Description: URL of your API endpoint
Value: Value:
Fn::Sub: "https://${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}" Fn::Sub: 'https://${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}'
HttpApiId: HttpApiId:
Description: Api ID of HttpApi Description: Api ID of HttpApi
Value: Value:

View File

@@ -9,9 +9,12 @@ from aws_lambda_powertools.utilities.typing import LambdaContext
from routes.authentication import router as authentication from routes.authentication import router as authentication
from routes.authorize import router as authorize from routes.authorize import router as authorize
from routes.forgot import router as forgot
from routes.jwks import router as jwks from routes.jwks import router as jwks
from routes.lookup import router as lookup
from routes.openid_configuration import router as openid_configuration from routes.openid_configuration import router as openid_configuration
from routes.register import router as register from routes.register import router as register
from routes.reset import router as reset
from routes.revoke import router as revoke from routes.revoke import router as revoke
from routes.token import router as token from routes.token import router as token
from routes.userinfo import router as userinfo from routes.userinfo import router as userinfo
@@ -21,9 +24,12 @@ tracer = Tracer()
app = APIGatewayHttpResolver(enable_validation=True) app = APIGatewayHttpResolver(enable_validation=True)
app.include_router(authentication) app.include_router(authentication)
app.include_router(authorize) app.include_router(authorize)
app.include_router(forgot)
app.include_router(jwks) app.include_router(jwks)
app.include_router(lookup)
app.include_router(openid_configuration) app.include_router(openid_configuration)
app.include_router(register) app.include_router(register)
app.include_router(reset)
app.include_router(revoke) app.include_router(revoke)
app.include_router(token) app.include_router(token)
app.include_router(userinfo) app.include_router(userinfo)

View File

@@ -0,0 +1,12 @@
from typing import Annotated
from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.openapi.params import Body
from pydantic import EmailStr
router = Router()
@router.post('/forgot')
def forgot(email: Annotated[EmailStr, Body(embed=True)]):
return {}

View File

@@ -0,0 +1,49 @@
from typing import Annotated
from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.openapi.params import Path
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey, TransactKey
from layercake.extra_types import CnpjStr, CpfStr
from layercake.funcs import pick
from pydantic import EmailStr
from boto3clients import dynamodb_client
from config import OAUTH2_TABLE
router = Router()
dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
@router.get('/lookup')
def lookup(
email: Annotated[EmailStr, Path] = 'unknown',
cpf: Annotated[CpfStr, Path] = 'unknown',
cnpj: Annotated[CnpjStr, Path] = 'unknown',
):
r = dyn.collection.get_items(
KeyPair(
pk='email',
sk=SortKey(email, path_spec='user_id'),
rename_key='id',
)
+ KeyPair(
pk='cpf',
sk=SortKey(cpf, path_spec='user_id'),
rename_key='id',
)
+ KeyPair(
pk='cnpj',
sk=SortKey(cnpj, path_spec='user_id'),
rename_key='org_id',
),
flatten_top=False,
)
if 'id' in r:
user = dyn.collection.get_items(
TransactKey(r['id']) + SortKey('0') + SortKey('FRESH_USER')
)
return r | pick(('name', 'email'), user) if 'FRESH_USER' in user else {}
return r

View File

@@ -1,8 +1,41 @@
from http import HTTPStatus
from typing import Annotated
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 ServiceError
from aws_lambda_powertools.event_handler.openapi.params import Body
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey
from layercake.extra_types import CnpjStr, CpfStr, NameStr
from pydantic import BaseModel, EmailStr
from boto3clients import dynamodb_client
from config import OAUTH2_TABLE
router = Router() router = Router()
dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
class UserConflictError(ServiceError):
def __init__(self, msg: str | dict):
super().__init__(HTTPStatus.CONFLICT, msg)
class Org(BaseModel):
id: str | None
name: str
cnpj: CnpjStr
@router.get('/register') @router.get('/register')
def register(): def register(
name: Annotated[NameStr, Body(embed=True)],
email: Annotated[EmailStr, Body(embed=True)],
password: Annotated[str, Body(min_length=6, embed=True)],
cpf: Annotated[CpfStr, Body(embed=True)],
user_id: Annotated[str | None, Body(embed=True)] = None,
org: Annotated[Org | None, Body(embed=True)] = None,
):
if user_id:
...
return {} return {}

View File

@@ -0,0 +1,14 @@
from typing import Annotated
from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.openapi.params import Body, Path
router = Router()
@router.post('/reset')
def reset(
new_password: Annotated[str, Body(min_length=6, embed=True)],
code: Annotated[str, Path],
):
return {}

View File

@@ -68,6 +68,24 @@ Resources:
Path: /register Path: /register
Method: POST Method: POST
ApiId: !Ref HttpApi ApiId: !Ref HttpApi
Forgot:
Type: HttpApi
Properties:
Path: /forgot
Method: POST
ApiId: !Ref HttpApi
Reset:
Type: HttpApi
Properties:
Path: /reset
Method: POST
ApiId: !Ref HttpApi
Lookup:
Type: HttpApi
Properties:
Path: /lookup
Method: GET
ApiId: !Ref HttpApi
Authorize: Authorize:
Type: HttpApi Type: HttpApi
Properties: Properties:

View File

@@ -0,0 +1,30 @@
from http import HTTPMethod
from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey
from ..conftest import HttpApiProxy, LambdaContext
def test_register(
app,
seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext,
):
r = app.lambda_handler(
http_api_proxy(
raw_path='/register',
method=HTTPMethod.POST,
body={
'name': '07879819908',
},
),
lambda_context,
)
assert len(r['cookies']) == 1
session = dynamodb_persistence_layer.collection.query(PartitionKey('SESSION'))
# One seesion if created from seeds
assert len(session['items']) == 2

View File

@@ -28,9 +28,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
subscription = enrollment_layer.collection.get_items( subscription = enrollment_layer.collection.get_items(
TransactKey(enrollment_id) TransactKey(enrollment_id)
+ SortKey('METADATA#SUBSCRIPTION_COVERED') + SortKey('METADATA#SUBSCRIPTION_COVERED')
# Post-migration: uncomment the following line + SortKey('CANCELED', path_spec='canceled_by', rename_key='canceled_by')
# + SortKey('CANCELED', path_spec='canceled_by', rename_key='canceled_by')
+ SortKey('CANCELED', path_spec='author', rename_key='canceled_by')
) )
created_at: datetime = fromisoformat(new_image['created_at']) # type: ignore created_at: datetime = fromisoformat(new_image['created_at']) # type: ignore
@@ -67,7 +65,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
'created_at': now_, 'created_at': now_,
} }
| pick(('user', 'course', 'enrolled_at'), old_enrollment) | pick(('user', 'course', 'enrolled_at'), old_enrollment)
# Add created_by if present # Add `created_by` if present
| ({'author': canceled_by} if canceled_by else {}), | ({'author': canceled_by} if canceled_by else {}),
# Post-migration: uncomment the following line # Post-migration: uncomment the following line
# | ({'created_by': canceled_by} if canceled_by else {}), # | ({'created_by': canceled_by} if canceled_by else {}),

View File

@@ -68,7 +68,7 @@ Resources:
detail-type: [INSERT] detail-type: [INSERT]
detail: detail:
new_image: new_image:
sk: ["METADATA#SUBSCRIPTION_COVERED"] sk: ['METADATA#SUBSCRIPTION_COVERED']
billing_period: billing_period:
- exists: false - exists: false
@@ -92,7 +92,7 @@ Resources:
detail-type: [MODIFY] detail-type: [MODIFY]
detail: detail:
new_image: new_image:
sk: ["0"] sk: ['0']
status: [CANCELED] status: [CANCELED]
subscription_covered: [true] subscription_covered: [true]
old_image: old_image:
@@ -180,7 +180,7 @@ Resources:
detail-type: [INSERT] detail-type: [INSERT]
detail: detail:
new_image: new_image:
sk: ["0"] sk: ['0']
cnpj: cnpj:
- exists: true - exists: true
# Post-migration: rename `tenant_id` to `org_id` # Post-migration: rename `tenant_id` to `org_id`
@@ -207,7 +207,7 @@ Resources:
detail-type: [INSERT] detail-type: [INSERT]
detail: detail:
new_image: new_image:
sk: ["0"] sk: ['0']
cpf: cpf:
- exists: true - exists: true
user_id: user_id:
@@ -233,7 +233,7 @@ Resources:
detail-type: [MODIFY] detail-type: [MODIFY]
detail: detail:
new_image: new_image:
sk: ["0"] sk: ['0']
cnpj: cnpj:
- exists: true - exists: true
status: [CANCELED, EXPIRED] status: [CANCELED, EXPIRED]
@@ -256,7 +256,7 @@ Resources:
detail-type: [INSERT] detail-type: [INSERT]
detail: detail:
new_image: new_image:
sk: ["0"] sk: ['0']
cnpj: cnpj:
- exists: true - exists: true
total: [0] total: [0]

76
orders-events/uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 2 revision = 3
requires-python = ">=3.13" requires-python = ">=3.13"
[[package]] [[package]]
@@ -67,15 +67,15 @@ wheels = [
[[package]] [[package]]
name = "aws-lambda-powertools" name = "aws-lambda-powertools"
version = "3.19.0" version = "3.23.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "jmespath" }, { name = "jmespath" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/58/db/eb2708f7c27ab02b8d85936ce9308538e1e22c8c8224be5f00da3e6f44f7/aws_lambda_powertools-3.19.0.tar.gz", hash = "sha256:8897ba4be0b3a51f2b8f68946d650f3ef574fa2c40395544de03bd0c61979999", size = 689768, upload-time = "2025-08-12T08:45:46.887Z" } sdist = { url = "https://files.pythonhosted.org/packages/38/24/78f320a310d98df8c831e15c5f04fec20ba4958253deb165ab2d10d3392b/aws_lambda_powertools-3.23.0.tar.gz", hash = "sha256:30ab45960989dd75a4d84de4f156509458f8782038d532eee2f815488d7cc929", size = 702835, upload-time = "2025-11-13T16:44:23.659Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/52/5a73194286af329309263e9c4e2a57b8feac63bb6027be8d2d6222cd4da7/aws_lambda_powertools-3.19.0-py3-none-any.whl", hash = "sha256:98f18d35f843cd46b80ccadcf39eefc0c489325bea116383bd93048a5241d9fc", size = 832645, upload-time = "2025-08-12T08:45:44.982Z" }, { url = "https://files.pythonhosted.org/packages/70/48/f59597b0acbe3bcd829ae5b13b49a29039c5b2a5a6771f765ad3f3f576a3/aws_lambda_powertools-3.23.0-py3-none-any.whl", hash = "sha256:f3d16f1b0304c686cc956ecf0f6f8907d21992a4a5070e2388c21571d8c84cc2", size = 848256, upload-time = "2025-11-13T16:44:21.459Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -576,7 +576,7 @@ wheels = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.11.0" version = "0.11.2"
source = { directory = "../layercake" } source = { directory = "../layercake" }
dependencies = [ dependencies = [
{ name = "arnparse" }, { name = "arnparse" },
@@ -593,6 +593,7 @@ dependencies = [
{ name = "pycpfcnpj" }, { name = "pycpfcnpj" },
{ name = "pydantic", extra = ["email"] }, { name = "pydantic", extra = ["email"] },
{ name = "pydantic-extra-types" }, { name = "pydantic-extra-types" },
{ name = "python-calamine" },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "pytz" }, { name = "pytz" },
{ name = "requests" }, { name = "requests" },
@@ -605,7 +606,7 @@ dependencies = [
requires-dist = [ requires-dist = [
{ name = "arnparse", specifier = ">=0.0.2" }, { name = "arnparse", specifier = ">=0.0.2" },
{ name = "authlib", specifier = ">=1.6.5" }, { name = "authlib", specifier = ">=1.6.5" },
{ name = "aws-lambda-powertools", extras = ["all"], specifier = ">=3.18.0" }, { name = "aws-lambda-powertools", extras = ["all"], specifier = ">=3.23.0" },
{ name = "dictdiffer", specifier = ">=0.9.0" }, { name = "dictdiffer", specifier = ">=0.9.0" },
{ name = "ftfy", specifier = ">=6.3.1" }, { name = "ftfy", specifier = ">=6.3.1" },
{ name = "glom", specifier = ">=24.11.0" }, { name = "glom", specifier = ">=24.11.0" },
@@ -617,6 +618,7 @@ requires-dist = [
{ name = "pycpfcnpj", specifier = ">=1.8" }, { name = "pycpfcnpj", specifier = ">=1.8" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.10.6" }, { name = "pydantic", extras = ["email"], specifier = ">=2.10.6" },
{ name = "pydantic-extra-types", specifier = ">=2.10.3" }, { name = "pydantic-extra-types", specifier = ">=2.10.3" },
{ name = "python-calamine", specifier = ">=0.5.4" },
{ name = "python-multipart", specifier = ">=0.0.20" }, { name = "python-multipart", specifier = ">=0.0.20" },
{ name = "pytz", specifier = ">=2025.1" }, { name = "pytz", specifier = ">=2025.1" },
{ name = "requests", specifier = ">=2.32.3" }, { name = "requests", specifier = ">=2.32.3" },
@@ -1062,6 +1064,68 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" }, { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" },
] ]
[[package]]
name = "python-calamine"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9b/32/99a794a1ca7b654cecdb76d4d61f21658b6f76574321341eb47df4365807/python_calamine-0.6.1.tar.gz", hash = "sha256:5974989919aa0bb55a136c1822d6f8b967d13c0fd0f245e3293abb4e63ab0f4b", size = 138354, upload-time = "2025-11-26T10:48:35.331Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/b6/d9b1a6432d33d43ded44ca01dff2c2a41f68a169413bdbe7677fc6598bfc/python_calamine-0.6.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:44dcffccbc3d9d258848d84ed685803ecb196f6b44bff271418283a0d015a6ea", size = 877262, upload-time = "2025-11-26T10:46:49.271Z" },
{ url = "https://files.pythonhosted.org/packages/4d/09/29a113debc6c389065057c9f72e8837760b36ae86a6363a31c18b699adfb/python_calamine-0.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:66373ae273ef356a55b53c2348335274b6d25c08d75a399a3f167d93e13aa1b6", size = 854634, upload-time = "2025-11-26T10:46:50.716Z" },
{ url = "https://files.pythonhosted.org/packages/89/c4/0a68314336b8b1d04ae1cda98cc8c191829547d652394f34e5360d9563c9/python_calamine-0.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02482677cea6d3c2a09008469b7f5544d4d8c79af8fc7d49edcc669cfc75f640", size = 927779, upload-time = "2025-11-26T10:46:52.146Z" },
{ url = "https://files.pythonhosted.org/packages/29/ab/ce23029f808e31e12fe9ca26b038b67c8f065b9c666a1e73aacaa086d177/python_calamine-0.6.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6794c55fa3d3dc88deda7377fc721b6506d186ec149e04b38109b1f58cc0b61f", size = 912282, upload-time = "2025-11-26T10:46:53.875Z" },
{ url = "https://files.pythonhosted.org/packages/90/d9/e4bfad521a92ebb330f16a0ab7ad57da35ded14d90e9e395e97aacd63bef/python_calamine-0.6.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79c33a5384221d8ab7d4b91b83374317b403ef945b5aa18f6e6ea6cbba661393", size = 1071785, upload-time = "2025-11-26T10:46:55.735Z" },
{ url = "https://files.pythonhosted.org/packages/ee/e8/18894883669644da9d14f8c6db0db00b793eaac3cd7268bcafb4a73b9837/python_calamine-0.6.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e36211a7feaa56d12e8ea1ddeeae6c4887764c351c275b034c07c9b7d66455e", size = 964443, upload-time = "2025-11-26T10:46:57.208Z" },
{ url = "https://files.pythonhosted.org/packages/0c/0d/7482fcded940d1adc4c8eaf47488a69ef1e3fd86eb8c6d33a981ddf5f82a/python_calamine-0.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c3e6aeedeb289311907f8d59b2a32a404433d1af4dfce0ba4e3badd30f9775d", size = 932682, upload-time = "2025-11-26T10:46:59.006Z" },
{ url = "https://files.pythonhosted.org/packages/ee/88/4898de6ce811c936168b48c92d310bba0e8f4ab6e56059b537d9d6d72c05/python_calamine-0.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a2aa2989e5231cda2a15d21fd6e7cf3fc4ce09535756bdb7b2f32197fd6a566a", size = 975624, upload-time = "2025-11-26T10:47:00.844Z" },
{ url = "https://files.pythonhosted.org/packages/10/1e/85ef4693452cc21cb912e32e33c8aa4add399b3fb0c1af8036692fd33f61/python_calamine-0.6.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:e5761dc896446d6e9dd40c7e781908c1ae919d31bdd00b5dedc033525f440dec", size = 1110373, upload-time = "2025-11-26T10:47:02.483Z" },
{ url = "https://files.pythonhosted.org/packages/2f/18/67aaa61c4bea9fd99ed44ff50e93fac70096b992275bae3552f98f6a1229/python_calamine-0.6.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:d1118d9d4f626f62663bfd5c83de07bc8455463081de6bc3b4264414e81a56a9", size = 1179486, upload-time = "2025-11-26T10:47:04.067Z" },
{ url = "https://files.pythonhosted.org/packages/db/f5/73baef823b41f7b50a86ddb36d1ea2c19882414568aaa2d8ed7afb96dc71/python_calamine-0.6.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7e5500a8769bdf0efaef10bcce2613d5240823891172d1a943b776f18977c2f1", size = 1108067, upload-time = "2025-11-26T10:47:05.873Z" },
{ url = "https://files.pythonhosted.org/packages/aa/f2/db7fc4d14ff0bf8a8bf3ee43daad2e63fc2f46605e5972d97543e0f95e62/python_calamine-0.6.1-cp313-cp313-win32.whl", hash = "sha256:ec7928740519a8471ad8f1ec429301fb8a31a9c6adbfea51d7ff6ef2cb116835", size = 695391, upload-time = "2025-11-26T10:47:07.254Z" },
{ url = "https://files.pythonhosted.org/packages/1d/c9/2e6b5d073885051ee7b5947156678c0cf5dfedf0dd10c5f23b694dcef824/python_calamine-0.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8f24740645a773cefae8507a13d03981867fa3dbd7fad1c3c667a1a3cd43235b", size = 747094, upload-time = "2025-11-26T10:47:08.69Z" },
{ url = "https://files.pythonhosted.org/packages/f2/c4/8ff9ecfe3b9b2bf556474e8ee8de541edfd650fd3e77752fa5705cbee3dc/python_calamine-0.6.1-cp313-cp313-win_arm64.whl", hash = "sha256:8e4ac2732aadc98bee412b59770dc6f4a6a886b5308cb57bfea53e877ae1a913", size = 716857, upload-time = "2025-11-26T10:47:11.062Z" },
{ url = "https://files.pythonhosted.org/packages/ec/0d/83e44b3cbc7712ffac7750b14a817e34637904bcaa435626799506bf998b/python_calamine-0.6.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:caab3bafa99b62d0aed0abf261a9f9df045eef11c5410ed91aa1b25f8381a087", size = 873582, upload-time = "2025-11-26T10:47:12.463Z" },
{ url = "https://files.pythonhosted.org/packages/1f/7e/b47cfe737f885b139dae63f4139cb2ed1515994b465cf0370e25ce8d0065/python_calamine-0.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3aefcdea5bdd2972e999264435b97e71855f02481688d213a4473d372b8288b0", size = 850739, upload-time = "2025-11-26T10:47:13.989Z" },
{ url = "https://files.pythonhosted.org/packages/9a/ea/6aa2f277271323c5fbbde8718a7cad5ecf1fed9f637f648b0f6ae2c240cd/python_calamine-0.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2e9d10c91308eacfc1f76ff08bb7a8316c61f8f47619f9e4e254dd888fb3e9b", size = 923053, upload-time = "2025-11-26T10:47:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/00/2a/bf6ff24816fa60646d61a00f8a69113239a6a97207cdb2d541936003d030/python_calamine-0.6.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:71eb5069b3d3639594a4fdccb3cb95a1b8f650e12def39a752ad8ff19eea620f", size = 907953, upload-time = "2025-11-26T10:47:17.535Z" },
{ url = "https://files.pythonhosted.org/packages/c1/24/54bb664dc9cc1252207bf5512d9870be23fdba2e5b94300d7e32e8c39a82/python_calamine-0.6.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:def9e6be95496c660b6dc55b37eac3c6a479a71522e849f3a1ed4435788c6599", size = 1071663, upload-time = "2025-11-26T10:47:18.967Z" },
{ url = "https://files.pythonhosted.org/packages/c0/b7/4e2e5c8fd00ee7d80d272cb5e3cf170615a99911b515a2b4347995df0aa8/python_calamine-0.6.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c4740797c7e794acd907c7fa84ec09931ed2dfc3c9d1c689f7c7d236498d74cc", size = 961235, upload-time = "2025-11-26T10:47:21.117Z" },
{ url = "https://files.pythonhosted.org/packages/b8/61/25193d600bf0e48513d275a69e5cdb158c27d11573bed74a28eb88d88592/python_calamine-0.6.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b67f1a9f7452fa6ee736ac5a59349bbfc66087b96402051656c9b5a54a111ef", size = 930561, upload-time = "2025-11-26T10:47:22.904Z" },
{ url = "https://files.pythonhosted.org/packages/dc/3d/b0f434622c31182b64bd2e0e6c81cf35cf240ccee38cfb8074fbde9add98/python_calamine-0.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1f369ebb8d6bf2ac66fbe38f5e6adf7b6a81fa71c1b5e2e7b2bb4a5c9667711", size = 971200, upload-time = "2025-11-26T10:47:24.837Z" },
{ url = "https://files.pythonhosted.org/packages/39/8e/502bbb06fa70f1f52f4f46efc0b331b31124110986a5378c1be711ad05e9/python_calamine-0.6.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:99bf12605466097219ebb133df54e41e479cb2559359d2dbad624dc301d4286b", size = 1106302, upload-time = "2025-11-26T10:47:26.706Z" },
{ url = "https://files.pythonhosted.org/packages/c7/63/6fbda3f58aa5907cdfb628fc96e26e10820000858a9dd4fe6053e05a9310/python_calamine-0.6.1-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:96a44d48b9c4b05fb70396674ca7c90e4b4286845b5937606b60babe90f1fa4c", size = 1174437, upload-time = "2025-11-26T10:47:28.229Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/9e027e79de13424844ab33b6e2ad2b2be9ac40b653040bc8459bbfe4b48f/python_calamine-0.6.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f7bfaf556391841ea59d0d0a63c5af7b5285ab260103656e65f55384b31b2010", size = 1105843, upload-time = "2025-11-26T10:47:29.848Z" },
{ url = "https://files.pythonhosted.org/packages/cd/80/231c1f02d3d5adfde8c1f324da2c7907b63adb6f9ef36c3fd7db5b5fe083/python_calamine-0.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a46ff8aa52ea3ef7851d6b5fd496be72a10db4f2d8942b42ecb0634ff9c1e441", size = 746797, upload-time = "2025-11-26T10:47:31.333Z" },
{ url = "https://files.pythonhosted.org/packages/88/2d/8c18519847dd53227c472231bcca37086027dd54b40ae13c48da7bacea53/python_calamine-0.6.1-cp313-cp313t-win_arm64.whl", hash = "sha256:7ac72743c3b2398ed55b9130482db097da8cb80d61b4b7aaf4008c7831ac11d3", size = 711966, upload-time = "2025-11-26T10:47:32.995Z" },
{ url = "https://files.pythonhosted.org/packages/66/89/974515fe4e871fc8ff2495ebd1a59585fe56956b83096bd8f17c76716951/python_calamine-0.6.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:957412de027ef6c05da0ad687c7a5111229108c1c81780a94ea64ca6afa10074", size = 874587, upload-time = "2025-11-26T10:47:34.823Z" },
{ url = "https://files.pythonhosted.org/packages/9f/1c/185a871429bcd19a00d0df8a5f5a6469dfd5d5e86039d43df6d98b913cd1/python_calamine-0.6.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5393b60b81e2c7d6f54b26bca8fb47c032bc35531ea3bb38ae5ffdefd6ba2b6d", size = 851804, upload-time = "2025-11-26T10:47:36.809Z" },
{ url = "https://files.pythonhosted.org/packages/16/f0/a1b18653d621efac176ae63b3b4b4fdcf2b9d8706ffec75b0d4dbf02c1d2/python_calamine-0.6.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efdf70f647fe51638f4a2d0efb0644f132eb2bc32b0268f2c8477e23d56302f4", size = 925164, upload-time = "2025-11-26T10:47:38.622Z" },
{ url = "https://files.pythonhosted.org/packages/e4/4e/1ad2bcea9bbd9e5eed89626391d63759c800cd9064e13dd8f17d9084ddbf/python_calamine-0.6.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8bf893d3526af30d0e4152de54621cf440d5d9fe99882adac02803a9f870da76", size = 908880, upload-time = "2025-11-26T10:47:40.239Z" },
{ url = "https://files.pythonhosted.org/packages/e5/bb/bd5fe13c89f2e39f439f6f3535f34c3d29fb5280fa7e6a6b9f101547a1eb/python_calamine-0.6.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2905f241beff9945b1c4a3622ddc9cf604b1825a26683b35a8f97533c983b228", size = 1077935, upload-time = "2025-11-26T10:47:41.738Z" },
{ url = "https://files.pythonhosted.org/packages/98/8d/fde8575220ecbbf1a3a3eeb6c9fd96288bfadf1eb9fca4eb89ebfb81ce8e/python_calamine-0.6.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39a722be084690516e0bf6260cc452cf783ef72f01a18c0d1daf428dc88cf090", size = 961729, upload-time = "2025-11-26T10:47:43.238Z" },
{ url = "https://files.pythonhosted.org/packages/a7/75/d6da93f82e07359710bb472822e4e4f964bc712a16a86b009f97679ea0c0/python_calamine-0.6.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e9180c7018ecaf5d8648b6a9c54381d467bf622dccc5d8fa90ae727b21ca46", size = 931109, upload-time = "2025-11-26T10:47:44.855Z" },
{ url = "https://files.pythonhosted.org/packages/58/79/abdacdf1ffec109ebb52eae3edbb110de3350d54c2a6232e3d88acabc8ec/python_calamine-0.6.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18d00677bd9f2fad3924d1101d95ac0057f98ebde406034d5782c1f14d4f6c64", size = 972567, upload-time = "2025-11-26T10:47:46.424Z" },
{ url = "https://files.pythonhosted.org/packages/56/36/b7aa35eab36515216759be0fa2f6702ec1ac20168f239d220a0027c3c2f4/python_calamine-0.6.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:df8c6bdfc6eefbeae35a8f9fdfbf85d954f944b9c8aea8e43e1cdde1d50eb686", size = 1108588, upload-time = "2025-11-26T10:47:48.019Z" },
{ url = "https://files.pythonhosted.org/packages/19/d1/33c947f2541006f6d196bf7b9f1d5211592c36398027381b27c69dea8a6f/python_calamine-0.6.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:96107062d9e5f696e5b15b4c67b40acc136607bc880c2368797051e26478bd9e", size = 1175173, upload-time = "2025-11-26T10:47:49.631Z" },
{ url = "https://files.pythonhosted.org/packages/cf/84/46ca9e32572ea0c8ba0fbe489c7a15dc0af0d266331e3e0ae44a7d841767/python_calamine-0.6.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:c3d9f2f5f5712dc5c59707a1211781339738b9ede7611c049995327e26e99f6d", size = 1107963, upload-time = "2025-11-26T10:47:51.638Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d7/043fbe723313ab52d3e7f81465287d507a3237d442ac913ed168172dc9f2/python_calamine-0.6.1-cp314-cp314-win32.whl", hash = "sha256:46563dd5424a7e0e6d8845bf4263455364749517493690a7af8c98c7803d7348", size = 694668, upload-time = "2025-11-26T10:47:54.028Z" },
{ url = "https://files.pythonhosted.org/packages/e9/93/5690f52c267dbcde420a2db0e39158eb78ae85083137db2bda3387232116/python_calamine-0.6.1-cp314-cp314-win_amd64.whl", hash = "sha256:8fdff080b3c46527d90f8d8c593400d39f02c126bd4ed477b845603f86524b52", size = 744792, upload-time = "2025-11-26T10:47:55.488Z" },
{ url = "https://files.pythonhosted.org/packages/30/4b/360c6cfd78bee2707d1f294bd74ecb2662abfc9ee9786a373869403c5737/python_calamine-0.6.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8d7a18a2385d7302f4d82ff2789765e725afa95339f35e33b27d43ef7914e91", size = 714327, upload-time = "2025-11-26T10:47:57.035Z" },
{ url = "https://files.pythonhosted.org/packages/18/26/d0f619823b511606490359d8b7f2090f17233373eac5fd9ad7bb5bab01a8/python_calamine-0.6.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c863c5f447fab38d72f272ab388e9e38552a1e034446c97a358008397d290fca", size = 874069, upload-time = "2025-11-26T10:47:58.686Z" },
{ url = "https://files.pythonhosted.org/packages/f6/76/a0687797d3ee024611fb4ba9e3d658742bcfed10ab979c6ba8cb7028c225/python_calamine-0.6.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a20b752042ab833724d4118ae107072b9b575142dc7e9c142989c3613c0b7094", size = 852456, upload-time = "2025-11-26T10:48:00.325Z" },
{ url = "https://files.pythonhosted.org/packages/01/09/6ebea8e51791fb2fe6d9651f0de54adae20fdb7eb9b9654897c855b7a939/python_calamine-0.6.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:350b02f2101132e9faf04784849370eabfc4d65b070fe76f07cbe46deee67850", size = 923253, upload-time = "2025-11-26T10:48:01.894Z" },
{ url = "https://files.pythonhosted.org/packages/54/63/a32eaca9cb65608109ec393a2ebcef5e9fad7c6cfc7b464a5f6cf1b595ba/python_calamine-0.6.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec23faed1922a1e1c966fe1f09a573de4921303b97304bda82f5d764c55f905b", size = 909063, upload-time = "2025-11-26T10:48:03.759Z" },
{ url = "https://files.pythonhosted.org/packages/90/cc/64a81e3ebd0d8fe79b2120f748db7dcd733abe11a9d97d00921ab60c02c4/python_calamine-0.6.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd14ea56bf194d6da8103d5b3c16fcafed666843d3ad4ae77d1efbb04912de5", size = 1070734, upload-time = "2025-11-26T10:48:05.362Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a9/04c29089240763f559ab69be6794fe4209acf16306c051fe0fc4afb40f8a/python_calamine-0.6.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e06444e75411a7a5cff3ee5b4c7f831897b549cc720b9a66740be1045980e634", size = 960622, upload-time = "2025-11-26T10:48:06.935Z" },
{ url = "https://files.pythonhosted.org/packages/19/3e/9659b179b9e28b7895f32d0b0f0a09474b263fe001abaf1009b51b1b7b9c/python_calamine-0.6.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acb4e4277b94d3e07d6045de2b2b1995cd093399f54dacc441acdb86ec4e6a4f", size = 929758, upload-time = "2025-11-26T10:48:08.56Z" },
{ url = "https://files.pythonhosted.org/packages/45/43/4cb1603b1452ecb3b1a34863b193fce54dc2b048b961a51652d2116a5998/python_calamine-0.6.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1f722f72abb43fc2eabf2e74472ec2a30a6fbcf90836927da430d36a0fe26c83", size = 971930, upload-time = "2025-11-26T10:48:10.212Z" },
{ url = "https://files.pythonhosted.org/packages/80/d8/939fb61b1a085a8f96a2e3e86872c23f23377070dc582ba0d1066cbc973b/python_calamine-0.6.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ac3e464ab5df1ef1a2eff0932a2c5431a35c41b4c7dd8030fd76b4abba53a11c", size = 1106265, upload-time = "2025-11-26T10:48:12.107Z" },
{ url = "https://files.pythonhosted.org/packages/7d/d8/22103aab600f89ab99d8b9538e92b37f4e6e520a8caceb73e421cb6b996b/python_calamine-0.6.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:ee671cb13e1e68f4669e85ca8cc365dcc62a1a023d288c1b3feeab98512a63f5", size = 1175335, upload-time = "2025-11-26T10:48:13.655Z" },
{ url = "https://files.pythonhosted.org/packages/69/cf/950bf18c38964f84639fe530162c40aea23f1473eeb78668096211984e56/python_calamine-0.6.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3019d81aea47e8fea6c08a2c5310faeef1d3119e2b11409f1aae86b4dc5aaff3", size = 1104826, upload-time = "2025-11-26T10:48:15.41Z" },
{ url = "https://files.pythonhosted.org/packages/0c/37/ea8e77509b9ca8ea1e70f4660b854e4d38b84c76aba4ee7c973423a613ba/python_calamine-0.6.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89d11e9022bc1aec124d5a5bc5a34e703a6b7e22171558231e05c84ac56ec79b", size = 745873, upload-time = "2025-11-26T10:48:17.028Z" },
{ url = "https://files.pythonhosted.org/packages/f4/99/6a2be914635f50ccd9296fcb39f7566f354d28ca20acc93085ce610e9d23/python_calamine-0.6.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a57ad2e1feb443ef0b197b7717200f786c3e3a3412bf88a9bfef0792ab848f58", size = 711796, upload-time = "2025-11-26T10:48:18.57Z" },
]
[[package]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"