This commit is contained in:
2025-07-02 20:09:04 -03:00
parent 9b927bdbcd
commit 238c215f76
78 changed files with 10075 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
build:
sam build --use-container
deploy: build
sam deploy --debug

View File

@@ -0,0 +1,13 @@
import os
import boto3
def get_dynamodb_client():
if os.getenv('AWS_LAMBDA_FUNCTION_NAME'):
return boto3.client('dynamodb')
return boto3.client('dynamodb', endpoint_url='http://127.0.0.1:8000')
dynamodb_client = get_dynamodb_client()

Binary file not shown.

View File

@@ -0,0 +1,53 @@
import base64
import io
import locale
from datetime import date
from uuid import uuid4
import qrcode
from jinja2 import Template
from PIL import Image
from weasyprint import HTML
locale.setlocale(locale.LC_TIME, 'pt_BR')
today = date.today()
with open('nr10_complementar_sep.html', encoding='utf-8') as f:
html = f.read()
def cpf_fmt(s: str) -> str:
"""Returns a string as a Brazilian CPF number."""
return '{}.{}.{}-{}'.format(s[:3], s[3:6], s[6:9], s[9:])
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_H,
box_size=10,
border=3,
)
qr.add_data('https://eduseg.com.br')
qr.make(fit=True)
img = qr.make_image(fill_color='black', back_color='white')
img = img.resize((120, 120), Image.NEAREST)
buffer = io.BytesIO()
img.save(buffer, format='PNG')
img_str = base64.b64encode(buffer.getvalue()).decode('utf-8')
qrcode_base64 = f'data:image/png;base64,{img_str}'
template = Template(html)
html_rendered = template.render(
id=uuid4(),
name='Sérgio Rafael de Siqueira',
cpf=cpf_fmt('07879819908'),
progress=91.99,
course='NR-10 Complementar (SEP)',
today=today.strftime('%-d de %B de %Y'),
started_date=today.strftime('%d/%m/%Y'),
finished_date=today.strftime('%d/%m/%Y'),
qrcode=qrcode_base64,
)
HTML(string=html_rendered, base_url='').write_pdf('cert.pdf')

View File

@@ -0,0 +1,28 @@
[project]
name = "certs"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"jinja2>=3.1.6",
"layercake",
"qrcode>=8.2",
]
[tool.uv.sources]
layercake = { path = "../layercake" }
[tool.ruff]
target-version = "py311"
src = ["app"]
[tool.ruff.format]
quote-style = "single"
[tool.ruff.lint]
select = ["E", "F", "I"]
[dependency-groups]
dev = [
"ruff>=0.11.9",
]

View File

@@ -0,0 +1,251 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>NR-10 Complementar (SEP)</title>
<meta name="author" content="EDUSEG® <https://eduseg.com.br>" />
<style>
html,
body,
div,
h1,
h2,
ul,
p,
a {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
@font-face {
font-family: "SF-Pro";
src: url("fonts/SF-Pro.ttf") format("truetype");
}
@page {
size: A4 landscape;
margin: 0;
}
html {
font-family: SF-Pro;
font-size: 13pt;
line-height: 1.4;
}
section {
width: 29.7cm;
height: 21cm;
break-after: page;
box-sizing: border-box;
padding: 5rem;
}
strong {
font-weight: bold;
}
#cover {
background-color: #a7e400;
justify-content: center;
position: relative;
display: flex;
flex-direction: column;
gap: 1rem;
}
#cover h1 {
font-weight: bolder;
font-size: 26pt;
}
#cover .qrcode {
width: 120px;
height: 120px;
background-color: #fff;
position: absolute;
top: 5rem;
right: 5rem;
}
#cover .signatures {
display: flex;
justify-content: space-between;
margin-top: 2.5rem;
}
.sign1 {
width: 250px;
border-top: #000 solid 1px;
}
#back {
background-color: white;
display: flex;
flex-direction: row;
gap: 0.5rem;
}
#back h1,
#back h2 {
font-weight: bold;
font-size: 16pt;
}
#back ul {
padding-left: 1rem;
}
.space-y-0\.5 > :not(:last-child) {
margin-bottom: 0.125rem;
}
.space-y-2\.5 > :not(:last-child) {
margin-bottom: 0.625rem;
}
</style>
</head>
<body>
<section id="cover">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1072.73 329.6"
style="width: 14rem"
>
<g>
<g>
<path
fill="#8cd366"
d="M152.18,217.62l-68.61-30.91c-1.88-1.2-4.28-1.2-6.16,0l-68.61,30.91c-3.81,2.43-8.8-.3-8.8-4.82V8.98C0,5.82,2.56,3.26,5.72,3.26h149.54c3.16,0,5.72,2.56,5.72,5.72v203.81c0,4.52-5,7.26-8.8,4.82Z"
></path>
<path
fill="#2e3524"
d="M93.97,74.01H26.61v20.16h67.36v-20.16Z"
></path>
<path
fill="#2e3524"
d="M107.44,111.16H26.61v26.8h80.83v-26.8Z"
></path>
<path
fill="#2e3524"
d="M107.44,30.27H26.61v26.8h80.83v-26.8Z"
></path>
<path
fill="#2e3524"
d="M134.38,131.23c0-3.72-3.02-6.73-6.73-6.73s-6.73,3.02-6.73,6.73,3.02,6.73,6.73,6.73,6.73-3.02,6.73-6.73Z"
></path>
</g>
<g>
<path
fill="#f9f7e8"
d="M244.7,3.24h92.33v44.43h-44.15v88.85h39.38v39.62h-39.38v105.77h44.15v44.42h-92.33V3.24Z"
></path>
<path
fill="#f9f7e8"
d="M362.72,3.24h57.79c10.71,0,20.47,2.35,29.29,7.06,8.83,4.7,15.79,11.18,20.87,19.39,5.08,8.21,7.63,17.45,7.63,27.67v214.88c0,10.22-2.48,19.46-7.42,27.67-4.96,8.21-11.83,14.69-20.68,19.39-8.83,4.7-18.73,7.08-29.7,7.08h-57.79V3.24ZM427.55,283.88c1.74-1.87,2.6-4.18,2.6-6.86V52.56c-.26-2.69-1.34-4.97-3.22-6.86-1.88-1.87-4.15-2.83-6.82-2.83h-14v243.85h14.41c2.93,0,5.27-.94,7.01-2.83h.02Z"
></path>
<path
fill="#f9f7e8"
d="M531.5,322.49c-8.71-4.7-15.6-11.16-20.68-19.39-5.08-8.21-7.63-17.42-7.63-27.67V3.24h48.15v279.41c0,2.69.93,4.99,2.82,6.86,1.86,1.9,4.15,2.83,6.82,2.83,2.93,0,5.27-.94,7.01-2.83,1.74-1.87,2.6-4.18,2.6-6.86V3.24h48.16v272.21c0,10.25-2.48,19.46-7.42,27.67-4.96,8.21-11.83,14.69-20.68,19.39-8.85,4.7-18.73,7.08-29.7,7.08s-20.8-2.35-29.5-7.08l.05-.02Z"
></path>
<path
fill="#f9f7e8"
d="M672.79,322.49c-8.7-4.7-15.6-11.16-20.68-19.39-5.08-8.21-7.63-17.42-7.63-27.67v-78.75h48.16v85.95c0,2.69.93,4.99,2.82,6.87,1.86,1.9,4.15,2.83,6.82,2.83,2.93,0,5.27-.94,7.01-2.83,1.74-1.87,2.6-4.18,2.6-6.87v-77.88c0-5.66-2.22-10.3-6.63-13.94-4.41-3.62-11.57-8.02-21.47-13.13-8.3-4.3-15.05-8.14-20.27-11.52-5.22-3.36-9.71-7.87-13.45-13.54-3.75-5.66-5.63-12.24-5.63-19.8V54.12c0-10.22,2.53-19.44,7.63-27.67,5.08-8.21,11.97-14.66,20.68-19.39,8.68-4.7,18.53-7.06,29.5-7.06s20.87,2.35,29.69,7.06c8.83,4.7,15.72,11.18,20.68,19.39,4.96,8.21,7.42,17.45,7.42,27.67v71.09h-48.16V46.92c0-2.69-.88-4.97-2.6-6.86-1.74-1.87-4.08-2.83-7.01-2.83-2.67,0-4.96.94-6.82,2.83-1.89,1.9-2.82,4.18-2.82,6.86v69.79c0,6.19,2.34,11.26,7.04,15.14,4.67,3.91,12.24,8.83,22.68,14.74,8.04,4.32,14.57,8.09,19.68,11.3,5.08,3.24,9.37,7.46,12.83,12.72,3.48,5.26,5.22,11.26,5.22,17.98v86.83c0,10.25-2.48,19.46-7.42,27.67-4.96,8.21-11.83,14.69-20.68,19.39-8.85,4.71-18.72,7.08-29.7,7.08s-20.8-2.35-29.5-7.08Z"
></path>
<path
fill="#f9f7e8"
d="M784.56,3.24h92.33v44.43h-44.15v88.85h39.38v39.62h-39.38v105.77h44.15v44.42h-92.33V3.24Z"
></path>
<path
fill="#f9f7e8"
d="M920.63,322.49c-5.63-4.18-10.11-10.1-13.45-17.76-3.34-7.68-5.01-16.49-5.01-26.45V53.71c0-9.96,2.53-19.06,7.63-27.26,5.08-8.21,12.05-14.66,20.87-19.39,8.83-4.7,18.6-7.06,29.32-7.06s20.54,2.35,29.5,7.06c8.97,4.7,15.91,11.18,20.87,19.39,4.96,8.21,7.42,17.3,7.42,27.26v94.51h-48.16V46.92c0-2.69-.88-4.97-2.6-6.86-1.74-1.87-4.08-2.83-7.01-2.83-2.67,0-4.96.94-6.82,2.83-1.89,1.9-2.82,4.18-2.82,6.86v231.36c0,2.69.93,4.99,2.82,6.87,1.86,1.9,4.15,2.83,6.82,2.83,2.93,0,5.27-.94,7.01-2.83,1.74-1.87,2.6-4.18,2.6-6.87v-46.03h-11.64v-51.29h59.8v145.4h-48.16v-14.14c-2.96,5.4-6.82,9.48-11.64,12.31-4.82,2.83-10.83,4.25-18.06,4.25s-13.64-2.09-19.27-6.26l-.02-.02Z"
></path>
<path
fill="#f9f7e8"
d="M1053.27,25.05h-6.13l-.06-3.69h5.48c.83-.02,1.61-.15,2.33-.4.72-.27,1.3-.64,1.73-1.14.44-.51.65-1.14.65-1.87,0-.93-.16-1.67-.48-2.22-.3-.55-.83-.94-1.59-1.16-.74-.25-1.74-.37-3.01-.37h-3.78v20.42h-4.12V10.54h7.9c1.87,0,3.49.27,4.86.82,1.38.53,2.44,1.34,3.18,2.44.76,1.08,1.14,2.43,1.14,4.06,0,1.02-.24,1.93-.71,2.73-.47.8-1.17,1.49-2.1,2.07-.91.57-2.03,1.03-3.35,1.39-.06,0-.12.07-.2.2-.06.13-.11.2-.17.2-.32.19-.53.33-.63.43-.08.08-.16.12-.25.14-.08.02-.31.03-.68.03ZM1052.99,25.05l.6-2.81c2.95,0,4.97.64,6.05,1.93,1.08,1.27,1.62,2.89,1.62,4.86v1.53c0,.7.03,1.37.08,2.02.08.62.21,1.15.4,1.59v.45h-4.23c-.19-.49-.3-1.19-.34-2.1-.02-.91-.03-1.57-.03-1.99v-1.48c0-1.38-.31-2.39-.94-3.04s-1.69-.97-3.21-.97ZM1035.75,22.92c0,2.52.43,4.87,1.28,7.04.87,2.16,2.08,4.05,3.64,5.68,1.55,1.61,3.34,2.87,5.37,3.78,2.05.89,4.22,1.33,6.53,1.33s4.51-.44,6.53-1.33c2.03-.91,3.8-2.17,5.34-3.78,1.53-1.63,2.74-3.52,3.61-5.68.87-2.18,1.31-4.52,1.31-7.04s-.44-4.86-1.31-7.01c-.87-2.16-2.07-4.04-3.61-5.65-1.53-1.61-3.31-2.86-5.34-3.75-2.03-.91-4.2-1.36-6.53-1.36s-4.49.45-6.53,1.36c-2.03.89-3.81,2.14-5.37,3.75-1.55,1.61-2.77,3.49-3.64,5.65-.85,2.16-1.28,4.5-1.28,7.01ZM1032.4,22.92c0-3.01.52-5.8,1.56-8.38,1.04-2.57,2.49-4.82,4.34-6.73,1.86-1.93,4-3.43,6.42-4.49,2.44-1.08,5.06-1.62,7.84-1.62s5.39.54,7.81,1.62c2.44,1.06,4.58,2.56,6.42,4.49,1.86,1.91,3.31,4.16,4.35,6.73,1.06,2.57,1.59,5.37,1.59,8.38s-.53,5.8-1.59,8.38c-1.04,2.57-2.49,4.84-4.35,6.79-1.83,1.93-3.97,3.44-6.42,4.52-2.42,1.08-5.03,1.62-7.81,1.62s-5.39-.54-7.84-1.62c-2.42-1.08-4.56-2.58-6.42-4.52-1.85-1.95-3.3-4.21-4.34-6.79-1.04-2.57-1.56-5.37-1.56-8.38Z"
></path>
</g>
</g>
</svg>
<p>Certificamos que</p>
<h1>{{ name }}</h1>
<p>
Portador(a) do CPF <strong>{{ cpf }} </strong>, concluiu o curso
de <strong>NR-10 Complementar (SEP)</strong> com aproveitamento
de
<strong>{{ progress }}%</strong>
</p>
<p>Realizado entre {{ started_date }} e {{ finished_date }}</p>
<p>Florianópolis, SC, {{ today }}</p>
<div class="signatures">
<div class="sign1"></div>
<div class="sign2">
<p>Tiago Maciel do Santos</p>
<p>CEO/Diretor</p>
</div>
</div>
<div class="qrcode">
<img src="{{ qrcode }}" />
</div>
</section>
<section id="back">
<div class="space-y-2.5">
<h1>Conteúdo programático ministrado</h1>
<ul>
<li>Organização do sistema elétrico de potência</li>
<li>Organização do trabalho</li>
<li>Aspectos comportamentais</li>
<li>Condições impeditivas para serviços</li>
<li>Riscos típicos no SEP e sua prevenção</li>
<li>Técnicas de análise de riscos no SEP</li>
<li>Procedimentos de trabalho (análise e discussão)</li>
<li>Técnicas de análise de riscos no SEP</li>
<li>Equipamentos e ferramentas de trabalho</li>
<li>Sistemas de proteção coletiva</li>
<li>Equipamentos de proteção individual</li>
<li>Posturas e vestuários de trabalhos</li>
<li>
Segurança com veículos e transporte de pessoas,
materiais e equipamentos
</li>
<li>Sinalização e isolamento de áreas de trabalho</li>
<li>
Liberação de instalação para serviço, operação e uso
</li>
<li>
Treinamento em técnicas de remoção, atendimento e
transporte de acidentados
</li>
<li>Acidentes típicos</li>
<li>Responsabilidades</li>
</ul>
</div>
<div class="space-y-2.5">
<dd class="space-y-0.5">
<h2>Carga horária</h2>
<p>40 horas</p>
</dd>
<dd class="space-y-0.5">
<h2>Instrutor e responsável técnico</h2>
<div>
<p>Francis Ricardo Baretta</p>
<p>CPF 039.539.409-02</p>
<p>Eng. de Segurança no Trabalho Eng. Eletricista</p>
<p>CREA/SC 126693-0</p>
</div>
</dd>
</div>
</section>
</body>
</html>

