Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/workflow_testing.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ on:
pull_request:
branches:
- main
- dev

jobs:
test:
Expand Down
51 changes: 51 additions & 0 deletions certified_builder/certificates_on_solana.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import logging
import httpx
from pydantic import BaseModel
from config import config

logger = logging.getLogger(__name__)


class CertificatesOnSolanaException(Exception):
"""Custom exception for CertificatesOnSolana errors."""
def __init__(self, message: str = "Error registering certificate on Solana",
details: str = "",
cause: Exception = None):
super().__init__(message)
self.details = details
self.cause = cause
class CertificatesOnSolana:

"""
A class to manage certificates on the Solana blockchain Service."""

@staticmethod
def register_certificate_on_solana(certificate_data: dict) -> dict:
logger.info("Registering certificate on Solana blockchain")
"""
Registers a certificate on the Solana blockchain.

Args:
certificate_data (dict): A dictionary containing certificate details.

Returns:
dict: A dictionary with the registration result.
"""
try:
with httpx.Client(timeout=60.0) as client:
response = client.post(
url= config.SERVICE_URL_REGISTRATION_API_SOLANA,
headers={
"x-api-key": config.SERVICE_API_KEY_REGISTRATION_API_SOLANA,
"Content-Type": "application/json"
},
json=certificate_data
)
logger.info(f"Solana response status code: {response.status_code}")
response.raise_for_status()
solana_response = response.json()
return solana_response
logger.info("Certificate registered successfully on Solana")
except Exception as e:
logger.error(f"Error registering certificate on Solana: {str(e)}")
raise CertificatesOnSolanaException(details=str(e), cause=e)
82 changes: 76 additions & 6 deletions certified_builder/certified_builder.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import logging
import tempfile
import os
from typing import List
from models.participant import Participant
from PIL import Image, ImageDraw, ImageFont
from io import BytesIO
import os
from models.participant import Participant
from certified_builder.utils.fetch_file_certificate import fetch_file_certificate
import tempfile
from certified_builder.certificates_on_solana import CertificatesOnSolana
from certified_builder.make_qrcode import MakeQRCode

FONT_NAME = os.path.join(os.path.dirname(__file__), "fonts/PinyonScript/PinyonScript-Regular.ttf")
VALIDATION_CODE = os.path.join(os.path.dirname(__file__), "fonts/ChakraPetch/ChakraPetch-SemiBold.ttf")
DETAILS_FONT = os.path.join(os.path.dirname(__file__), "fonts/ChakraPetch/ChakraPetch-Regular.ttf")
Expand Down Expand Up @@ -44,6 +47,25 @@ def build_certificates(self, participants: List[Participant]):

for participant in participants:
try:
# Register certificate on Solana, with returned data extract url for verification
solana_response = CertificatesOnSolana.register_certificate_on_solana(
certificate_data={
"name": participant.name_completed(),
"event": participant.event.product_name,
"email": participant.email,
"certificate_code": participant.formated_validation_code()
}
)
# solana_response = {
# "blockchain": {
# "verificacao_url": "https://www.google.com"
# }
# }
participant.authenticity_verification_url = solana_response.get("blockchain", {}).get("verificacao_url", "")

if not participant.authenticity_verification_url:
raise RuntimeError("Failed to get authenticity verification URL from Solana response")

