diff --git a/.github/workflows/workflow_build.yaml b/.github/workflows/workflow_build.yaml index f900b64..09e0af2 100644 --- a/.github/workflows/workflow_build.yaml +++ b/.github/workflows/workflow_build.yaml @@ -1,4 +1,4 @@ -name: Publish Docker Image to AWS ECR Private +name: Deploy Lambda ZIP on: workflow_dispatch: @@ -7,7 +7,7 @@ on: - master jobs: build: - name: Build and push Docker image + name: Package and deploy Lambda ZIP runs-on: ubuntu-latest environment: certified-builder-py steps: @@ -22,24 +22,40 @@ jobs: aws-region: us-east-1 audience: sts.amazonaws.com - - name: Login to Amazon ECR Private - run: aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.us-east-1.amazonaws.com - - - name: Build Docker image + - name: Prepare package directories run: | - docker build -t ${{ secrets.ECR_REPOSITORY_API }}:latest . - docker tag ${{ secrets.ECR_REPOSITORY_API }}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.us-east-1.amazonaws.com/${{ secrets.ECR_REPOSITORY_API }}:latest - - - name: Push Docker image + rm -rf dist + mkdir -p dist/package + + - name: Build Lambda ZIP run: | - docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.us-east-1.amazonaws.com/${{ secrets.ECR_REPOSITORY_API }}:latest + docker run --rm \ + -v "$PWD:/work" \ + -w /work \ + python:3.13-slim \ + bash -lc ' + set -euo pipefail + apt-get update >/dev/null + apt-get install -y curl zip >/dev/null + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="/root/.local/bin:$PATH" + uv export --format requirements.txt --no-hashes > /tmp/requirements.txt + python -m pip install --upgrade pip >/dev/null + python -m pip install --no-cache-dir -r /tmp/requirements.txt -t dist/package >/dev/null + cp -R src dist/package/ + cp lambda_function.py pyproject.toml uv.lock dist/package/ + cd dist/package + zip -qr ../lambda.zip . + ' - - name: Update Lambda function + - name: Deploy Lambda ZIP run: | aws lambda update-function-code \ - --function-name tech-floripa-certified-api-dev \ - --image-uri ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.us-east-1.amazonaws.com/${{ secrets.ECR_REPOSITORY_API }}:latest - + --function-name tech-floripa-certificates-api-dev \ + --zip-file fileb://dist/lambda.zip + aws lambda wait function-updated \ + --function-name tech-floripa-certificates-api-dev + - name: Complete run: | - echo "Docker image has been pushed to AWS ECR Private and Lambda function has been updated" + echo "Lambda ZIP deployed successfully" diff --git a/Dockerfile b/Dockerfile index ba17563..5ffb348 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,50 +1,34 @@ -FROM public.ecr.aws/lambda/python:3.13 - -# Install system dependencies -# Removido dnf update para evitar conflitos de versão -# Removido curl pois já está disponível na imagem base -# Adicionado tar necessário para o instalador do UV -RUN dnf install -y \ - freetype-devel \ - libjpeg-turbo-devel \ - zlib-devel \ - gcc \ - make \ - python3-devel \ - fontconfig \ - ca-certificates \ - tar && \ - dnf clean all - -# Download the latest UV installer +FROM python:3.13-slim + +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl zip ca-certificates && \ + rm -rf /var/lib/apt/lists/* + ADD https://astral.sh/uv/install.sh /uv-installer.sh -# Run the installer then remove it RUN sh /uv-installer.sh && rm /uv-installer.sh -# Ensure the installed binary is on the PATH ENV PATH="/root/.local/bin/:$PATH" +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PYTHONPATH=/var/task +ENV AWS_LAMBDA_RUNTIME_API="" -# Set working directory -WORKDIR ${LAMBDA_TASK_ROOT} +WORKDIR /var/task -# Copy project configuration files for UV COPY pyproject.toml . COPY uv.lock . -# Install dependencies globally using UV -# Export dependencies to requirements.txt and install them globally RUN uv export --format requirements.txt > requirements.txt && \ - uv pip install --system -r requirements.txt + uv pip install --system -r requirements.txt && \ + pip install --no-cache-dir awslambdaric -# Copy the entire application -COPY . . +ADD https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie /usr/local/bin/aws-lambda-rie +RUN chmod +x /usr/local/bin/aws-lambda-rie -# Set environment variables -ENV PYTHONPATH=${LAMBDA_TASK_ROOT} -ENV FONTCONFIG_PATH=/etc/fonts -ENV PYTHONDONTWRITEBYTECODE=1 -ENV PYTHONUNBUFFERED=1 +COPY . . +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh -# Set the CMD to your handler -CMD [ "lambda_function.lambda_handler" ] \ No newline at end of file +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD [ "lambda_function.lambda_handler" ] diff --git a/README.md b/README.md index 0e2dbe9..83e7bf9 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,19 @@ Esta API é responsável por gerenciar a criação, consulta e download de certi ## Tecnologias Utilizadas -- **Python 3.12** +- **Python 3.13** - **AWS Lambda** - **API Gateway** - **Boto3**: AWS SDK para Python. - **Pydantic**: Para validação de dados. - **Docker**: Para containerização da aplicação. +## Desenvolvimento e Deploy + +- **Local**: `docker compose up --build` expõe a Lambda em `http://localhost:9000`. +- **Smoke test**: `python test_local.py` ou `bash test_lambda_container.sh`. +- **Deploy**: o workflow `.github/workflows/workflow_build.yaml` gera um ZIP e atualiza a função `tech-floripa-certificates-api-dev` com `aws lambda update-function-code`. + ## Endpoints A seguir estão os endpoints disponíveis na API. @@ -60,17 +66,12 @@ Cria múltiplos certificados a partir de uma lista de dados recebida diretamente "first_name": "João", "last_name": "Silva", "email": "joao.silva@example.com", - "phone": "(48) 99999-9999", - "cpf": "123.456.789-00", - "city": "Florianópolis", "product_id": 5678, "product_name": "Workshop de Python Avançado", "certificate_details": "In recognition of their participation in the Workshop de Python Avançado, held on January 15, 2025, at IFSC – Câmpus Florianópolis, Brazil, with a total duration of 8 hours.", "certificate_logo": "https://example.com/logo.png", "certificate_background": "https://example.com/background.png", "order_date": "2025-01-10 14:30:00", - "checkin_latitude": "-27.5667", - "checkin_longitude": "-48.5156", "time_checkin": "2025-01-15 09:00:00" }, { @@ -78,17 +79,12 @@ Cria múltiplos certificados a partir de uma lista de dados recebida diretamente "first_name": "Maria", "last_name": "Santos", "email": "maria.santos@example.com", - "phone": "(48) 88888-8888", - "cpf": "987.654.321-00", - "city": "São José", "product_id": 5678, "product_name": "Workshop de Python Avançado", "certificate_details": "In recognition of their participation in the Workshop de Python Avançado, held on January 15, 2025, at IFSC – Câmpus Florianópolis, Brazil, with a total duration of 8 hours.", "certificate_logo": "https://example.com/logo.png", "certificate_background": "https://example.com/background.png", "order_date": "2025-01-10 14:35:00", - "checkin_latitude": "-27.5667", - "checkin_longitude": "-48.5156", "time_checkin": "2025-01-15 09:05:00" } ] @@ -99,9 +95,6 @@ Cria múltiplos certificados a partir de uma lista de dados recebida diretamente - `first_name`: Primeiro nome do participante (string) - `last_name`: Sobrenome do participante (string) - `email`: Email do participante (string) - - `phone`: Telefone do participante (string) - - `cpf`: CPF do participante (string, pode ser vazio) - - `city`: Cidade do participante (string) - `product_id`: ID do produto (integer) - `product_name`: Nome do produto (string) - `certificate_details`: Detalhes do certificado (string) @@ -109,8 +102,6 @@ Cria múltiplos certificados a partir de uma lista de dados recebida diretamente - `certificate_background`: URL do background do certificado (string) - `order_date`: Data do pedido no formato "YYYY-MM-DD HH:MM:SS" (string) - **Campos opcionais:** - - `checkin_latitude`: Latitude do check-in (string, opcional) - - `checkin_longitude`: Longitude do check-in (string, opcional) - `time_checkin`: Data e hora do check-in no formato "YYYY-MM-DD HH:MM:SS" (string, opcional) - **Nota:** Certificados sem `time_checkin` serão marcados como inválidos e não serão processados. - **Saída (sucesso):** @@ -135,7 +126,7 @@ Cria múltiplos certificados a partir de uma lista de dados recebida diretamente Consulta certificados com base em diferentes critérios. -- **Endpoint:** `GET /v1/certificate/fetch` +- **Endpoint:** `GET /api/v1/certificate/fetch` - **Entrada (query parameters):** - `order_id` (opcional): ID do pedido. - `email` (opcional): Email do participante. @@ -149,7 +140,6 @@ Consulta certificados com base em diferentes critérios. "product_id": "integer", "participant_name": "string", "participant_email": "string", - "participant_document": "string", "certificate_url": "string", "created_at": "string", "updated_at": "string", @@ -170,14 +160,61 @@ Consulta certificados com base em diferentes critérios. } ``` +### Listar Certificados de um Usuário + +Lista os certificados de um usuário a partir do e-mail informado no path. + +- **Endpoint:** `GET /api/v1/users/{email}/certificates` +- **Entrada (path parameters):** + - `email` (obrigatório): e-mail do participante. Suporta URL encoding, por exemplo `user%2Bqa%40example.com`. +- **Entrada (query parameters):** + - `success` (opcional): `true` para retornar apenas certificados gerados com sucesso ou `false` para retornar apenas falhas. +- **Saída (sucesso):** + ```json + { + "email": "user@example.com", + "certificates": [ + { + "id": "string (uuid)", + "order_id": 123, + "product_id": 456, + "participant_name": "string", + "participant_email": "string", + "certificate_url": "string", + "created_at": "2025-01-20T10:30:45", + "updated_at": "2025-01-20T10:30:45", + "success": true + } + ] + } + ``` +- **Comportamento para vazio:** + ```json + { + "email": "user@example.com", + "certificates": [] + } + ``` + ### Download do Certificado Gera uma página para download de um certificado. -- **Endpoint:** `GET /v1/certificate/download` +- **Endpoint:** `GET /api/v1/certificate/download` - **Entrada (query parameters):** - `id` (obrigatório): UUID do certificado. - **Saída (sucesso):** - Retorna uma página HTML com o link para download do certificado. - **Saída (erro):** - - Retorna uma página HTML indicando o erro (certificado não encontrado, UUID inválido, etc.). \ No newline at end of file + - Retorna uma página HTML indicando o erro (certificado não encontrado, UUID inválido, etc.). + +## Exemplos Locais + +- Listar todos os certificados de um e-mail: + - `test_list_user_certificates("suzi.harima94@gmail.com")` +- Listar apenas certificados com sucesso: + - `test_list_user_certificates("suzi.harima94@gmail.com", success="true")` +- Listar apenas certificados com falha: + - `test_list_user_certificates("suzi.harima94@gmail.com", success="false")` +- Testar e-mail com URL encoding: + - `test_list_user_certificates("user+qa@example.com")` diff --git a/compose.yaml b/compose.yaml index 18c24dc..3eaf177 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,7 +6,7 @@ services: dockerfile: Dockerfile ports: - 9000:8080 - environment: + env_file: - .env - \ No newline at end of file + diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..6f87689 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +handler="${1:-lambda_function.lambda_handler}" + +if [ -z "${AWS_LAMBDA_RUNTIME_API:-}" ]; then + exec /usr/local/bin/aws-lambda-rie python -m awslambdaric "$handler" +fi + +exec python -m awslambdaric "$handler" diff --git a/scripts/scrub_pii.py b/scripts/scrub_pii.py new file mode 100644 index 0000000..18f1d25 --- /dev/null +++ b/scripts/scrub_pii.py @@ -0,0 +1,211 @@ +""" +Limpeza (scrub) de PII já armazenada em itens existentes do DynamoDB. + +Remove os atributos de PII que não são mais usados para gerar, entregar ou +validar um certificado. Os nomes dos atributos diferem por tabela: + + - participants: cpf, phone, city + - orders: participant_cpf, participant_phone, participant_city, + checkin_latitude, checkin_longitude + - certificates: participant_cpf, participant_phone, participant_city + +Segurança: + - Roda em DRY-RUN por padrão (apenas mostra o que faria, não escreve nada). + - Para aplicar de verdade é preciso passar --apply E o nome explícito de cada + tabela. NÃO existe default apontando para produção. + - Deve ser executado DEPOIS de remover os índices cpf/city (ver + py-certify-infra), pois um GSI exige que o atributo-chave exista nos itens. + +Uso: + # dry-run (padrão) — só participants + python scripts/scrub_pii.py --participants-table + + # aplicar de verdade nas três tabelas + python scripts/scrub_pii.py \ + --participants-table \ + --orders-table \ + --certificates-table \ + --apply +""" + +from __future__ import annotations + +import argparse +import logging +import os +import sys +from typing import Optional + +logger = logging.getLogger("scrub_pii") + +# Atributos de PII a remover, por entidade/tabela. +ATTRS_POR_ENTIDADE: dict[str, list[str]] = { + "participants": ["cpf", "phone", "city"], + "orders": [ + "participant_cpf", + "participant_phone", + "participant_city", + "checkin_latitude", + "checkin_longitude", + ], + "certificates": ["participant_cpf", "participant_phone", "participant_city"], +} + + +def montar_remocao( + item: dict, atributos_alvo: list[str] +) -> Optional[tuple[str, dict[str, str]]]: + """ + Monta a UpdateExpression de REMOVE apenas para os atributos-alvo que + realmente existem no item. Usa ExpressionAttributeNames para evitar + conflito com palavras reservadas do DynamoDB. + + Retorna (update_expression, expression_attribute_names) ou None se o item + não tiver nenhum dos atributos-alvo. + """ + presentes = [attr for attr in atributos_alvo if attr in item] + if not presentes: + return None + + nomes = {f"#a{i}": attr for i, attr in enumerate(presentes)} + update_expression = "REMOVE " + ", ".join(nomes.keys()) + return update_expression, nomes + + +def _chaves_da_tabela(client, table_name: str) -> list[str]: + """Retorna os nomes das colunas-chave (hash/range) da tabela.""" + descricao = client.describe_table(TableName=table_name) + return [k["AttributeName"] for k in descricao["Table"]["KeySchema"]] + + +def _projecao(chaves: list[str], atributos_alvo: list[str]) -> tuple[str, dict[str, str]]: + """ + Monta uma ProjectionExpression que busca apenas as chaves + os atributos-alvo + (evita puxar o item inteiro, minimizando o manuseio de PII). + """ + campos = list(dict.fromkeys([*chaves, *atributos_alvo])) + nomes = {f"#p{i}": campo for i, campo in enumerate(campos)} + return ", ".join(nomes.keys()), nomes + + +def limpar_tabela( + client, table_name: str, entidade: str, aplicar: bool, limite: Optional[int] +) -> tuple[int, int]: + """ + Varre a tabela e remove os atributos de PII dos itens que os possuem. + + Retorna (itens_varridos, itens_afetados). + """ + atributos_alvo = ATTRS_POR_ENTIDADE[entidade] + chaves = _chaves_da_tabela(client, table_name) + proj_expr, proj_nomes = _projecao(chaves, atributos_alvo) + + varridos = 0 + afetados = 0 + paginator = client.get_paginator("scan") + for pagina in paginator.paginate( + TableName=table_name, + ProjectionExpression=proj_expr, + ExpressionAttributeNames=proj_nomes, + ): + for item_dynamo in pagina.get("Items", []): + varridos += 1 + # item_dynamo vem no formato low-level ({"attr": {"S": "..."}}). Para + # decidir a presença basta olhar as chaves do dict. + remocao = montar_remocao(item_dynamo, atributos_alvo) + if remocao is None: + continue + + afetados += 1 + update_expression, nomes = remocao + chave = {k: item_dynamo[k] for k in chaves} + removidos = list(nomes.values()) + + if aplicar: + client.update_item( + TableName=table_name, + Key=chave, + UpdateExpression=update_expression, + ExpressionAttributeNames=nomes, + ) + logger.info("[%s] removido %s de %s", table_name, removidos, chave) + else: + logger.info( + "[dry-run][%s] removeria %s de %s", table_name, removidos, chave + ) + + if limite is not None and varridos >= limite: + return varridos, afetados + + return varridos, afetados + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--participants-table") + parser.add_argument("--orders-table") + parser.add_argument("--certificates-table") + parser.add_argument("--region", default=os.environ.get("AWS_REGION", "us-east-1")) + parser.add_argument( + "--apply", + action="store_true", + help="Aplica de verdade. Sem esta flag roda em dry-run.", + ) + parser.add_argument( + "--limit", type=int, default=None, help="Para após N itens (para testes)." + ) + args = parser.parse_args(argv) + + logging.basicConfig(level=logging.INFO, format="%(message)s") + + alvos = { + "participants": args.participants_table, + "orders": args.orders_table, + "certificates": args.certificates_table, + } + alvos = {entidade: nome for entidade, nome in alvos.items() if nome} + + if not alvos: + parser.error( + "informe ao menos uma tabela (--participants-table / --orders-table " + "/ --certificates-table). Não há default apontando para produção." + ) + + if args.apply: + logger.warning( + "*** MODO --apply: alterações serão gravadas em %s ***", list(alvos.values()) + ) + else: + logger.info("Modo DRY-RUN (nada será gravado). Use --apply para efetivar.") + + import boto3 # import tardio: mantém as funções puras testáveis sem boto3 + + client = boto3.client("dynamodb", region_name=args.region) + + total_varridos = 0 + total_afetados = 0 + for entidade, table_name in alvos.items(): + varridos, afetados = limpar_tabela( + client, table_name, entidade, args.apply, args.limit + ) + logger.info( + "%s: %d itens varridos, %d itens %s", + table_name, + varridos, + afetados, + "atualizados" if args.apply else "seriam atualizados", + ) + total_varridos += varridos + total_afetados += afetados + + logger.info( + "TOTAL: %d varridos, %d %s", + total_varridos, + total_afetados, + "atualizados" if args.apply else "seriam atualizados", + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/application/dto/fetch_certificate_dto.py b/src/application/dto/fetch_certificate_dto.py index 019db27..f5dd169 100644 --- a/src/application/dto/fetch_certificate_dto.py +++ b/src/application/dto/fetch_certificate_dto.py @@ -25,7 +25,6 @@ class FetchCertificateResponseDto(BaseModel): product_id: Optional[int] = None participant_name: Optional[str] = None participant_email: Optional[str] = None - participant_document: Optional[str] = None certificate_url: Optional[str] = None certificate_key: Optional[str] = None created_at: Optional[str] = None diff --git a/src/application/dto/list_user_certificates_dto.py b/src/application/dto/list_user_certificates_dto.py new file mode 100644 index 0000000..e3eb5dd --- /dev/null +++ b/src/application/dto/list_user_certificates_dto.py @@ -0,0 +1,25 @@ +from typing import Optional + +from pydantic import BaseModel + + +class ListUserCertificatesRequestDto(BaseModel): + email: str + success: Optional[bool] = None + + +class UserCertificateItemDto(BaseModel): + id: Optional[str] = None + order_id: Optional[int] = None + product_id: Optional[int] = None + participant_name: Optional[str] = None + participant_email: Optional[str] = None + certificate_url: Optional[str] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + success: Optional[bool] = None + + +class ListUserCertificatesResponseDto(BaseModel): + email: str + certificates: list[UserCertificateItemDto] diff --git a/src/application/list_user_certificates.py b/src/application/list_user_certificates.py new file mode 100644 index 0000000..fa0ee66 --- /dev/null +++ b/src/application/list_user_certificates.py @@ -0,0 +1,85 @@ +import logging +from datetime import datetime + +from src.application.dto.list_user_certificates_dto import ( + ListUserCertificatesRequestDto, + ListUserCertificatesResponseDto, + UserCertificateItemDto, +) +from src.domain.entity.certificate import Certificate +from src.domain.repository.certificate_repository import CertificateRepository + + +logger = logging.getLogger(__name__) + + +class ListUserCertificates: + def __init__(self, certificate_repository: CertificateRepository | None = None): + if certificate_repository is None: + from src.infrastructure.container.dependency_container import container + + certificate_repository = container.get("certificate_repository") + + self.certificate_repository = certificate_repository + + def execute(self, request: ListUserCertificatesRequestDto) -> ListUserCertificatesResponseDto: + logger.info( + "Listing certificates for email=%s success=%s", + request.email, + request.success, + ) + + certificates = self.certificate_repository.get_by_participant_email(request.email) + + if request.success is not None: + certificates = [ + certificate for certificate in certificates if certificate.success is request.success + ] + + sorted_certificates = sorted( + certificates, + key=self._sort_key, + reverse=True, + ) + + return ListUserCertificatesResponseDto( + email=request.email, + certificates=[self._to_item_dto(certificate) for certificate in sorted_certificates], + ) + + def _to_item_dto(self, certificate: Certificate) -> UserCertificateItemDto: + return UserCertificateItemDto( + id=str(certificate.id), + order_id=certificate.order_id, + product_id=certificate.product_id, + participant_name=f"{certificate.participant_first_name or ''} {certificate.participant_last_name or ''}".strip(), + participant_email=certificate.participant_email, + certificate_url=certificate.certificate_url, + created_at=certificate.generated_date, + updated_at=certificate.generated_date, + success=certificate.success, + ) + + def _sort_key(self, certificate: Certificate) -> tuple[bool, datetime]: + generated_at = self._parse_generated_date(certificate.generated_date) + return generated_at is not None, generated_at or datetime.min + + def _parse_generated_date(self, value: str | None) -> datetime | None: + if not value: + return None + + normalized_value = value.replace("Z", "+00:00") + + try: + return datetime.fromisoformat(normalized_value) + except ValueError: + pass + + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f"): + try: + return datetime.strptime(value, fmt) + except ValueError: + continue + + logger.warning("Unable to parse generated_date=%s for sorting", value) + return None diff --git a/src/application/mapper/tech_order.py b/src/application/mapper/tech_order.py index bec2ab5..881b0c8 100644 --- a/src/application/mapper/tech_order.py +++ b/src/application/mapper/tech_order.py @@ -13,16 +13,11 @@ def to_entity(tech_order_response: TechOrdersResponse) -> Order: participant_first_name=tech_order_response.first_name, participant_last_name=tech_order_response.last_name, participant_email=tech_order_response.email, - participant_phone=tech_order_response.phone, - participant_cpf=tech_order_response.cpf, - participant_city=tech_order_response.city, product_id=tech_order_response.product_id, product_name=tech_order_response.product_name, certificate_details=tech_order_response.certificate_details, certificate_logo=tech_order_response.certificate_logo, certificate_background=tech_order_response.certificate_background, - checkin_latitude=tech_order_response.checkin_latitude, - checkin_longitude=tech_order_response.checkin_longitude, time_checkin=tech_order_response.time_checkin ) @@ -35,9 +30,6 @@ def to_entity(tech_product_response: TechOrdersResponse) -> Product: certificate_details=tech_product_response.certificate_details, certificate_logo=tech_product_response.certificate_logo, certificate_background=tech_product_response.certificate_background, - checkin_latitude=tech_product_response.checkin_latitude, - checkin_longitude=tech_product_response.checkin_longitude, - time_checkin=tech_product_response.time_checkin ) class TechParticipantMapper: @@ -46,10 +38,7 @@ def to_entity(tech_order_response: TechOrdersResponse) -> Order: return Participant( first_name=tech_order_response.first_name, last_name=tech_order_response.last_name, - email=tech_order_response.email, - phone=tech_order_response.phone, - cpf=tech_order_response.cpf, - city=tech_order_response.city + email=tech_order_response.email ) class CertificateMapper: @@ -65,8 +54,5 @@ def to_entity(tech_order_response: TechOrdersResponse) -> Certificate: certificate_background=tech_order_response.certificate_background, participant_email=tech_order_response.email, participant_first_name=tech_order_response.first_name, - participant_last_name=tech_order_response.last_name, - participant_cpf=tech_order_response.cpf, - participant_phone=tech_order_response.phone, - participant_city=tech_order_response.city + participant_last_name=tech_order_response.last_name ) \ No newline at end of file diff --git a/src/application/strategy/fetch_certificate_strategy.py b/src/application/strategy/fetch_certificate_strategy.py index f9b293b..f6cb6ae 100644 --- a/src/application/strategy/fetch_certificate_strategy.py +++ b/src/application/strategy/fetch_certificate_strategy.py @@ -53,7 +53,6 @@ def execute(self, request: FetchCertificateRequestDto) -> List[FetchCertificateR product_id=certificate.product_id, participant_name=f"{certificate.participant_first_name or ''} {certificate.participant_last_name or ''}".strip(), participant_email=certificate.participant_email, - participant_document=certificate.participant_cpf, certificate_url=certificate.certificate_url, certificate_key=certificate.certificate_key, created_at=certificate.generated_date, diff --git a/src/domain/entity/certificate.py b/src/domain/entity/certificate.py index a38bc4a..67acaf3 100644 --- a/src/domain/entity/certificate.py +++ b/src/domain/entity/certificate.py @@ -18,6 +18,3 @@ class Certificate(BaseModel): participant_email: Optional[str] participant_first_name: Optional[str] participant_last_name: Optional[str] - participant_cpf: Optional[str] - participant_phone: Optional[str] - participant_city: Optional[str] diff --git a/src/domain/entity/order.py b/src/domain/entity/order.py index 3f03398..2569bf5 100644 --- a/src/domain/entity/order.py +++ b/src/domain/entity/order.py @@ -8,13 +8,8 @@ class Order(BaseModel): certificate_details: str certificate_logo: str certificate_background: str - checkin_latitude: str - checkin_longitude: str time_checkin: str participant_email: str participant_first_name: str participant_last_name: str - participant_cpf: str - participant_phone: str - participant_city: str diff --git a/src/domain/entity/participant.py b/src/domain/entity/participant.py index 8a15af5..19373dc 100644 --- a/src/domain/entity/participant.py +++ b/src/domain/entity/participant.py @@ -1,12 +1,8 @@ from pydantic import BaseModel, Field import uuid -from typing import Optional class Participant(BaseModel): id: uuid.UUID = Field(default_factory=uuid.uuid4) first_name: str last_name: str - email: str - phone: Optional[str] = None - cpf: Optional[str] = None - city: Optional[str] = None \ No newline at end of file + email: str \ No newline at end of file diff --git a/src/domain/entity/product.py b/src/domain/entity/product.py index 4353ca7..661a935 100644 --- a/src/domain/entity/product.py +++ b/src/domain/entity/product.py @@ -7,8 +7,5 @@ class Product(BaseModel): certificate_details: str certificate_logo: Optional[str] = None certificate_background: Optional[str] = None - checkin_latitude: Optional[str] = None - checkin_longitude: Optional[str] = None - time_checkin: Optional[str] = None \ No newline at end of file diff --git a/src/domain/repository/participant_repository.py b/src/domain/repository/participant_repository.py index 2027fd0..0ee1d9a 100644 --- a/src/domain/repository/participant_repository.py +++ b/src/domain/repository/participant_repository.py @@ -1,5 +1,5 @@ from abc import abstractmethod -from typing import List, Optional +from typing import Optional from src.domain.entity.participant import Participant from src.domain.repository.base_repository import BaseRepository @@ -13,23 +13,8 @@ class ParticipantRepository(BaseRepository[Participant]): def get_by_email(self, email: str) -> Optional[Participant]: """Busca participante por email""" pass - - @abstractmethod - def get_by_cpf(self, cpf: str) -> Optional[Participant]: - """Busca participante por CPF""" - pass - - @abstractmethod - def get_by_city(self, city: str) -> List[Participant]: - """Busca participantes por cidade""" - pass - + @abstractmethod def email_exists(self, email: str) -> bool: """Verifica se um email já existe""" pass - - @abstractmethod - def cpf_exists(self, cpf: str) -> bool: - """Verifica se um CPF já existe""" - pass diff --git a/src/domain/response/tech_floripa.py b/src/domain/response/tech_floripa.py index 4b3b004..c7942cf 100644 --- a/src/domain/response/tech_floripa.py +++ b/src/domain/response/tech_floripa.py @@ -6,17 +6,12 @@ class TechOrdersResponse(BaseModel): first_name: str last_name: str email: str - phone: str - cpf: str - city: str product_id: int product_name: str certificate_details: str certificate_logo: str certificate_background: str order_date: str - checkin_latitude: Optional[str] - checkin_longitude: Optional[str] time_checkin: Optional[str] diff --git a/src/infrastructure/aws/dynamodb_service.py b/src/infrastructure/aws/dynamodb_service.py index 598f48a..8dd37eb 100644 --- a/src/infrastructure/aws/dynamodb_service.py +++ b/src/infrastructure/aws/dynamodb_service.py @@ -80,7 +80,14 @@ def get_item(self, key: Dict, table_name: str) -> Optional[Dict]: logger.error(f"Erro ao buscar item na tabela {table_name}: {str(e)}") raise - def update_item(self, key: Dict, update_expression: str, expression_values: Dict, table_name: str) -> Dict: + def update_item( + self, + key: Dict, + update_expression: str, + expression_values: Dict, + table_name: str, + expression_attribute_names: Dict = None, + ) -> Dict: """ Atualiza um item na tabela DynamoDB. @@ -99,13 +106,16 @@ def update_item(self, key: Dict, update_expression: str, expression_values: Dict expression_values = self._convert_to_dynamodb_format(expression_values) logger.info(f"Atualizando item na tabela {table_name} com chave: {key}") - response = self.aws.update_item( + update_kwargs = dict( TableName=self.build_table_name(table_name), Key=dynamodb_key, UpdateExpression=update_expression, ExpressionAttributeValues=expression_values, - ReturnValues="ALL_NEW" ) + if expression_attribute_names: + update_kwargs["ExpressionAttributeNames"] = expression_attribute_names + update_kwargs["ReturnValues"] = "ALL_NEW" + response = self.aws.update_item(**update_kwargs) logger.info(f"Item atualizado com sucesso: {response}") return response except ClientError as e: @@ -159,12 +169,18 @@ def scan_table(self, table_name: str, filter_expression: str = None, expression_ scan_kwargs['ExpressionAttributeValues'] = expression_values logger.info(f"Escaneando tabela {table_name}") - response = self.aws.scan(**scan_kwargs) - items = [] - if 'Items' in response: - for item in response['Items']: - items.append(self._convert_from_dynamodb_format(item)) + while True: + response = self.aws.scan(**scan_kwargs) + + if 'Items' in response: + for item in response['Items']: + items.append(self._convert_from_dynamodb_format(item)) + + if 'LastEvaluatedKey' not in response: + break + + scan_kwargs['ExclusiveStartKey'] = response['LastEvaluatedKey'] logger.info(f"Encontrados {len(items)} itens na tabela {table_name}") return items @@ -173,7 +189,16 @@ def scan_table(self, table_name: str, filter_expression: str = None, expression_ logger.error(f"Erro ao escanear tabela {table_name}: {str(e)}") raise - def query_table(self, table_name: str, key_condition_expression: str, expression_values: Dict) -> List[Dict]: + def query_table( + self, + table_name: str, + key_condition_expression: str, + expression_values: Dict, + index_name: str = None, + filter_expression: str = None, + expression_attribute_names: Dict = None, + scan_index_forward: bool = True, + ) -> List[Dict]: """ Consulta uma tabela DynamoDB. @@ -186,19 +211,35 @@ def query_table(self, table_name: str, key_condition_expression: str, expression List[Dict]: Lista de itens encontrados """ try: - expression_values = self._convert_to_dynamodb_format(expression_values) - + query_kwargs = { + "TableName": self.build_table_name(table_name), + "KeyConditionExpression": key_condition_expression, + "ExpressionAttributeValues": self._convert_to_dynamodb_format(expression_values), + "ScanIndexForward": scan_index_forward, + } + + if index_name: + query_kwargs['IndexName'] = index_name + + if filter_expression: + query_kwargs['FilterExpression'] = filter_expression + + if expression_attribute_names: + query_kwargs['ExpressionAttributeNames'] = expression_attribute_names + logger.info(f"Consultando tabela {table_name} com expressão: {key_condition_expression}") - response = self.aws.query( - TableName=self.build_table_name(table_name), - KeyConditionExpression=key_condition_expression, - ExpressionAttributeValues=expression_values - ) - items = [] - if 'Items' in response: - for item in response['Items']: - items.append(self._convert_from_dynamodb_format(item)) + while True: + response = self.aws.query(**query_kwargs) + + if 'Items' in response: + for item in response['Items']: + items.append(self._convert_from_dynamodb_format(item)) + + if 'LastEvaluatedKey' not in response: + break + + query_kwargs['ExclusiveStartKey'] = response['LastEvaluatedKey'] logger.info(f"Encontrados {len(items)} itens na consulta") return items @@ -315,4 +356,3 @@ def get_all_tables_info(self) -> Dict[str, Dict[str, str]]: Dict: Informações das tabelas (nome e ARN) """ return self.config.dynamodb_tables - diff --git a/src/infrastructure/container/dependency_container.py b/src/infrastructure/container/dependency_container.py index 7c947f0..2d87b1b 100644 --- a/src/infrastructure/container/dependency_container.py +++ b/src/infrastructure/container/dependency_container.py @@ -50,6 +50,7 @@ def _register_services(self): self._services['send_for_build_certificate'] = self._create_send_for_build_certificate self._services['create_certificate'] = self._create_create_certificate self._services['fetch_certificate'] = self._create_fetch_certificate + self._services['list_user_certificates'] = self._create_list_user_certificates self._services['fetch_order_tech_floripa'] = self._create_fetch_order_tech_floripa self._services['download_certificate'] = self._create_download_certificate @@ -140,6 +141,12 @@ def _create_fetch_certificate(self): from src.application.fetch_certificate import FetchCertificate return FetchCertificate() + def _create_list_user_certificates(self): + """Cria uma instância do ListUserCertificates.""" + from src.application.list_user_certificates import ListUserCertificates + certificate_repository = self.get('certificate_repository') + return ListUserCertificates(certificate_repository) + def _create_download_certificate(self): """Cria uma instância do DownloadCertificate.""" from src.application.download_certificate import DownloadCertificate diff --git a/src/infrastructure/repository/certificate_repository_impl.py b/src/infrastructure/repository/certificate_repository_impl.py index 47e89f8..188986a 100644 --- a/src/infrastructure/repository/certificate_repository_impl.py +++ b/src/infrastructure/repository/certificate_repository_impl.py @@ -1,328 +1,236 @@ -import json import logging +import uuid from typing import List, Optional, Union + from src.domain.entity.certificate import Certificate from src.domain.repository.certificate_repository import CertificateRepository from src.infrastructure.aws.dynamodb_service import DynamoDBService -import uuid logger = logging.getLogger() logger.setLevel(logging.INFO) -class CertificateRepositoryImpl(CertificateRepository): + +def _normalize_email(email: Optional[str]) -> Optional[str]: + if email is None: + return None + return email.strip().lower() + + +def _email_product_key(email: Optional[str], product_id: Optional[int]) -> Optional[str]: + normalized_email = _normalize_email(email) + if not normalized_email or product_id is None: + return None + return f"{normalized_email}#{product_id}" + + +def _success_flag(success: Optional[bool]) -> int: + return 1 if success else 0 + + +class CertificateRepositoryImpl(CertificateRepository): def __init__(self, dynamodb_service: DynamoDBService, table_name: str = "certificates"): self.dynamodb_service = dynamodb_service self.table_name = table_name - + def create(self, entity: Certificate) -> Certificate: - try: - item = entity.model_dump() + item = self._prepare_item(entity) self.dynamodb_service.put_item(item, self.table_name) - + logger.info(f"Certificado criado com sucesso: {entity.id}") return entity - + except Exception as e: logger.error(f"Erro ao criar certificado: {str(e)}") raise - + def get_by_id(self, entity_id: str, order_id: int = None) -> Optional[Certificate]: - try: - # Como a tabela tem chave composta (id + order_id), precisamos de ambos - if order_id is None: - # Se não fornecer order_id, usa scan para buscar apenas por id - filter_expression = "id = :id" - expression_values = {":id": entity_id} - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression, - expression_values - ) - - if items: - return Certificate(**items[0]) + certificate = self.find_by_id(entity_id) + if not certificate: return None - else: - # Se fornecer order_id, usa get_item com chave composta - key = {"id": entity_id, "order_id": order_id} - item = self.dynamodb_service.get_item(key, self.table_name) - - if item: - return Certificate(**item) + + if order_id is not None and certificate.order_id != order_id: return None - + + return certificate + except Exception as e: logger.error(f"Erro ao buscar certificado por ID {entity_id}: {str(e)}") raise - + def get_all(self) -> List[Certificate]: - try: items = self.dynamodb_service.scan_table(self.table_name) - certificates = [] - - for item in items: - certificates.append(Certificate(**item)) - - return certificates - + return [Certificate(**item) for item in items] + except Exception as e: logger.error(f"Erro ao buscar todos os certificados: {str(e)}") raise - + def update(self, entity_id: str, entity: Certificate) -> Optional[Certificate]: - try: - # Verifica se o certificado existe - if not self.exists(entity_id): + existing_certificate = self.find_by_id(entity_id) + if not existing_certificate: return None - - # Converte a entidade para dicionário - update_data = entity.model_dump() - - # Remove o ID do update_data para não atualizar a chave primária - if 'id' in update_data: - del update_data['id'] - - # Constrói a expressão de atualização + + update_data = self._prepare_item(entity) + update_data.pop("id", None) + update_data.pop("order_id", None) + update_expression = "SET " expression_values = {} expression_names = {} - + for key, value in update_data.items(): if value is not None: update_expression += f"#{key} = :{key}, " expression_values[f":{key}"] = value expression_names[f"#{key}"] = key - - # Remove a vírgula extra no final + update_expression = update_expression.rstrip(", ") - - # Converte os valores para o formato JSON do DynamoDB - expression_values = self.dynamodb_service._convert_to_dynamodb_format(expression_values) - - # Atualiza o item - key = {"id": entity_id} - # Converte a chave para o formato JSON do DynamoDB - key = self.dynamodb_service._convert_to_dynamodb_format(key) - response = self.dynamodb_service.aws.update_item( - TableName=self.table_name, - Key=key, - UpdateExpression=update_expression, - ExpressionAttributeValues=expression_values, - ExpressionAttributeNames=expression_names, - ReturnValues="ALL_NEW" + + response = self.dynamodb_service.update_item( + {"order_id": existing_certificate.order_id}, + update_expression, + expression_values, + self.table_name, + expression_attribute_names=expression_names, ) - - if 'Attributes' in response: - return Certificate(**response['Attributes']) + + if "Attributes" in response: + result_dict = self.dynamodb_service._convert_from_dynamodb_format(response["Attributes"]) + return Certificate(**result_dict) return None - + except Exception as e: logger.error(f"Erro ao atualizar certificado {entity_id}: {str(e)}") raise - + def delete(self, entity_id: str, order_id: int = None) -> bool: - try: - # Como a tabela tem chave composta (id + order_id), precisamos de ambos - if order_id is None: - # Se não fornecer order_id, usa scan para buscar e depois deletar - filter_expression = "id = :id" - expression_values = {":id": entity_id} - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression, - expression_values - ) - - if items: - # Deleta o primeiro item encontrado - item = items[0] - key = {"id": item['id'], "order_id": item['order_id']} - self.dynamodb_service.delete_item(key, self.table_name) - - logger.info(f"Certificado {entity_id} removido com sucesso") - return True + certificate = self.get_by_id(entity_id, order_id) + if not certificate: return False - else: - # Se fornecer order_id, usa delete_item com chave composta - key = {"id": entity_id, "order_id": order_id} - self.dynamodb_service.delete_item(key, self.table_name) - - logger.info(f"Certificado {entity_id} removido com sucesso") - return True - + + self.dynamodb_service.delete_item({"order_id": certificate.order_id}, self.table_name) + logger.info(f"Certificado {entity_id} removido com sucesso") + return True + except Exception as e: logger.error(f"Erro ao remover certificado {entity_id}: {str(e)}") return False - + def exists(self, entity_id: str, order_id: int = None) -> bool: try: - # Como a tabela tem chave composta (id + order_id), precisamos de ambos - if order_id is None: - # Se não fornecer order_id, usa scan para buscar apenas por id - filter_expression = "id = :id" - expression_values = {":id": entity_id} - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression, - expression_values - ) - - return len(items) > 0 - else: - # Se fornecer order_id, usa get_item com chave composta - key = {"id": entity_id, "order_id": order_id} - item = self.dynamodb_service.get_item(key, self.table_name) - return item is not None - + return self.get_by_id(entity_id, order_id) is not None + except Exception as e: logger.error(f"Erro ao verificar existência do certificado {entity_id}: {str(e)}") return False - + def find_by_id(self, entity_id: Union[str, uuid.UUID]) -> Optional[Certificate]: try: - # Converte UUID para string se necessário - if isinstance(entity_id, uuid.UUID): - entity_id = str(entity_id) - - filter_expression = "id = :id" - expression_values = {":id": entity_id} - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression, - expression_values + normalized_id = str(entity_id) + items = self.dynamodb_service.query_table( + self.table_name, + "id = :id", + {":id": normalized_id}, + index_name="certificate_id_idx", ) - if items: return Certificate(**items[0]) return None - + except Exception as e: logger.error(f"Erro ao buscar certificado por UUID {entity_id}: {str(e)}") raise - + def get_by_order_id(self, order_id: int) -> List[Certificate]: - try: - # Como a tabela tem chave composta (id + order_id), usamos scan para buscar por order_id - filter_expression = "order_id = :order_id" - expression_values = {":order_id": order_id} - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression, - expression_values - ) - - certificates = [] - for item in items: - certificates.append(Certificate(**item)) - - return certificates - + item = self.dynamodb_service.get_item({"order_id": order_id}, self.table_name) + if not item: + return [] + return [Certificate(**item)] + except Exception as e: logger.error(f"Erro ao buscar certificados por order_id {order_id}: {str(e)}") raise - + def get_by_participant_email(self, email: str) -> List[Certificate]: - try: - filter_expression = "participant_email = :email" - expression_values = {":email": email} - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression, - expression_values + items = self.dynamodb_service.query_table( + self.table_name, + "participant_email = :email", + {":email": _normalize_email(email)}, + index_name="certificates_by_email_idx", + scan_index_forward=False, ) - - certificates = [] - for item in items: - certificates.append(Certificate(**item)) - - return certificates - + return [Certificate(**item) for item in items] + except Exception as e: logger.error(f"Erro ao buscar certificados por email {email}: {str(e)}") raise - + def get_by_email_and_product_id(self, email: str, product_id: int) -> List[Certificate]: - """ - Busca certificados por email do participante e product_id. - Usado pelo endpoint que recebe email como path e product_id como query parameter. - """ try: - filter_expression = "participant_email = :email AND product_id = :product_id" - expression_values = { - ":email": email, - ":product_id": product_id - } - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression, - expression_values + items = self.dynamodb_service.query_table( + self.table_name, + "participant_email_product_key = :email_product_key", + {":email_product_key": _email_product_key(email, product_id)}, + index_name="certificates_by_email_product_idx", + scan_index_forward=False, + ) + certificates = [Certificate(**item) for item in items] + logger.info( + "Encontrados %s certificados para email %s e product_id %s", + len(certificates), + email, + product_id, ) - - certificates = [] - for item in items: - certificates.append(Certificate(**item)) - - logger.info(f"Encontrados {len(certificates)} certificados para email {email} e product_id {product_id}") return certificates - + except Exception as e: logger.error(f"Erro ao buscar certificados por email {email} e product_id {product_id}: {str(e)}") raise - + def get_by_product_id(self, product_id: int) -> List[Certificate]: - try: - filter_expression = "product_id = :product_id" - expression_values = {":product_id": product_id} - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression, - expression_values + items = self.dynamodb_service.query_table( + self.table_name, + "product_id = :product_id", + {":product_id": product_id}, + index_name="certificates_by_product_idx", + scan_index_forward=False, ) - - certificates = [] - for item in items: - certificates.append(Certificate(**item)) - - return certificates - + return [Certificate(**item) for item in items] + except Exception as e: logger.error(f"Erro ao buscar certificados por product_id {product_id}: {str(e)}") raise - + def get_successful_certificates(self) -> List[Certificate]: - try: - filter_expression = "success = :success" - expression_values = {":success": True} - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression, - expression_values + items = self.dynamodb_service.query_table( + self.table_name, + "success_flag = :success_flag", + {":success_flag": 1}, + index_name="certificates_by_success_idx", + scan_index_forward=False, ) - - certificates = [] - for item in items: - certificates.append(Certificate(**item)) - - return certificates - + return [Certificate(**item) for item in items] + except Exception as e: logger.error(f"Erro ao buscar certificados bem-sucedidos: {str(e)}") raise + + def _prepare_item(self, entity: Certificate) -> dict: + item = entity.model_dump() + item["id"] = str(entity.id) + item["participant_email"] = _normalize_email(item.get("participant_email")) + item["participant_email_product_key"] = _email_product_key( + item.get("participant_email"), + item.get("product_id"), + ) + item["success_flag"] = _success_flag(item.get("success")) + return item diff --git a/src/infrastructure/repository/order_repository_impl.py b/src/infrastructure/repository/order_repository_impl.py index ded6521..b41e4b6 100644 --- a/src/infrastructure/repository/order_repository_impl.py +++ b/src/infrastructure/repository/order_repository_impl.py @@ -1,7 +1,8 @@ -import json import logging import uuid +from datetime import datetime, timedelta from typing import List, Optional, Union + from src.domain.entity.order import Order from src.domain.repository.order_repository import OrderRepository from src.infrastructure.aws.dynamodb_service import DynamoDBService @@ -9,229 +10,223 @@ logger = logging.getLogger() logger.setLevel(logging.INFO) + +def _normalize_email(email: str) -> str: + return email.strip().lower() + + +def _parse_order_date(value: str) -> datetime: + normalized_value = value.replace("Z", "+00:00") + for parser in ( + lambda: datetime.fromisoformat(normalized_value), + lambda: datetime.strptime(value, "%Y-%m-%d %H:%M:%S"), + lambda: datetime.strptime(value, "%Y-%m-%d"), + ): + try: + return parser() + except ValueError: + continue + raise ValueError(f"Formato de order_date não suportado: {value}") + + +def _as_naive_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value + return value.astimezone().replace(tzinfo=None) + + +def _order_year_month(value: str) -> str: + return _parse_order_date(value).strftime("%Y-%m") + + +def _order_date_order_id(value: str, order_id: int) -> str: + return f"{_parse_order_date(value).isoformat()}#{order_id:020d}" + + class OrderRepositoryImpl(OrderRepository): def __init__(self, dynamodb_service: DynamoDBService, table_name: str = "orders"): self.dynamodb_service = dynamodb_service self.table_name = table_name - - def create(self, entity: Order) -> Order: + def create(self, entity: Order) -> Order: try: - item = entity.model_dump() + item = self._prepare_item(entity) self.dynamodb_service.put_item(item, self.table_name) return entity - + except Exception as e: logger.error(f"Erro ao criar pedido: {str(e)}") raise - + def get_by_id(self, entity_id: int) -> Optional[Order]: - try: - key = {"order_id": entity_id} - item = self.dynamodb_service.get_item(key, self.table_name) + item = self.dynamodb_service.get_item({"order_id": entity_id}, self.table_name) if item: return Order(**item) return None - + except Exception as e: logger.error(f"Erro ao buscar pedido por ID {entity_id}: {str(e)}") raise - + def find_by_id(self, entity_id: Union[str, uuid.UUID]) -> Optional[Order]: - """ - Busca pedido por ID, aceitando tanto string quanto UUID. - Implementa o método abstrato definido na classe base BaseRepository. - Nota: Como Order usa int como ID, converte string/UUID para int. - """ try: - # Converte para string primeiro, depois para int if isinstance(entity_id, uuid.UUID): - # Se for UUID, converte para string e depois tenta extrair um int - # Isso pode não ser ideal - talvez precise de uma lógica específica id_str = str(entity_id) - # Por enquanto, vamos tentar usar os primeiros dígitos como int - # Isso é uma solução temporária - talvez precise ajustar conforme a lógica de negócio - id_int = int(id_str.replace('-', '')[:10]) # Pega os primeiros 10 dígitos + id_int = int(id_str.replace("-", "")[:10]) else: - # Se for string, tenta converter para int id_int = int(str(entity_id)) - - # Reutiliza a lógica existente do get_by_id + return self.get_by_id(id_int) - + except (ValueError, TypeError) as e: logger.error(f"Erro ao converter ID {entity_id} para int: {str(e)}") return None except Exception as e: logger.error(f"Erro ao buscar pedido por ID {entity_id}: {str(e)}") raise - + def get_all(self) -> List[Order]: - try: items = self.dynamodb_service.scan_table(self.table_name) - orders = [] - - for item in items: - orders.append(Order(**item)) - - return orders - + return [Order(**item) for item in items] + except Exception as e: logger.error(f"Erro ao buscar todos os pedidos: {str(e)}") raise - + def update(self, entity_id: int, entity: Order) -> Optional[Order]: - try: if not self.exists(entity_id): return None - - update_data = entity.model_dump() - - if 'id' in update_data: - del update_data['id'] - - # Constrói a expressão de atualização + + update_data = self._prepare_item(entity) + update_data.pop("order_id", None) + update_expression = "SET " expression_values = {} - + expression_names = {} + for key, value in update_data.items(): if value is not None: update_expression += f"#{key} = :{key}, " expression_values[f":{key}"] = value - - # Remove a vírgula extra no final + expression_names[f"#{key}"] = key + update_expression = update_expression.rstrip(", ") - - # Adiciona os nomes dos atributos - expression_names = {f"#{key}": key for key in update_data.keys() if update_data[key] is not None} - - # Atualiza o item - key = {"order_id": entity_id} + response = self.dynamodb_service.update_item( - key, + {"order_id": entity_id}, update_expression, expression_values, - self.table_name + self.table_name, + expression_attribute_names=expression_names, ) - - if 'Attributes' in response: - return Order(**response['Attributes']) + + if "Attributes" in response: + result_dict = self.dynamodb_service._convert_from_dynamodb_format(response["Attributes"]) + return Order(**result_dict) return None - + except Exception as e: logger.error(f"Erro ao atualizar pedido {entity_id}: {str(e)}") raise - + def delete(self, entity_id: int) -> bool: - try: - key = {"order_id": entity_id} - self.dynamodb_service.delete_item(key, self.table_name) + self.dynamodb_service.delete_item({"order_id": entity_id}, self.table_name) return True - + except Exception as e: logger.error(f"Erro ao remover pedido {entity_id}: {str(e)}") return False - + def exists(self, entity_id: int) -> bool: - try: - key = {"order_id": entity_id} - item = self.dynamodb_service.get_item(key, self.table_name) - return item is not None - + return self.get_by_id(entity_id) is not None + except Exception as e: logger.error(f"Erro ao verificar existência do pedido {entity_id}: {str(e)}") return False - + def get_by_order_id(self, order_id: int) -> Optional[Order]: - try: - filter_expression = "orderId = :order_id" - expression_values = {":order_id": order_id} - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression, - expression_values - ) - - if items: - return Order(**items[0]) - return None - + return self.get_by_id(order_id) + except Exception as e: logger.error(f"Erro ao buscar pedido por order_id {order_id}: {str(e)}") raise - + def get_by_participant_email(self, email: str) -> List[Order]: - try: - filter_expression = "participantEmail = :email" - expression_values = {":email": email} - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression, - expression_values + items = self.dynamodb_service.query_table( + self.table_name, + "participant_email = :email", + {":email": _normalize_email(email)}, + index_name="orders_by_email_idx", + scan_index_forward=False, ) - - orders = [] - for item in items: - orders.append(Order(**item)) - - return orders - + return [Order(**item) for item in items] + except Exception as e: logger.error(f"Erro ao buscar pedidos por email {email}: {str(e)}") raise - + def get_by_product_id(self, product_id: int) -> List[Order]: - try: - filter_expression = "productId = :product_id" - expression_values = {":product_id": product_id} - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression, - expression_values + items = self.dynamodb_service.query_table( + self.table_name, + "product_id = :product_id", + {":product_id": product_id}, + index_name="orders_by_product_idx", + scan_index_forward=False, ) - - orders = [] - for item in items: - orders.append(Order(**item)) - - return orders - + return [Order(**item) for item in items] + except Exception as e: logger.error(f"Erro ao buscar pedidos por product_id {product_id}: {str(e)}") raise - + def get_orders_by_date_range(self, start_date: str, end_date: str) -> List[Order]: - try: - filter_expression = "orderDate BETWEEN :start_date AND :end_date" - expression_values = { - ":start_date": start_date, - ":end_date": end_date - } - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression, - expression_values - ) - - orders = [] - for item in items: - orders.append(Order(**item)) - - return orders - + start_dt = _as_naive_utc(_parse_order_date(start_date)) + end_dt = _as_naive_utc(_parse_order_date(end_date)) + if start_dt > end_dt: + start_dt, end_dt = end_dt, start_dt + + current_month = datetime(start_dt.year, start_dt.month, 1) + final_month = datetime(end_dt.year, end_dt.month, 1) + items = [] + + while current_month <= final_month: + month_key = current_month.strftime("%Y-%m") + month_start = max(start_dt, current_month) + next_month = (current_month.replace(day=28) + timedelta(days=4)).replace(day=1) + month_end_boundary = next_month - timedelta(microseconds=1) + month_end = min(end_dt, month_end_boundary) + + month_items = self.dynamodb_service.query_table( + self.table_name, + "order_year_month = :order_year_month AND order_date_order_id BETWEEN :start_range AND :end_range", + { + ":order_year_month": month_key, + ":start_range": f"{month_start.isoformat()}#00000000000000000000", + ":end_range": f"{month_end.isoformat()}#99999999999999999999", + }, + index_name="orders_by_month_idx", + ) + items.extend(month_items) + current_month = next_month + + return [Order(**item) for item in items] + except Exception as e: logger.error(f"Erro ao buscar pedidos por intervalo de datas {start_date} - {end_date}: {str(e)}") raise + + def _prepare_item(self, entity: Order) -> dict: + item = entity.model_dump() + item["participant_email"] = _normalize_email(item["participant_email"]) + item["order_year_month"] = _order_year_month(item["order_date"]) + item["order_date_order_id"] = _order_date_order_id(item["order_date"], item["order_id"]) + return item diff --git a/src/infrastructure/repository/participant_repository_impl.py b/src/infrastructure/repository/participant_repository_impl.py index f0613c3..e5ad7dd 100644 --- a/src/infrastructure/repository/participant_repository_impl.py +++ b/src/infrastructure/repository/participant_repository_impl.py @@ -1,7 +1,7 @@ -import json import logging import uuid from typing import List, Optional, Union + from src.domain.entity.participant import Participant from src.domain.repository.participant_repository import ParticipantRepository from src.infrastructure.aws.dynamodb_service import DynamoDBService @@ -9,205 +9,134 @@ logger = logging.getLogger() logger.setLevel(logging.INFO) -class ParticipantRepositoryImpl(ParticipantRepository): + +def _normalize_email(email: Optional[str]) -> Optional[str]: + if email is None: + return None + return email.strip().lower() + + +class ParticipantRepositoryImpl(ParticipantRepository): def __init__(self, dynamodb_service: DynamoDBService, table_name: str = "participants"): self.dynamodb_service = dynamodb_service self.table_name = table_name - - def create(self, entity: Participant) -> Participant: + + def create(self, entity: Participant) -> Participant: try: item = entity.model_dump() + item["id"] = str(entity.id) + item["email"] = _normalize_email(item.get("email")) self.dynamodb_service.put_item(item, self.table_name) return entity except Exception as e: logger.error(f"Erro ao criar participante: {str(e)}") raise - - def get_by_id(self, entity_id: str) -> Optional[Participant]: + + def get_by_id(self, entity_id: str) -> Optional[Participant]: try: - key = {"id": entity_id} - item = self.dynamodb_service.get_item(key, self.table_name) - + item = self.dynamodb_service.get_item({"id": entity_id}, self.table_name) if item: return Participant(**item) return None - + except Exception as e: logger.error(f"Erro ao buscar participante por ID {entity_id}: {str(e)}") raise - + def find_by_id(self, entity_id: Union[str, uuid.UUID]) -> Optional[Participant]: - """ - Busca participante por ID, aceitando tanto string quanto UUID. - Implementa o método abstrato definido na classe base BaseRepository. - """ try: - # Converte UUID para string se necessário id_str = str(entity_id) if isinstance(entity_id, uuid.UUID) else entity_id - - # Reutiliza a lógica existente do get_by_id return self.get_by_id(id_str) - + except Exception as e: logger.error(f"Erro ao buscar participante por ID {entity_id}: {str(e)}") raise - - def get_all(self) -> List[Participant]: + + def get_all(self) -> List[Participant]: try: items = self.dynamodb_service.scan_table(self.table_name) - participants = [] - - for item in items: - participants.append(Participant(**item)) - - return participants - + return [Participant(**item) for item in items] + except Exception as e: logger.error(f"Erro ao buscar todos os participantes: {str(e)}") raise - - def update(self, entity_id: str, entity: Participant) -> Optional[Participant]: + + def update(self, entity_id: str, entity: Participant) -> Optional[Participant]: try: - # Verifica se o participante existe if not self.exists(entity_id): return None - - # Converte a entidade para dicionário + update_data = entity.model_dump() - - # Remove o ID do update_data para não atualizar a chave primária - if 'id' in update_data: - del update_data['id'] - - # Constrói a expressão de atualização + update_data["email"] = _normalize_email(update_data.get("email")) + update_data.pop("id", None) + update_expression = "SET " expression_values = {} - + expression_names = {} + for key, value in update_data.items(): if value is not None: update_expression += f"#{key} = :{key}, " expression_values[f":{key}"] = value - - # Remove a vírgula extra no final + expression_names[f"#{key}"] = key + update_expression = update_expression.rstrip(", ") - - # Adiciona os nomes dos atributos - expression_names = {f"#{key}": key for key in update_data.keys() if update_data[key] is not None} - - # Atualiza o item - key = {"id": entity_id} - response = self.dynamodb_service.aws.update_item( - TableName=self.table_name, - Key=key, - UpdateExpression=update_expression, - ExpressionAttributeValues=expression_values, - ExpressionAttributeNames=expression_names, - ReturnValues="ALL_NEW" + + response = self.dynamodb_service.update_item( + {"id": entity_id}, + update_expression, + expression_values, + self.table_name, + expression_attribute_names=expression_names, ) - - if 'Attributes' in response: - return Participant(**response['Attributes']) + + if "Attributes" in response: + result_dict = self.dynamodb_service._convert_from_dynamodb_format(response["Attributes"]) + return Participant(**result_dict) return None - + except Exception as e: logger.error(f"Erro ao atualizar participante {entity_id}: {str(e)}") raise - - def delete(self, entity_id: str) -> bool: + + def delete(self, entity_id: str) -> bool: try: - key = {"id": entity_id} - self.dynamodb_service.delete_item(key, self.table_name) + self.dynamodb_service.delete_item({"id": entity_id}, self.table_name) return True - + except Exception as e: logger.error(f"Erro ao remover participante {entity_id}: {str(e)}") return False - - def exists(self, entity_id: str) -> bool: + + def exists(self, entity_id: str) -> bool: try: - key = {"id": entity_id} - item = self.dynamodb_service.get_item(key, self.table_name) - return item is not None - + return self.get_by_id(entity_id) is not None + except Exception as e: logger.error(f"Erro ao verificar existência do participante {entity_id}: {str(e)}") return False - - def get_by_email(self, email: str) -> Optional[Participant]: + + def get_by_email(self, email: str) -> Optional[Participant]: try: - filter_expression = "email = :email" - expression_values = {":email": email} - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression, - expression_values + items = self.dynamodb_service.query_table( + self.table_name, + "email = :email", + {":email": _normalize_email(email)}, + index_name="participants_by_email_idx", ) - if items: return Participant(**items[0]) return None - + except Exception as e: logger.error(f"Erro ao buscar participante por email {email}: {str(e)}") raise - - def get_by_cpf(self, cpf: str) -> Optional[Participant]: - try: - filter_expression = "cpf = :cpf" - expression_values = {":cpf": cpf} - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression, - expression_values - ) - - if items: - return Participant(**items[0]) - return None - - except Exception as e: - logger.error(f"Erro ao buscar participante por CPF {cpf}: {str(e)}") - raise - - def get_by_city(self, city: str) -> List[Participant]: - try: - filter_expression = "city = :city" - expression_values = {":city": city} - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression, - expression_values - ) - - participants = [] - for item in items: - participants.append(Participant(**item)) - - return participants - - except Exception as e: - logger.error(f"Erro ao buscar participantes por cidade {city}: {str(e)}") - raise - + def email_exists(self, email: str) -> bool: - try: participant = self.get_by_email(email) return participant is not None - + except Exception as e: logger.error(f"Erro ao verificar existência do email {email}: {str(e)}") return False - - def cpf_exists(self, cpf: str) -> bool: - - try: - participant = self.get_by_cpf(cpf) - return participant is not None - - except Exception as e: - logger.error(f"Erro ao verificar existência do CPF {cpf}: {str(e)}") - return False diff --git a/src/infrastructure/repository/product_repository_impl.py b/src/infrastructure/repository/product_repository_impl.py index cade392..a9051e4 100644 --- a/src/infrastructure/repository/product_repository_impl.py +++ b/src/infrastructure/repository/product_repository_impl.py @@ -1,7 +1,7 @@ -import json import logging import uuid from typing import List, Optional, Union + from src.domain.entity.product import Product from src.domain.repository.product_repository import ProductRepository from src.infrastructure.aws.dynamodb_service import DynamoDBService @@ -9,226 +9,169 @@ logger = logging.getLogger() logger.setLevel(logging.INFO) + +def _flag(value: Optional[str]) -> int: + return 1 if value else 0 + + class ProductRepositoryImpl(ProductRepository): - def __init__(self, dynamodb_service: DynamoDBService, table_name: str = "products"): self.dynamodb_service = dynamodb_service self.table_name = table_name - + def create(self, entity: Product) -> Product: - try: - item = entity.model_dump() + item = self._prepare_item(entity) self.dynamodb_service.put_item(item, self.table_name) - return entity - + except Exception as e: logger.error(f"Erro ao criar produto: {str(e)}") raise - + def get_by_id(self, entity_id: int) -> Optional[Product]: - try: - # Usa product_id como chave primária conforme definido no schema da tabela - key = {"product_id": entity_id} - item = self.dynamodb_service.get_item(key, self.table_name) - + item = self.dynamodb_service.get_item({"product_id": entity_id}, self.table_name) if item: return Product(**item) return None - + except Exception as e: logger.error(f"Erro ao buscar produto por ID {entity_id}: {str(e)}") raise - + def find_by_id(self, entity_id: Union[str, uuid.UUID]) -> Optional[Product]: - """ - Busca produto por ID, aceitando tanto string quanto UUID. - Implementa o método abstrato definido na classe base BaseRepository. - Nota: Como Product usa int como ID, converte string/UUID para int. - """ try: - # Converte para string primeiro, depois para int if isinstance(entity_id, uuid.UUID): - # Se for UUID, converte para string e depois tenta extrair um int id_str = str(entity_id) - # Tenta usar os primeiros dígitos como int - id_int = int(id_str.replace('-', '')[:10]) # Pega os primeiros 10 dígitos + id_int = int(id_str.replace("-", "")[:10]) else: - # Se for string, tenta converter para int id_int = int(str(entity_id)) - - # Reutiliza a lógica existente do get_by_id + return self.get_by_id(id_int) - + except (ValueError, TypeError) as e: logger.error(f"Erro ao converter ID {entity_id} para int: {str(e)}") return None except Exception as e: logger.error(f"Erro ao buscar produto por ID {entity_id}: {str(e)}") raise - + def get_all(self) -> List[Product]: try: items = self.dynamodb_service.scan_table(self.table_name) - products = [] - - for item in items: - products.append(Product(**item)) - - return products - + return [Product(**item) for item in items] + except Exception as e: logger.error(f"Erro ao buscar todos os produtos: {str(e)}") raise - + def update(self, entity_id: int, entity: Product) -> Optional[Product]: - try: - # Verifica se o produto existe if not self.exists(entity_id): return None - - # Converte a entidade para dicionário - update_data = entity.model_dump() - - # Remove o ID do update_data para não atualizar a chave primária - if 'id' in update_data: - del update_data['id'] - - # Constrói a expressão de atualização + + update_data = self._prepare_item(entity) + update_data.pop("product_id", None) + update_expression = "SET " expression_values = {} - + expression_names = {} + for key, value in update_data.items(): if value is not None: update_expression += f"#{key} = :{key}, " expression_values[f":{key}"] = value - - # Remove a vírgula extra no final + expression_names[f"#{key}"] = key + update_expression = update_expression.rstrip(", ") - - # Adiciona os nomes dos atributos - expression_names = {f"#{key}": key for key in update_data.keys() if update_data[key] is not None} - - # Atualiza o item - key = {"product_id": entity_id} + response = self.dynamodb_service.update_item( - key, + {"product_id": entity_id}, update_expression, expression_values, - self.table_name + self.table_name, + expression_attribute_names=expression_names, ) - - if 'Attributes' in response: - return Product(**response['Attributes']) + + if "Attributes" in response: + result_dict = self.dynamodb_service._convert_from_dynamodb_format(response["Attributes"]) + return Product(**result_dict) return None - + except Exception as e: logger.error(f"Erro ao atualizar produto {entity_id}: {str(e)}") raise - + def delete(self, entity_id: int) -> bool: - try: - key = {"product_id": entity_id} - self.dynamodb_service.delete_item(key, self.table_name) - + self.dynamodb_service.delete_item({"product_id": entity_id}, self.table_name) return True - + except Exception as e: logger.error(f"Erro ao remover produto {entity_id}: {str(e)}") return False - - def exists(self, entity_id: int) -> bool: + + def exists(self, entity_id: int) -> bool: try: - key = {"product_id": entity_id} - item = self.dynamodb_service.get_item(key, self.table_name) - return item is not None - + return self.get_by_id(entity_id) is not None + except Exception as e: logger.error(f"Erro ao verificar existência do produto {entity_id}: {str(e)}") return False - + def get_by_product_id(self, product_id: int) -> Optional[Product]: - try: - filter_expression = "productId = :product_id" - expression_values = {":product_id": product_id} - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression, - expression_values - ) - - if items: - return Product(**items[0]) - return None - + return self.get_by_id(product_id) + except Exception as e: logger.error(f"Erro ao buscar produto por product_id {product_id}: {str(e)}") raise - + def get_by_name(self, product_name: str) -> List[Product]: - try: - filter_expression = "productName = :product_name" - expression_values = {":product_name": product_name} - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression, - expression_values + items = self.dynamodb_service.query_table( + self.table_name, + "product_name = :product_name", + {":product_name": product_name}, + index_name="products_by_name_idx", ) - - products = [] - for item in items: - products.append(Product(**item)) - - return products - + return [Product(**item) for item in items] + except Exception as e: logger.error(f"Erro ao buscar produtos por nome {product_name}: {str(e)}") raise - + def get_products_with_logo(self) -> List[Product]: - try: - filter_expression = "attribute_exists(certificateLogo)" - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression + items = self.dynamodb_service.query_table( + self.table_name, + "has_certificate_logo_flag = :flag", + {":flag": 1}, + index_name="products_by_has_logo_idx", ) - - products = [] - for item in items: - products.append(Product(**item)) - - return products - + return [Product(**item) for item in items] + except Exception as e: logger.error(f"Erro ao buscar produtos com logo: {str(e)}") raise - + def get_products_with_background(self) -> List[Product]: - try: - filter_expression = "attribute_exists(certificateBackground)" - - items = self.dynamodb_service.scan_table( - self.table_name, - filter_expression + items = self.dynamodb_service.query_table( + self.table_name, + "has_certificate_background_flag = :flag", + {":flag": 1}, + index_name="products_by_has_background_idx", ) - - products = [] - for item in items: - products.append(Product(**item)) - - return products - + return [Product(**item) for item in items] + except Exception as e: logger.error(f"Erro ao buscar produtos com background: {str(e)}") raise + + def _prepare_item(self, entity: Product) -> dict: + item = entity.model_dump() + item["has_certificate_logo_flag"] = _flag(item.get("certificate_logo")) + item["has_certificate_background_flag"] = _flag(item.get("certificate_background")) + return item diff --git a/src/main/handler/certificate.py b/src/main/handler/certificate.py index 5b03d67..0386662 100644 --- a/src/main/handler/certificate.py +++ b/src/main/handler/certificate.py @@ -4,7 +4,13 @@ from src.main.presentation.http_types.create_certificates import CreateCertificatesRequest from src.main.presentation.http_types.fetch_certificate import FetchCertificateRequest, FetchCertificateResponse from src.main.presentation.http_types.download_certificate import DownloadCertificateRequest, DownloadCertificateResponse +from src.main.presentation.http_types.list_user_certificates import ( + ListUserCertificatesRequest, + ListUserCertificatesResponse, + UserCertificateItemResponse, +) from src.application.dto.fetch_certificate_dto import FetchCertificateRequestDto +from src.application.dto.list_user_certificates_dto import ListUserCertificatesRequestDto from src.domain.response.build_order import BuildOrderResponse from src.domain.response.tech_floripa import TechOrdersResponse from src.domain.response.processed_orders import ProcessedOrdersResponse @@ -13,6 +19,7 @@ from src.application.fetch_order_tech_floripa import FetchOrderTechFloripa from src.application.fetch_certificate import FetchCertificate from src.application.download_certificate import DownloadCertificate +from src.application.list_user_certificates import ListUserCertificates from src.infrastructure.container.dependency_container import container @@ -77,7 +84,6 @@ def fetch_certificate_handler(request: FetchCertificateRequest) -> List[FetchCer product_id=app_response.product_id, participant_name=app_response.participant_name, participant_email=app_response.participant_email, - participant_document=app_response.participant_document, certificate_url=app_response.certificate_url, created_at=app_response.created_at, updated_at=app_response.updated_at, @@ -109,6 +115,38 @@ def download_certificate_handler(request: DownloadCertificateRequest) -> Downloa return response +def list_user_certificates_handler( + request: ListUserCertificatesRequest, +) -> ListUserCertificatesResponse: + logger.info(f"Listing certificates for request: {request}") + + application_request = ListUserCertificatesRequestDto( + email=request.email, + success=request.success, + ) + + list_user_certificates: ListUserCertificates = container.get("list_user_certificates") + application_response = list_user_certificates.execute(application_request) + + return ListUserCertificatesResponse( + email=application_response.email, + certificates=[ + UserCertificateItemResponse( + id=item.id, + order_id=item.order_id, + product_id=item.product_id, + participant_name=item.participant_name, + participant_email=item.participant_email, + certificate_url=item.certificate_url, + created_at=item.created_at, + updated_at=item.updated_at, + success=item.success, + ) + for item in application_response.certificates + ], + ) + + def create_certificates_handler(request: CreateCertificatesRequest) -> BuildOrderResponse: """ Handler para processar uma lista de certificados recebida diretamente. diff --git a/src/main/presentation/controller/certificate.py b/src/main/presentation/controller/certificate.py index 60b90f1..b708d7a 100644 --- a/src/main/presentation/controller/certificate.py +++ b/src/main/presentation/controller/certificate.py @@ -1,5 +1,7 @@ import logging from typing import Annotated, List, Optional +from urllib.parse import unquote + from aws_lambda_powertools.event_handler.openapi.params import Query from aws_lambda_powertools.utilities.parser import parse @@ -12,7 +14,17 @@ from src.main.presentation.http_types.create_certificates import CreateCertificatesRequest from src.main.presentation.http_types.fetch_certificate import FetchCertificateRequest, FetchCertificateResponse from src.main.presentation.http_types.download_certificate import DownloadCertificateRequest, DownloadCertificateResponse -from src.main.handler.certificate import create_certificate_handler, create_certificates_handler, fetch_certificate_handler, download_certificate_handler +from src.main.presentation.http_types.list_user_certificates import ( + ListUserCertificatesRequest, + ListUserCertificatesResponse, +) +from src.main.handler.certificate import ( + create_certificate_handler, + create_certificates_handler, + fetch_certificate_handler, + download_certificate_handler, + list_user_certificates_handler, +) from src.main.presentation.template_loader import template_loader from src.domain.response.build_order import BuildOrderResponse from src.domain.response.failed import FailedResponse @@ -149,3 +161,24 @@ def download_certificate( content_type="text/html", body=html_content ) + + +@app.get(f"{config.PREFIX_API_VERSION}/users//certificates") +def list_user_certificates( + email: str, + success: Annotated[Optional[bool], Query()] = None, +) -> ListUserCertificatesResponse: + try: + request = ListUserCertificatesRequest( + email=unquote(email), + success=success, + ) + response = list_user_certificates_handler(request) + return response + except Exception as e: + logger.error(f"Erro ao listar certificados do usuário: {e}") + return FailedResponse( + details=str(e), + message="Internal Server Error", + status=500 + ) diff --git a/src/main/presentation/http_types/create_certificates.py b/src/main/presentation/http_types/create_certificates.py index c5bafab..4cf4826 100644 --- a/src/main/presentation/http_types/create_certificates.py +++ b/src/main/presentation/http_types/create_certificates.py @@ -8,17 +8,12 @@ class CertificateItemRequest(BaseModel): first_name: str last_name: str email: str - phone: str - cpf: str - city: str product_id: int product_name: str certificate_details: str certificate_logo: str certificate_background: str order_date: str - checkin_latitude: Optional[str] = None - checkin_longitude: Optional[str] = None time_checkin: Optional[str] = None diff --git a/src/main/presentation/http_types/fetch_certificate.py b/src/main/presentation/http_types/fetch_certificate.py index c555d13..b662e3f 100644 --- a/src/main/presentation/http_types/fetch_certificate.py +++ b/src/main/presentation/http_types/fetch_certificate.py @@ -14,7 +14,6 @@ class FetchCertificateResponse(BaseModel): product_id: Optional[int] = None participant_name: Optional[str] = None participant_email: Optional[str] = None - participant_document: Optional[str] = None certificate_url: Optional[str] = None created_at: Optional[str] = None updated_at: Optional[str] = None diff --git a/src/main/presentation/http_types/list_user_certificates.py b/src/main/presentation/http_types/list_user_certificates.py new file mode 100644 index 0000000..6a1cbd4 --- /dev/null +++ b/src/main/presentation/http_types/list_user_certificates.py @@ -0,0 +1,26 @@ +from typing import Optional +import uuid + +from pydantic import BaseModel + + +class ListUserCertificatesRequest(BaseModel): + email: str + success: Optional[bool] = None + + +class UserCertificateItemResponse(BaseModel): + id: Optional[uuid.UUID] = None + order_id: Optional[int] = None + product_id: Optional[int] = None + participant_name: Optional[str] = None + participant_email: Optional[str] = None + certificate_url: Optional[str] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + success: Optional[bool] = None + + +class ListUserCertificatesResponse(BaseModel): + email: str + certificates: list[UserCertificateItemResponse] diff --git a/test_local.py b/test_local.py index d357261..0aed14e 100644 --- a/test_local.py +++ b/test_local.py @@ -9,7 +9,13 @@ from lambda_function import lambda_handler -def create_api_gateway_event(method: str, path: str, body: dict = None, query_string_parameters: dict = None) -> dict: +def create_api_gateway_event( + method: str, + path: str, + body: dict = None, + query_string_parameters: dict = None, + path_parameters: dict = None, +) -> dict: """ Cria um evento simulado do API Gateway REST. @@ -24,7 +30,7 @@ def create_api_gateway_event(method: str, path: str, body: dict = None, query_st return { "httpMethod": method, "path": path, - "pathParameters": None, + "pathParameters": path_parameters, "queryStringParameters": query_string_parameters, "headers": { "Content-Type": "application/json", @@ -109,6 +115,51 @@ def test_create_certificate(): print(f"Erro durante o teste: {e}") return None + +def test_list_user_certificates(email: str, success: str = None): + """Testa o endpoint de listagem de certificados por email.""" + print("🧪 Testando endpoint de listagem de certificados por email...") + + encoded_email = email.replace("@", "%40").replace("+", "%2B") + path = f"/api/v1/users/{encoded_email}/certificates" + query_params = {"success": success} if success is not None else None + + event = create_api_gateway_event( + method="GET", + path=path, + query_string_parameters=query_params, + path_parameters={"email": encoded_email}, + ) + + context = MockLambdaContext() + + try: + print(f"Enviando requisição: path={path} query={query_params}") + response = lambda_handler(event, context) + print("Resposta recebida:") + print(f"Status Code: {response.get('statusCode', 'N/A')}") + + body = response.get('body', {}) + if isinstance(body, str): + try: + body = json.loads(body) + except json.JSONDecodeError: + pass + + print(f" Body: {json.dumps(body, indent=2, ensure_ascii=False)}") + + status_code = response.get('statusCode', 500) + if 200 <= status_code < 300: + print("Teste executado com sucesso!") + else: + print("Teste falhou!") + + return response + except Exception as e: + print(f"Erro durante o teste: {e}") + return None + + def test_fetch_certificate_by_order_id(): """Testa o endpoint de busca de certificados.""" print("🧪 Testando endpoint de busca de certificados...") @@ -318,6 +369,9 @@ def test_fetch_certificate_by_email_and_product_id(): if __name__ == "__main__": # Executa os testes # test_create_certificate() + # test_list_user_certificates("suzi.harima94@gmail.com") + # test_list_user_certificates("suzi.harima94@gmail.com", success="true") + # test_list_user_certificates("suzi.harima94@gmail.com", success="false") test_fetch_certificate_by_order_id() test_fetch_certificate_by_product_id() test_fetch_certificate_by_email() diff --git a/tests/test_dynamodb_repository_derivations.py b/tests/test_dynamodb_repository_derivations.py new file mode 100644 index 0000000..0d91582 --- /dev/null +++ b/tests/test_dynamodb_repository_derivations.py @@ -0,0 +1,109 @@ +import os +import sys +import types +import unittest +import uuid +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +os.environ.setdefault("REGION", "us-east-1") +os.environ.setdefault("BUILDER_QUEUE_URL", "https://example.com/queue") +os.environ.setdefault("S3_BUCKET_NAME", "bucket") +os.environ.setdefault("URL_SERVICE_TECH", "https://example.com") + +botocore_module = types.ModuleType("botocore") +botocore_exceptions = types.ModuleType("botocore.exceptions") + + +class _ClientError(Exception): + pass + + +botocore_exceptions.ClientError = _ClientError +sys.modules.setdefault("botocore", botocore_module) +sys.modules.setdefault("botocore.exceptions", botocore_exceptions) + +boto3_module = types.ModuleType("boto3") +boto3_module.client = lambda *args, **kwargs: object() +sys.modules.setdefault("boto3", boto3_module) + +from src.domain.entity.certificate import Certificate +from src.domain.entity.order import Order +from src.domain.entity.product import Product +from src.infrastructure.repository.certificate_repository_impl import CertificateRepositoryImpl +from src.infrastructure.repository.order_repository_impl import OrderRepositoryImpl +from src.infrastructure.repository.product_repository_impl import ProductRepositoryImpl + + +class FakeDynamoDBService: + pass + + +class DynamoDBRepositoryDerivationsTestCase(unittest.TestCase): + def test_certificate_prepare_item_adds_query_keys(self): + repository = CertificateRepositoryImpl(FakeDynamoDBService()) + certificate = Certificate( + id=uuid.uuid4(), + success=True, + certificate_key="key-1", + certificate_url="https://example.com/1.pdf", + generated_date="2025-01-10T10:00:00", + order_id=1, + order_date="2025-01-01 10:00:00", + product_id=100, + product_name="Curso", + certificate_details="Detalhes", + certificate_logo="logo.png", + certificate_background="background.png", + participant_email=" User+Test@Example.com ", + participant_first_name="User", + participant_last_name="One", + ) + + item = repository._prepare_item(certificate) + + self.assertEqual(item["participant_email"], "user+test@example.com") + self.assertEqual(item["participant_email_product_key"], "user+test@example.com#100") + self.assertEqual(item["success_flag"], 1) + self.assertEqual(item["id"], str(certificate.id)) + + def test_order_prepare_item_adds_month_and_sort_key(self): + repository = OrderRepositoryImpl(FakeDynamoDBService()) + order = Order( + order_id=25, + order_date="2025-02-03 14:15:16", + product_id=99, + product_name="Curso", + certificate_details="Detalhes", + certificate_logo="logo.png", + certificate_background="background.png", + time_checkin="13:00", + participant_email=" Test@Example.com ", + participant_first_name="Test", + participant_last_name="User", + ) + + item = repository._prepare_item(order) + + self.assertEqual(item["participant_email"], "test@example.com") + self.assertEqual(item["order_year_month"], "2025-02") + self.assertTrue(item["order_date_order_id"].startswith("2025-02-03T14:15:16#")) + + def test_product_prepare_item_adds_boolean_flags(self): + repository = ProductRepositoryImpl(FakeDynamoDBService()) + product = Product( + product_id=7, + product_name="Curso", + certificate_details="Detalhes", + certificate_logo="logo.png", + certificate_background=None, + ) + + item = repository._prepare_item(product) + + self.assertEqual(item["has_certificate_logo_flag"], 1) + self.assertEqual(item["has_certificate_background_flag"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_list_user_certificates.py b/tests/test_list_user_certificates.py new file mode 100644 index 0000000..70114f0 --- /dev/null +++ b/tests/test_list_user_certificates.py @@ -0,0 +1,133 @@ +import sys +import unittest +import uuid +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from src.application.dto.list_user_certificates_dto import ListUserCertificatesRequestDto +from src.application.list_user_certificates import ListUserCertificates +from src.domain.entity.certificate import Certificate + + +class FakeCertificateRepository: + def __init__(self, certificates): + self.certificates = certificates + self.last_email = None + + def get_by_participant_email(self, email: str): + self.last_email = email + return list(self.certificates) + + +class ListUserCertificatesTestCase(unittest.TestCase): + def setUp(self): + self.email = "user@example.com" + self.encoded_email = "user+qa@example.com" + self.certificates = [ + self._certificate( + generated_date="2025-01-10T10:00:00", + success=True, + order_id=1, + ), + self._certificate( + generated_date=None, + success=True, + order_id=2, + ), + self._certificate( + generated_date="2025-01-12T09:30:00", + success=False, + order_id=3, + ), + self._certificate( + generated_date="2025-01-11T08:00:00", + success=True, + order_id=4, + ), + ] + + def test_lists_all_certificates_sorted_by_generated_date_desc(self): + service = ListUserCertificates(FakeCertificateRepository(self.certificates)) + + response = service.execute(ListUserCertificatesRequestDto(email=self.email)) + + self.assertEqual(response.email, self.email) + self.assertEqual([item.order_id for item in response.certificates], [3, 4, 1, 2]) + + def test_filters_success_true_before_sorting(self): + service = ListUserCertificates(FakeCertificateRepository(self.certificates)) + + response = service.execute( + ListUserCertificatesRequestDto(email=self.email, success=True) + ) + + self.assertEqual([item.order_id for item in response.certificates], [4, 1, 2]) + self.assertTrue(all(item.success is True for item in response.certificates)) + + def test_filters_success_false(self): + service = ListUserCertificates(FakeCertificateRepository(self.certificates)) + + response = service.execute( + ListUserCertificatesRequestDto(email=self.email, success=False) + ) + + self.assertEqual([item.order_id for item in response.certificates], [3]) + self.assertEqual(response.certificates[0].success, False) + + def test_returns_empty_list_when_no_records_match_email(self): + service = ListUserCertificates(FakeCertificateRepository([])) + + response = service.execute(ListUserCertificatesRequestDto(email=self.email)) + + self.assertEqual(response.email, self.email) + self.assertEqual(response.certificates, []) + + def test_returns_empty_list_when_status_filter_has_no_matches(self): + only_successful = [ + self._certificate( + generated_date="2025-01-10T10:00:00", + success=True, + order_id=10, + ) + ] + service = ListUserCertificates(FakeCertificateRepository(only_successful)) + + response = service.execute( + ListUserCertificatesRequestDto(email=self.email, success=False) + ) + + self.assertEqual(response.email, self.email) + self.assertEqual(response.certificates, []) + + def test_preserves_email_value_used_in_lookup(self): + repository = FakeCertificateRepository([]) + service = ListUserCertificates(repository) + + response = service.execute(ListUserCertificatesRequestDto(email=self.encoded_email)) + + self.assertEqual(repository.last_email, self.encoded_email) + self.assertEqual(response.email, self.encoded_email) + + def _certificate(self, generated_date, success, order_id): + return Certificate( + id=uuid.uuid4(), + success=success, + certificate_key=f"key-{order_id}", + certificate_url=f"https://example.com/{order_id}.pdf", + generated_date=generated_date, + order_id=order_id, + order_date="2025-01-01 10:00:00", + product_id=100 + order_id, + product_name="Curso", + certificate_details="Detalhes", + certificate_logo="logo.png", + certificate_background="background.png", + participant_email=self.email, + participant_first_name="User", + participant_last_name=str(order_id), + ) + + +if __name__ == "__main__": + unittest.main()