Micro-SaaS — Code Generation

Niche : générateur de contrats pour freelances Généré le : 18/02/2026 à 03:01


"""
Freelance Contract Generator
----------------------------

Ce module permet de générer automatiquement un contrat de freelance à partir
d'un modèle Jinja2. Le résultat peut être exporté en fichier texte (Markdown) ou PDF.

Dépendances externes :
    - jinja2
    - weasyprint (optionnel, pour l'export PDF)

Installation :
    pip install jinja2 weasyprint
"""

from __future__ import annotations

import pathlib
import sys
from dataclasses import dataclass, asdict, field
from datetime import date
from typing import Any, Dict, Mapping

from jinja2 import Environment, FileSystemLoader, Template, TemplateError

# --------------------------------------------------------------------------- #
#                               Data Model                                   #
# --------------------------------------------------------------------------- #


@dataclass(frozen=True)
class Party:
    """Informations d'une partie prenante (client ou freelance)."""

    name: str
    address: str
    email: str
    phone: str | None = None

    def _as_dict(self) -> Dict[str, Any]:
        """Retourne un dict compatible Jinja2 (sans les champs None)."""
        return {k: v for k, v in asdict(self).items() if v is not None}


@dataclass(frozen=True)
class ProjectDetails:
    """Détails du projet à insérer dans le contrat."""

    title: str
    description: str
    start_date: date
    end_date: date | None = None
    hourly_rate: float | None = None
    fixed_price: float | None = None
    milestones: list[Dict[str, Any]] = field(default_factory=list)

    def _as_dict(self) -> Dict[str, Any]:
        d = asdict(self)
        # Formatage des dates et suppression des valeurs None
        d["start_date"] = self.start_date.isoformat()
        if self.end_date:
            d["end_date"] = self.end_date.isoformat()
        d = {k: v for k, v in d.items() if v is not None}
        return d


@dataclass(frozen=True)
class ContractData:
    """Enveloppe des données requises pour le rendu du contrat."""

    client: Party
    freelancer: Party
    project: ProjectDetails
    contract_date: date = field(default_factory=date.today)

    def to_context(self) -> Mapping[str, Any]:
        """Construit le contexte Jinja2 à partir des dataclasses."""
        return {
            "client": self.client._as_dict(),
            "freelancer": self.freelancer._as_dict(),
            "project": self.project._as_dict(),
            "contract_date": self.contract_date.isoformat(),
        }


# --------------------------------------------------------------------------- #
#                           Contract Generation Logic                         #
# --------------------------------------------------------------------------- #


class TemplateNotFoundError(FileNotFoundError):
    """Exception levée lorsqu'aucun fichier de modèle n'est trouvé."""


class ContractGenerationError(RuntimeError):
    """Exception générique pour les erreurs de génération du contrat."""