1006
enrollments-events/app/certs/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
import os
USER_TABLE: str = os.getenv('USER_TABLE') # type: ignore
ORDER_TABLE: str = os.getenv('ORDER_TABLE') # type: ignore
ENROLLMENT_TABLE: str = os.getenv('ENROLLMENT_TABLE') # type: ignore
COURSE_TABLE: str = os.getenv('COURSE_TABLE') # type: ignore
# Post-migration: remove the lines below
if os.getenv('AWS_LAMBDA_FUNCTION_NAME'):
SQLITE_DATABASE = 'courses_export_2025-06-18_110214.db'
else:
SQLITE_DATABASE = 'app/courses_export_2025-06-18_110214.db'
SQLITE_TABLE = 'courses'
OLD_ENROLLMENT_TABLE: str = os.getenv('OLD_ENROLLMENT_TABLE') # type: ignore

View File

@@ -0,0 +1,25 @@
from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.data_classes import (
EventBridgeEvent,
event_source,
)
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import DynamoDBPersistenceLayer
from boto3clients import dynamodb_client
from config import (
COURSE_TABLE,
ENROLLMENT_TABLE,
)
logger = Logger(__name__)
enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
course_layer = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client)
@event_source(data_class=EventBridgeEvent)
@logger.inject_lambda_context
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
new_image = event.detail['new_image']
return True

View File

@@ -0,0 +1,4 @@
"""
Stopgap events. Everything here is a quick fix and should be replaced with
proper solutions.
"""

View File

@@ -0,0 +1,59 @@
import json
import sqlite3
from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.data_classes import (
EventBridgeEvent,
event_source,
)
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import DynamoDBPersistenceLayer
from sqlite_utils import Database
from boto3clients import dynamodb_client
from config import (
COURSE_TABLE,
ENROLLMENT_TABLE,
SQLITE_DATABASE,
SQLITE_TABLE,
)
sqlite3.register_converter('json', json.loads)
logger = Logger(__name__)
enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
course_layer = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client)
deduplication_window = {'offset_days': 90}
class DeduplicationConflictError(Exception):
def __init__(self, *args):
super().__init__('Enrollment already exists')
@event_source(data_class=EventBridgeEvent)
@logger.inject_lambda_context
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
new_image = event.detail['new_image']
return True
class CourseNotFoundError(Exception):
def __init__(self, *args):
super().__init__('Course not found')
def _get_course(course_id: str) -> dict:
with sqlite3.connect(
database=SQLITE_DATABASE, detect_types=sqlite3.PARSE_DECLTYPES
) as conn:
db = Database(conn)
rows = db[SQLITE_TABLE].rows_where(
"json->>'$.metadata__betaeducacao_id' = ?", [course_id]
)
for row in rows:
return row['json']
raise CourseNotFoundError

Binary file not shown.

View File

@@ -0,0 +1,33 @@
[project]
name = "enrollments-events"
version = "0.1.0"
description = ""
readme = ""
requires-python = ">=3.13"
dependencies = ["layercake"]
[dependency-groups]
dev = [
"jsonlines>=4.0.0",
"pytest>=8.3.4",
"pytest-cov>=6.0.0",
"ruff>=0.9.1",
]
[tool.pytest.ini_options]
pythonpath = ["app/"]
addopts = "--cov --cov-report html -v"
[tool.ruff]
target-version = "py311"
src = ["app"]
[tool.ruff.format]
quote-style = "single"
[tool.ruff.lint]
select = ["E", "F", "I"]
[tool.uv.sources]
layercake = { path = "../layercake" }

View File

@@ -0,0 +1,3 @@
{
"extraPaths": ["app/"]
}

View File

@@ -0,0 +1,9 @@
version = 0.1
[default.deploy.parameters]
stack_name = "saladeaula-enrollments-events"
resolve_s3 = true
s3_prefix = "enrollments-events"
region = "sa-east-1"
confirm_changeset = false
capabilities = "CAPABILITY_IAM"
image_repositories = []

View File

@@ -0,0 +1,82 @@
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Parameters:
UserTable:
Type: String
Default: betaeducacao-prod-users_d2o3r5gmm4it7j
EnrollmentTable:
Type: String
Default: betaeducacao-prod-enrollments
CourseTable:
Type: String
Default: saladeaula_courses
OrderTable:
Type: String
Default: betaeducacao-prod-orders
Globals:
Function:
CodeUri: app/
Runtime: python3.13
Tracing: Active
Architectures:
- x86_64
Layers:
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:79
Environment:
Variables:
TZ: America/Sao_Paulo
LOG_LEVEL: DEBUG
DYNAMODB_PARTITION_KEY: id
POWERTOOLS_LOGGER_SAMPLE_RATE: 0.1
POWERTOOLS_LOGGER_LOG_EVENT: true
USER_TABLE: !Ref UserTable
ENROLLMENT_TABLE: !Ref EnrollmentTable
ORDER_TABLE: !Ref OrderTable
COURSE_TABLE: !Ref CourseTable
Resources:
EventLog:
Type: AWS::Logs::LogGroup
Properties:
RetentionInDays: 90
EventEnrollFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.stopgap.enroll.lambda_handler
LoggingConfig:
LogGroup: !Ref EventLog
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref EnrollmentTable
Events:
DynamoDBEvent:
Type: EventBridgeRule
Properties:
Pattern:
resources: [!Ref EnrollmentTable]
detail-type: [INSERT]
detail:
new_image:
sk: ["0"]
EventIssueCertFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.issue_cert.lambda_handler
LoggingConfig:
LogGroup: !Ref EventLog
Events:
DynamoDBEvent:
Type: EventBridgeRule
Properties:
Pattern:
resources: [!Ref EnrollmentTable]
detail:
new_image:
sk: ["0"]
status: [COMPLETED]
old_image:
status: [PENDING]

View File

View File

@@ -0,0 +1,74 @@
import os
from dataclasses import dataclass
import jsonlines
import pytest
PYTEST_TABLE_NAME = 'pytest'
PK = 'id'
SK = 'sk'
# https://docs.pytest.org/en/7.1.x/reference/reference.html#pytest.hookspec.pytest_configure
def pytest_configure():
os.environ['TZ'] = 'America/Sao_Paulo'
os.environ['DYNAMODB_PARTITION_KEY'] = PK
os.environ['DYNAMODB_SORT_KEY'] = SK
os.environ['USER_TABLE'] = PYTEST_TABLE_NAME
os.environ['COURSE_TABLE'] = PYTEST_TABLE_NAME
os.environ['ORDER_TABLE'] = PYTEST_TABLE_NAME
os.environ['ENROLLMENT_TABLE'] = PYTEST_TABLE_NAME
# Post-migration: remove it
os.environ['OLD_ENROLLMENT_TABLE'] = PYTEST_TABLE_NAME
@dataclass
class LambdaContext:
function_name: str = 'test'
memory_limit_in_mb: int = 128
invoked_function_arn: str = 'arn:aws:lambda:eu-west-1:809313241:function:test'
aws_request_id: str = '52fdfc07-2182-154f-163f-5f0f9a621d72'
@pytest.fixture
def lambda_context() -> LambdaContext:
return LambdaContext()
@pytest.fixture
def dynamodb_client():
from boto3clients import dynamodb_client as client
client.create_table(
AttributeDefinitions=[
{'AttributeName': PK, 'AttributeType': 'S'},
{'AttributeName': SK, 'AttributeType': 'S'},
],
TableName=PYTEST_TABLE_NAME,
KeySchema=[
{'AttributeName': PK, 'KeyType': 'HASH'},
{'AttributeName': SK, 'KeyType': 'RANGE'},
],
ProvisionedThroughput={
'ReadCapacityUnits': 123,
'WriteCapacityUnits': 123,
},
)
yield client
client.delete_table(TableName=PYTEST_TABLE_NAME)
@pytest.fixture()
def dynamodb_persistence_layer(dynamodb_client):
from layercake.dynamodb import DynamoDBPersistenceLayer
return DynamoDBPersistenceLayer(PYTEST_TABLE_NAME, dynamodb_client)
@pytest.fixture()
def dynamodb_seeds(dynamodb_client):
with jsonlines.open('tests/seeds.jsonl') as lines:
for line in lines:
dynamodb_client.put_item(TableName=PYTEST_TABLE_NAME, Item=line)

