diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 68407a7..2774c6d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,6 +41,13 @@ jobs: cache: 'pip' cache-dependency-path: '**/requirements*.txt' + - name: Install system dependencies (WeasyPrint) + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b \ + libffi-dev libgdk-pixbuf-2.0-0 shared-mime-info + - name: Install dependencies run: | python -m pip install --upgrade pip @@ -102,6 +109,13 @@ jobs: cache: 'pip' cache-dependency-path: '**/requirements*.txt' + - name: Install system dependencies (WeasyPrint) + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b \ + libffi-dev libgdk-pixbuf-2.0-0 shared-mime-info + - name: Install dependencies run: | python -m pip install --upgrade pip @@ -166,6 +180,13 @@ jobs: cache: 'pip' cache-dependency-path: '**/requirements*.txt' + - name: Install system dependencies (WeasyPrint) + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b \ + libffi-dev libgdk-pixbuf-2.0-0 shared-mime-info + - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/Dockerfile b/Dockerfile index 76429f1..017ca7b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,8 +5,14 @@ WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 -RUN apt-get update && apt-get install -y --no-install-recommends gcc && \ - rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpango-1.0-0 \ + libpangoft2-1.0-0 \ + libharfbuzz0b \ + libffi8 \ + libgdk-pixbuf-2.0-0 \ + shared-mime-info \ + && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir --upgrade pip && \ diff --git a/app/api/dependencies.py b/app/api/dependencies.py index 333d38e..3c72539 100644 --- a/app/api/dependencies.py +++ b/app/api/dependencies.py @@ -104,6 +104,7 @@ ) from app.infrastructure.services.null_email_service import NullEmailService from app.infrastructure.services.resend_email_service import ResendEmailService +from app.infrastructure.services.weasyprint_pdf_service import WeasyPrintPDFService from app.shared.interfaces.email_service import IEmailService # ===================================================================== @@ -601,4 +602,6 @@ async def get_get_complete_cv_use_case( async def get_generate_cv_pdf_use_case( get_cv_uc: GetCompleteCVUseCase = Depends(get_get_complete_cv_use_case), ) -> GenerateCVPDFUseCase: - return GenerateCVPDFUseCase(get_cv_use_case=get_cv_uc) + return GenerateCVPDFUseCase( + get_cv_use_case=get_cv_uc, pdf_service=WeasyPrintPDFService() + ) diff --git a/app/api/v1/routers/cv_router.py b/app/api/v1/routers/cv_router.py index 07a4d75..3c23833 100644 --- a/app/api/v1/routers/cv_router.py +++ b/app/api/v1/routers/cv_router.py @@ -1,5 +1,7 @@ +from io import BytesIO + from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import FileResponse +from fastapi.responses import StreamingResponse from app.api.dependencies import ( get_generate_cv_pdf_use_case, @@ -29,7 +31,7 @@ async def get_complete_cv( "/download", summary="Descargar CV en PDF", description="Genera y descarga el CV en formato PDF profesional", - response_class=FileResponse, + response_class=StreamingResponse, ) async def download_cv_pdf( use_case: GenerateCVPDFUseCase = Depends(get_generate_cv_pdf_use_case), @@ -37,7 +39,8 @@ async def download_cv_pdf( result = await use_case.execute(GenerateCVPDFRequest()) if not result.success: raise HTTPException(status_code=500, detail=result.message) - raise HTTPException( - status_code=501, - detail=result.message, + return StreamingResponse( + BytesIO(result.pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": 'attachment; filename="Alex_Zapata_CV.pdf"'}, ) diff --git a/app/application/dto/cv_dto.py b/app/application/dto/cv_dto.py index 4be9054..c772280 100644 --- a/app/application/dto/cv_dto.py +++ b/app/application/dto/cv_dto.py @@ -95,5 +95,5 @@ class GenerateCVPDFResponse: """Response containing PDF generation result.""" success: bool - file_path: str + pdf_bytes: bytes message: str = "PDF generated successfully" diff --git a/app/application/services/__init__.py b/app/application/services/__init__.py index e69de29..a035be5 100644 --- a/app/application/services/__init__.py +++ b/app/application/services/__init__.py @@ -0,0 +1,3 @@ +from .pdf_service import IPDFService + +__all__ = ["IPDFService"] diff --git a/app/application/services/pdf_service.py b/app/application/services/pdf_service.py new file mode 100644 index 0000000..ddcf394 --- /dev/null +++ b/app/application/services/pdf_service.py @@ -0,0 +1,8 @@ +from abc import ABC, abstractmethod + +from app.application.dto.cv_dto import CompleteCVResponse + + +class IPDFService(ABC): + @abstractmethod + def generate_cv_pdf(self, cv_data: CompleteCVResponse) -> bytes: ... diff --git a/app/application/use_cases/cv/generate_cv_pdf.py b/app/application/use_cases/cv/generate_cv_pdf.py index 1a29304..5fec200 100644 --- a/app/application/use_cases/cv/generate_cv_pdf.py +++ b/app/application/use_cases/cv/generate_cv_pdf.py @@ -1,84 +1,23 @@ -""" -Generate CV PDF Use Case. - -Generates the CV as a PDF file (placeholder implementation). -""" +import asyncio from app.application.dto import ( GenerateCVPDFRequest, GenerateCVPDFResponse, GetCompleteCVRequest, ) +from app.application.services.pdf_service import IPDFService from app.shared.interfaces import IQueryUseCase from .get_complete_cv import GetCompleteCVUseCase class GenerateCVPDFUseCase(IQueryUseCase[GenerateCVPDFRequest, GenerateCVPDFResponse]): - """ - Use case for generating CV as PDF. - - Note: This is a placeholder for future PDF generation functionality. - In a real implementation, this would: - 1. Get complete CV data - 2. Generate PDF using a template engine (e.g., ReportLab, WeasyPrint) - 3. Save to file system or cloud storage - 4. Return file path or URL - - For now, it returns a success response with a placeholder path. - - Business Rules: - - Profile must exist - - Multiple format options supported - - Photo inclusion is configurable - - Dependencies: - - GetCompleteCVUseCase: To get CV data - - Future Implementation: - - PDF template engine integration - - File storage service - - Multiple format support (standard, compact, detailed) - """ - - def __init__(self, get_cv_use_case: GetCompleteCVUseCase): - """ - Initialize use case with dependencies. - - Args: - get_cv_use_case: Use case to get complete CV data - """ + def __init__(self, get_cv_use_case: GetCompleteCVUseCase, pdf_service: IPDFService): self.get_cv_use_case = get_cv_use_case + self.pdf_service = pdf_service - async def execute(self, request: GenerateCVPDFRequest) -> GenerateCVPDFResponse: - """ - Execute the use case. - - Args: - request: Generate PDF request with format options - - Returns: - GenerateCVPDFResponse with file path - - Note: - This is a placeholder implementation. - Actual PDF generation should be implemented in infrastructure layer. - """ - # Get CV data + async def execute(self, _request: GenerateCVPDFRequest) -> GenerateCVPDFResponse: cv_data = await self.get_cv_use_case.execute(GetCompleteCVRequest()) - - # TODO: Implement actual PDF generation - # This would involve: - # 1. Creating a PDF template - # 2. Populating it with cv_data - # 3. Saving to storage - # 4. Returning the path/URL - - # For now, return a placeholder response - file_path = f"/tmp/cv_{cv_data.profile.id}_{request.format}.pdf" - - return GenerateCVPDFResponse( - success=True, - file_path=file_path, - message=f"PDF generation not yet implemented. Would generate {request.format} format at {file_path}", - ) + # WeasyPrint is synchronous; run in thread pool to avoid blocking the event loop + pdf_bytes = await asyncio.to_thread(self.pdf_service.generate_cv_pdf, cv_data) + return GenerateCVPDFResponse(success=True, pdf_bytes=pdf_bytes) diff --git a/app/infrastructure/services/__init__.py b/app/infrastructure/services/__init__.py index 367606d..c8bcb88 100644 --- a/app/infrastructure/services/__init__.py +++ b/app/infrastructure/services/__init__.py @@ -1,7 +1,9 @@ from .null_email_service import NullEmailService from .resend_email_service import ResendEmailService +from .weasyprint_pdf_service import WeasyPrintPDFService __all__ = [ "NullEmailService", "ResendEmailService", + "WeasyPrintPDFService", ] diff --git a/app/infrastructure/services/templates/cv_template.html b/app/infrastructure/services/templates/cv_template.html new file mode 100644 index 0000000..74e2781 --- /dev/null +++ b/app/infrastructure/services/templates/cv_template.html @@ -0,0 +1,652 @@ + + + + + + + + + + + + + + + + + + + +
+ + + + + + {% if cv.certifications %} + + {% endif %} + + + {% if cv.skills %} + + {% endif %} + + + {% if cv.tools %} + + {% endif %} + + + {% if cv.languages is defined and cv.languages %} + + {% endif %} + + + + +
+
{{ cv.profile.name | upper }}
+ {% if cv.profile.headline %} +
{{ cv.profile.headline | upper }}
+ {% endif %} + {% if cv.profile.bio %} +
{{ cv.profile.bio }}
+ {% endif %} +
+ + + {% if cv.work_experiences %} +
+
Experiencia Reciente
+
+ {% for exp in cv.work_experiences %} +
+
+ {{ exp.role }}{% if exp.company %} — {{ exp.company }}{% endif %} +
+
+ {% if exp.location %}{{ exp.location }}{% endif %} + + {{- exp.start_date.strftime('%b %Y') if exp.start_date else '' -}} + {%- if exp.is_current %} – Actualidad + {%- elif exp.end_date %} – {{ exp.end_date.strftime('%b %Y') }} + {%- endif -%} + + {% if exp.duration_months and exp.duration_months > 0 %} + {% set tot = exp.duration_months | int %} + {% set yrs = tot // 12 %} + {% set mos = tot % 12 %} + + {%- if yrs > 0 %}{{ yrs }} {{ 'año' if yrs == 1 else 'años' }}{% endif %} + {%- if yrs > 0 and mos > 0 %} {% endif %} + {%- if mos > 0 %}{{ mos }} {{ 'mes' if mos == 1 else 'meses' }}{% endif -%} + + {% endif %} +
+ {% if exp.description %} +
{{ exp.description }}
+ {% endif %} + {% if exp.technologies %} +
+ {% for tech in exp.technologies %}{{ tech }}{% endfor %} +
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + + {% if cv.education %} +
+
Estudios
+
+ {% for edu in cv.education %} +
+
+ {{ edu.degree }}{% if edu.field %} en {{ edu.field }}{% endif %} — {{ + edu.institution }} +
+
+ + {{- edu.start_date.strftime('%Y') if edu.start_date else '' -}} + {%- if edu.is_ongoing %} – Actualidad + {%- elif edu.end_date %} – {{ edu.end_date.strftime('%Y') }} + {%- endif -%} + +
+ {% if edu.description %} +
{{ edu.description }}
+ {% endif %} + {% if edu.technologies %} +
+ {% for tech in edu.technologies %}{{ tech }}{% endfor %} +
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + + {% if cv.additional_training %} +
+
Formación adicional
+
+ {% for item in cv.additional_training %} +
+
+ {% if item.certificate_url %} + {{ item.title }} + {% else %}{{ item.title }}{% endif %} + — {{ item.provider }} +
+
+ {% if item.completion_date %} + {{ item.completion_date.strftime('%b %Y') }} + {% endif %} + {% if item.duration %} + {{ item.duration }} + {% endif %} +
+ {% if item.description %} +
{{ item.description }}
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + + {% if cv.projects %} +
+
Proyectos
+
+ {% for proj in cv.projects %} +
+
+ {% if proj.live_url or proj.repo_url %} + {{ + proj.title }} + {% else %}{{ proj.title }}{% endif %} + {% if proj.is_ongoing %}En curso{% endif %} +
+
+ + {{- proj.start_date.strftime('%b %Y') if proj.start_date else '' -}} + {%- if proj.is_ongoing %} – Actualidad + {%- elif proj.end_date %} – {{ proj.end_date.strftime('%b %Y') }} + {%- endif -%} + +
+ {% if proj.description %} +
{{ proj.description }}
+ {% endif %} + {% if proj.technologies %} +
+ {% for tech in proj.technologies %}{{ tech }}{% endfor %} +
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + +
+ + + + \ No newline at end of file diff --git a/app/infrastructure/services/weasyprint_pdf_service.py b/app/infrastructure/services/weasyprint_pdf_service.py new file mode 100644 index 0000000..e691d82 --- /dev/null +++ b/app/infrastructure/services/weasyprint_pdf_service.py @@ -0,0 +1,17 @@ +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader +from weasyprint import HTML + +from app.application.dto.cv_dto import CompleteCVResponse +from app.application.services.pdf_service import IPDFService + +_TEMPLATES_DIR = Path(__file__).parent / "templates" +_jinja_env = Environment(loader=FileSystemLoader(str(_TEMPLATES_DIR)), autoescape=True) + + +class WeasyPrintPDFService(IPDFService): + def generate_cv_pdf(self, cv_data: CompleteCVResponse) -> bytes: + template = _jinja_env.get_template("cv_template.html") + html_str = template.render(cv=cv_data) + return HTML(string=html_str).write_pdf() diff --git a/docker/development/Dockerfile.dev b/docker/development/Dockerfile.dev index aa3ba31..23afb56 100644 --- a/docker/development/Dockerfile.dev +++ b/docker/development/Dockerfile.dev @@ -11,7 +11,12 @@ ENV PYTHONUNBUFFERED=1 # Instalar dependencias del sistema RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc \ + libpango-1.0-0 \ + libpangoft2-1.0-0 \ + libharfbuzz0b \ + libffi-dev \ + libgdk-pixbuf-2.0-0 \ + shared-mime-info \ && rm -rf /var/lib/apt/lists/* # Copiar archivos de dependencias diff --git a/requirements.txt b/requirements.txt index 98a5f88..966d65a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,3 +33,5 @@ typing-inspection==0.4.2 typing_extensions==4.15.0 uvicorn==0.40.0 resend==2.10.0 +weasyprint==63.1 +jinja2==3.1.6