# Download template and logo only if they are not shared
if not all_same_background:
certificate_template = self._download_image(participant.certificate.background)
Expand Down Expand Up @@ -111,9 +133,10 @@ def generate_certificate(self, participant: Participant, certificate_template: I
# Create transparent layer for text and logo
overlay = Image.new("RGBA", certificate_template.size, (255, 255, 255, 0))

# Optimize logo size
logo_size = (150, 150)
logo = logo.resize(logo_size, Image.Resampling.LANCZOS)
# Optimize logo size (evita upscaling para reduzir pixelização)
logo_max_size = (150, 150)
if logo.width > logo_max_size[0] or logo.height > logo_max_size[1]:
logo.thumbnail(logo_max_size, Image.Resampling.LANCZOS)

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


qrcode_size = (150, 150)
qr_code_image_io = MakeQRCode.generate_qr_code(participant.authenticity_verification_url)
qr_code_image = Image.open(qr_code_image_io).convert("RGBA")
# comentário: para manter o QR nítido, usamos NEAREST ao redimensionar
if qr_code_image.size != qrcode_size:
qr_code_image = qr_code_image.resize(qrcode_size, Image.Resampling.NEAREST)

# Add QR code to overlay
# preciso que a posição do QR code seja abaixo do logo, alinhado à esquerda
overlay.paste(qr_code_image, (50, 200), qr_code_image)

# Add "Scan to Validate" text below the QR code
# comentário: camada de texto criada para ficar logo abaixo do QR code, centralizada ao QR e com espaçamento justo
try:
# calcula centralização do texto com base na largura do QR
tmp_img = Image.new("RGBA", certificate_template.size, (255, 255, 255, 0))
tmp_draw = ImageDraw.Draw(tmp_img)
tmp_font = ImageFont.truetype(DETAILS_FONT, 16)
text_bbox = tmp_draw.textbbox((0, 0), "Scan to Validate", font=tmp_font)
text_w = text_bbox[2] - text_bbox[0]
text_x = 50 + int((qrcode_size[0] - text_w) / 2)
text_y = 185 + qrcode_size[1] # espaçamento curto (quase colado)

scan_text_image = self.create_scan_to_validate_image(
size=certificate_template.size,
position=(text_x, text_y)
)
overlay.paste(scan_text_image, (0, 0), scan_text_image)
logger.info("Texto 'Scan to Validate' adicionado abaixo do QR code")
except Exception as e:
logger.warning(f"Falha ao adicionar texto 'Scan to Validate': {str(e)}")


# Add name
name_image = self.create_name_image(participant.name_completed(), certificate_template.size)

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

def create_scan_to_validate_image(self, size: tuple, position: tuple) -> Image:
"""Create image with the 'Scan to Validate' label using DETAILS_FONT at a given position."""
try:
# comentário: imagem transparente do tamanho do canvas com o texto posicionado
text_image = Image.new("RGBA", size, (255, 255, 255, 0))
draw = ImageDraw.Draw(text_image)
font = ImageFont.truetype(DETAILS_FONT, 16)
draw.text(position, "Scan to Validate", fill=TEXT_COLOR, font=font)
return text_image
except Exception as e:
logger.error(f"Erro ao criar imagem do texto 'Scan to Validate': {str(e)}")
raise

def calculate_text_position(self, text: str, font: ImageFont, draw: ImageDraw, size: tuple) -> tuple:
"""Calculate centered position for text."""
text_bbox = draw.textbbox((0, 0), text, font=font)
Expand Down
32 changes: 32 additions & 0 deletions certified_builder/make_qrcode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@

import qrcode
import logging
from io import BytesIO
from qrcode.image.pil import PilImage

logger = logging.getLogger(__name__)

class MakeQRCode:
@staticmethod
def generate_qr_code(data: str) -> BytesIO:
try:
logger.info("Generating QR code ")
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)

qr.add_data(data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="transparent", image_factory=PilImage)
img = img.convert("RGBA")
byte_io = BytesIO()
img.save(byte_io, format='PNG')
byte_io.seek(0)
logger.info("QR code generated successfully")
return byte_io
except Exception as e:
logging.error(f"Failed to generate QR code: {e}")
raise
3 changes: 2 additions & 1 deletion config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ class Config(BaseSettings):
REGION: str
BUCKET_NAME: str
QUEUE_URL: str

SERVICE_URL_REGISTRATION_API_SOLANA: str
SERVICE_API_KEY_REGISTRATION_API_SOLANA: str

class Config:
env_file = ".env"
Expand Down
3 changes: 2 additions & 1 deletion models/participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class Participant(BaseModel):
validation_code: Optional[str] = Field(default_factory= lambda: ''.join(random.choices(string.hexdigits, k=9)), init=False)
certificate: Optional[Certificate] = None
event: Optional[Event] = None
authenticity_verification_url: Optional[str] = None

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

return name_certificate
return name_certificate
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ six==1.17.0
sniffio==1.3.1
typing_extensions==4.12.2
urllib3==2.3.0
qrcode==8.2
78 changes: 78 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import sys
import types
import pytest


def _install_config_mock() -> None:
"""
Instala um módulo falso chamado `config` antes dos imports dos testes.
- Evita que `config.Config()` leia variáveis de ambiente no import.
- Disponibiliza `config.config` com os campos usados no código.
"""

# comentário: cria um módulo dinâmico chamado 'config'
mock_module = types.ModuleType("config")

class MockConfig:
# comentário: valores estáveis para o ambiente de testes (sem dependências externas)
REGION = "us-east-1"
BUCKET_NAME = "test-bucket"
QUEUE_URL = "https://sqs.us-east-1.amazonaws.com/000000000000/test"
SERVICE_URL_REGISTRATION_API_SOLANA = "https://example.test/solana/register"
SERVICE_API_KEY_REGISTRATION_API_SOLANA = "test-api-key"