class ContractGenerator:
    """
    Générateur de contrat utilisant un modèle Jinja2.
    Le modèle doit être fourni sous forme de fichier texte (ex. .md ou .txt).
    """

    def __init__(self, template_dir: pathlib.Path | str) -> None:
        self.template_dir = pathlib.Path(template_dir).expanduser().resolve()
        if not self.template_dir.is_dir():
            raise TemplateNotFoundError(f"Le répertoire '{self.template_dir}' n'existe pas.")
        self.env = Environment(
            loader=FileSystemLoader(self.template_dir),
            autoescape=False,  # Le contrat est du texte brut, pas du HTML
            trim_blocks=True,
            lstrip_blocks=True,
        )

    def load_template(self, template_name: str) -> Template:
        """Charge le template et gère les éventuelles erreurs."""
        try:
            return self.env.get_template(template_name)
        except TemplateError as exc:
            raise TemplateNotFoundError(f"Impossible de charger le template '{template_name}': {exc}") from exc

    def render(self, contract_data: ContractData, template_name: str) -> str:
        """
        Rendu du contrat à partir des données et du template.
        Retourne le texte rendu (Markdown ou texte brut).
        """
        template = self.load_template(template_name)
        context = contract_data.to_context()
        try:
            return template.render(**context)
        except Exception as exc:  # pragma: no cover – attrapage générique pour le rendu
            raise ContractGenerationError(f"Erreur lors du rendu du contrat: {exc}") from exc

    @staticmethod
    def save_to_file(content: str, output_path: pathlib.Path | str) -> None:
        """Écrit le contenu dans le fichier indiqué. Crée les dossiers parents si besoin."""
        output_path = pathlib.Path(output_path).expanduser().resolve()
        try:
            output_path.parent.mkdir(parents=True, exist_ok=True)
            output_path.write_text(content, encoding="utf-8")
        except OSError as exc:
            raise ContractGenerationError(f"Impossible d'écrire le fichier '{output_path}': {exc}") from exc

    @staticmethod
    def convert_markdown_to_pdf(md_content: str, pdf_path: pathlib.Path | str) -> None:
        """
        Convertit le markdown en PDF à l'aide de WeasyPrint.
        Nécessite l'installation de la dépendance `weasyprint`.
        """
        try:
            from weasyprint import HTML  # import local pour éviter une dépendance obligatoire
        except ImportError as exc:
            raise ContractGenerationError(
                "WeasyPrint n'est pas installé. Exécutez 'pip install weasyprint' pour exporter en PDF."
            ) from exc

        # Construction d'un HTML simple à partir du Markdown.
        # On utilise la fonction `markdown` du module `markdown` si disponible,
        # sinon on laisse le Markdown brut (WeasyPrint ne gère pas le markdown nativement).
        try:
            import markdown

            html_body = markdown.markdown(md_content, extensions=["extra", "smarty"])
        except ImportError:
            # Fallback: le markdown sera traité comme du texte brut par le navigateur PDF.
            html_body = f"<pre>{md_content}</pre>"

        html_doc = f"""<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="utf-8">
    <title>Contrat Freelance</title>
    <style>
        body {{ font-family: "Helvetica", "Arial", sans-serif; margin: 2cm; }}
        h1, h2, h3 {{ color: #2c3e50; }}
    </style>
</head>
<body>
{html_body}
</body>
</html>"""

        pdf_path = pathlib.Path(pdf_path).expanduser().resolve()
        try:
            pdf_path.parent.mkdir(parents=True, exist_ok=True)
            HTML(string=html_doc).write_pdf(target=str(pdf_path))
        except Exception as exc:  # pragma: no cover – gestion générique
            raise ContractGenerationError(f"Échec de la génération du PDF '{pdf_path}': {exc}") from exc


# --------------------------------------------------------------------------- #
#                              Example Usage (comment)                        #
# --------------------------------------------------------------------------- #

"""
# Exemple d'utilisation du générateur de contrat :

from pathlib import Path
from datetime import date

# 1. Définir les parties et le projet
client = Party(
    name="Acme Corp.",
    address="123 Rue de la République, 75001 Paris, France",
    email="contact@acme.example.com",
    phone="+33 1 23 45 67 89"
)

freelancer = Party(
    name="Jean Dupont",
    address="45 Avenue des Champs, 75008 Paris, France",
    email="jean.dupont@example.com",
    phone="+33 6 12 34 56 78"
)

project = ProjectDetails(
    title="Développement d'une application web",
    description=(
        "Conception et implémentation d'une application web full‑stack "
        "utilisant Django et React."
    ),
    start_date=date(2024, 5, 1),
    end_date=date(2024, 8, 31),
    hourly_rate=65.0,
    milestones=[
        {"title": "Spécifications fonctionnelles", "due": "2024‑05‑15"},
        {"title": "Prototype front‑end", "due": "2024‑06‑30"},
        {"title": "Version bêta", "due": "2024‑07‑31"},
        {"title": "Livraison finale", "due": "2024‑08‑31"},
    ],
)

contract_data = ContractData(
    client=client,
    freelancer=freelancer,
    project=project
)

# 2. Instancier le générateur (le répertoire `templates/` doit contenir `contract.md`)
generator = ContractGenerator(template_dir=Path(__file__).parent / "templates")

# 3. Rendre le texte du contrat
try:
    markdown_text = generator.render(contract_data, template_name="contract.md")
except (TemplateNotFoundError, ContractGenerationError) as e:
    sys.exit(f"Erreur lors de la génération du contrat : {e}")

# 4. Sauvegarder le contrat au format Markdown
output_md_path = Path("output/contrat_freelance.md")
generator.save_to_file(markdown_text, output_md_path)
print(f"Contrat sauvegardé au format Markdown : {output_md_path}")

# 5. (Optionnel) Convertir le Markdown en PDF
output_pdf_path = Path("output/contrat_freelance.pdf")
try:
    generator.convert_markdown_to_pdf(markdown_text, output_pdf_path)
    print(f"Contrat PDF généré : {output_pdf_path}")
except ContractGenerationError as e:
    print(f"Conversion PDF échouée : {e}")
"""

# --------------------------------------------------------------------------- #
#                               End of Module                                 #
# --------------------------------------------------------------------------- #