Skip to content

Commit 1691852

Browse files
Merge pull request #3 from PythonFloripa/dev
Dev
2 parents 1b6aa92 + 94640cd commit 1691852

File tree

11 files changed

+413
-9
lines changed

11 files changed

+413
-9
lines changed

.github/workflows/workflow_testing.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ on:
33
pull_request:
44
branches:
55
- main
6+
- dev
67

78
jobs:
89
test:
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import logging
2+
import httpx
3+
from pydantic import BaseModel
4+
from config import config
5+
6+
logger = logging.getLogger(__name__)
7+
8+
9+
class CertificatesOnSolanaException(Exception):
10+
"""Custom exception for CertificatesOnSolana errors."""
11+
def __init__(self, message: str = "Error registering certificate on Solana",
12+
details: str = "",
13+
cause: Exception = None):
14+
super().__init__(message)
15+
self.details = details
16+
self.cause = cause
17+
class CertificatesOnSolana:
18+
19+
"""
20+
A class to manage certificates on the Solana blockchain Service."""
21+
22+
@staticmethod
23+
def register_certificate_on_solana(certificate_data: dict) -> dict:
24+
logger.info("Registering certificate on Solana blockchain")
25+
"""
26+
Registers a certificate on the Solana blockchain.
27+
28+
Args:
29+
certificate_data (dict): A dictionary containing certificate details.
30+
31+
Returns:
32+
dict: A dictionary with the registration result.
33+
"""
34+
try:
35+
with httpx.Client(timeout=60.0) as client:
36+
response = client.post(
37+
url= config.SERVICE_URL_REGISTRATION_API_SOLANA,
38+
headers={
39+
"x-api-key": config.SERVICE_API_KEY_REGISTRATION_API_SOLANA,
40+
"Content-Type": "application/json"
41+
},
42+
json=certificate_data
43+
)
44+
logger.info(f"Solana response status code: {response.status_code}")
45+
response.raise_for_status()
46+
solana_response = response.json()
47+
return solana_response
48+
logger.info("Certificate registered successfully on Solana")
49+
except Exception as e:
50+
logger.error(f"Error registering certificate on Solana: {str(e)}")
51+
raise CertificatesOnSolanaException(details=str(e), cause=e)

certified_builder/certified_builder.py

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import logging
2+
import tempfile
3+
import os
24
from typing import List
3-
from models.participant import Participant
45
from PIL import Image, ImageDraw, ImageFont
56
from io import BytesIO
6-
import os
7+
from models.participant import Participant
78
from certified_builder.utils.fetch_file_certificate import fetch_file_certificate
8-
import tempfile
9+
from certified_builder.certificates_on_solana import CertificatesOnSolana
10+
from certified_builder.make_qrcode import MakeQRCode
11+
912
FONT_NAME = os.path.join(os.path.dirname(__file__), "fonts/PinyonScript/PinyonScript-Regular.ttf")
1013
VALIDATION_CODE = os.path.join(os.path.dirname(__file__), "fonts/ChakraPetch/ChakraPetch-SemiBold.ttf")
1114
DETAILS_FONT = os.path.join(os.path.dirname(__file__), "fonts/ChakraPetch/ChakraPetch-Regular.ttf")
@@ -44,6 +47,25 @@ def build_certificates(self, participants: List[Participant]):
4447