# comentário: expõe tanto a classe quanto a instância, como o módulo real faria
mock_module.Config = MockConfig
mock_module.config = MockConfig()

# comentário: injeta o módulo mock no sys.modules antes de qualquer import nos testes
sys.modules["config"] = mock_module

# debug estratégico para validar no log do CI
print("[tests] Módulo 'config' mockado instalado para o ambiente de testes")


# comentário: instala o mock assim que o pytest carrega o conftest (antes dos testes)
_install_config_mock()


@pytest.fixture(autouse=True)
def _mock_solana_registration(monkeypatch, request):
"""
Mocka a integração com o serviço de registro na Solana para todos os testes.
- Evita chamadas HTTP reais.
- Garante que o fluxo avance e permita assertivas como `save_certificate` ter sido chamada.
"""

# comentário: não mockar no teste específico que valida o módulo certificates_on_solana
try:
test_file = str(request.node.fspath)
if test_file.endswith("test_certificates_on_solana.py"):
return
except Exception:
pass

# comentário: resposta estável usada nos demais testes
fake_response = {
"blockchain": {
"verificacao_url": "https://example.test/verify/abc123"
}
}

# comentário: função fake substitui o método estático
def _fake_register_certificate_on_solana(certificate_data: dict) -> dict:
# debug estratégico para CI
print("[tests] Mock CertificatesOnSolana.register_certificate_on_solana called")
return fake_response

# comentário: injeta o mock no alvo correto
from certified_builder.certificates_on_solana import CertificatesOnSolana
monkeypatch.setattr(
CertificatesOnSolana,
"register_certificate_on_solana",
staticmethod(_fake_register_certificate_on_solana),
raising=False,
)



74 changes: 74 additions & 0 deletions tests/test_certificates_on_solana.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import pytest
from unittest.mock import patch, MagicMock
from certified_builder.certificates_on_solana import CertificatesOnSolana, CertificatesOnSolanaException
from certified_builder import certificates_on_solana as module_under_test


@pytest.fixture
def sample_payload():
return {
"name": "User Test",
"event": "Evento X",
"email": "user@example.com",
"certificate_code": "ABC-123-XYZ",
}


def test_register_certificate_success(sample_payload, monkeypatch):
# Configura URLs/chaves do módulo
monkeypatch.setattr(module_under_test.config, "SERVICE_URL_REGISTRATION_API_SOLANA", "https://api.test/solana")
monkeypatch.setattr(module_under_test.config, "SERVICE_API_KEY_REGISTRATION_API_SOLANA", "secret-key")

# Mock do client httpx
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"ok": True, "blockchain": {"verificacao_url": "https://verify"}}
mock_response.raise_for_status.return_value = None

mock_client_instance = MagicMock()
mock_client_instance.post.return_value = mock_response
mock_client_instance.__enter__.return_value = mock_client_instance
mock_client_instance.__exit__.return_value = False

with patch("certified_builder.certificates_on_solana.httpx.Client", return_value=mock_client_instance) as mock_client_cls:
result = CertificatesOnSolana.register_certificate_on_solana(sample_payload)

# Retorno
assert result["ok"] is True
assert result["blockchain"]["verificacao_url"] == "https://verify"

# Chamada correta
mock_client_cls.assert_called_once()
mock_client_instance.post.assert_called_once()
call_kwargs = mock_client_instance.post.call_args.kwargs
assert call_kwargs["url"] == "https://api.test/solana"
assert call_kwargs["headers"]["x-api-key"] == "secret-key"
assert call_kwargs["headers"]["Content-Type"] == "application/json"
assert call_kwargs["json"] == sample_payload


def test_register_certificate_http_error_raises(sample_payload, monkeypatch):
monkeypatch.setattr(module_under_test.config, "SERVICE_URL_REGISTRATION_API_SOLANA", "https://api.test/solana")
monkeypatch.setattr(module_under_test.config, "SERVICE_API_KEY_REGISTRATION_API_SOLANA", "secret-key")

mock_response = MagicMock()
mock_response.status_code = 500
mock_response.json.return_value = {"ok": False}

# raise_for_status levanta erro
def _raise():
raise Exception("boom")
mock_response.raise_for_status.side_effect = _raise

mock_client_instance = MagicMock()
mock_client_instance.post.return_value = mock_response
mock_client_instance.__enter__.return_value = mock_client_instance
mock_client_instance.__exit__.return_value = False

with patch("certified_builder.certificates_on_solana.httpx.Client", return_value=mock_client_instance):
with pytest.raises(CertificatesOnSolanaException) as exc:
CertificatesOnSolana.register_certificate_on_solana(sample_payload)

assert "boom" in str(exc.value.details)


Loading