This commit is contained in:
2025-06-03 20:13:07 -03:00
parent 957f9c4a72
commit c3e6ed4a50
13 changed files with 312 additions and 75 deletions

Binary file not shown.

View File

@@ -1,8 +1,12 @@
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')
@@ -17,6 +21,22 @@ def cpf_fmt(s: str) -> str:
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(),
@@ -27,6 +47,7 @@ html_rendered = template.render(
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

@@ -3,14 +3,14 @@
<head>
<meta charset="utf-8" />
<title>NR-10 Complementar (SEP)</title>
<link href="style.css" rel="stylesheet" />
<meta name="author" content="EDUSEG® <https://eduseg.com.br>" />
<meta name="dcterms.created" content="{{ dcterms.created }}" />
<style>
html,
body,
div,
h1,
h2,
ul,
p,
a {
margin: 0;
@@ -43,9 +43,6 @@
break-after: page;
box-sizing: border-box;
padding: 5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
strong {
@@ -55,11 +52,35 @@
#cover {
background-color: #a7e400;
justify-content: center;
position: relative;
display: flex;
flex-direction: column;
gap: 1rem;
}
#cover h1 {
font-weight: bolder;
font-size: 24pt;
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 {
@@ -79,8 +100,12 @@
padding-left: 1rem;
}
.space-y > :not(:last-child) {
margin-bottom: 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>
@@ -156,12 +181,24 @@
de
<strong>{{ progress }}%</strong>
</p>
<p>Realizado entre {{ start_date }} e {{ finish_date }}</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">
<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>
@@ -193,18 +230,20 @@
</ul>
</div>
<div class="space-y">
<dd>
<div class="space-y-2.5">
<dd class="space-y-0.5">
<h2>Carga horária</h2>
<p>40 horas</p>
</dd>
<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>

View File

@@ -7,6 +7,7 @@ requires-python = ">=3.13"
dependencies = [
"jinja2>=3.1.6",
"layercake",
"qrcode>=8.2",
]
[tool.uv.sources]

26
certs/uv.lock generated
View File

@@ -184,6 +184,7 @@ source = { virtual = "." }
dependencies = [
{ name = "jinja2" },
{ name = "layercake" },
{ name = "qrcode" },
]
[package.dev-dependencies]
@@ -195,6 +196,7 @@ dev = [
requires-dist = [
{ name = "jinja2", specifier = ">=3.1.6" },
{ name = "layercake", directory = "../layercake" },
{ name = "qrcode", specifier = ">=8.2" },
]
[package.metadata.requires-dev]
@@ -244,6 +246,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "44.0.3"
@@ -471,7 +482,7 @@ wheels = [
[[package]]
name = "layercake"
version = "0.2.15"
version = "0.6.5"
source = { directory = "../layercake" }
dependencies = [
{ name = "arnparse" },
@@ -517,7 +528,6 @@ dev = [
{ name = "jsonlines", specifier = ">=4.0.0" },
{ name = "pytest", specifier = ">=8.3.5" },
{ name = "pytest-cov", specifier = ">=6.0.0" },
{ name = "pytest-env", specifier = ">=1.1.5" },
{ name = "ruff", specifier = ">=0.11.1" },
]
@@ -765,6 +775,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
]
[[package]]
name = "qrcode"
version = "8.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
]
[[package]]
name = "requests"
version = "2.32.3"

View File

@@ -1,3 +1,4 @@
import uuid
from datetime import datetime
from http import HTTPStatus
@@ -17,7 +18,7 @@ from config import (
)
from middlewares import Tenant, TenantMiddleware
from models import Course, Enrollment, User
from rules.enrollment import enroll
from rules.enrollment import DeduplicationWindow, Vacancy, enroll
router = Router()
@@ -31,9 +32,18 @@ processor = BatchProcessor()
class Item(BaseModel):
user: User
course: Course
deduplication_window: dict = {}
vacancy: Vacancy | None = None
deduplication_window: DeduplicationWindow | None = None
schedule_date: datetime | None = None
@property
def id(self) -> str:
if not self.vacancy:
return str(uuid.uuid4())
_, idx = self.vacancy.sk.split('#')
return idx
class Payload(BaseModel):
items: tuple[Item, ...]
@@ -59,6 +69,7 @@ def enroll_(payload: Payload):
def handler(record: Item, context: dict):
tenant: Tenant = context['tenant']
enrollment = Enrollment(
id=record.id,
user=record.user,
course=record.course,
)
@@ -69,7 +80,8 @@ def handler(record: Item, context: dict):
'id': str(tenant.id),
'name': tenant.name,
},
deduplication_window=record.deduplication_window, # type: ignore
deduplication_window=record.deduplication_window,
vacancy=record.vacancy,
persistence_layer=enrollment_layer,
)

View File

@@ -40,6 +40,7 @@ def update_course(
persistence_layer: DynamoDBPersistenceLayer,
):
now_ = now()
with persistence_layer.transact_writer() as transact:
transact.update(
key=KeyPair(id, '0'),

View File

@@ -1,6 +1,7 @@
from dataclasses import asdict, dataclass
from datetime import timedelta
from enum import Enum
from typing import TypedDict
from typing import Self, TypedDict
from uuid import uuid4
from layercake.dateutils import now, ttl
@@ -10,22 +11,31 @@ from layercake.strutils import md5_hash
from config import ORDER_TABLE
from models import Course, Enrollment
Tenant = TypedDict('Tenant', {'id': str, 'name': str})
Author = TypedDict('Author', {'id': str, 'name': str})
DeduplicationWindow = TypedDict('DeduplicationWindow', {'offset_days': int})
class Tenant(TypedDict):
class RelatedId(str):
def __new__(cls, id: str, kind: str) -> Self:
return super().__new__(cls, id)
def __init__(self, id: str, kind: str) -> None:
# __init__ is used to store the parameters for later reference.
# For immutable types like str, __init__ cannot change the instance's value.
self.id = id
self.kind = kind
@dataclass(frozen=True)
class Vacancy:
id: str
name: str
sk: str
class Author(TypedDict):
id: str
name: str
class Vacancy(TypedDict): ...
class DeduplicationWindow(TypedDict):
offset_days: int
@property
def order_id(self) -> RelatedId:
idx, _ = self.sk.split('#')
return RelatedId(idx, 'order')
class LifecycleEvents(str, Enum):
@@ -55,6 +65,8 @@ def enroll(
*,
tenant: Tenant,
vacancy: Vacancy | None = None,
author: Author | None = None,
related_ids: frozenset[RelatedId] = frozenset(),
deduplication_window: DeduplicationWindow | None = None,
persistence_layer: DynamoDBPersistenceLayer,
) -> bool:
@@ -66,12 +78,15 @@ def enroll(
lock_hash = md5_hash('%s%s' % (user.id, course.id))
with persistence_layer.transact_writer() as transact:
if vacancy:
related_ids = frozenset({vacancy.order_id}) | related_ids
transact.put(
item={
'sk': '0',
'create_date': now_,
'metadata__tenant_id': tenant_id,
'metadata__related_ids': {tenant_id, user.id},
'metadata__related_ids': {tenant_id, user.id} | related_ids,
**enrollment.model_dump(),
},
)
@@ -95,17 +110,9 @@ def enroll(
'ttl': ttl(days=3, start_dt=now_),
},
)
transact.put(
item={
'id': enrollment.id,
'sk': LifecycleEvents.ACCESS_PERIOD_REMINDER_30_DAYS,
'name': user.name,
'email': user.email,
'course': course.name,
'create_date': now_,
'ttl': ttl(start_dt=now_ + timedelta(days=course.access_period - 30)),
},
)
# Enrollment expires by default when the access period ends.
# When the course is finished, it is automatically removed,
# and the `schedules#course_archived` event is created.
transact.put(
item={
'id': enrollment.id,
@@ -117,25 +124,78 @@ def enroll(
'ttl': ttl(start_dt=now_ + timedelta(days=course.access_period)),
},
)
transact.put(
item={
'id': enrollment.id,
'sk': LifecycleEvents.ACCESS_PERIOD_REMINDER_30_DAYS,
'name': user.name,
'email': user.email,
'course': course.name,
'create_date': now_,
'ttl': ttl(start_dt=now_ + timedelta(days=course.access_period - 30)),
},
)
for related_id in related_ids:
kind = related_id.kind.lower()
transact.put(
item={
'id': enrollment.id,
'sk': f'related_ids#{kind}',
'create_date': now_,
f'{kind}_id': related_id,
}
)
if vacancy:
transact.put(
item={
'id': enrollment.id,
'sk': 'parent_vacancy',
'vacancy': asdict(vacancy),
'create_date': now_,
}
)
class VacancyDoesNotExistError(Exception):
def __init__(self, *args):
super().__init__('Vacancy does not exist')
transact.delete(
key=KeyPair(vacancy.id, vacancy.sk),
cond_expr='attribute_exists(sk)',
exc_cls=VacancyDoesNotExistError,
)
transact.put(
item={
'id': enrollment.id,
'sk': 'metadata#cancel_policy',
'create_date': now_,
}
)
if author:
transact.put(
item={
'id': enrollment.id,
'sk': 'metadata#author',
'user_id': author['id'],
'name': author['name'],
'create_date': now_,
},
)
class DeduplicationConflictError(Exception):
def __init__(self, *args):
super().__init__('Enrollment already exists')
# Prevents the user from enrolling in the same course again until
# the deduplication window expires or is removed
transact.condition(
key=KeyPair('lock', lock_hash),
cond_expr='attribute_not_exists(sk)',
exc_cls=DeduplicationConflictError,
)
# the deduplication window expires or is removed.
if deduplication_window:
offset_days = deduplication_window['offset_days']
ttl_expiration = ttl(
start_dt=now_ + timedelta(days=course.access_period - offset_days)
)
transact.put(
item={
'id': 'lock',
@@ -144,6 +204,8 @@ def enroll(
'create_date': now_,
'ttl': ttl_expiration,
},
cond_expr='attribute_not_exists(sk)',
exc_cls=DeduplicationConflictError,
)
transact.put(
item={
@@ -163,6 +225,12 @@ def enroll(
'create_date': now_,
},
)
else:
transact.condition(
key=KeyPair('lock', lock_hash),
cond_expr='attribute_not_exists(sk)',
exc_cls=DeduplicationConflictError,
)
return True

View File

@@ -1,3 +1,4 @@
from datetime import timedelta
from types import SimpleNamespace
from typing import TypedDict
@@ -9,6 +10,7 @@ from layercake.dynamodb import (
ComposeKey,
DynamoDBPersistenceLayer,
KeyPair,
SortKey,
)
User = TypedDict('User', {'id': str, 'name': str, 'cpf': str})
@@ -23,7 +25,12 @@ def update_user(
now_ = now()
user = SimpleNamespace(**data)
# Get the user's CPF, if it exists.
old_cpf = persistence_layer.get_item(KeyPair(user.id, '0')).get('cpf', None)
old_cpf = persistence_layer.collection.get_item(
KeyPair(
pk=user.id,
sk=SortKey('0', path_spec='cpf'),
)
)
with persistence_layer.transact_writer() as transact:
transact.update(
@@ -128,9 +135,7 @@ def del_email(
) -> bool:
"""Delete any email except the primary email."""
with persistence_layer.transact_writer() as transact:
transact.delete(
key=KeyPair('email', email),
)
transact.delete(key=KeyPair('email', email))
transact.delete(
key=KeyPair(id, ComposeKey(email, prefix='emails')),
cond_expr='email_primary <> :primary',

View File

@@ -3,10 +3,11 @@ from http import HTTPMethod, HTTPStatus
from layercake.dynamodb import (
ComposeKey,
DynamoDBCollection,
DynamoDBPersistenceLayer,
KeyPair,
PartitionKey,
SortKey,
TransactKey,
)
from ..conftest import HttpApiProxy, LambdaContext
@@ -53,6 +54,10 @@ def test_enroll(
'deduplication_window': {
'offset_days': 60,
},
'vacancy': {
'id': 'vacancies#cJtK9SsnJhKPyxESe7g3DG',
'sk': '3CNrFB9dy2RLit2pdeUWy4#8c9b55ef-e988-43ee-b2da-8594850605d7',
},
},
],
},
@@ -62,12 +67,75 @@ def test_enroll(
assert r['statusCode'] == HTTPStatus.OK
fail, _ = json.loads(r['body'])
fail, succ = json.loads(r['body'])
assert fail['cause'] == {
'type': 'DeduplicationConflictError',
'message': 'Enrollment already exists',
}
enrollment = dynamodb_persistence_layer.collection.get_items(
TransactKey(
'8c9b55ef-e988-43ee-b2da-8594850605d7',
)
+ SortKey('0')
+ SortKey('parent_vacancy', path_spec='vacancy')
+ SortKey('related_ids#order', path_spec='order_id'),
)
assert succ['output']['id'] == '8c9b55ef-e988-43ee-b2da-8594850605d7'
assert enrollment['related_ids#order'] == '3CNrFB9dy2RLit2pdeUWy4'
assert enrollment['parent_vacancy'] == {
'sk': '3CNrFB9dy2RLit2pdeUWy4#8c9b55ef-e988-43ee-b2da-8594850605d7',
'id': 'vacancies#cJtK9SsnJhKPyxESe7g3DG',
}
def test_enroll_vacancy(
mock_app,
dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext,
):
r = mock_app.lambda_handler(
http_api_proxy(
raw_path='/enrollments',
method=HTTPMethod.POST,
headers={'X-Tenant': 'cJtK9SsnJhKPyxESe7g3DG'},
body={
'items': [
{
'user': {
'id': '9a41e867-55e1-4573-bd27-7b5d1d1bcfde',
'name': 'Tiago Maciel',
'email': 'tiago@somosbeta.com.br',
'cpf': '08679004901',
},
'course': {
'id': '6d69a34a-cefd-40aa-a89b-dceb694c3e61',
'name': 'pytest',
},
'deduplication_window': {
'offset_days': 60,
},
'vacancy': {
'id': 'vacancies#cJtK9SsnJhKPyxESe7g3DG',
'sk': '3CNrFB9dy2RLit2pdeUWy4#does_not_exist',
},
},
],
},
),
lambda_context,
)
assert r['statusCode'] == HTTPStatus.OK
fail, *_ = json.loads(r['body'])
assert fail['cause'] == {
'type': 'VacancyDoesNotExistError',
'message': 'Vacancy does not exist',
}
def test_vacancies(
mock_app,
@@ -119,11 +187,12 @@ def test_cancel_enrollment(
assert r['statusCode'] == HTTPStatus.OK
collect = DynamoDBCollection(dynamodb_persistence_layer)
enrollment = collect.get_item(KeyPair('43ea4475-c369-4f90-b576-135b7df5106b', '0'))
enrollment = dynamodb_persistence_layer.collection.get_item(
KeyPair('43ea4475-c369-4f90-b576-135b7df5106b', '0')
)
assert enrollment['status'] == 'CANCELED'
vacancies = collect.query(
vacancies = dynamodb_persistence_layer.collection.query(
PartitionKey(ComposeKey('cJtK9SsnJhKPyxESe7g3DG', 'vacancies'))
)
assert len(vacancies['items']) == 1
assert len(vacancies['items']) == 2

View File

@@ -247,12 +247,10 @@ def test_patch_email(
assert r['statusCode'] == HTTPStatus.OK
collect = DynamoDBCollection(dynamodb_persistence_layer)
user = collect.get_item(KeyPair('5OxmMjL-ujoR5IMGegQz', '0'))
print(user)
# assert user['emails'] == {
# 'sergio@somosbeta.com.br',
# }
user = dynamodb_persistence_layer.collection.get_item(
KeyPair('5OxmMjL-ujoR5IMGegQz', '0')
)
assert user['email'] == 'osergiosiqueira@gmail.com'
def test_delete_email(

View File

@@ -22,3 +22,4 @@
{"id": {"S": "cpf"}, "sk": {"S": "07879819908"}}
{"id": {"S": "cpf"}, "sk": {"S": "08679004901"}}
{"id": {"S": "lock"}, "sk": {"S": "c2116a43f8f1aed659a10c83dab17ed3"}}
{"id": {"S": "vacancies#cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "3CNrFB9dy2RLit2pdeUWy4#8c9b55ef-e988-43ee-b2da-8594850605d7"}}

View File

@@ -65,11 +65,11 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
)
for k, v in ids.items():
kind = k.removesuffix('_id')
transact.put(
item={
'id': new_image['id'],
'sk': 'related_ids#%s'
% k.removesuffix('_id'), # e.g. related_ids#user
'sk': f'related_ids#{kind}', # e.g. related_ids#user
'create_date': now_,
k: v,
}