4548
for participant in participants:
4649
try:
50+
# Register certificate on Solana, with returned data extract url for verification
51+
solana_response = CertificatesOnSolana.register_certificate_on_solana(
52+
certificate_data={
53+
"name": participant.name_completed(),
54+
"event": participant.event.product_name,
55+
"email": participant.email,
56+
"certificate_code": participant.formated_validation_code()
57+
}
58+
)
59+
# solana_response = {
60+
# "blockchain": {
61+
# "verificacao_url": "https://www.google.com"
62+
# }
63+
# }
64+
participant.authenticity_verification_url = solana_response.get("blockchain", {}).get("verificacao_url", "")
65+
66+
if not participant.authenticity_verification_url:
67+
raise RuntimeError("Failed to get authenticity verification URL from Solana response")
68+
4769
# Download template and logo only if they are not shared
4870
if not all_same_background:
4971
certificate_template = self._download_image(participant.certificate.background)
@@ -111,9 +133,10 @@ def generate_certificate(self, participant: Participant, certificate_template: I
111133
# Create transparent layer for text and logo
112134
overlay = Image.new("RGBA", certificate_template.size, (255, 255, 255, 0))
113135

114-
# Optimize logo size
115-
logo_size = (150, 150)
116-
logo = logo.resize(logo_size, Image.Resampling.LANCZOS)
136+
# Optimize logo size (evita upscaling para reduzir pixelização)
137+
logo_max_size = (150, 150)
138+
if logo.width > logo_max_size[0] or logo.height > logo_max_size[1]:
139+
logo.thumbnail(logo_max_size, Image.Resampling.LANCZOS)
117140

118141
# Paste logo - handle potential transparency issues
119142
try:
@@ -124,6 +147,40 @@ def generate_certificate(self, participant: Participant, certificate_template: I
124147
# Fallback without using the logo as its own mask
125148
overlay.paste(logo, (50, 50))
126149

150+
151+
qrcode_size = (150, 150)
152+
qr_code_image_io = MakeQRCode.generate_qr_code(participant.authenticity_verification_url)
153+
qr_code_image = Image.open(qr_code_image_io).convert("RGBA")
154+
# comentário: para manter o QR nítido, usamos NEAREST ao redimensionar
155+
if qr_code_image.size != qrcode_size:
156+
qr_code_image = qr_code_image.resize(qrcode_size, Image.Resampling.NEAREST)
157+
158+
# Add QR code to overlay
159+
# preciso que a posição do QR code seja abaixo do logo, alinhado à esquerda
160+
overlay.paste(qr_code_image, (50, 200), qr_code_image)
161+
162+
# Add "Scan to Validate" text below the QR code
163+
# comentário: camada de texto criada para ficar logo abaixo do QR code, centralizada ao QR e com espaçamento justo
164+
try:
165+
# calcula centralização do texto com base na largura do QR
166+
tmp_img = Image.new("RGBA", certificate_template.size, (255, 255, 255, 0))
167+
tmp_draw = ImageDraw.Draw(tmp_img)
168+
tmp_font = ImageFont.truetype(DETAILS_FONT, 16)
169+
text_bbox = tmp_draw.textbbox((0, 0), "Scan to Validate", font=tmp_font)
170+
text_w = text_bbox[2] - text_bbox[0]
171+
text_x = 50 + int((qrcode_size[0] - text_w) / 2)
172+
text_y = 185 + qrcode_size[1] # espaçamento curto (quase colado)
173+
174+
scan_text_image = self.create_scan_to_validate_image(
175+
size=certificate_template.size,
176+
position=(text_x, text_y)
177+
)
178+
overlay.paste(scan_text_image, (0, 0), scan_text_image)
179+
logger.info("Texto 'Scan to Validate' adicionado abaixo do QR code")
180+
except Exception as e:
181+
logger.warning(f"Falha ao adicionar texto 'Scan to Validate': {str(e)}")
182+
183+
127184
# Add name
128185
name_image = self.create_name_image(participant.name_completed(), certificate_template.size)
129186

@@ -243,6 +300,19 @@ def create_validation_code_image(self, validation_code: str, size: tuple) -> Ima
243300
logger.error(f"Erro ao criar imagem do código de validação: {str(e)}")
244301
raise
245302

303+
def create_scan_to_validate_image(self, size: tuple, position: tuple) -> Image:
304+
"""Create image with the 'Scan to Validate' label using DETAILS_FONT at a given position."""
305+
try:
306+
# comentário: imagem transparente do tamanho do canvas com o texto posicionado
307+
text_image = Image.new("RGBA", size, (255, 255, 255, 0))
308+
draw = ImageDraw.Draw(text_image)
309+
font = ImageFont.truetype(DETAILS_FONT, 16)
310+
draw.text(position, "Scan to Validate", fill=TEXT_COLOR, font=font)
311+
return text_image
312+
except Exception as e:
313+
logger.error(f"Erro ao criar imagem do texto 'Scan to Validate': {str(e)}")
314+
raise
315+
246316
def calculate_text_position(self, text: str, font: ImageFont, draw: ImageDraw, size: tuple) -> tuple:
247317
"""Calculate centered position for text."""
248318
text_bbox = draw.textbbox((0, 0), text, font=font)

certified_builder/make_qrcode.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
2+
import qrcode
3+
import logging
4+
from io import BytesIO
5+
from qrcode.image.pil import PilImage
6+
7+
logger = logging.getLogger(__name__)
8+
9+
class MakeQRCode:
10+
@staticmethod
11+
def generate_qr_code(data: str) -> BytesIO:
12+
try:
13+
logger.info("Generating QR code ")
14+
qr = qrcode.QRCode(
15+
version=1,
16+
error_correction=qrcode.constants.ERROR_CORRECT_L,
17+
box_size=10,
18+
border=4,
19+
)
20+
21+
qr.add_data(data)
22+
qr.make(fit=True)
23+
img = qr.make_image(fill_color="black", back_color="transparent", image_factory=PilImage)
24+
img = img.convert("RGBA")
25+
byte_io = BytesIO()
26+
img.save(byte_io, format='PNG')
27+
byte_io.seek(0)
28+
logger.info("QR code generated successfully")
29+
return byte_io
30+
except Exception as e:
31+
logging.error(f"Failed to generate QR code: {e}")
32+
raise

config.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ class Config(BaseSettings):
77
REGION: str
88
BUCKET_NAME: str
99
QUEUE_URL: str
10-
10+
SERVICE_URL_REGISTRATION_API_SOLANA: str
11+
SERVICE_API_KEY_REGISTRATION_API_SOLANA: str
1112

1213
class Config:
1314
env_file = ".env"

models/participant.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class Participant(BaseModel):
2121
validation_code: Optional[str] = Field(default_factory= lambda: ''.join(random.choices(string.hexdigits, k=9)), init=False)
2222
certificate: Optional[Certificate] = None
2323
event: Optional[Event] = None
24+
authenticity_verification_url: Optional[str] = None
2425

2526
def __str__(self):
2627
return f"Participant: {self.first_name} {self.last_name} - {self.email}"
@@ -117,4 +118,4 @@ def create_name_certificate(self):
117118
logger.info(f"Código de validação antes da sanitização: {self.formated_validation_code()}")
118119
logger.info(f"Nome do certificado após a sanitização: {name_certificate}")
119120

120-
return name_certificate
121+
return name_certificate

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ six==1.17.0
2525
sniffio==1.3.1
2626
typing_extensions==4.12.2
2727
urllib3==2.3.0
28+
qrcode==8.2

tests/conftest.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import sys
2+
import types
3+
import pytest
4+
5+
6+
def _install_config_mock() -> None:
7+
"""
8+
Instala um módulo falso chamado `config` antes dos imports dos testes.
9+
- Evita que `config.Config()` leia variáveis de ambiente no import.
10+
- Disponibiliza `config.config` com os campos usados no código.
11+
"""
12+
13+
# comentário: cria um módulo dinâmico chamado 'config'
14+
mock_module = types.ModuleType("config")
15+
16+
class MockConfig:
17+
# comentário: valores estáveis para o ambiente de testes (sem dependências externas)
18+
REGION = "us-east-1"
19+
BUCKET_NAME = "test-bucket"
20+
QUEUE_URL = "https://sqs.us-east-1.amazonaws.com/000000000000/test"
21+
SERVICE_URL_REGISTRATION_API_SOLANA = "https://example.test/solana/register"
22+
SERVICE_API_KEY_REGISTRATION_API_SOLANA = "test-api-key"
23+
24+
# comentário: expõe tanto a classe quanto a instância, como o módulo real faria
25+
mock_module.Config = MockConfig
26+
mock_module.config = MockConfig()
27+
28+
# comentário: injeta o módulo mock no sys.modules antes de qualquer import nos testes
29+
sys.modules["config"] = mock_module
30+
31+
# debug estratégico para validar no log do CI
32+
print("[tests] Módulo 'config' mockado instalado para o ambiente de testes")
33+
34+
35+
# comentário: instala o mock assim que o pytest carrega o conftest (antes dos testes)
36+
_install_config_mock()
37+
38+
39+
@pytest.fixture(autouse=True)
40+
def _mock_solana_registration(monkeypatch, request):
41+
"""
42+
Mocka a integração com o serviço de registro na Solana para todos os testes.
43+
- Evita chamadas HTTP reais.
44+
- Garante que o fluxo avance e permita assertivas como `save_certificate` ter sido chamada.
45+
"""
46+
47+
# comentário: não mockar no teste específico que valida o módulo certificates_on_solana
48+
try:
49+
test_file = str(request.node.fspath)
50+
if test_file.endswith("test_certificates_on_solana.py"):
51+
return
52+
except Exception:
53+
pass
54+
55+
# comentário: resposta estável usada nos demais testes
56+
fake_response = {
57+
"blockchain": {
58+
"verificacao_url": "https://example.test/verify/abc123"
59+
}
60+
}
61+
62+
# comentário: função fake substitui o método estático
63+
def _fake_register_certificate_on_solana(certificate_data: dict) -> dict:
64+
# debug estratégico para CI
65+
print("[tests] Mock CertificatesOnSolana.register_certificate_on_solana called")
66+
return fake_response
67+
68+
# comentário: injeta o mock no alvo correto
69+
from certified_builder.certificates_on_solana import CertificatesOnSolana
70+
monkeypatch.setattr(
71+
CertificatesOnSolana,
72+
"register_certificate_on_solana",
73+
staticmethod(_fake_register_certificate_on_solana),
74+
raising=False,
75+
)
76+
77+
78+
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import pytest
2+
from unittest.mock import patch, MagicMock
3+
from certified_builder.certificates_on_solana import CertificatesOnSolana, CertificatesOnSolanaException
4+
from certified_builder import certificates_on_solana as module_under_test
5+
6+
7+
@pytest.fixture
8+
def sample_payload():
9+
return {
10+
"name": "User Test",
11+
"event": "Evento X",
12+
"email": "[email protected]",
13+
"certificate_code": "ABC-123-XYZ",
14+
}
15+
16+
17+
def test_register_certificate_success(sample_payload, monkeypatch):
18+
# Configura URLs/chaves do módulo
19+
monkeypatch.setattr(module_under_test.config, "SERVICE_URL_REGISTRATION_API_SOLANA", "https://api.test/solana")
20+
monkeypatch.setattr(module_under_test.config, "SERVICE_API_KEY_REGISTRATION_API_SOLANA", "secret-key")
21+
22+
# Mock do client httpx
23+
mock_response = MagicMock()
24+
mock_response.status_code = 200
25+
mock_response.json.return_value = {"ok": True, "blockchain": {"verificacao_url": "https://verify"}}
26+
mock_response.raise_for_status.return_value = None
27+
28+
mock_client_instance = MagicMock()
29+
mock_client_instance.post.return_value = mock_response
30+
mock_client_instance.__enter__.return_value = mock_client_instance
31+
mock_client_instance.__exit__.return_value = False
32+
33+
with patch("certified_builder.certificates_on_solana.httpx.Client", return_value=mock_client_instance) as mock_client_cls:
34+
result = CertificatesOnSolana.register_certificate_on_solana(sample_payload)
35+
36+
# Retorno
37+
assert result["ok"] is True
38+
assert result["blockchain"]["verificacao_url"] == "https://verify"
39+
40+
# Chamada correta
41+
mock_client_cls.assert_called_once()
42+
mock_client_instance.post.assert_called_once()
43+
call_kwargs = mock_client_instance.post.call_args.kwargs
44+
assert call_kwargs["url"] == "https://api.test/solana"
45+
assert call_kwargs["headers"]["x-api-key"] == "secret-key"
46+
assert call_kwargs["headers"]["Content-Type"] == "application/json"
47+
assert call_kwargs["json"] == sample_payload
48+
49+
50+
def test_register_certificate_http_error_raises(sample_payload, monkeypatch):
51+
monkeypatch.setattr(module_under_test.config, "SERVICE_URL_REGISTRATION_API_SOLANA", "https://api.test/solana")
52+
monkeypatch.setattr(module_under_test.config, "SERVICE_API_KEY_REGISTRATION_API_SOLANA", "secret-key")
53+
54+
mock_response = MagicMock()
55+
mock_response.status_code = 500
56+
mock_response.json.return_value = {"ok": False}
57+
58+
# raise_for_status levanta erro
59+
def _raise():
60+
raise Exception("boom")
61+
mock_response.raise_for_status.side_effect = _raise
62+
63+
mock_client_instance = MagicMock()
64+
mock_client_instance.post.return_value = mock_response
65+
mock_client_instance.__enter__.return_value = mock_client_instance
66+
mock_client_instance.__exit__.return_value = False
67+
68+
with patch("certified_builder.certificates_on_solana.httpx.Client", return_value=mock_client_instance):
69+
with pytest.raises(CertificatesOnSolanaException) as exc:
70+
CertificatesOnSolana.register_certificate_on_solana(sample_payload)
71+
72+
assert "boom" in str(exc.value.details)
73+
74+

0 commit comments

Comments
 (0)