View File

@@ -0,0 +1,39 @@
import pprint
import app.events.stopgap.enroll as app
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import (
DynamoDBPersistenceLayer,
SortKey,
TransactKey,
)
def test_enroll(
dynamodb_seeds,
dynamodb_client,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext,
):
event = {
'detail': {
'new_image': {
'id': '47ZxxcVBjvhDS5TE98tpfQ',
'sk': '0',
}
}
}
assert app.lambda_handler(event, lambda_context) # type: ignore
result = dynamodb_persistence_layer.collection.get_items(
TransactKey('47ZxxcVBjvhDS5TE98tpfQ')
+ SortKey('0')
+ SortKey('metadata#tenant')
+ SortKey('metadata#author')
+ SortKey('metadata#konviva')
+ SortKey('metadata#lock')
+ SortKey('metadata#deduplication_window')
+ SortKey('metadata#cert')
)
pprint.pprint(result)

View File

@@ -0,0 +1,251 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>NR-10 Complementar (SEP)</title>
<meta name="author" content="EDUSEG® <https://eduseg.com.br>" />
<style>
html,
body,
div,
h1,
h2,
ul,
p,
a {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
@font-face {
font-family: "SF-Pro";
src: url("fonts/SF-Pro.ttf") format("truetype");
}
@page {
size: A4 landscape;
margin: 0;
}
html {
font-family: SF-Pro, Helvetica, Arial, sans-serif;
font-size: 13pt;
line-height: 1.4;
}
section {
width: 29.7cm;
height: 21cm;
break-after: page;
box-sizing: border-box;
padding: 5rem;
}
strong {
font-weight: bold;
}
#cover {
background-color: #a7e400;
justify-content: center;
position: relative;
display: flex;
flex-direction: column;
gap: 1rem;
}
#cover h1 {
font-weight: bolder;
font-size: 26pt;
}
#cover .qrcode {
width: 120px;
height: 120px;
background-color: #fff;
position: absolute;
top: 5rem;
right: 5rem;
}
#cover .signatures {
display: flex;
justify-content: space-between;
margin-top: 2.5rem;
}
.sign1 {
width: 250px;
border-top: #000 solid 1px;
}
#back {
background-color: white;
display: flex;
flex-direction: row;
gap: 0.5rem;
}
#back h1,
#back h2 {
font-weight: bold;
font-size: 16pt;
}
#back ul {
padding-left: 1rem;
}
.space-y-0\.5 > :not(:last-child) {
margin-bottom: 0.125rem;
}
.space-y-2\.5 > :not(:last-child) {
margin-bottom: 0.625rem;
}
</style>
</head>
<body>
<section id="cover">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1072.73 329.6"
style="width: 14rem"
>
<g>
<g>
<path
fill="#8cd366"
d="M152.18,217.62l-68.61-30.91c-1.88-1.2-4.28-1.2-6.16,0l-68.61,30.91c-3.81,2.43-8.8-.3-8.8-4.82V8.98C0,5.82,2.56,3.26,5.72,3.26h149.54c3.16,0,5.72,2.56,5.72,5.72v203.81c0,4.52-5,7.26-8.8,4.82Z"
></path>
<path
fill="#2e3524"
d="M93.97,74.01H26.61v20.16h67.36v-20.16Z"
></path>
<path
fill="#2e3524"
d="M107.44,111.16H26.61v26.8h80.83v-26.8Z"
></path>
<path
fill="#2e3524"
d="M107.44,30.27H26.61v26.8h80.83v-26.8Z"
></path>
<path
fill="#2e3524"
d="M134.38,131.23c0-3.72-3.02-6.73-6.73-6.73s-6.73,3.02-6.73,6.73,3.02,6.73,6.73,6.73,6.73-3.02,6.73-6.73Z"
></path>
</g>
<g>
<path
fill="#f9f7e8"
d="M244.7,3.24h92.33v44.43h-44.15v88.85h39.38v39.62h-39.38v105.77h44.15v44.42h-92.33V3.24Z"
></path>
<path
fill="#f9f7e8"
d="M362.72,3.24h57.79c10.71,0,20.47,2.35,29.29,7.06,8.83,4.7,15.79,11.18,20.87,19.39,5.08,8.21,7.63,17.45,7.63,27.67v214.88c0,10.22-2.48,19.46-7.42,27.67-4.96,8.21-11.83,14.69-20.68,19.39-8.83,4.7-18.73,7.08-29.7,7.08h-57.79V3.24ZM427.55,283.88c1.74-1.87,2.6-4.18,2.6-6.86V52.56c-.26-2.69-1.34-4.97-3.22-6.86-1.88-1.87-4.15-2.83-6.82-2.83h-14v243.85h14.41c2.93,0,5.27-.94,7.01-2.83h.02Z"
></path>
<path
fill="#f9f7e8"
d="M531.5,322.49c-8.71-4.7-15.6-11.16-20.68-19.39-5.08-8.21-7.63-17.42-7.63-27.67V3.24h48.15v279.41c0,2.69.93,4.99,2.82,6.86,1.86,1.9,4.15,2.83,6.82,2.83,2.93,0,5.27-.94,7.01-2.83,1.74-1.87,2.6-4.18,2.6-6.86V3.24h48.16v272.21c0,10.25-2.48,19.46-7.42,27.67-4.96,8.21-11.83,14.69-20.68,19.39-8.85,4.7-18.73,7.08-29.7,7.08s-20.8-2.35-29.5-7.08l.05-.02Z"
></path>
<path
fill="#f9f7e8"
d="M672.79,322.49c-8.7-4.7-15.6-11.16-20.68-19.39-5.08-8.21-7.63-17.42-7.63-27.67v-78.75h48.16v85.95c0,2.69.93,4.99,2.82,6.87,1.86,1.9,4.15,2.83,6.82,2.83,2.93,0,5.27-.94,7.01-2.83,1.74-1.87,2.6-4.18,2.6-6.87v-77.88c0-5.66-2.22-10.3-6.63-13.94-4.41-3.62-11.57-8.02-21.47-13.13-8.3-4.3-15.05-8.14-20.27-11.52-5.22-3.36-9.71-7.87-13.45-13.54-3.75-5.66-5.63-12.24-5.63-19.8V54.12c0-10.22,2.53-19.44,7.63-27.67,5.08-8.21,11.97-14.66,20.68-19.39,8.68-4.7,18.53-7.06,29.5-7.06s20.87,2.35,29.69,7.06c8.83,4.7,15.72,11.18,20.68,19.39,4.96,8.21,7.42,17.45,7.42,27.67v71.09h-48.16V46.92c0-2.69-.88-4.97-2.6-6.86-1.74-1.87-4.08-2.83-7.01-2.83-2.67,0-4.96.94-6.82,2.83-1.89,1.9-2.82,4.18-2.82,6.86v69.79c0,6.19,2.34,11.26,7.04,15.14,4.67,3.91,12.24,8.83,22.68,14.74,8.04,4.32,14.57,8.09,19.68,11.3,5.08,3.24,9.37,7.46,12.83,12.72,3.48,5.26,5.22,11.26,5.22,17.98v86.83c0,10.25-2.48,19.46-7.42,27.67-4.96,8.21-11.83,14.69-20.68,19.39-8.85,4.71-18.72,7.08-29.7,7.08s-20.8-2.35-29.5-7.08Z"
></path>
<path
fill="#f9f7e8"
d="M784.56,3.24h92.33v44.43h-44.15v88.85h39.38v39.62h-39.38v105.77h44.15v44.42h-92.33V3.24Z"
></path>
<path
fill="#f9f7e8"
d="M920.63,322.49c-5.63-4.18-10.11-10.1-13.45-17.76-3.34-7.68-5.01-16.49-5.01-26.45V53.71c0-9.96,2.53-19.06,7.63-27.26,5.08-8.21,12.05-14.66,20.87-19.39,8.83-4.7,18.6-7.06,29.32-7.06s20.54,2.35,29.5,7.06c8.97,4.7,15.91,11.18,20.87,19.39,4.96,8.21,7.42,17.3,7.42,27.26v94.51h-48.16V46.92c0-2.69-.88-4.97-2.6-6.86-1.74-1.87-4.08-2.83-7.01-2.83-2.67,0-4.96.94-6.82,2.83-1.89,1.9-2.82,4.18-2.82,6.86v231.36c0,2.69.93,4.99,2.82,6.87,1.86,1.9,4.15,2.83,6.82,2.83,2.93,0,5.27-.94,7.01-2.83,1.74-1.87,2.6-4.18,2.6-6.87v-46.03h-11.64v-51.29h59.8v145.4h-48.16v-14.14c-2.96,5.4-6.82,9.48-11.64,12.31-4.82,2.83-10.83,4.25-18.06,4.25s-13.64-2.09-19.27-6.26l-.02-.02Z"
></path>
<path
fill="#f9f7e8"
d="M1053.27,25.05h-6.13l-.06-3.69h5.48c.83-.02,1.61-.15,2.33-.4.72-.27,1.3-.64,1.73-1.14.44-.51.65-1.14.65-1.87,0-.93-.16-1.67-.48-2.22-.3-.55-.83-.94-1.59-1.16-.74-.25-1.74-.37-3.01-.37h-3.78v20.42h-4.12V10.54h7.9c1.87,0,3.49.27,4.86.82,1.38.53,2.44,1.34,3.18,2.44.76,1.08,1.14,2.43,1.14,4.06,0,1.02-.24,1.93-.71,2.73-.47.8-1.17,1.49-2.1,2.07-.91.57-2.03,1.03-3.35,1.39-.06,0-.12.07-.2.2-.06.13-.11.2-.17.2-.32.19-.53.33-.63.43-.08.08-.16.12-.25.14-.08.02-.31.03-.68.03ZM1052.99,25.05l.6-2.81c2.95,0,4.97.64,6.05,1.93,1.08,1.27,1.62,2.89,1.62,4.86v1.53c0,.7.03,1.37.08,2.02.08.62.21,1.15.4,1.59v.45h-4.23c-.19-.49-.3-1.19-.34-2.1-.02-.91-.03-1.57-.03-1.99v-1.48c0-1.38-.31-2.39-.94-3.04s-1.69-.97-3.21-.97ZM1035.75,22.92c0,2.52.43,4.87,1.28,7.04.87,2.16,2.08,4.05,3.64,5.68,1.55,1.61,3.34,2.87,5.37,3.78,2.05.89,4.22,1.33,6.53,1.33s4.51-.44,6.53-1.33c2.03-.91,3.8-2.17,5.34-3.78,1.53-1.63,2.74-3.52,3.61-5.68.87-2.18,1.31-4.52,1.31-7.04s-.44-4.86-1.31-7.01c-.87-2.16-2.07-4.04-3.61-5.65-1.53-1.61-3.31-2.86-5.34-3.75-2.03-.91-4.2-1.36-6.53-1.36s-4.49.45-6.53,1.36c-2.03.89-3.81,2.14-5.37,3.75-1.55,1.61-2.77,3.49-3.64,5.65-.85,2.16-1.28,4.5-1.28,7.01ZM1032.4,22.92c0-3.01.52-5.8,1.56-8.38,1.04-2.57,2.49-4.82,4.34-6.73,1.86-1.93,4-3.43,6.42-4.49,2.44-1.08,5.06-1.62,7.84-1.62s5.39.54,7.81,1.62c2.44,1.06,4.58,2.56,6.42,4.49,1.86,1.91,3.31,4.16,4.35,6.73,1.06,2.57,1.59,5.37,1.59,8.38s-.53,5.8-1.59,8.38c-1.04,2.57-2.49,4.84-4.35,6.79-1.83,1.93-3.97,3.44-6.42,4.52-2.42,1.08-5.03,1.62-7.81,1.62s-5.39-.54-7.84-1.62c-2.42-1.08-4.56-2.58-6.42-4.52-1.85-1.95-3.3-4.21-4.34-6.79-1.04-2.57-1.56-5.37-1.56-8.38Z"
></path>
</g>
</g>
</svg>
<p>Certificamos que</p>
<h1>{{ name }}</h1>
<p>
Portador(a) do CPF <strong>{{ cpf }} </strong>, concluiu o curso
de <strong>NR-10 Complementar (SEP)</strong> com aproveitamento
de
<strong>{{ progress }}%</strong>
</p>
<p>Realizado entre {{ started_date }} e {{ finished_date }}</p>
<p>Florianópolis, SC, {{ today }}</p>
<div class="signatures">
<div class="sign1"></div>
<div class="sign2">
<p>Tiago Maciel do Santos</p>
<p>CEO/Diretor</p>
</div>
</div>
<div class="qrcode">
<img src="{{ qrcode }}" />
</div>
</section>
<section id="back">
<div class="space-y-2.5">
<h1>Conteúdo programático ministrado</h1>
<ul>
<li>Organização do sistema elétrico de potência</li>
<li>Organização do trabalho</li>
<li>Aspectos comportamentais</li>
<li>Condições impeditivas para serviços</li>
<li>Riscos típicos no SEP e sua prevenção</li>
<li>Técnicas de análise de riscos no SEP</li>
<li>Procedimentos de trabalho (análise e discussão)</li>
<li>Técnicas de análise de riscos no SEP</li>
<li>Equipamentos e ferramentas de trabalho</li>
<li>Sistemas de proteção coletiva</li>
<li>Equipamentos de proteção individual</li>
<li>Posturas e vestuários de trabalhos</li>
<li>
Segurança com veículos e transporte de pessoas,
materiais e equipamentos
</li>
<li>Sinalização e isolamento de áreas de trabalho</li>
<li>
Liberação de instalação para serviço, operação e uso
</li>
<li>
Treinamento em técnicas de remoção, atendimento e
transporte de acidentados
</li>
<li>Acidentes típicos</li>
<li>Responsabilidades</li>
</ul>
</div>
<div class="space-y-2.5">
<dd class="space-y-0.5">
<h2>Carga horária</h2>
<p>40 horas</p>
</dd>
<dd class="space-y-0.5">
<h2>Instrutor e responsável técnico</h2>
<div>
<p>Francis Ricardo Baretta</p>
<p>CPF 039.539.409-02</p>
<p>Eng. de Segurança no Trabalho Eng. Eletricista</p>
<p>CREA/SC 126693-0</p>
</div>
</dd>
</div>
</section>
</body>
</html>

