add cancel schedule
This commit is contained in:
@@ -1,10 +1,14 @@
|
|||||||
|
from http import HTTPStatus
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
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.openapi.params import Query
|
from aws_lambda_powertools.event_handler.exceptions import NotFoundError
|
||||||
from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey
|
from aws_lambda_powertools.event_handler.openapi.params import Body, Query
|
||||||
|
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, PartitionKey
|
||||||
|
from pydantic import FutureDatetime
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
@@ -23,3 +27,33 @@ def scheduled(
|
|||||||
start_key=start_key,
|
start_key=start_key,
|
||||||
limit=150,
|
limit=150,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledNotFoundError(NotFoundError): ...
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete('/<org_id>/enrollments/scheduled')
|
||||||
|
def cancel(
|
||||||
|
org_id: str,
|
||||||
|
scheduled_for: Annotated[FutureDatetime, Body(embed=True)],
|
||||||
|
lock_hash: Annotated[str, Body(embed=True)],
|
||||||
|
):
|
||||||
|
with dyn.transact_writer() as transact:
|
||||||
|
transact.delete(
|
||||||
|
key=KeyPair(
|
||||||
|
pk=f'SCHEDULED#ORG#{org_id}',
|
||||||
|
sk=f'{scheduled_for.isoformat()}#{lock_hash}',
|
||||||
|
),
|
||||||
|
cond_expr='attribute_exists(sk)',
|
||||||
|
exc_cls=ScheduledNotFoundError,
|
||||||
|
)
|
||||||
|
transact.delete(
|
||||||
|
key=KeyPair(
|
||||||
|
pk='LOCK#SCHEDULED',
|
||||||
|
sk=lock_hash,
|
||||||
|
),
|
||||||
|
cond_expr='attribute_exists(sk)',
|
||||||
|
exc_cls=ScheduledNotFoundError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(status_code=HTTPStatus.NO_CONTENT)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from '@repo/ui/components/ui/dropdown-menu'
|
} from '@repo/ui/components/ui/dropdown-menu'
|
||||||
import { Spinner } from '@repo/ui/components/ui/spinner'
|
import { Spinner } from '@repo/ui/components/ui/spinner'
|
||||||
@@ -138,6 +139,7 @@ function ActionMenu({ id }: { id: string }) {
|
|||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<RevokeItem id={id} />
|
<RevokeItem id={id} />
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ function RemoveDedupItem({
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
toast.info('A proteção contra duplicação foi removida')
|
toast.info('A proteção contra duplicação foi removida.')
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
onSuccess?.()
|
onSuccess?.()
|
||||||
}
|
}
|
||||||
@@ -279,7 +279,7 @@ function CancelItem({
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
toast.info('A matrícula foi cancelada')
|
toast.info('A matrícula foi cancelada.')
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
onSuccess?.()
|
onSuccess?.()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import type { Route } from './+types/route'
|
import type { Route } from './+types/route'
|
||||||
|
|
||||||
|
import type { MouseEvent } from 'react'
|
||||||
|
import { useRequest, useToggle } from 'ahooks'
|
||||||
import {
|
import {
|
||||||
BanIcon,
|
BanIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
|
CircleXIcon,
|
||||||
EllipsisIcon,
|
EllipsisIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
RocketIcon,
|
||||||
UserIcon
|
UserIcon
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import { Fragment, Suspense } from 'react'
|
import { Fragment, Suspense } from 'react'
|
||||||
import { Await } from 'react-router'
|
import { Await } from 'react-router'
|
||||||
@@ -37,6 +42,27 @@ import { Link } from 'react-router'
|
|||||||
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
|
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
|
||||||
import { initials } from '@repo/ui/lib/utils'
|
import { initials } from '@repo/ui/lib/utils'
|
||||||
import { Abbr } from '@repo/ui/components/abbr'
|
import { Abbr } from '@repo/ui/components/abbr'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '@repo/ui/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger
|
||||||
|
} from '@repo/ui/components/ui/alert-dialog'
|
||||||
|
import { Spinner } from '@repo/ui/components/ui/spinner'
|
||||||
|
import { useParams } from 'react-router'
|
||||||
|
import { useRevalidator } from 'react-router'
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta({}: Route.MetaArgs) {
|
||||||
return [{ title: 'Matrículas agendadas' }]
|
return [{ title: 'Matrículas agendadas' }]
|
||||||
@@ -100,7 +126,10 @@ export default function Route({
|
|||||||
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) => (
|
{scheduled.map(([run_at, items], index) => (
|
||||||
<div className="grid lg:grid-cols-5 gap-2.5" key={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')
|
||||||
@@ -112,7 +141,7 @@ export default function Route({
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
{items.map(
|
{items.map(
|
||||||
(
|
(
|
||||||
{ user, course, created_by, scheduled_at },
|
{ sk, user, course, created_by, scheduled_at },
|
||||||
index
|
index
|
||||||
) => (
|
) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
@@ -148,11 +177,9 @@ export default function Route({
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
{/*<ItemActions>
|
<ItemActions className="self-start">
|
||||||
<Button variant="ghost" size="icon-sm">
|
<ActionMenu sk={sk} />
|
||||||
<EllipsisIcon />
|
</ItemActions>
|
||||||
</Button>
|
|
||||||
</ItemActions>*/}
|
|
||||||
</Item>
|
</Item>
|
||||||
|
|
||||||
{index !== items.length - 1 && <ItemSeparator />}
|
{index !== items.length - 1 && <ItemSeparator />}
|
||||||
@@ -172,6 +199,93 @@ export default function Route({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ActionMenu({ sk }: { sk: string }) {
|
||||||
|
const { revalidate } = useRevalidator()
|
||||||
|
|
||||||
|
const onSuccess = () => {
|
||||||
|
revalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon-sm" className="cursor-pointer">
|
||||||
|
<EllipsisIcon />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent align="end" className="*:cursor-pointer w-42">
|
||||||
|
<DropdownMenuItem disabled>
|
||||||
|
<RocketIcon /> Matricular agora
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<CancelItem sk={sk} onSuccess={onSuccess} />
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CancelItem({ sk, onSuccess }: { sk: string; onSuccess?: () => void }) {
|
||||||
|
const { orgid } = useParams()
|
||||||
|
const [open, { set: setOpen }] = useToggle(false)
|
||||||
|
const { runAsync, loading } = useRequest(
|
||||||
|
async () => {
|
||||||
|
const [scheduled_for, lock_hash] = sk.split('#')
|
||||||
|
return await fetch(`/~/api/orgs/${orgid}/enrollments/scheduled`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({ scheduled_for, lock_hash })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ manual: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const cancel = async (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const r = await runAsync()
|
||||||
|
if (r.ok) {
|
||||||
|
toast.info('O agendamento foi cancelada.')
|
||||||
|
onSuccess?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<CircleXIcon /> Cancelar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Tem certeza absoluta?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Esta ação não pode ser desfeita. Isso{' '}
|
||||||
|
<span className="font-bold">
|
||||||
|
cancela permanentemente o agendamento
|
||||||
|
</span>{' '}
|
||||||
|
desta matrícula.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter className="*:cursor-pointer">
|
||||||
|
<AlertDialogAction asChild>
|
||||||
|
<Button onClick={cancel} disabled={loading} variant="destructive">
|
||||||
|
{loading ? <Spinner /> : null} Continuar
|
||||||
|
</Button>
|
||||||
|
</AlertDialogAction>
|
||||||
|
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function grouping(items) {
|
function grouping(items) {
|
||||||
const newItems = Object.entries(
|
const newItems = Object.entries(
|
||||||
items.reduce((acc, item) => {
|
items.reduce((acc, item) => {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from '@repo/ui/components/ui/dropdown-menu'
|
} from '@repo/ui/components/ui/dropdown-menu'
|
||||||
import { Spinner } from '@repo/ui/components/ui/spinner'
|
import { Spinner } from '@repo/ui/components/ui/spinner'
|
||||||
@@ -74,6 +75,7 @@ function ActionMenu({ row }: { row: any }) {
|
|||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<UnlinkItem id={row.id} />
|
<UnlinkItem id={row.id} />
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -62,14 +62,14 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
|||||||
enrollment['course'] = old_course
|
enrollment['course'] = old_course
|
||||||
|
|
||||||
created_at: datetime = fromisoformat(enrollment['created_at']) # type: ignore
|
created_at: datetime = fromisoformat(enrollment['created_at']) # type: ignore
|
||||||
start_date, end_date = get_billing_period(
|
start_period, end_period = get_billing_period(
|
||||||
billing_day=new_image['billing_day'],
|
billing_day=new_image['billing_day'],
|
||||||
date_=created_at,
|
date_=created_at,
|
||||||
)
|
)
|
||||||
pk = 'BILLING#ORG#{org_id}'.format(org_id=org_id)
|
pk = 'BILLING#ORG#{org_id}'.format(org_id=org_id)
|
||||||
sk = 'START#{start}#END#{end}'.format(
|
sk = 'START#{start}#END#{end}'.format(
|
||||||
start=start_date.isoformat(),
|
start=start_period.isoformat(),
|
||||||
end=end_date.isoformat(),
|
end=end_period.isoformat(),
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info('Enrollment found', data=enrollment)
|
logger.info('Enrollment found', data=enrollment)
|
||||||
@@ -91,7 +91,8 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
|||||||
'id': pk,
|
'id': pk,
|
||||||
'sk': f'{sk}#SCHEDULE#AUTO_CLOSE',
|
'sk': f'{sk}#SCHEDULE#AUTO_CLOSE',
|
||||||
'ttl': ttl(
|
'ttl': ttl(
|
||||||
start_dt=datetime.combine(end_date, time()) + timedelta(days=1)
|
start_dt=datetime.combine(end_period, time())
|
||||||
|
+ timedelta(days=1)
|
||||||
),
|
),
|
||||||
'created_at': now_,
|
'created_at': now_,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,17 +32,17 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
created_at: datetime = fromisoformat(new_image['created_at']) # type: ignore
|
created_at: datetime = fromisoformat(new_image['created_at']) # type: ignore
|
||||||
start_date, end_date = get_billing_period(
|
start_period, end_period = get_billing_period(
|
||||||
billing_day=int(subscription['billing_day']),
|
billing_day=int(subscription['billing_day']),
|
||||||
date_=created_at,
|
date_=created_at,
|
||||||
)
|
)
|
||||||
pk = 'BILLING#ORG#{org_id}'.format(org_id=subscription['org_id'])
|
pk = 'BILLING#ORG#{org_id}'.format(org_id=subscription['org_id'])
|
||||||
sk = 'START#{start}#END#{end}'.format(
|
sk = 'START#{start}#END#{end}'.format(
|
||||||
start=start_date.isoformat(),
|
start=start_period.isoformat(),
|
||||||
end=end_date.isoformat(),
|
end=end_period.isoformat(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if now_.date() > end_date:
|
if now_.date() > end_period:
|
||||||
logger.debug('Enrollment outside the billing period')
|
logger.debug('Enrollment outside the billing period')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
|||||||
now_ = now()
|
now_ = now()
|
||||||
# Key pattern `BILLING#ORG#{org_id}`
|
# Key pattern `BILLING#ORG#{org_id}`
|
||||||
*_, org_id = keys['id'].split('#')
|
*_, org_id = keys['id'].split('#')
|
||||||
# Key pattern `START#{start_date}#END#{end_date}#SCHEDULE#AUTO_CLOSE`
|
# Key pattern `START#{start_period}#END#{end_period}#SCHEDULE#AUTO_CLOSE`
|
||||||
_, start_date, _, end_date, *_ = keys['sk'].split('#')
|
_, start_period, _, end_period, *_ = keys['sk'].split('#')
|
||||||
|
|
||||||
result = order_layer.collection.query(
|
result = order_layer.collection.query(
|
||||||
KeyPair(
|
KeyPair(
|
||||||
pk=keys['id'],
|
pk=keys['id'],
|
||||||
sk=f'START#{start_date}#END#{end_date}#ENROLLMENT',
|
sk=f'START#{start_period}#END#{end_period}#ENROLLMENT',
|
||||||
),
|
),
|
||||||
limit=150,
|
limit=150,
|
||||||
)
|
)
|
||||||
@@ -40,8 +40,8 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
|||||||
{
|
{
|
||||||
'template_uri': BILLING_TEMPLATE_URI,
|
'template_uri': BILLING_TEMPLATE_URI,
|
||||||
'args': {
|
'args': {
|
||||||
'start_date': start_date,
|
'start_date': start_period,
|
||||||
'end_date': end_date,
|
'end_date': end_period,
|
||||||
'items': result['items'],
|
'items': result['items'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -56,7 +56,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
|||||||
logger.info('The request timed out')
|
logger.info('The request timed out')
|
||||||
raise
|
raise
|
||||||
|
|
||||||
object_key = f'billing/{org_id}/{start_date}_{end_date}.pdf'
|
object_key = f'billing/{org_id}/{start_period}_{end_period}.pdf'
|
||||||
s3_uri = f's3://{BUCKET_NAME}/{object_key}'
|
s3_uri = f's3://{BUCKET_NAME}/{object_key}'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -74,7 +74,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
|||||||
transact.update(
|
transact.update(
|
||||||
key=KeyPair(
|
key=KeyPair(
|
||||||
pk=keys['id'],
|
pk=keys['id'],
|
||||||
sk=f'START#{start_date}#END#{end_date}',
|
sk=f'START#{start_period}#END#{end_period}',
|
||||||
),
|
),
|
||||||
update_expr='SET #status = :status, s3_uri = :s3_uri, \
|
update_expr='SET #status = :status, s3_uri = :s3_uri, \
|
||||||
updated_at = :updated_at',
|
updated_at = :updated_at',
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from boto3clients import dynamodb_client, s3_client, sesv2_client
|
|||||||
from config import EMAIL_SENDER, USER_TABLE
|
from config import EMAIL_SENDER, USER_TABLE
|
||||||
|
|
||||||
SUBJECT = (
|
SUBJECT = (
|
||||||
'Relatório de matrículas realizadas entre {start_date} e {end_date} na EDUSEG®'
|
'Relatório de matrículas realizadas entre {start_period} e {end_period} na EDUSEG®'
|
||||||
)
|
)
|
||||||
REPLY_TO = ('Carolina Brand', 'carolina@somosbeta.com.br')
|
REPLY_TO = ('Carolina Brand', 'carolina@somosbeta.com.br')
|
||||||
BCC = [
|
BCC = [
|
||||||
@@ -24,7 +24,7 @@ MESSAGE = """
|
|||||||
Oi, tudo bem?<br/><br/>
|
Oi, tudo bem?<br/><br/>
|
||||||
|
|
||||||
Em anexo você encontra o relatório das matrículas realizadas no período de
|
Em anexo você encontra o relatório das matrículas realizadas no período de
|
||||||
<strong>{start_date}</strong> a <strong>{end_date}</strong>.<br/><br/>
|
<strong>{start_period}</strong> a <strong>{end_period}</strong>.<br/><br/>
|
||||||
|
|
||||||
<a href="https://admin.saladeaula.digital/{org_id}/billing?start={start_query}&end={end_query}">
|
<a href="https://admin.saladeaula.digital/{org_id}/billing?start={start_query}&end={end_query}">
|
||||||
📈 Clique aqui para ver mais detalhes do relatório
|
📈 Clique aqui para ver mais detalhes do relatório
|
||||||
@@ -43,8 +43,8 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
|||||||
new_image = event.detail['new_image']
|
new_image = event.detail['new_image']
|
||||||
# Key pattern `BILLING#ORG#{org_id}`
|
# Key pattern `BILLING#ORG#{org_id}`
|
||||||
*_, org_id = new_image['id'].split('#')
|
*_, org_id = new_image['id'].split('#')
|
||||||
# Key pattern `START#{start_date}#END#{end_date}
|
# Key pattern `START#{start_period}#END#{v}
|
||||||
_, start_date, _, end_date, *_ = new_image['sk'].split('#')
|
_, start_period, _, end_period, *_ = new_image['sk'].split('#')
|
||||||
|
|
||||||
emailmsg = Message(
|
emailmsg = Message(
|
||||||
from_=EMAIL_SENDER,
|
from_=EMAIL_SENDER,
|
||||||
@@ -52,24 +52,24 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
|||||||
reply_to=REPLY_TO,
|
reply_to=REPLY_TO,
|
||||||
bcc=BCC,
|
bcc=BCC,
|
||||||
subject=SUBJECT.format(
|
subject=SUBJECT.format(
|
||||||
start_date=_locale_dateformat(start_date),
|
start_period=_locale_dateformat(start_period),
|
||||||
end_date=_locale_dateformat(end_date),
|
end_period=_locale_dateformat(end_period),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
emailmsg.add_alternative(
|
emailmsg.add_alternative(
|
||||||
MESSAGE.format(
|
MESSAGE.format(
|
||||||
org_id=org_id,
|
org_id=org_id,
|
||||||
start_date=_locale_dateformat(start_date),
|
start_period=_locale_dateformat(start_period),
|
||||||
end_date=_locale_dateformat(end_date),
|
end_period=_locale_dateformat(end_period),
|
||||||
start_query=start_date,
|
start_query=start_period,
|
||||||
end_query=start_date,
|
end_query=end_period,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
attachment = MIMEApplication(_get_file_bytes(new_image['s3_uri']))
|
attachment = MIMEApplication(_get_file_bytes(new_image['s3_uri']))
|
||||||
attachment.add_header(
|
attachment.add_header(
|
||||||
'Content-Disposition',
|
'Content-Disposition',
|
||||||
'attachment',
|
'attachment',
|
||||||
filename=f'{start_date}_{end_date}.pdf',
|
filename=f'{start_period}_{end_period}.pdf',
|
||||||
)
|
)
|
||||||
emailmsg.attach(attachment)
|
emailmsg.attach(attachment)
|
||||||
|
|
||||||
|
|||||||
@@ -30,14 +30,14 @@ def test_cancel_enrollment(
|
|||||||
lambda_context: LambdaContext,
|
lambda_context: LambdaContext,
|
||||||
):
|
):
|
||||||
now_ = now()
|
now_ = now()
|
||||||
start_date, end_date = get_billing_period(
|
start_period, end_period = get_billing_period(
|
||||||
billing_day=6,
|
billing_day=6,
|
||||||
date_=now_,
|
date_=now_,
|
||||||
)
|
)
|
||||||
pk = 'BILLING#ORG#cJtK9SsnJhKPyxESe7g3DG'
|
pk = 'BILLING#ORG#cJtK9SsnJhKPyxESe7g3DG'
|
||||||
sk = 'START#{start}#END#{end}#ENROLLMENT#{enrollment_id}'.format(
|
sk = 'START#{start}#END#{end}#ENROLLMENT#{enrollment_id}'.format(
|
||||||
start=start_date.isoformat(),
|
start=start_period.isoformat(),
|
||||||
end=end_date.isoformat(),
|
end=end_period.isoformat(),
|
||||||
enrollment_id=enrollment_id,
|
enrollment_id=enrollment_id,
|
||||||
)
|
)
|
||||||
# Add up-to-date enrollment item to billing
|
# Add up-to-date enrollment item to billing
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import * as React from "react"
|
import * as React from 'react'
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils'
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
|
||||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="list"
|
role="list"
|
||||||
data-slot="item-group"
|
data-slot="item-group"
|
||||||
className={cn("group/item-group flex flex-col", className)}
|
className={cn('group/item-group flex flex-col', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -24,42 +24,42 @@ function ItemSeparator({
|
|||||||
<Separator
|
<Separator
|
||||||
data-slot="item-separator"
|
data-slot="item-separator"
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
className={cn("my-0", className)}
|
className={cn('my-0', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemVariants = cva(
|
const itemVariants = cva(
|
||||||
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
'group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-transparent",
|
default: 'bg-transparent',
|
||||||
outline: "border-border",
|
outline: 'border-border',
|
||||||
muted: "bg-muted/50",
|
muted: 'bg-muted/50'
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "p-4 gap-4 ",
|
default: 'p-4 gap-4 ',
|
||||||
sm: "py-3 px-4 gap-2.5",
|
sm: 'py-3 px-4 gap-2.5'
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: 'default',
|
||||||
size: "default",
|
size: 'default'
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function Item({
|
function Item({
|
||||||
className,
|
className,
|
||||||
variant = "default",
|
variant = 'default',
|
||||||
size = "default",
|
size = 'default',
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> &
|
}: React.ComponentProps<'div'> &
|
||||||
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot : "div"
|
const Comp = asChild ? Slot : 'div'
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="item"
|
data-slot="item"
|
||||||
@@ -72,27 +72,27 @@ function Item({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const itemMediaVariants = cva(
|
const itemMediaVariants = cva(
|
||||||
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
|
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-transparent",
|
default: 'bg-transparent',
|
||||||
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||||
image:
|
image:
|
||||||
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
|
'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover'
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: 'default'
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function ItemMedia({
|
function ItemMedia({
|
||||||
className,
|
className,
|
||||||
variant = "default",
|
variant = 'default',
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="item-media"
|
data-slot="item-media"
|
||||||
@@ -103,12 +103,12 @@ function ItemMedia({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="item-content"
|
data-slot="item-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
|
'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -116,12 +116,12 @@ function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="item-title"
|
data-slot="item-title"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
|
'flex w-fit items-center gap-2 text-sm leading-snug font-medium',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -129,13 +129,13 @@ function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
data-slot="item-description"
|
data-slot="item-description"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
|
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
|
||||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -143,22 +143,22 @@ function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="item-actions"
|
data-slot="item-actions"
|
||||||
className={cn("flex items-center gap-2", className)}
|
className={cn('flex items-center gap-2', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="item-header"
|
data-slot="item-header"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex basis-full items-center justify-between gap-2",
|
'flex basis-full items-center justify-between gap-2',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -166,12 +166,12 @@ function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="item-footer"
|
data-slot="item-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex basis-full items-center justify-between gap-2",
|
'flex basis-full items-center justify-between gap-2',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -189,5 +189,5 @@ export {
|
|||||||
ItemTitle,
|
ItemTitle,
|
||||||
ItemDescription,
|
ItemDescription,
|
||||||
ItemHeader,
|
ItemHeader,
|
||||||
ItemFooter,
|
ItemFooter
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user