View File

@@ -0,0 +1,3 @@
{"id": {"S": "47ZxxcVBjvhDS5TE98tpfQ"}, "sk": {"S": "0"}, "course": {"M": {"id": {"S": "42"}, "name": {"S": "NR-35 Segurança nos Trabalhos em Altura (Teórico)"},"time_in_days": {"N": "720"}}},"create_date": {"S": "2025-04-10T11:58:33.303347-03:00"},"konviva:id": {"N": "238662"},"progress": {"N": "16.67"},"score": {"NULL": true},"status": {"S": "IN_PROGRESS"}, "update_date": {"S": "2025-04-10T15:44:03.023054-03:00"}, "user": {"M": {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "cpf": {"S": "07879819908"}, "email": {"S": "sergio@somosbeta.com.br"}, "name": {"S": "Sérgio Rafael Siqueira"}}}}
{"id": {"S": "47ZxxcVBjvhDS5TE98tpfQ"}, "sk": {"S": "konviva"}, "create_date": {"S": "2025-04-10T11:58:35.035729-03:00"}, "konviva_id": {"N": "238662"}}
{"id": {"S": "47ZxxcVBjvhDS5TE98tpfQ"}, "sk": {"S": "tenant"}, "create_date": {"S": "2025-04-10T11:58:33.303347-03:00"}, "name": {"S": "Beta Educação"},"org_id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}}

1113
enrollments-events/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

View File

5
order-events/Makefile Normal file
View File

@@ -0,0 +1,5 @@
build:
sam build --use-container
deploy: build
sam deploy --debug

View File

@@ -0,0 +1,13 @@
import os
import boto3
def get_dynamodb_client():
if os.getenv('AWS_LAMBDA_FUNCTION_NAME'):
return boto3.client('dynamodb')
return boto3.client('dynamodb', endpoint_url='http://localhost:8000')
dynamodb_client = get_dynamodb_client()

View File

@@ -0,0 +1,5 @@
import os
USER_TABLE: str = os.getenv('USER_TABLE') # type: ignore
ORDER_TABLE: str = os.getenv('ORDER_TABLE') # type: ignore
ENROLLMENT_TABLE: str = os.getenv('ENROLLMENT_TABLE') # type: ignore

View File

View File

@@ -0,0 +1,78 @@
from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.data_classes import (
EventBridgeEvent,
event_source,
)
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dateutils import now
from layercake.dynamodb import (
DynamoDBPersistenceLayer,
KeyPair,
SortKey,
)
from boto3clients import dynamodb_client
from config import ORDER_TABLE, USER_TABLE
logger = Logger(__name__)
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
@event_source(data_class=EventBridgeEvent)
@logger.inject_lambda_context
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
new_image = event.detail['new_image']
now_ = now()
ids = user_layer.collection.get_items(
KeyPair(
pk='cnpj',
sk=SortKey(new_image['cnpj'], path_spec='user_id'),
rename_key='org_id',
)
+ KeyPair(
pk='email',
sk=SortKey(new_image['email'], path_spec='user_id'),
rename_key='user_id',
),
flatten_top=False,
)
# Sometimes the function executes before the user insertion completes,
# so an exception is raised to trigger a retry.
if len(ids) < 2:
raise ValueError('IDs not found.')
with order_layer.transact_writer() as transact:
transact.update(
key=KeyPair(new_image['id'], '0'),
update_expr='SET metadata__tenant_id = :tenant_id, \
metadata__related_ids = :related_ids, \
update_date = :update_date',
expr_attr_values={
':tenant_id': ids['org_id'],
':related_ids': set(ids.values()),
':update_date': now_,
},
)
transact.put(
item={
'id': new_image['id'],
'sk': 'metadata#tenant',
'tenant_id': f'ORG#{ids["org_id"]}',
'create_date': now_,
}
)
for k, v in ids.items():
kind = k.removesuffix('_id')
transact.put(
item={
'id': new_image['id'],
'sk': f'related_ids#{kind}', # e.g. related_ids#user
'create_date': now_,
k: v,
}
)
return True

View File

@@ -0,0 +1,57 @@
from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.data_classes import (
EventBridgeEvent,
event_source,
)
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import (
ComposeKey,
DynamoDBPersistenceLayer,
KeyPair,
SortKey,
)
from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE, ORDER_TABLE
logger = Logger(__name__)
enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
class TenantDoesNotExistError(Exception):
def __init__(self, *args):
super().__init__('Tenant does not exist')
@event_source(data_class=EventBridgeEvent)
@logger.inject_lambda_context
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
new_image = event.detail['new_image']
order_id = new_image['id']
tenant_id = order_layer.collection.get_item(
KeyPair(
order_id,
SortKey('metadata#tenant', path_spec='tenant_id'),
),
exc_cls=TenantDoesNotExistError,
)
result = enrollment_layer.collection.query(
KeyPair(
# Post-migration: rename `vacancies` to `slots`
ComposeKey(tenant_id, prefix='vacancies'),
order_id,
)
)
with enrollment_layer.batch_writer() as batch:
for pair in result['items']:
batch.delete_item(
Key={
# Post-migration: rename `vacancies` to `slots`
'id': {'S': ComposeKey(pair['id'], prefix='vacancies')},
'sk': {'S': pair['sk']},
}
)
return True

View File

@@ -0,0 +1,4 @@
"""
Stopgap events. Everything here is a quick fix and should be replaced with
proper solutions.
"""

View File

@@ -0,0 +1,71 @@
from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.data_classes import (
EventBridgeEvent,
event_source,
)
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import (
ComposeKey,
DynamoDBPersistenceLayer,
KeyPair,
SortKey,
TransactKey,
)
from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE, ORDER_TABLE, USER_TABLE
logger = Logger(__name__)
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
@event_source(data_class=EventBridgeEvent)
@logger.inject_lambda_context
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
"""Remove slots if the Tenant has a `metadata#billing_policy` and
the order is positive."""
new_image = event.detail['new_image']
order_id = new_image['id']
data = order_layer.collection.get_items(
TransactKey(order_id)
+ SortKey('0')
+ KeyPair(
pk=order_id,
sk=SortKey(
sk='metadata#tenant',
path_spec='tenant_id',
remove_prefix='metadata#',
),
rename_key='tenant_id',
)
)
tenant_id = data['tenant_id'].removeprefix('ORG#')
policy = user_layer.collection.get_item(
KeyPair(pk=tenant_id, sk='metadata#billing_policy'),
raise_on_error=False,
default=False,
)
# Skip if missing billing policy or order is zero/negative
if not policy or data['total'] <= 0:
return False
result = enrollment_layer.collection.query(
KeyPair(
ComposeKey(tenant_id, prefix='vacancies'),
order_id,
)
)
with enrollment_layer.batch_writer() as batch:
for pair in result['items']:
batch.delete_item(
Key={
'id': {'S': ComposeKey(pair['id'], prefix='vacancies')},
'sk': {'S': pair['sk']},
}
)
return True

View File

@@ -0,0 +1,46 @@
from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.data_classes import (
EventBridgeEvent,
event_source,
)
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dateutils import now
from layercake.dynamodb import (
DynamoDBPersistenceLayer,
KeyPair,
)
from boto3clients import dynamodb_client
from config import ORDER_TABLE
logger = Logger(__name__)
order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
@event_source(data_class=EventBridgeEvent)
@logger.inject_lambda_context
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
new_image = event.detail['new_image']
now_ = now()
with order_layer.transact_writer() as transact:
transact.update(
key=KeyPair(new_image['id'], '0'),
update_expr='SET #status = :status, update_date = :update_date',
expr_attr_names={
'#status': 'status',
},
expr_attr_values={
':status': 'PAID',
':update_date': now_,
},
)
transact.put(
item={
'id': new_image['id'],
'sk': 'paid_date',
'create_date': now_,
}
)
return True

View File

@@ -0,0 +1,33 @@
[project]
name = "orders-events"
version = "0.1.0"
description = ""
readme = ""
requires-python = ">=3.13"
dependencies = ["layercake"]
[dependency-groups]
dev = [
"jsonlines>=4.0.0",
"pytest>=8.3.4",
"pytest-cov>=6.0.0",
"ruff>=0.9.1",
]
[tool.pytest.ini_options]
pythonpath = ["app/"]
addopts = "--cov --cov-report html -v"
[tool.ruff]
target-version = "py311"
src = ["app"]
[tool.ruff.format]
quote-style = "single"
[tool.ruff.lint]
select = ["E", "F", "I"]
[tool.uv.sources]
layercake = { path = "../layercake" }

View File

@@ -0,0 +1,3 @@
{
"extraPaths": ["app/"]
}

View File

@@ -0,0 +1,9 @@
version = 0.1
[default.deploy.parameters]
stack_name = "saladeaula-orders-events"
resolve_s3 = true
s3_prefix = "orders-events"
region = "sa-east-1"
confirm_changeset = false
capabilities = "CAPABILITY_IAM"
image_repositories = []

139
order-events/template.yaml Normal file
View File

@@ -0,0 +1,139 @@
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Parameters:
UserTable:
Type: String
Default: betaeducacao-prod-users_d2o3r5gmm4it7j
EnrollmentTable:
Type: String
Default: betaeducacao-prod-enrollments
OrderTable:
Type: String
Default: betaeducacao-prod-orders
Globals:
Function:
CodeUri: app/
Runtime: python3.13
Tracing: Active
Architectures:
- x86_64
Layers:
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:79
Environment:
Variables:
TZ: America/Sao_Paulo
LOG_LEVEL: DEBUG
DYNAMODB_PARTITION_KEY: id
POWERTOOLS_LOGGER_SAMPLE_RATE: 0.1
POWERTOOLS_LOGGER_LOG_EVENT: true
USER_TABLE: !Ref UserTable
ORDER_TABLE: !Ref OrderTable
ENROLLMENT_TABLE: !Ref EnrollmentTable
Resources:
EventLog:
Type: AWS::Logs::LogGroup
Properties:
RetentionInDays: 90
EventAssignTenantCnpjFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.assign_tenant_cnpj.lambda_handler
LoggingConfig:
LogGroup: !Ref EventLog
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref UserTable
- DynamoDBCrudPolicy:
TableName: !Ref OrderTable
Events:
Event:
Type: EventBridgeRule
Properties:
Pattern:
resources: [!Ref OrderTable]
detail-type: [INSERT]
detail:
new_image:
sk: ["0"]
cnpj:
- exists: true
metadata__tenant_id:
- exists: false
EventRemoveSlotsOnCanceledFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.delete_slots_on_canceled.lambda_handler
LoggingConfig:
LogGroup: !Ref EventLog
Policies:
- DynamoDBWritePolicy:
TableName: !Ref OrderTable
- DynamoDBWritePolicy:
TableName: !Ref EnrollmentTable
Events:
Event:
Type: EventBridgeRule
Properties:
Pattern:
resources: [!Ref OrderTable]
detail-type: [MODIFY]
detail:
new_image:
sk: ["0"]
cnpj:
- exists: true
status: [CANCELED, EXPIRED]
EventSetAsPaidFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.stopgap.set_as_paid.lambda_handler
LoggingConfig:
LogGroup: !Ref EventLog
Policies:
- DynamoDBWritePolicy:
TableName: !Ref OrderTable
Events:
Event:
Type: EventBridgeRule
Properties:
Pattern:
resources: [!Ref OrderTable]
detail-type: [INSERT]
detail:
new_image:
sk: ["0"]
cnpj:
- exists: true
total: [0]
status: [CREATING, PENDING]
payment_method: [MANUAL]
EventRemoveSlotsFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.stopgap.remove_slots.lambda_handler
LoggingConfig:
LogGroup: !Ref EventLog
Policies:
- DynamoDBReadPolicy:
TableName: !Ref UserTable
- DynamoDBReadPolicy:
TableName: !Ref OrderTable
- DynamoDBCrudPolicy:
TableName: !Ref EnrollmentTable
Events:
DynamoDBEvent:
Type: EventBridgeRule
Properties:
Pattern:
resources: [!Ref OrderTable]
detail:
new_image:
sk: [generated_items]
status: [SUCCESS]

View File

View File

@@ -0,0 +1,72 @@
import os
from dataclasses import dataclass
import jsonlines
import pytest
PYTEST_TABLE_NAME = 'pytest'
PK = 'id'
SK = 'sk'
# https://docs.pytest.org/en/7.1.x/reference/reference.html#pytest.hookspec.pytest_configure
def pytest_configure():
os.environ['TZ'] = 'America/Sao_Paulo'
os.environ['DYNAMODB_PARTITION_KEY'] = PK
os.environ['DYNAMODB_SORT_KEY'] = SK
os.environ['USER_TABLE'] = PYTEST_TABLE_NAME
os.environ['COURSE_TABLE'] = PYTEST_TABLE_NAME
os.environ['ENROLLMENT_TABLE'] = PYTEST_TABLE_NAME
os.environ['ORDER_TABLE'] = PYTEST_TABLE_NAME
@dataclass
class LambdaContext:
function_name: str = 'test'
memory_limit_in_mb: int = 128
invoked_function_arn: str = 'arn:aws:lambda:eu-west-1:809313241:function:test'
aws_request_id: str = '52fdfc07-2182-154f-163f-5f0f9a621d72'
@pytest.fixture
def lambda_context() -> LambdaContext:
return LambdaContext()
@pytest.fixture
def dynamodb_client():
from boto3clients import dynamodb_client as client
client.create_table(
AttributeDefinitions=[
{'AttributeName': PK, 'AttributeType': 'S'},
{'AttributeName': SK, 'AttributeType': 'S'},
],
TableName=PYTEST_TABLE_NAME,
KeySchema=[
{'AttributeName': PK, 'KeyType': 'HASH'},
{'AttributeName': SK, 'KeyType': 'RANGE'},
],
ProvisionedThroughput={
'ReadCapacityUnits': 123,
'WriteCapacityUnits': 123,
},
)
yield client
client.delete_table(TableName=PYTEST_TABLE_NAME)
@pytest.fixture()
def dynamodb_persistence_layer(dynamodb_client):
from layercake.dynamodb import DynamoDBPersistenceLayer
return DynamoDBPersistenceLayer(PYTEST_TABLE_NAME, dynamodb_client)
@pytest.fixture()
def dynamodb_seeds(dynamodb_client):
with jsonlines.open('tests/seeds.jsonl') as lines:
for line in lines:
dynamodb_client.put_item(TableName=PYTEST_TABLE_NAME, Item=line)

View File

View File

@@ -0,0 +1,30 @@
from layercake.dynamodb import PartitionKey
import events.stopgap.remove_slots as app
from ...conftest import LambdaContext
def test_remove_slots(
dynamodb_seeds,
dynamodb_persistence_layer,
lambda_context: LambdaContext,
):
event = {
'detail': {
'new_image': {
'id': '9omWNKymwU5U4aeun6mWzZ',
'sk': 'generated_items',
'create_date': '2024-07-23T20:43:37.303418-03:00',
'status': 'SUCCESS',
'scope': 'MILTI_USER',
}
},
}
assert app.lambda_handler(event, lambda_context) # type: ignore
result = dynamodb_persistence_layer.collection.query(
PartitionKey('vacancies#cJtK9SsnJhKPyxESe7g3DG')
)
assert len(result['items']) == 0

View File

@@ -0,0 +1,24 @@
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
import events.stopgap.set_as_paid as app
def test_set_as_paid(
dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext,
):
event = {
'detail': {
'new_image': {
'id': '9omWNKymwU5U4aeun6mWzZ',
}
}
}
assert app.lambda_handler(event, lambda_context) # type: ignore
doc = dynamodb_persistence_layer.get_item(
key=KeyPair('9omWNKymwU5U4aeun6mWzZ', '0'),
)
assert doc['status'] == 'PAID'

View File

@@ -0,0 +1,28 @@
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey
import events.assign_tenant_cnpj as app
def test_assign_tenant_cnpj(
dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext,
):
event = {
'detail': {
'new_image': {
'id': '9omWNKymwU5U4aeun6mWzZ',
'cnpj': '15608435000190',
'email': 'sergio@somosbeta.com.br',
}
}
}
assert app.lambda_handler(event, lambda_context) # type: ignore
result = dynamodb_persistence_layer.collection.query(
PartitionKey('9omWNKymwU5U4aeun6mWzZ')
)
assert 4 == len(result['items'])

View File

@@ -0,0 +1,29 @@
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey
import events.assign_tenant_cnpj as app
def test_assign_tenant_cnpj(
dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext,
):
event = {
'detail': {
'new_image': {
'id': '9omWNKymwU5U4aeun6mWzZ',
'cnpj': '15608435000190',
'email': 'sergio@somosbeta.com.br',
}
}
}
assert app.lambda_handler(event, lambda_context) # type: ignore
result = dynamodb_persistence_layer.collection.query(
PartitionKey('9omWNKymwU5U4aeun6mWzZ')
)
assert 4 == len(result['items'])
print(result['items'])

View File

@@ -0,0 +1,27 @@
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey
import events.remove_slots_on_canceled as app
def test_delete_slots_on_canceled(
dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
lambda_context: LambdaContext,
):
event = {
'detail': {
'new_image': {
'id': '9omWNKymwU5U4aeun6mWzZ',
'status': 'CANCELED',
}
}
}
assert app.lambda_handler(event, lambda_context) # type: ignore
result = dynamodb_persistence_layer.collection.query(
PartitionKey('vacancies#cJtK9SsnJhKPyxESe7g3DG')
)
assert len(result['items']) == 0

View File

@@ -0,0 +1,10 @@
{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "metadata#payment_policy"}, "due_days": {"N": "90"}}
{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "metadata#billing_policy"}, "billing_day": {"N": "1"}, "payment_method": {"S": "PIX"}}
{"id": {"S": "9omWNKymwU5U4aeun6mWzZ"}, "sk": {"S": "0"}, "total": {"N": "398"}, "status": {"S": "PENDING"}}
{"id": {"S": "9omWNKymwU5U4aeun6mWzZ"}, "sk": {"S": "metadata#tenant"}, "tenant_id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}}
{"id": {"S": "cnpj"}, "sk": {"S": "15608435000190"}, "user_id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}}
{"id": {"S": "email"}, "sk": {"S": "sergio@somosbeta.com.br"}, "user_id": {"S": "5OxmMjL-ujoR5IMGegQz"}}
{"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "0"}, "name": {"S": "Sérgio R Siqueira"}}
{"id": {"S": "vacancies#cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "9omWNKymwU5U4aeun6mWzZ#1"}}
{"id": {"S": "vacancies#cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "9omWNKymwU5U4aeun6mWzZ#2"}}
{"id": {"S": "vacancies#cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "9omWNKymwU5U4aeun6mWzZ#3"}}

1113
order-events/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

5
users-events/Makefile Normal file
View File

@@ -0,0 +1,5 @@
build:
sam build --use-container
deploy: build
sam deploy --debug

View File

@@ -0,0 +1,14 @@
import os
import boto3
def get_dynamodb_client():
if os.getenv('AWS_LAMBDA_FUNCTION_NAME'):
return boto3.client('dynamodb')
return boto3.client('dynamodb', endpoint_url='http://127.0.0.1:8000')
dynamodb_client = get_dynamodb_client()
s3_client = boto3.client('s3')

View File

@@ -0,0 +1,4 @@
import os
USER_TABLE: str = os.getenv('USER_TABLE') # type: ignore
CHUNK_SIZE = 50

View File

@@ -0,0 +1,83 @@
import csv
from typing import TextIO
from smart_open import open
def byte_ranges(
csvfile: str,
chunk_size: int = 100,
**kwargs,
) -> list[tuple[int, int]]:
"""Compute byte ranges for reading a CSV file in fixed-size line chunks.
Returns pairs (start_byte, end_byte) for each fixed-size group of lines.
Parameters
----------
csvfile : str
Path to the CSV file, opened in binary mode internally.
chunk_size : int, optional
Number of lines per chunk. Default is 100.
**kwargs :
Extra options passed to `open()`, e.g., buffering.
Returns
-------
list of tuple[int, int]
Byte ranges covering each chunk of lines.
Example
-------
>>> byte_ranges("users.csv", chunk_size=500)
[(0, 3125), (3126, 6150), (6151, 9124)]
"""
line_offsets = [0]
with open(csvfile, 'rb', **kwargs) as fp:
while True:
if not fp.readline():
break
line_offsets.append(fp.tell())
total_lines = len(line_offsets) - 1
byte_ranges = []
for start_line in range(1, total_lines + 1, chunk_size):
# Calculate the end line index, bounded by total lines
end_line = min(start_line + chunk_size - 1, total_lines)
# Get byte range for this chunk
start_byte = line_offsets[start_line - 1]
end_byte = line_offsets[end_line] - 1
byte_ranges.append((start_byte, end_byte))
return byte_ranges
def detect_delimiter(sample: TextIO) -> str:
"""Detect the delimiter character used in a CSV file.
Parameters
----------
sample : TextIO
A file-like object opened in text mode (e.g., from `open('file.csv')`).
Must be readable and at position 0.
Returns
-------
str
The detected delimiter character (e.g., ',', ';', '\\t').
Raises
------
csv.Error
If the file cannot be parsed as CSV or delimiter detection fails.
ValueError
If the file is empty or contains no detectable delimiter.
"""
sniffer = csv.Sniffer()
dialect = sniffer.sniff(sample.read())
sample.seek(0)
return dialect.delimiter

View File

View File

@@ -0,0 +1,20 @@
from aws_lambda_powertools.utilities.data_classes import (
EventBridgeEvent,
event_source,
)
from aws_lambda_powertools.utilities.typing import LambdaContext
from boto3clients import s3_client
from config import CHUNK_SIZE
from csv_utils import byte_ranges
transport_params = {'client': s3_client}
@event_source(data_class=EventBridgeEvent)
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
new_image = event.detail['new_image']
csvfile = new_image['s3uri']
pairs = byte_ranges(csvfile, CHUNK_SIZE, transport_params=transport_params)
return True

View File

@@ -0,0 +1,14 @@
from aws_lambda_powertools.utilities.data_classes import (
EventBridgeEvent,
event_source,
)
from aws_lambda_powertools.utilities.typing import LambdaContext
from boto3clients import s3_client
transport_params = {'client': s3_client}
@event_source(data_class=EventBridgeEvent)
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
return True

View File

@@ -0,0 +1,55 @@
import csv
from io import StringIO
from typing import TYPE_CHECKING
from aws_lambda_powertools.utilities.data_classes import (
EventBridgeEvent,
event_source,
)
from aws_lambda_powertools.utilities.typing import LambdaContext
from boto3clients import s3_client
if TYPE_CHECKING:
from mypy_boto3_s3.client import S3Client
else:
S3Client = object
transport_params = {'client': s3_client}
@event_source(data_class=EventBridgeEvent)
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
new_image = event.detail['new_image']
csvfile = new_image['s3_uri']
data = _get_s3_object_range(
csvfile,
start_byte=new_image['start_byte'],
end_byte=new_image['end_byte'],
s3_client=s3_client,
)
reader = csv.reader(data)
for x in reader:
print(x)
return True
def _get_s3_object_range(
s3_uri: str,
*,
start_byte: int,
end_byte: int,
s3_client: S3Client,
) -> StringIO:
bucket, key = s3_uri.replace('s3://', '').split('/', 1)
response = s3_client.get_object(
Bucket=bucket,
Key=key,
Range=f'bytes={start_byte}-{end_byte}',
)
return StringIO(response['Body'].read().decode('utf-8'))

View File

@@ -0,0 +1,40 @@
import urllib.parse as urllib_parse
from email.utils import parseaddr
from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.data_classes import SESEvent, event_source
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey
from boto3clients import dynamodb_client
from config import USER_TABLE
from ses_utils import get_header_value
logger = Logger(__name__)
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
@logger.inject_lambda_context
@event_source(data_class=SESEvent)
def lambda_handler(event: SESEvent, context: LambdaContext) -> dict:
ses = event.record.ses
to = urllib_parse.unquote(ses.receipt.recipients[0]).lower()
name, email_from = parseaddr(get_header_value(ses.mail.headers, 'from'))
org_id = user_layer.collection.get_item(
KeyPair('email', SortKey(to, path_spec='user_id')),
raise_on_error=False,
default={},
)
if not org_id:
return {'disposition': 'STOP_RULE_SET'}
print(
{
'id': f'mailbox#{org_id}',
'sk': ses.mail.message_id,
}
)
return {'disposition': 'CONTINUE'}

View File

@@ -0,0 +1,20 @@
from typing import Any, Iterator
from aws_lambda_powertools.utilities.data_classes.ses_event import SESMailHeader
def get_header_value(
headers: Iterator[SESMailHeader],
header_name: str,
*,
default: Any = None,
raise_on_missing: bool = True,
) -> str:
for header in headers:
if header.name.lower() == header_name:
return header.value
if raise_on_missing:
raise ValueError(f'{header_name} not found.')
return default

62
users-events/cf.py Normal file
View File

@@ -0,0 +1,62 @@
# /// script
# dependencies = [
# "cloudflare"
# ]
# ///
from cloudflare import Cloudflare
CLOUDFLARE_ACCOUNT_ID = '5436b62470020c04b434ad31c3e4cf4e'
CLOUDFLARE_API_TOKEN = 'gFndkBJCzH4pRX7mKXokdWfw1xhm8-9FHfvLfhwa'
client = Cloudflare(api_token=CLOUDFLARE_API_TOKEN)
assistant = """
You are a data analysis assistant specialized in identifying Brazilian
personal data from CSV files.
These CSV files may or may not include headers.
Your task is to analyze the content and identify only three possible
data types: 'name', 'cpf', and 'email'.
Ignore all other fields.
"""
csv_content = """
,RICARDO GALLES BONET,ricardo.bonet@fanucamerica.com,424.430.528-93,NR-10 (RECICLAGEM)
,RULIO SIEFERT SERA,rulio.sera@fanucamerica.com,063.916.859-08,NR-10 (RECICLAGEM)
,MACIEL FERREIRA BOMFIM,maciel.bomfim@fanucamerica.com,334.547.088-85,NR-10 (RECICLAGEM)
,JAIME EDUARDO GALVEZ AVILES,jaime.galvez@fanucamerica.com,280.238.818-50,NR-12
,JAIME EDUARDO GALVEZ AVILES,jaime.galvez@fanucamerica.com,280.238.818-50,NR-35 (RECICLAGEM)
,HIGOR MACHADO SILVA,higor.silva@fanucamerica.com,419.879.878-88,NR-12
,LÁZARO SOUZA DIAS,lazaro.dias@fanucamerica.com,067.179.825-19,NR-12
,JOÃO PEDRO AGUIAR GALASSO,joao.pedro@fanucamerica.com,570.403.588-40,NR-12
"""
prompt = f"""
Here is a CSV sample:
{csv_content}
Your task is to:
- Detect which columns most likely contain "name", "cpf", or "email".
- Skip any category that is not present in the data.
- Return ONLY a valid Python list of tuples, like:
[('name', index), ('cpf', index), ('email', index)]
- Use the column index that most likely matches each data type,
based on frequency and data format.
- Don't include explanations, code, or any additional text.
"""
r = client.ai.run(
model_name='@cf/meta/llama-3-8b-instruct',
account_id=CLOUDFLARE_ACCOUNT_ID,
messages=[
{'role': 'system', 'content': assistant},
{'role': 'user', 'content': prompt},
],
)
print(r)

View File

@@ -0,0 +1,33 @@
[project]
name = "users-events"
version = "0.1.0"
description = ""
readme = ""
requires-python = ">=3.13"
dependencies = ["layercake"]
[dependency-groups]
dev = [
"boto3-stubs[essential]>=1.38.26",
"jsonlines>=4.0.0",
"pytest>=8.3.4",
"pytest-cov>=6.0.0",
"ruff>=0.9.1",
]
[tool.pytest.ini_options]
pythonpath = ["app/"]
addopts = "--cov --cov-report html -v"
[tool.ruff]
target-version = "py311"
src = ["app"]
[tool.ruff.format]
quote-style = "single"
[tool.ruff.lint]
select = ["E", "F", "I"]
[tool.uv.sources]
layercake = { path = "../layercake" }

View File

@@ -0,0 +1,3 @@
{
"extraPaths": ["app/"]
}

View File

@@ -0,0 +1,9 @@
version = 0.1
[default.deploy.parameters]
stack_name = "saladeaula-users-events"
resolve_s3 = true
s3_prefix = "users-events"
region = "sa-east-1"
confirm_changeset = false
capabilities = "CAPABILITY_IAM"
image_repositories = []

114
users-events/template.yaml Normal file
View File

@@ -0,0 +1,114 @@
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Parameters:
BucketName:
Type: String
Default: saladeaula.digital
UserTable:
Type: String
Default: betaeducacao-prod-users_d2o3r5gmm4it7j
Globals:
Function:
CodeUri: app/
Runtime: python3.13
Tracing: Active
Architectures:
- x86_64
Layers:
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:78
Environment:
Variables:
TZ: America/Sao_Paulo
LOG_LEVEL: DEBUG
DYNAMODB_PARTITION_KEY: id
POWERTOOLS_LOGGER_SAMPLE_RATE: 0.1
POWERTOOLS_LOGGER_LOG_EVENT: true
USER_TABLE: !Ref UserTable
Resources:
EventLog:
Type: AWS::Logs::LogGroup
Properties:
RetentionInDays: 90
EventCsvChunksFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.batch.csv_chunks.lambda_handler
LoggingConfig:
LogGroup: !Ref EventLog
Policies:
- S3CrudPolicy:
BucketName: !Ref BucketName
Events:
DynamoDBEvent:
Type: EventBridgeRule
Properties:
Pattern:
resources: [betaeducacao-prod-users_d2o3r5gmm4it7j]
detail:
new_image:
sk:
- prefix: batch_jobs#
EventEmailReceivingFunction:
Type: AWS::Serverless::Function
Properties:
Handler: events.email_receiving.lambda_handler
LoggingConfig:
LogGroup: !Ref EventLog
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref UserTable
LambdaInvokePermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !GetAtt EventEmailReceivingFunction.Arn
Action: lambda:InvokeFunction
Principal: ses.amazonaws.com
SourceArn: !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:receipt-rule-set/*
BucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref BucketName
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service: ses.amazonaws.com
Action: s3:PutObject
Resource: !Sub arn:aws:s3:::${BucketName}/*
Condition:
StringEquals:
aws:SourceAccount: !Ref AWS::AccountId
StringLike:
aws:SourceArn: !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:receipt-rule-set/*
EmailReceiptRuleSet:
Type: AWS::SES::ReceiptRuleSet
Properties:
RuleSetName: users.noreply.saladeaula.digital
EmailReceiptRule:
Type: AWS::SES::ReceiptRule
DependsOn:
- LambdaInvokePermission
- BucketPolicy
Properties:
RuleSetName: !Ref EmailReceiptRuleSet
Rule:
Name: lambda
Enabled: true
Actions:
- LambdaAction:
FunctionArn: !GetAtt EventEmailReceivingFunction.Arn
InvocationType: RequestResponse
- S3Action:
BucketName: !Ref BucketName
ObjectKeyPrefix: "mailbox"
ScanEnabled: true

View File

View File

@@ -0,0 +1,69 @@
import os
from dataclasses import dataclass
import jsonlines
import pytest
PYTEST_TABLE_NAME = 'pytest'
PK = 'id'
SK = 'sk'
# https://docs.pytest.org/en/7.1.x/reference/reference.html#pytest.hookspec.pytest_configure
def pytest_configure():
os.environ['TZ'] = 'America/Sao_Paulo'
os.environ['DYNAMODB_PARTITION_KEY'] = PK
os.environ['DYNAMODB_SORT_KEY'] = SK
os.environ['USER_TABLE'] = PYTEST_TABLE_NAME
@dataclass
class LambdaContext:
function_name: str = 'test'
memory_limit_in_mb: int = 128
invoked_function_arn: str = 'arn:aws:lambda:eu-west-1:809313241:function:test'
aws_request_id: str = '52fdfc07-2182-154f-163f-5f0f9a621d72'
@pytest.fixture
def lambda_context() -> LambdaContext:
return LambdaContext()
@pytest.fixture
def dynamodb_client():
from boto3clients import dynamodb_client as client
client.create_table(
AttributeDefinitions=[
{'AttributeName': PK, 'AttributeType': 'S'},
{'AttributeName': SK, 'AttributeType': 'S'},
],
TableName=PYTEST_TABLE_NAME,
KeySchema=[
{'AttributeName': PK, 'KeyType': 'HASH'},
{'AttributeName': SK, 'KeyType': 'RANGE'},
],
ProvisionedThroughput={
'ReadCapacityUnits': 123,
'WriteCapacityUnits': 123,
},
)
yield client
client.delete_table(TableName=PYTEST_TABLE_NAME)
@pytest.fixture()
def dynamodb_persistence_layer(dynamodb_client):
from layercake.dynamodb import DynamoDBPersistenceLayer
return DynamoDBPersistenceLayer(PYTEST_TABLE_NAME, dynamodb_client)
@pytest.fixture()
def dynamodb_seeds(dynamodb_client):
with jsonlines.open('tests/seeds.jsonl') as lines:
for line in lines:
dynamodb_client.put_item(TableName=PYTEST_TABLE_NAME, Item=line)

View File

View File

@@ -0,0 +1,13 @@
import events.batch.csv_into_chunks as app
def test_chunk_csv(lambda_context):
event = {
'detail': {
'new_image': {
's3uri': 's3://saladeaula.digital/samples/large_users.csv',
},
},
}
app.lambda_handler(event, lambda_context) # type: ignore

View File

@@ -0,0 +1,136 @@
from aws_lambda_powertools.utilities.typing import LambdaContext
import events.email_receiving as app
event = {
'Records': [
{
'eventSource': 'aws:ses',
'eventVersion': '1.0',
'ses': {
'mail': {
'timestamp': '2025-05-29T15:50:41.604Z',
'source': 'sergio@somosbeta.com.br',
'messageId': '2994higq3tr7efijr3lj65etntffapgg1q7hea81',
'destination': [
'org+15608435000190@users.noreply.saladeaula.digital'
],
'headersTruncated': False,
'headers': [
{'name': 'Return-Path', 'value': '<sergio@somosbeta.com.br>'},
{
'name': 'Received',
'value': 'from mail-lf1-f54.google.com (mail-lf1-f54.google.com [209.85.167.54]) by inbound-smtp.sa-east-1.amazonaws.com with SMTP id 2994higq3tr7efijr3lj65etntffapgg1q7hea81 for org+35980592000130@users.noreply.saladeaula.digital; Thu, 29 May 2025 15:50:41 +0000 (UTC)',
},
{'name': 'X-SES-Spam-Verdict', 'value': 'PASS'},
{'name': 'X-SES-Virus-Verdict', 'value': 'PASS'},
{
'name': 'Received-SPF',
'value': 'pass (spfCheck: domain of somosbeta.com.br designates 209.85.167.54 as permitted sender) client-ip=209.85.167.54; envelope-from=sergio@somosbeta.com.br; helo=mail-lf1-f54.google.com;',
},
{
'name': 'Authentication-Results',
'value': 'amazonses.com; spf=pass (spfCheck: domain of somosbeta.com.br designates 209.85.167.54 as permitted sender) client-ip=209.85.167.54; envelope-from=sergio@somosbeta.com.br; helo=mail-lf1-f54.google.com; dkim=pass header.i=@somosbeta.com.br; dmarc=none header.from=somosbeta.com.br;',
},
{
'name': 'X-SES-RECEIPT',
'value': 'AEFBQUFBQUFBQUFHVWpuODdPY2tGUlordE5YWkVEUlZNWWZFYkpDMU5MUURyaHNVSldnTGhEVWhCQzd5UGpzWHI4LzJoS1VaN0lOU0FkMzJFU0h6MjVuUzk2c09KUXlzbUJQdHd6T0d0Y2ptZXhRVk1KY3RkOXpRamZMb3hwSGJIVlFla2tBcmZvRmYwQS9WU3hBVlBqcUpDYm00eTdiRnRqNW45ek9ld0ZyTGJKV3k2TXRpc0J6aGhBdmFvZDFDQ000Zm9QTng3VHljNXArM0hjT2ZsYkhtM3RCZnpRV1NOczU2RDdmL0RKclJOcDNvY2ZxV1hmajNYMkczVHpsWEZCMm40Z2pQM29udkMyb01vN3JwU0p2TUI1WGorN2JPd2RPYW5lUDN3T3RMRlhsdEpGbGNCa3c9PQ==',
},
{
'name': 'X-SES-DKIM-SIGNATURE',
'value': 'a=rsa-sha256; q=dns/txt; b=KPtFiBwsOTBl1YVLRTSfaZ+X6h7uSSOu/i1Cw6Pd+wvMBHRWy9EYcWUjyDjsLG/uYHShLW4+LHsSg9HiqrAP2jVJSAawrIwZr1wPQo7ovQvWuZfHQN/StgXIgBU+L7Bp6GSR26LRufxjj7q9YBmEeirjJ3d0G8E/rF2QqeITlpo=; c=relaxed/simple; s=bm3ypaoivbtdzmy3b6w37fzb5voa2uru; d=amazonses.com; t=1748533842; v=1; bh=kTUCV1DQAazu4FsUi1MrelD2QvSfHGArZ/c6A79t3/E=; h=From:To:Cc:Bcc:Subject:Date:Message-ID:MIME-Version:Content-Type:X-SES-RECEIPT;',
},
{
'name': 'Received',
'value': 'by mail-lf1-f54.google.com with SMTP id 2adb3069b0e04-54b0d638e86so1570269e87.1 for <org+35980592000130@users.noreply.saladeaula.digital>; Thu, 29 May 2025 08:50:40 -0700 (PDT)',
},
{
'name': 'DKIM-Signature',
'value': 'v=1; a=rsa-sha256; c=relaxed/relaxed; d=somosbeta.com.br; s=google; t=1748533838; x=1749138638; darn=users.noreply.saladeaula.digital; h=to:subject:message-id:date:from:in-reply-to:references:mime-version:from:to:cc:subject:date:message-id:reply-to; bh=kTUCV1DQAazu4FsUi1MrelD2QvSfHGArZ/c6A79t3/E=; b=Qi8gk/kTpwXCLDM7FPS7ULTy+9gO/4WsGL9zY1xEDw0Rp38f4rVR8L95hIhwK2daA27mq3pv9TdrK3XKQQIuSvRVvaM0b/evkZD8QhaT9tCmL0eKEBB4czGB0OSS3Q4qP34GFWMmXIaxoKIo1td76JnXbto9ZQvjUTBr3GGlF3Lm/MPTaAHs1b3dalv2diTvyj1tzoeb4wGePKsqLh5LKGOxbbWsxPeHEJ8sLM4LyJjxoqSOO0wgKdH5S/ZNpHWcJtXBntjiDUZNeQ5ucEn8ZLbADCObZZV/gH9i/cB1BmlSvJP3D07uJTAEBqyepd+W9fIW2mox/+fmOb3OEHRthQ==',
},
{
'name': 'X-Google-DKIM-Signature',
'value': 'v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20230601; t=1748533838; x=1749138638; h=to:subject:message-id:date:from:in-reply-to:references:mime-version :x-gm-message-state:from:to:cc:subject:date:message-id:reply-to; bh=kTUCV1DQAazu4FsUi1MrelD2QvSfHGArZ/c6A79t3/E=; b=UnL/uRXRahnH5uStZ276LH4kqpHigngw4iql9GHKmFaIKxJ8hLGn/wu7ie4ljnw8m/ I4CvhDKH4TVtIWPS81fm06PMgeqQYRX3jLhHvIltROCNVX6ZFzbAXgiAlk0NS1npvDYJ evVgSaPPco4D/8pMWZX4fUjU+8me32ChKxHsklEAts/LiD+MvTuHCHZovSEj1aXAz91b yGZe1bx2+phuqzUZyIOeheKjl4TNjEBx83omOzf9HtClKhjzCwHjfZ8uk2lhJ10ogKZ0 GNQ5OlnPkgdAg0/+HsifvGR6xfkFsiunIDyinBWoOhMU1o0+DiicxOIjY8QEayF3MLUt REoA==',
},
{
'name': 'X-Gm-Message-State',
'value': 'AOJu0Yxw0icQkFV090vn5hx/hKp0ePH78Wr0iqi4V3x4mpVXrRX8te2o 30aBYeZRPwn8SRSrq/kbn4bLcs5mPDB+iRP9IGFxS7KLSQi+KG4PQeDHyW3R/AgOPHACUUXUUyz Vcwny029WGY5PVhxlikAYdDfhNdO8GM2DKMV1+Oxy/a+qmt5LZeuy',
},
{
'name': 'X-Gm-Gg',
'value': 'ASbGncslCMPPU/pax0+RNy/cQR/Y/wUroSJMvI2DCCMq6Qld+Ih1jG4+HnhQPqn3nTK EEW6/99tqazq+SKy+31AB77ajVczvJQTElRSW/+bhd42l7by2hicTKElcR3GWivlrqd1TywUZOB DkB9J/vupSV0PDCJfZVi+7Tb9Pb61nnxaU+SQ=',
},
{
'name': 'X-Google-Smtp-Source',
'value': 'AGHT+IFYi41KmJjGcfQmUvWJDdTAzGIv2JlL9XAwBpAb53mMOOm3tttzkhbvfuiKh/DI9NjITHuO3xuEPqnPI9lpum8=',
},
{
'name': 'X-Received',
'value': 'by 2002:a05:6512:1392:b0:553:2f61:58f1 with SMTP id 2adb3069b0e04-5532f615a8dmr2268707e87.53.1748533837647; Thu, 29 May 2025 08:50:37 -0700 (PDT)',
},
{'name': 'MIME-Version', 'value': '1.0'},
{
'name': 'References',
'value': '<CAMThe4mV9=1-BLiOi9MU3fAS=C6uYE9+3hKUjibrwxxngYNn2Q@mail.gmail.com>',
},
{
'name': 'In-Reply-To',
'value': '<CAMThe4mV9=1-BLiOi9MU3fAS=C6uYE9+3hKUjibrwxxngYNn2Q@mail.gmail.com>',
},
{
'name': 'From',
'value': 'Sérgio Rafael Siqueira <sergio@somosbeta.com.br>',
},
{'name': 'Date', 'value': 'Thu, 29 May 2025 12:50:26 -0300'},
{
'name': 'X-Gm-Features',
'value': 'AX0GCFvofROqzf21KTgiIJq_AULCNljEuNFUJBk2xQGwVKmPjim_4slYIOP0WRw',
},
{
'name': 'Message-ID',
'value': '<CAMThe4=yMRJg4YOcACYAR509N1RyWyQgAghyVmr=NuSJnbondg@mail.gmail.com>',
},
{'name': 'Subject', 'value': 'Re: test'},
{
'name': 'To',
'value': 'org+15608435000190@users.noreply.saladeaula.digital',
},
{
'name': 'Content-Type',
'value': 'multipart/alternative; boundary="00000000000045b8c206364842b3"',
},
],
'commonHeaders': {
'returnPath': 'sergio@somosbeta.com.br',
'from': ['"Sérgio Rafael Siqueira" <sergio@somosbeta.com.br>'],
'date': 'Thu, 29 May 2025 12:50:26 -0300',
'to': ['org+15608435000190@users.noreply.saladeaula.digital'],
'messageId': '<CAMThe4=yMRJg4YOcACYAR509N1RyWyQgAghyVmr=NuSJnbondg@mail.gmail.com>',
'subject': 'Re: test',
},
},
'receipt': {
'timestamp': '2025-05-29T15:50:41.604Z',
'processingTimeMillis': 1105,
'recipients': [
'org+15608435000190@users.noreply.saladeaula.digital'
],
'spamVerdict': {'status': 'PASS'},
'virusVerdict': {'status': 'PASS'},
'spfVerdict': {'status': 'PASS'},
'dkimVerdict': {'status': 'PASS'},
'dmarcVerdict': {'status': 'GRAY'},
'action': {
'type': 'Lambda',
'functionArn': 'arn:aws:lambda:sa-east-1:336641857101:function:saladeaula-user-managemen-EventEmailReceivingFunct-LmnnEfi9tL2O',
'invocationType': 'Event',
},
},
},
}
]
}
def test_email_receiving(dynamodb_seeds, lambda_context: LambdaContext):
assert app.lambda_handler(event, lambda_context) == {'disposition': 'CONTINUE'}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
CADASTRO DE COLABORADOR,,,,
,NOME COMPLETO,EMAIL (letra minúscula),CPF,TREINAMENTO
,ANDRE HENRIQUE LOPES ZAFALON,henrique.zafalon@fanucamerica.com,261.955.138-22,NR-35 (RECICLAGEM)
,SERGIO DA SILVA CUPERTINO,sergio.cupertino@fanucamerica.com,066.945.708-64,NR-10 (RECICLAGEM)
,SERGIO DA SILVA CUPERTINO,sergio.cupertino@fanucamerica.com,066.945.708-64,NR-35 (RECICLAGEM)
,ROVANE CAMPOS,rovane.campos@fanucamerica.com,095.958.578-82,NR-10 (RECICLAGEM)
,ROVANE CAMPOS,rovane.campos@fanucamerica.com,095.958.578-82,NR-35 (RECICLAGEM)
,MARCIO ATSUSHI KANEKO MASUDA,marcio.masuda@fanucamerica.com,293.042.798-10,NR-10 (RECICLAGEM)
,FABIO AKIRA HARAGUCHI,fabio.haraguchi@fanucamerica.com,287.018.428-03,NR-10 (RECICLAGEM)
,EMIDIO YOITI MOCHIZUKI,emidio.mochizuki@fanucamerica.com,268.579.208-26,NR-10 (RECICLAGEM)
,EMIDIO YOITI MOCHIZUKI,emidio.mochizuki@fanucamerica.com,268.579.208-26,NR-35 (RECICLAGEM)
,ERIC HIDEKI MORIKIO,eric.morikio@fanucamerica.com,417.359.838-61,NR-10 (RECICLAGEM)
,HENRIQUE DE FIGUEIREDO BASTOS FERRAZ,henrique.ferraz@fanucamerica.com,417.059.788-51,NR-10 (RECICLAGEM)
,LAYS MORETTI DA SILVA,lays.silva@fanucamerica.com,013.107.662-07,NR-10 (RECICLAGEM)
,LAYS MORETTI DA SILVA,lays.silva@fanucamerica.com,013.107.662-07,NR-12
,ANDRE DE SOUZA,andre.souza@fanucamerica.com,290.688.648-31,NR-10 (RECICLAGEM)
,ANDRE DE SOUZA,andre.souza@fanucamerica.com,290.688.648-31,NR-12
,RAFAEL TOSHIO BURATO MAEDA,rafael.maeda@fanucamerica.com,394.153.268-59,NR-10 (RECICLAGEM)
,RAFAEL TOSHIO BURATO MAEDA,rafael.maeda@fanucamerica.com,394.153.268-59,NR-12
,RAFAEL TOSHIO BURATO MAEDA,rafael.maeda@fanucamerica.com,394.153.268-59,NR-35 (RECICLAGEM)
,RICARDO GALLES BONET,ricardo.bonet@fanucamerica.com,424.430.528-93,NR-10 (RECICLAGEM)
,RULIO SIEFERT SERA,rulio.sera@fanucamerica.com,063.916.859-08,NR-10 (RECICLAGEM)
,MACIEL FERREIRA BOMFIM,maciel.bomfim@fanucamerica.com,334.547.088-85,NR-10 (RECICLAGEM)
,JAIME EDUARDO GALVEZ AVILES,jaime.galvez@fanucamerica.com,280.238.818-50,NR-12
,JAIME EDUARDO GALVEZ AVILES,jaime.galvez@fanucamerica.com,280.238.818-50,NR-35 (RECICLAGEM)
,HIGOR MACHADO SILVA,higor.silva@fanucamerica.com,419.879.878-88,NR-12
,LÁZARO SOUZA DIAS,lazaro.dias@fanucamerica.com,067.179.825-19,NR-12
,JOÃO PEDRO AGUIAR GALASSO,joao.pedro@fanucamerica.com,570.403.588-40,NR-12
1 CADASTRO DE COLABORADOR
2 NOME COMPLETO EMAIL (letra minúscula) CPF TREINAMENTO
3 ANDRE HENRIQUE LOPES ZAFALON henrique.zafalon@fanucamerica.com 261.955.138-22 NR-35 (RECICLAGEM)
4 SERGIO DA SILVA CUPERTINO sergio.cupertino@fanucamerica.com 066.945.708-64 NR-10 (RECICLAGEM)
5 SERGIO DA SILVA CUPERTINO sergio.cupertino@fanucamerica.com 066.945.708-64 NR-35 (RECICLAGEM)
6 ROVANE CAMPOS rovane.campos@fanucamerica.com 095.958.578-82 NR-10 (RECICLAGEM)
7 ROVANE CAMPOS rovane.campos@fanucamerica.com 095.958.578-82 NR-35 (RECICLAGEM)
8 MARCIO ATSUSHI KANEKO MASUDA marcio.masuda@fanucamerica.com 293.042.798-10 NR-10 (RECICLAGEM)
9 FABIO AKIRA HARAGUCHI fabio.haraguchi@fanucamerica.com 287.018.428-03 NR-10 (RECICLAGEM)
10 EMIDIO YOITI MOCHIZUKI emidio.mochizuki@fanucamerica.com 268.579.208-26 NR-10 (RECICLAGEM)
11 EMIDIO YOITI MOCHIZUKI emidio.mochizuki@fanucamerica.com 268.579.208-26 NR-35 (RECICLAGEM)
12 ERIC HIDEKI MORIKIO eric.morikio@fanucamerica.com 417.359.838-61 NR-10 (RECICLAGEM)
13 HENRIQUE DE FIGUEIREDO BASTOS FERRAZ henrique.ferraz@fanucamerica.com 417.059.788-51 NR-10 (RECICLAGEM)
14 LAYS MORETTI DA SILVA lays.silva@fanucamerica.com 013.107.662-07 NR-10 (RECICLAGEM)
15 LAYS MORETTI DA SILVA lays.silva@fanucamerica.com 013.107.662-07 NR-12
16 ANDRE DE SOUZA andre.souza@fanucamerica.com 290.688.648-31 NR-10 (RECICLAGEM)
17 ANDRE DE SOUZA andre.souza@fanucamerica.com 290.688.648-31 NR-12
18 RAFAEL TOSHIO BURATO MAEDA rafael.maeda@fanucamerica.com 394.153.268-59 NR-10 (RECICLAGEM)
19 RAFAEL TOSHIO BURATO MAEDA rafael.maeda@fanucamerica.com 394.153.268-59 NR-12
20 RAFAEL TOSHIO BURATO MAEDA rafael.maeda@fanucamerica.com 394.153.268-59 NR-35 (RECICLAGEM)
21 RICARDO GALLES BONET ricardo.bonet@fanucamerica.com 424.430.528-93 NR-10 (RECICLAGEM)
22 RULIO SIEFERT SERA rulio.sera@fanucamerica.com 063.916.859-08 NR-10 (RECICLAGEM)
23 MACIEL FERREIRA BOMFIM maciel.bomfim@fanucamerica.com 334.547.088-85 NR-10 (RECICLAGEM)
24 JAIME EDUARDO GALVEZ AVILES jaime.galvez@fanucamerica.com 280.238.818-50 NR-12
25 JAIME EDUARDO GALVEZ AVILES jaime.galvez@fanucamerica.com 280.238.818-50 NR-35 (RECICLAGEM)
26 HIGOR MACHADO SILVA higor.silva@fanucamerica.com 419.879.878-88 NR-12
27 LÁZARO SOUZA DIAS lazaro.dias@fanucamerica.com 067.179.825-19 NR-12
28 JOÃO PEDRO AGUIAR GALASSO joao.pedro@fanucamerica.com 570.403.588-40 NR-12

View File

@@ -0,0 +1,4 @@
{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "0"}, "name": {"S": "EDUSEG"}}
{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "admins#5OxmMjL-ujoR5IMGegQz"}, "name": {"S": "Sérgio R Siqueira"}, "email": {"S": "sergio@somosbeta.com.br"}}
{"id": {"S": "cnpj"}, "sk": {"S": "15608435000190"}, "user_id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}}
{"id": {"S": "email"}, "sk": {"S": "org+15608435000190@users.noreply.saladeaula.digital"}, "user_id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}}

View File

@@ -0,0 +1,29 @@
from csv_utils import byte_ranges, detect_delimiter
def test_detect_delimiter():
with open('tests/samples/users.csv') as fp:
assert detect_delimiter(fp) == ','
def test_byte_ranges():
csvpath = 'tests/samples/users.csv'
ranges = byte_ranges(csvpath, 10)
*_, pair = ranges
start_byte, end_byte = pair
assert ranges == [(0, 808), (809, 1655), (1656, 2303)]
expected = """,RICARDO GALLES BONET,ricardo.bonet@fanucamerica.com,424.430.528-93,NR-10 (RECICLAGEM)
,RULIO SIEFERT SERA,rulio.sera@fanucamerica.com,063.916.859-08,NR-10 (RECICLAGEM)
,MACIEL FERREIRA BOMFIM,maciel.bomfim@fanucamerica.com,334.547.088-85,NR-10 (RECICLAGEM)
,JAIME EDUARDO GALVEZ AVILES,jaime.galvez@fanucamerica.com,280.238.818-50,NR-12
,JAIME EDUARDO GALVEZ AVILES,jaime.galvez@fanucamerica.com,280.238.818-50,NR-35 (RECICLAGEM)
,HIGOR MACHADO SILVA,higor.silva@fanucamerica.com,419.879.878-88,NR-12
,LÁZARO SOUZA DIAS,lazaro.dias@fanucamerica.com,067.179.825-19,NR-12
,JOÃO PEDRO AGUIAR GALASSO,joao.pedro@fanucamerica.com,570.403.588-40,NR-12"""
with open(csvpath, 'rb') as f:
f.seek(start_byte)
data = f.read(end_byte - start_byte + 1)
assert data.decode('utf-8') == expected

1171
users-events/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff