diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..784f5ed --- /dev/null +++ b/.dockerignore @@ -0,0 +1,58 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Git +.git/ +.gitignore +.gitattributes + +# Docker +Dockerfile +.dockerignore +docker-compose*.yml + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +tests/ + +# Output files +_out_/ + +# Documentation +*.md +!README.md + +# OS +.DS_Store +Thumbs.db + +# Private config files +PrivateConfig.py +Config_Sample.py + +# Firebase certificates (should be mounted as secrets) +*.json +!resources/editions/data_sample.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0153bf3 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# Environment variables for IdentificationGenerator +# Copy this file to .env and update with your actual values + +# ===== Edition Configuration ===== +EDITION=2025 +TEST=False + +# ===== Database Configuration ===== +DB_PATH_T=hackeps-2025/dev/users +DB_PATH=hackeps-2025/prod/users + +# ===== API Configuration ===== +# Backend API base URL +BASE_URL=http://localhost:8000 +# Service authentication token (KEEP SECRET!) +SERVICE_TOKEN=your_service_token_here + +# ===== Optional Path Overrides ===== +# Uncomment and modify if you need custom paths +# EDITIONS_FOLDER=editions +# OUT_FOLDER=_out_ +# RES_FOLDER=resources +# FONT_FOLDER=fonts + +# ===== Optional File Name Overrides ===== +# DATA_FILE=data.json +# DB_CERT=2019_firebase_cert.json +# FONT_FILE=SpaceMono-Regular.ttf +# BOLD_FONT_FILE=SpaceMono-Bold.ttf + +# ===== Template File Overrides ===== +# BAK_FILE_CONTESTANT=plantilles/participant.png +# BAK_FILE_STAFF=plantilles/organitzador.png +# BAK_FILE_EMPRESA=plantilles/patrocinador.png diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..74cfd84 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,13 @@ +# GitHub Actions Workflows + +This directory will contain GitHub Actions workflows for CI/CD. + +## Planned Workflows + +- **CI/CD Pipeline**: Build, test, and deploy the application +- **Docker Build**: Automated Docker image building and pushing to registry +- **Testing**: Automated test runs on pull requests + +## Coming Soon + +Deployment workflows will be added here in the future. diff --git a/.gitignore b/.gitignore index 63edd56..a83e533 100644 --- a/.gitignore +++ b/.gitignore @@ -1,110 +1,118 @@ -#custom -resources/*_firebase_cert.json -resources/editions/*/data.json -_out_/ -*.pdf - -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ +#custom +resources/*_firebase_cert.json +resources/editions/*/data.json +_out_/ +generated_cards/ +*.pdf +PrivateConfig.py +Config.py +.idea/* +.vscode/* +# Docker +docker-compose.override.yml +.env + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 0e40fe8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ - -# Default ignored files -/workspace.xml \ No newline at end of file diff --git a/.idea/IdentificationGenerator.iml b/.idea/IdentificationGenerator.iml deleted file mode 100644 index 6711606..0000000 --- a/.idea/IdentificationGenerator.iml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index d4fc832..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index e59bda9..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index de19f71..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - // Use IntelliSense para saber los atributos posibles. - // Mantenga el puntero para ver las descripciones de los existentes atributos. - // Para más información, visite: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Python: Archivo actual", - "type": "python", - "request": "launch", - "program": "python main.py", - "console": "integratedTerminal" - } - ] -} \ No newline at end of file diff --git a/Config.py b/Config.py deleted file mode 100644 index 931a170..0000000 --- a/Config.py +++ /dev/null @@ -1,40 +0,0 @@ -from os import path -from PIL import ImageFont - -TEST = False - -EDITION='2022' -EDITIONS_FOLDER = 'editions' - -DB_PATH_T = 'hackeps-'+EDITION+'/dev/users' -DB_PATH = 'hackeps-'+EDITION+'/prod/users' - - -OUT_FOLDER = '_out_' -OUT_PATH = path.join('.', OUT_FOLDER) - -RES_FOLDER = 'resources' -RES_PATH = path.join('.', RES_FOLDER) - -DATA_FILE = 'data.json' -DATA_PATH = path.join(RES_PATH, EDITIONS_FOLDER, EDITION, DATA_FILE) - -DB_CERT = '2019_firebase_cert.json' -DB_CERT_PATH = path.join(RES_PATH, DB_CERT) - -BAK_FILE = 'plantilla.png' -BAK_PATH = path.join(RES_PATH, EDITIONS_FOLDER, EDITION, BAK_FILE) - -FONT_FOLDER = 'fonts' -FONT_FILE = 'SpaceMono-Bold.ttf' -FONT_PATH = path.join(RES_PATH, FONT_FOLDER, FONT_FILE) -TYPE_FONT_SIZE = 50 -NAME_FONT_SIZE = 25 -FONT_COLOR = (0,0,0) -WHITE_FONT_COLOR = (255,255,255) -TYPE_FONT = ImageFont.truetype(FONT_PATH, TYPE_FONT_SIZE) -NAME_FONT = ImageFont.truetype(FONT_PATH, NAME_FONT_SIZE) -# FONT = ImageFont.truetype("Symbola.ttf", 60, encoding='unic') - -MAIN_COLOR = (31, 33, 36) -BAK_COLOR = (247, 247, 242) \ No newline at end of file diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..6daa735 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,218 @@ +# Docker Quick Start Guide + +This guide will help you get the IdentificationGenerator API up and running with Docker. + +## Prerequisites + +- Docker installed ([Get Docker](https://docs.docker.com/get-docker/)) +- Docker Compose installed (usually comes with Docker Desktop) +- Git (to clone the repository) + +## Quick Start + +### 1. Setup Environment + +```bash +# Copy the example environment file +cp .env.example .env + +# Edit .env if needed (optional for basic testing) +# nano .env +``` + +### 2. Start the Application + +**Option A: Using Make (Recommended)** +```bash +# View available commands +make help + +# Start in production mode (background) +make up + +# Or start in development mode (with hot-reload) +make dev +``` + +**Option B: Using Docker Compose directly** +```bash +# Production mode +docker-compose up -d + +# Development mode +docker-compose -f docker-compose.dev.yml up +``` + +### 3. Verify It's Running + +Open your browser and visit: +- API: http://localhost:8000 +- Interactive Docs: http://localhost:8000/docs +- Alternative Docs: http://localhost:8000/redoc + +You should see the API documentation and be able to test endpoints. + +### 4. View Logs + +```bash +# Using Make +make logs + +# Using Docker Compose +docker-compose logs -f +``` + +## Common Tasks + +### Access Container Shell + +```bash +# Using Make +make shell + +# Using Docker Compose +docker exec -it identification-generator-api /bin/bash +``` + +### Run Tests + +```bash +# Using Make +make test + +# Using Docker Compose +docker-compose exec api uv run pytest +``` + +### Stop the Application + +```bash +# Using Make +make down + +# Using Docker Compose +docker-compose down +``` + +### Rebuild After Code Changes + +```bash +# Using Make +make rebuild + +# Using Docker Compose +docker-compose up -d --build +``` + +## Development Workflow + +### Hot-Reload Development + +1. Start in development mode: + ```bash + make dev + ``` + +2. Edit your code - changes will automatically reload + +3. View logs in real-time to see the reload happening + +### Running Tests + +```bash +# Run all tests +make test + +# Run specific test file +docker-compose exec api uv run pytest tests/test_specific.py + +# Run with verbose output +docker-compose exec api uv run pytest -v +``` + +## Troubleshooting + +### Port Already in Use + +If port 8000 is already in use: + +1. Edit `docker-compose.yml` +2. Change `"8000:8000"` to `"8080:8000"` (or another available port) +3. Access the API at http://localhost:8080 + +### Container Won't Start + +```bash +# Check logs +make logs + +# Common issues: +# 1. Missing dependencies - rebuild the image +make rebuild + +# 2. Permission issues - check file permissions +ls -la + +# 3. Port conflicts - change the port in docker-compose.yml +``` + +### Generated Files Not Appearing + +The generated ID cards are saved to `./generated_cards/` on your host machine. + +If files aren't appearing: +1. Check if the directory exists: `ls -la generated_cards/` +2. Check container logs for errors: `make logs` +3. Verify volume mount in docker-compose.yml + +### Firebase Connection Issues + +If you see Firebase-related errors: + +1. Ensure you have the Firebase credentials file +2. Place it at `resources/2019_firebase_cert.json` +3. Uncomment the volume mount in `docker-compose.yml`: + ```yaml + - ./resources/2019_firebase_cert.json:/app/resources/2019_firebase_cert.json:ro + ``` +4. Restart: `make restart` + +## File Structure + +``` +IdentificationGenerator/ +├── docker-compose.yml # Production configuration +├── docker-compose.dev.yml # Development configuration +├── Dockerfile # Production image +├── Dockerfile.dev # Development image +├── .dockerignore # Files to exclude from build +├── .env.example # Environment template +├── .env # Your environment (git-ignored) +├── Makefile # Convenient commands +├── generated_cards/ # Output directory (created automatically) +└── resources/ # Templates, fonts, etc. +``` + +## Next Steps + +- Read the main [README.md](README.md) for API documentation +- Check out the interactive API docs at http://localhost:8000/docs +- Explore the different endpoints for contestants, mentors, etc. +- Set up your Firebase credentials for full functionality + +## Getting Help + +- Check the logs: `make logs` +- View container status: `docker-compose ps` +- Access the shell for debugging: `make shell` +- Review the main README.md for more details + +## Clean Up + +To completely remove all containers, images, and generated files: + +```bash +make clean +``` + +**Warning:** This will delete all generated ID cards and Docker resources! diff --git a/DOCKERIZATION_SUMMARY.md b/DOCKERIZATION_SUMMARY.md new file mode 100644 index 0000000..35e9da7 --- /dev/null +++ b/DOCKERIZATION_SUMMARY.md @@ -0,0 +1,155 @@ +# Dockerization Summary + +## What Was Created + +This document summarizes all the Docker-related files added to the IdentificationGenerator project. + +### Core Docker Files + +1. **Dockerfile** - Production-ready Docker image + - Uses Python 3.12 slim base image + - Installs system dependencies (Cairo, Pango for SVG/PDF rendering) + - Uses `uv` for fast dependency management + - Includes health check + - Runs with uvicorn on port 8000 + +2. **Dockerfile.dev** - Development Docker image + - Same as production but includes dev dependencies + - Enables hot-reload for development + - Optimized for local development workflow + +3. **docker-compose.yml** - Production orchestration + - Defines the API service + - Sets up volume mounts for generated files + - Configures networking + - Includes commented nginx configuration for future use + +4. **docker-compose.dev.yml** - Development orchestration + - Mounts source code for hot-reload + - Enables interactive debugging + - Uses development Dockerfile + +5. **.dockerignore** - Build optimization + - Excludes unnecessary files from Docker build context + - Reduces image size and build time + +### Configuration Files + +6. **.env.example** - Environment variable template + - Shows required environment variables + - Safe to commit (no secrets) + +7. **docker-compose.override.yml.example** - Local customization template + - Shows how to customize Docker setup locally + - Examples for ports, volumes, resources + +### Documentation + +8. **README.md** - Updated with comprehensive Docker instructions + - Quick start guide + - Docker commands + - API endpoints + - Troubleshooting + +9. **DOCKER.md** - Detailed Docker guide + - Step-by-step setup + - Development workflows + - Common tasks + - Troubleshooting section + +10. **Makefile** - Convenient command shortcuts + - `make help` - Show available commands + - `make up` - Start production + - `make dev` - Start development + - `make test` - Run tests + - And more... + +### Other Updates + +11. **.gitignore** - Updated to exclude: + - `generated_cards/` directory + - `docker-compose.override.yml` + - `.env` file + +12. **.github/workflows/README.md** - Placeholder for future CI/CD + - Reserved for GitHub Actions workflows + - Will be implemented later + +## Key Features + +### Production Ready +- ✅ Optimized Docker image with multi-stage potential +- ✅ Health checks configured +- ✅ Proper environment variable handling +- ✅ Volume mounts for persistent data +- ✅ Resource limits ready to configure + +### Developer Friendly +- ✅ Hot-reload in development mode +- ✅ Easy-to-use Makefile commands +- ✅ Separate dev/prod configurations +- ✅ Interactive debugging support +- ✅ Comprehensive documentation + +### Best Practices +- ✅ Uses `uv` for fast dependency management +- ✅ Minimal base image (Python 3.12 slim) +- ✅ .dockerignore for efficient builds +- ✅ Proper .gitignore entries +- ✅ Environment variable configuration +- ✅ Volume mounts for generated files + +## Quick Start + +```bash +# 1. Setup +cp .env.example .env + +# 2. Start (choose one) +make up # Production +make dev # Development + +# 3. Access +# http://localhost:8000/docs +``` + +## File Tree + +``` +IdentificationGenerator/ +├── .dockerignore +├── .env.example +├── .github/ +│ └── workflows/ +│ └── README.md +├── .gitignore (updated) +├── docker-compose.dev.yml +├── docker-compose.override.yml.example +├── docker-compose.yml +├── DOCKER.md +├── Dockerfile +├── Dockerfile.dev +├── Makefile +└── README.md (updated) +``` + +## Next Steps + +1. **Test the setup**: Run `make up` to verify everything works +2. **Customize**: Copy override example if needed +3. **GitHub Actions**: Add CI/CD workflows when ready +4. **Production**: Configure nginx, SSL, and secrets management + +## Notes + +- GitHub Actions deployment workflows are planned but not yet implemented +- Firebase credentials should be mounted as secrets in production +- The `generated_cards/` directory is created automatically +- All Docker-related files are properly git-ignored + +## Support + +For issues or questions: +- Check DOCKER.md for troubleshooting +- Review README.md for API documentation +- Run `make help` for available commands diff --git a/Dockerfile b/Dockerfile index 30c1b19..6062a78 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,48 @@ -FROM python:3.10 -ENV PYTHONUNBUFFERED=1 -WORKDIR /app - -RUN pip install --no-cache-dir -U pip - -COPY requirements.txt /tmp/requirements.txt -RUN pip install --no-cache-dir -r /tmp/requirements.txt - -COPY . /app +# Use Python 3.12 slim image for smaller size +FROM python:3.12-slim + +# Set environment variables +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + UV_SYSTEM_PYTHON=1 + +# Set working directory +WORKDIR /app + +# Install system dependencies required for the project +# - cairo for cairosvg +# - fonts for text rendering +RUN apt-get update && apt-get install -y \ + libcairo2 \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libgdk-pixbuf2.0-0 \ + libffi-dev \ + shared-mime-info \ + fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +# Install uv for fast dependency management +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Copy dependency files +COPY pyproject.toml uv.lock ./ + +# Install dependencies using uv +RUN uv sync --frozen --no-dev + +# Copy application code +COPY . . + +# Create output directory +RUN mkdir -p _out_ + +# Expose port for FastAPI +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "import requests; requests.get('http://localhost:8000/')" || exit 1 + +# Run the application with uvicorn +CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..1356457 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,42 @@ +# Development Dockerfile with hot-reload support +FROM python:3.12-slim + +# Set environment variables +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + UV_SYSTEM_PYTHON=1 + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + libcairo2 \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libgdk-pixbuf2.0-0 \ + libffi-dev \ + shared-mime-info \ + fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Copy dependency files +COPY pyproject.toml uv.lock ./ + +# Install dependencies including dev dependencies +RUN uv sync --frozen + +# Copy application code (will be overridden by volume mount in dev) +COPY . . + +# Create output directory +RUN mkdir -p _out_ + +# Expose port +EXPOSE 8000 + +# Run with hot-reload enabled +CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b0ab552 --- /dev/null +++ b/Makefile @@ -0,0 +1,69 @@ +.PHONY: help build up down logs shell test clean dev prod restart + +# Default target +help: + @echo "Available commands:" + @echo " make build - Build Docker images" + @echo " make up - Start containers in production mode" + @echo " make down - Stop and remove containers" + @echo " make logs - View container logs" + @echo " make shell - Access container shell" + @echo " make test - Run tests in container" + @echo " make clean - Remove containers, images, and volumes" + @echo " make dev - Start in development mode with hot-reload" + @echo " make prod - Start in production mode" + @echo " make restart - Restart containers" + +# Build Docker images +build: + docker-compose build + +# Start containers (production) +up: + docker-compose up -d + +# Start containers (production, foreground) +prod: + docker-compose up + +# Start containers (development with hot-reload) +dev: + docker-compose -f docker-compose.dev.yml up + +# Stop containers +down: + docker-compose down + +# View logs +logs: + docker-compose logs -f + +# Access container shell +shell: + docker exec -it identification-generator-api /bin/bash + +# Run tests +test: + docker-compose exec api uv run pytest + +# Restart containers +restart: + docker-compose restart + +# Clean everything (containers, images, volumes) +clean: + docker-compose down -v --rmi all + rm -rf generated_cards/* + +# Rebuild and restart +rebuild: down build up + +# Check container status +status: + docker-compose ps + +# View API documentation +docs: + @echo "API Documentation available at:" + @echo " - Swagger UI: http://localhost:8000/docs" + @echo " - ReDoc: http://localhost:8000/redoc" diff --git a/Model.py b/Model.py deleted file mode 100644 index ab80de4..0000000 --- a/Model.py +++ /dev/null @@ -1,269 +0,0 @@ -import os - -import firebase_admin -from PIL import Image -from firebase_admin import firestore - -import Config -import Tools - - -class Card: - QR_PIX_SIZE = 1 - QR_POS = (450, 78) - _QR_SIZE = 267 - QR_SIZE = (_QR_SIZE, _QR_SIZE) - QR_BORDER_SIZE = 0 - TYPE_POS = (0, 475) - NAME_POS = (QR_POS[0], 370) - NICK_POS = (1198, 870) - - -class Assistant(object): - __ID:int = 1 - __DATA:str = 'empty' - _DATA_FILE:str = Config.DATA_PATH - - def __init__(self, id:str, type:str='', name:str=None): - self.id = id - self.type = type - self.name = name - self.card = None - self.qr = None - - def show(self): - self.card.show() - - def save(self): - self.card.save(os.path.join(Config.OUT_PATH, str(self.id) + '.png')) - - def generate_qr(self, crypt_id=False): - self.qr = Tools.generate_qr(self.id, Card.QR_PIX_SIZE, Card.QR_BORDER_SIZE) - self.qr = Tools.scale(self.qr, Card.QR_SIZE, False) - - def generate_card(self, rgb_back=(255, 255, 255)): - self.card = Image.open(Config.BAK_PATH) - Tools.draw_text(self.card, self.type, Card.TYPE_POS, Config.TYPE_FONT, Config.WHITE_FONT_COLOR) - if self.type == '': - self.smallen() - - def smallen(self): - blank = Image.new('RGB', (1082, 782),(255,255,255)) - blank.paste(self.card, (0, 0)) - self.card = blank - - @staticmethod - def get_data(): - data = Tools.DataFile.get_content(Assistant._DATA_FILE, 'JSON') - num = data[Assistant.__DATA] - res = [] - for _ in range(num): - res.append(Assistant('A' + str(Assistant.__ID))) - Assistant.__ID += 1 - if Config.TEST: - break - return res - - -class Guest(Assistant): - __ID:int = 1 - __TYPE:str = 'CONVIDAT' - __DATA:str = 'guests' - - def __init__(self, name:str, mtype:str='', logo:str='', has_qr:bool=False): - super().__init__('HackEPS_Guest_' + str(Guest.__ID), (mtype, Guest.__TYPE)[mtype == ''], name) - Guest.__ID += 1 - self.has_qr = has_qr - self.logo = logo - if has_qr: - self.generate_qr(True) - if not logo == '': - self.logopath = os.path.join(Config.RES_PATH, Config.EDITIONS_FOLDER, Config.EDITION, 'images', logo) - - def generate_card(self, rgb_back=(255, 255, 255)): - super().generate_card(rgb_back) - if self.logo != '': - logo = Image.open(self.logopath).convert("RGBA") - logo = Tools.scale(logo, Card.QR_SIZE) - self.card.paste(logo, Card.QR_POS) - elif self.has_qr: - self.card.paste(self.qr, Card.QR_POS) - if self.name != '': - Tools.centrate_text_relative(self.card, self.name,Config.NAME_FONT, Card.NAME_POS, Card.QR_SIZE) - self.smallen() - - @staticmethod - def get_data(name=None): - res = [] - data = Tools.DataFile.get_content(Guest._DATA_FILE, 'JSON') - for u in data[Guest.__DATA]: - if name is None or u['name'] == name: - res.append(Guest(u['name'], u['type'], u['logo'], u['qr'])) - if Config.TEST or (name is not None and u['name'] == name): - break - return res - - -class Company(Assistant): - __ID:int = 1 - __TYPE:str = 'EMPRESA' - __DATA:str = 'companies' - - def __init__(self, name:str, image): - super().__init__('C' + str(Company.__ID), Company.__TYPE, name) - Company.__ID += 1 - self.logopath = os.path.join(Config.RES_PATH, Config.EDITIONS_FOLDER, Config.EDITION, 'images', image) - - def generate_card(self, rgb_back=(255, 255, 255)): - super().generate_card(rgb_back) - image = Image.open(self.logopath).convert("RGBA") # .resize((550,350), Image.ANTIALIAS) - image = Tools.scale(image, Card.QR_SIZE) - self.card.paste(image, Card.QR_POS) - Tools.centrate_text_relative(self.card, self.name,Config.NAME_FONT, Card.NAME_POS, Card.QR_SIZE) - self.smallen() - - @staticmethod - def get_data(name=None): - res = [] - data = Tools.DataFile.get_content(Company._DATA_FILE, 'JSON') - for u in data[Company.__DATA]: - for _ in range(u['number_of_cards']): - if name is None or u['name'] == name: - res.append(Company(u['name'], u['logo'])) - if Config.TEST: - break - if Config.TEST or (name is not None and u['name'] == name): - break - return res - - -class Volunteer(Assistant): - __ID:int = 1 - __LOGO_PATH:str = os.path.join(Config.RES_PATH, 'editions', Config.EDITION, 'images', 'logogran.png') - __TYPE:str = 'VOLUNTARI/A' - __DATA:str = 'volunteers' - - def __init__(self, name:str): - super().__init__('V' + str(Volunteer.__ID), Volunteer.__TYPE, name) - Volunteer.__ID += 1 - - def generate_card(self, rgb_back=(255, 255, 255)): - super().generate_card(rgb_back) - image = Image.open(Volunteer.__LOGO_PATH).convert("RGBA") - image = Tools.scale(image, Card.QR_SIZE) - self.card.paste(image, Card.QR_POS) - Tools.centrate_text_relative(self.card, self.name,Config.NAME_FONT, Card.NAME_POS, Card.QR_SIZE) - self.smallen() - - @staticmethod - def get_data(name=None): - res = [] - data = Tools.DataFile.get_content(Volunteer._DATA_FILE, 'JSON') - for u in data[Volunteer.__DATA]: - if name is None or name == u['name']: - res.append(Volunteer(u['name'])) - if Config.TEST or (name is not None and name == u['name']): - break - return res - -class Mentor(Assistant): - __ID:int = 1 - __LOGO_PATH:str = os.path.join(Config.RES_PATH, 'editions', Config.EDITION, 'images', 'logogran.png') - __TYPE:str = 'MENTOR/A' - __DATA:str = 'mentors' - - def __init__(self, name:str): - super().__init__('M' + str(Mentor.__ID), Mentor.__TYPE, name) - Mentor.__ID += 1 - - def generate_card(self, rgb_back=(255, 255, 255)): - super().generate_card(rgb_back) - image = Image.open(Mentor.__LOGO_PATH).convert("RGBA") - image = Tools.scale(image, Card.QR_SIZE) - self.card.paste(image, Card.QR_POS) - Tools.centrate_text_relative(self.card, self.name,Config.NAME_FONT, Card.NAME_POS, Card.QR_SIZE) - self.smallen() - - @staticmethod - def get_data(name=None): - res = [] - data = Tools.DataFile.get_content(Mentor._DATA_FILE, 'JSON') - for u in data[Mentor.__DATA]: - if name is None or name == u['name']: - res.append(Mentor(u['name'])) - if Config.TEST or (name is not None and name == u['name']): - break - return res - -class Organizer(Assistant): - __ID:int = 1 - __LOGO_PATH:str = os.path.join(Config.RES_PATH, 'editions', Config.EDITION, 'images', 'logogran.png') - __TYPE:str = 'ORGANIZACIÓ' - __DATA:str = 'organizers' - - def __init__(self, name): - super().__init__('O' + str(Organizer.__ID), Organizer.__TYPE, name) - Organizer.__ID += 1 - - def generate_card(self, rgb_back=(255, 255, 255)): - super().generate_card(rgb_back) - image = Image.open(Organizer.__LOGO_PATH).convert("RGBA") - image = Tools.scale(image, Card.QR_SIZE) - self.card.paste(image, Card.QR_POS) - Tools.centrate_text_relative(self.card, self.name,Config.NAME_FONT, Card.NAME_POS, Card.QR_SIZE) - # Tools.draw_text(self.card, self.name, Card.NAME_POS, Config.NAME_FONT, Config.WHITE_FONT_COLOR, False) - self.smallen() - - @staticmethod - def get_data(name=None): - res = [] - print(Organizer._DATA_FILE) - data = Tools.DataFile.get_content(Organizer._DATA_FILE, 'JSON') - for u in data[Organizer.__DATA]: - if name is None or u['name'] == name: - res.append(Organizer(u['name'])) - if Config.TEST or (name is not None and u['name'] == name): - break - return res - - -class Contestant(Assistant): - __CRYPT_ID = False - __TYPE:str = 'HACKER' - __FIREBASE = None - __FIRE_PATH:str = Config.DB_PATH_T if Config.TEST else Config.DB_PATH - - def __init__(self, id, data): - super().__init__(id, Contestant.__TYPE) - self.generate_qr() - self.name = data['fullName'] - self.nick = '\"' + data['nickname'] + '\"' - if Config.TEST: - Contestant.__FIRE_PATH = Config.DB_PATH_T - - def generate_card(self, rgb_back=(255, 255, 255)): - super().generate_card(rgb_back) - self.card.paste(self.qr, Card.QR_POS) - Tools.centrate_text_relative(self.card, self.name,Config.NAME_FONT, Card.NAME_POS, Card.QR_SIZE) - self.smallen() - - @staticmethod - def __firebase_init(cred): - if Contestant.__FIREBASE is None: - Contestant.__FIREBASE = firebase_admin.initialize_app(cred) - return Contestant.__FIREBASE - - @staticmethod - def get_data(id=None, name=None): - cred = firebase_admin.credentials.Certificate(Config.DB_CERT_PATH) - Contestant.__firebase_init(cred) - db = firestore.client() - users_ref = db.collection(Contestant.__FIRE_PATH) - usrs = users_ref.stream() - users = [] - for usr in usrs: - if ((id is None and name is None) - or (id is not None and usr.id == id) - or (name is not None and name == usr.to_dict()['fullName'])): - users.append(Contestant(usr.id, usr.to_dict())) - return users diff --git a/PrivateConfig.sample.py b/PrivateConfig.sample.py new file mode 100644 index 0000000..2644fe9 --- /dev/null +++ b/PrivateConfig.sample.py @@ -0,0 +1,2 @@ +SERVICE_TOKEN = 'backend SERVICE TOKEN' +BASE_URL = 'backend url' \ No newline at end of file diff --git a/README.md b/README.md index d069e03..264a8ec 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,210 @@ -# IdentificationGenerator -Program used to generate Identification cards - - -to run as a container -- docker build -t test . -- docker run -it test /bin/bash \ No newline at end of file +# IdentificationGenerator + +A FastAPI-based service for generating identification cards for hackathon events. This application creates personalized ID cards with QR codes for contestants, mentors, organizers, volunteers, companies, and guests. + +## Features + +- 🎫 Generate ID cards for multiple user types +- 🔲 QR code generation for each participant +- 📄 PDF export functionality +- 🔥 Firebase integration for data storage +- 🚀 FastAPI REST API +- 🐳 Docker support for easy deployment + +## Prerequisites + +- Docker and Docker Compose (for containerized deployment) +- OR Python 3.12+ and uv (for local development) + +## Quick Start with Docker + +### Production Deployment + +1. **Clone the repository** + ```bash + git clone + cd IdentificationGenerator + ``` + +2. **Set up environment variables** + ```bash + cp .env.example .env + # Edit .env with your configuration + ``` + +3. **Build and run with Docker Compose** + ```bash + docker-compose up -d + ``` + +4. **Access the API** + - API: http://localhost:8000 + - API Documentation: http://localhost:8000/docs + - Alternative docs: http://localhost:8000/redoc + +### Development Mode + +For development with hot-reload: + +```bash +docker-compose -f docker-compose.dev.yml up +``` + +This will mount your source code into the container, enabling live reload on code changes. + +## Docker Commands + +### Build the image +```bash +docker build -t identification-generator . +``` + +### Run a container +```bash +docker run -d -p 8000:8000 --name id-gen identification-generator +``` + +### View logs +```bash +docker-compose logs -f +``` + +### Stop containers +```bash +docker-compose down +``` + +### Rebuild after changes +```bash +docker-compose up -d --build +``` + +### Access container shell +```bash +docker exec -it identification-generator-api /bin/bash +``` + +## Local Development (without Docker) + +### Setup + +1. **Install uv** (if not already installed) + ```bash + curl -LsSf https://astral.sh/uv/install.sh | sh + ``` + +2. **Install dependencies** + ```bash + uv sync + ``` + +3. **Run the application** + ```bash + uv run uvicorn main:app --reload + ``` + +## Configuration + +### Firebase Setup + +1. Obtain your Firebase service account credentials +2. Save the JSON file as `resources/2019_firebase_cert.json` +3. Update the path in `Config.py` if needed + +### Environment Variables + +Create a `.env` file based on `.env.example`: + +- `EDITION`: The year/edition of the event (default: 2025) +- `TEST`: Set to `True` for test mode, `False` for production + +## API Endpoints + +- `GET /` - Health check +- `GET /docs` - Interactive API documentation +- `/contestants/*` - Contestant management endpoints +- `/companies/*` - Company/sponsor endpoints +- `/guests/*` - Guest management +- `/mentors/*` - Mentor management +- `/organizers/*` - Organizer management +- `/volunteers/*` - Volunteer management + +## Project Structure + +``` +. +├── main.py # FastAPI application entry point +├── Config.py # Configuration settings +├── dependencies.py # Dependency injection +├── models/ # Data models +├── repositories/ # Data access layer +├── routers/ # API route handlers +├── services/ # Business logic +├── resources/ # Static resources (fonts, templates) +├── tests/ # Test suite +└── docker-compose.yml # Docker orchestration +``` + +## Generated Files + +Generated ID cards are stored in the `generated_cards/` directory (mounted as `_out_/` inside the container). + +## Testing + +Run tests with: + +```bash +# With uv +uv run pytest + +# In Docker +docker-compose exec api uv run pytest +``` + +## Production Deployment + +### Using Docker Compose + +1. Update `docker-compose.yml` with production settings +2. Set up proper secrets management for Firebase credentials +3. Configure nginx reverse proxy (optional, template included) +4. Set up SSL certificates +5. Deploy: + ```bash + docker-compose up -d + ``` + +### Health Checks + +The Docker container includes a health check that pings the API every 30 seconds. + +## Troubleshooting + +### Container won't start +- Check logs: `docker-compose logs api` +- Verify all required files are present +- Ensure Firebase credentials are properly mounted + +### Permission issues with generated files +- Check volume mount permissions +- Ensure the `generated_cards/` directory exists and is writable + +### Missing fonts or resources +- Verify the `resources/` directory is properly mounted +- Check that all required fonts are in `resources/fonts/` + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run tests +5. Submit a pull request + +## License + +[Add your license here] + +## Support + +For issues and questions, please open an issue on GitHub. \ No newline at end of file diff --git a/Tools.py b/Tools.py deleted file mode 100644 index 8c2405c..0000000 --- a/Tools.py +++ /dev/null @@ -1,130 +0,0 @@ -import hashlib -import json -import os - -import qrcode -from PIL import ImageDraw, Image -import Config - - -def create_dir(path): - exists = os.path.isdir(path) - if not exists: - try: - os.mkdir(path) - except OSError: - raise OSError('dir creation error') - - -def empty_dir(path, delete_files=True, delete_dirs=True): - exists = os.path.isdir(path) - if exists and (delete_files or delete_dirs): - for root, dirs, files in os.walk(path): - if delete_files: - for file in files: - os.remove(os.path.join(root, file)) - if delete_dirs: - for dir in dirs: - os.remove(os.path.join(root, dir)) - - -def draw_text(image, text, pos, font, fill, centrate=True, mayus=True): - if mayus: - text = text.upper() - draw = ImageDraw.Draw(image) - w, h = draw.textsize(text, font=font) - if centrate: - ImageDraw.Draw(image).text(((image.width-w)/2, pos[1]), text, font=font,fill=fill) - else: - ImageDraw.Draw(image).text(pos, text, font=font,fill=fill) - -def centrate_text_relative(image, text, font, relative_pos, relative_size, mayus=True): - #centrate relative on x and split in 2 lines if text width is bigger than relative_size - if mayus: - text = text.upper() - draw = ImageDraw.Draw(image) - w, h = draw.textsize(text, font=font) - x = relative_pos[0] + (relative_size[0] - w) / 2 - y = relative_pos[1] - if w > relative_size[0]: - #split in 2 lines - words = text.split(' ') - line1 = '' - line2 = '' - for word in words: - if draw.textsize(line1 + ' ' + word, font=font)[0] < relative_size[0]: - line1 += ' ' + word - else: - line2 += ' ' + word - x1 = relative_pos[0] + (relative_size[0] - draw.textsize(line1, font=font)[0]) / 2 - x2 = relative_pos[0] + (relative_size[0] - draw.textsize(line2, font=font)[0]) / 2 - draw.text((x1, y), line1, font=font) - draw.text((x2, y + h), line2, font=font) - else: - ImageDraw.Draw(image).text((x, y), text, font=font) - -def scale(image, max_size, add_mask=True, method=Image.ANTIALIAS): - """ - resize 'image' to 'max_size' keeping the aspect ratio - and place it in center of white 'max_size' image - """ - im_aspect = float(image.size[0]) / float(image.size[1]) - out_aspect = float(max_size[0]) / float(max_size[1]) - if im_aspect >= out_aspect: - scaled = image.resize((max_size[0], int((float(max_size[0]) / im_aspect) + 0.5)), method) - else: - scaled = image.resize((int((float(max_size[1]) * im_aspect) + 0.5), max_size[1]), method) - - offset = (((max_size[0] - scaled.size[0]) / 2), ((max_size[1] - scaled.size[1]) / 2)) - back = Image.new("RGBA", max_size, Config.BAK_COLOR) - if add_mask: - back.paste(scaled, (int(offset[0]), int(offset[1])), scaled) - else: - back.paste(scaled, (int(offset[0]), int(offset[1]))) - return back - -def generate_qr(input, size, border_size): - qr = qrcode.QRCode( - version=2, - error_correction=qrcode.constants.ERROR_CORRECT_H, - box_size=size, - border=border_size) - qr.add_data(input) - qr.make(fit=True) - qr = qr.make_image() - return qr - - -def crypt(input, method='md5'): - m = hashlib.new(method) - m.update(bytes(input, 'utf')) - return str(m.digest()) - - -class DataFile: - __contents = {} - - @staticmethod - def get_content(filepath, type, reload_if_cached=False): - if not reload_if_cached and filepath in DataFile.__contents: - return DataFile.__contents[filepath] - else: - with open(filepath, 'r', encoding='utf-8') as json_file: - if type == 'JSON': - data = json.load(json_file) - DataFile.__contents[filepath] = data - return data - else: - raise Exception(NotImplemented) - - @staticmethod - def clear_cached_content(filepath): - if filepath in DataFile.__contents: - del DataFile.__contents[filepath] - return True - else: - return False - - @staticmethod - def clear_cache(): - DataFile.__contents = {} diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..578cf15 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,41 @@ +""" +Configuration package for the Identification Generator. + +Provides centralized access to settings, constants, and path management. +""" +from config.settings import settings, Settings +from config.constants import ( + TYPE_FONT_SIZE, + NAME_FONT_SIZE, + FONT_COLOR, + WHITE_FONT_COLOR, + DARK_FONT_COLOR, + BROWN_COLOR, + MAIN_COLOR, + BAK_COLOR, + QR_VERSION, + QR_BOX_SIZE, + QR_BORDER, +) +from config.paths import paths, PathManager + +__all__ = [ + # Settings + "settings", + "Settings", + # Constants + "TYPE_FONT_SIZE", + "NAME_FONT_SIZE", + "FONT_COLOR", + "WHITE_FONT_COLOR", + "DARK_FONT_COLOR", + "BROWN_COLOR", + "MAIN_COLOR", + "BAK_COLOR", + "QR_VERSION", + "QR_BOX_SIZE", + "QR_BORDER", + # Paths + "paths", + "PathManager", +] diff --git a/config/constants.py b/config/constants.py new file mode 100644 index 0000000..097bddb --- /dev/null +++ b/config/constants.py @@ -0,0 +1,22 @@ +""" +Constants module for truly constant values that don't change across environments. +Includes colors, font sizes, and other fixed configuration values. +""" +from typing import Tuple + +# Font Sizes +TYPE_FONT_SIZE: int = 40 +NAME_FONT_SIZE: int = 60 + +# Colors (RGB tuples) +FONT_COLOR: Tuple[int, int, int] = (0, 0, 0) +WHITE_FONT_COLOR: Tuple[int, int, int] = (84, 49, 26) +DARK_FONT_COLOR: Tuple[int, int, int] = (35, 35, 35) +BROWN_COLOR: Tuple[int, int, int] = (101, 67, 33) +MAIN_COLOR: Tuple[int, int, int] = (31, 33, 36) +BAK_COLOR: Tuple[int, int, int] = (119, 177, 201) + +# QR Code Settings +QR_VERSION: int = 2 +QR_BOX_SIZE: int = 10 +QR_BORDER: int = 4 diff --git a/config/paths.py b/config/paths.py new file mode 100644 index 0000000..898608d --- /dev/null +++ b/config/paths.py @@ -0,0 +1,120 @@ +""" +Path management module for constructing file paths and lazy-loading fonts. +""" +from os import path +from typing import Optional +from PIL import ImageFont + +from config.settings import settings +from config.constants import TYPE_FONT_SIZE, NAME_FONT_SIZE + + +class PathManager: + """Manages path construction and font loading.""" + + def __init__(self): + self._type_font: Optional[ImageFont.FreeTypeFont] = None + self._name_font: Optional[ImageFont.FreeTypeFont] = None + self._bold_type_font: Optional[ImageFont.FreeTypeFont] = None + self._bold_name_font: Optional[ImageFont.FreeTypeFont] = None + + # Base Paths + @property + def out_path(self) -> str: + """Output folder path.""" + return path.join('.', settings.OUT_FOLDER) + + @property + def res_path(self) -> str: + """Resources folder path.""" + return path.join('.', settings.RES_FOLDER) + + @property + def data_path(self) -> str: + """Data file path.""" + return path.join( + self.res_path, + settings.EDITIONS_FOLDER, + settings.EDITION, + settings.DATA_FILE + ) + + @property + def db_cert_path(self) -> str: + """Database certificate path.""" + return path.join(self.res_path, settings.DB_CERT) + + # Template Paths + @property + def bak_path_contestant(self) -> str: + """Contestant card template path.""" + return path.join( + self.res_path, + settings.EDITIONS_FOLDER, + settings.EDITION, + settings.BAK_FILE_CONTESTANT + ) + + @property + def bak_path_staff(self) -> str: + """Staff card template path.""" + return path.join( + self.res_path, + settings.EDITIONS_FOLDER, + settings.EDITION, + settings.BAK_FILE_STAFF + ) + + @property + def bak_path_empresa(self) -> str: + """Company card template path.""" + return path.join( + self.res_path, + settings.EDITIONS_FOLDER, + settings.EDITION, + settings.BAK_FILE_EMPRESA + ) + + # Font Paths + @property + def font_path(self) -> str: + """Regular font file path.""" + return path.join(self.res_path, settings.FONT_FOLDER, settings.FONT_FILE) + + @property + def bold_font_path(self) -> str: + """Bold font file path.""" + return path.join(self.res_path, settings.FONT_FOLDER, settings.BOLD_FONT_FILE) + + # Lazy-loaded Fonts + @property + def type_font(self) -> ImageFont.FreeTypeFont: + """Regular type font (lazy-loaded).""" + if self._type_font is None: + self._type_font = ImageFont.truetype(self.font_path, TYPE_FONT_SIZE) + return self._type_font + + @property + def name_font(self) -> ImageFont.FreeTypeFont: + """Regular name font (lazy-loaded).""" + if self._name_font is None: + self._name_font = ImageFont.truetype(self.font_path, NAME_FONT_SIZE) + return self._name_font + + @property + def bold_type_font(self) -> ImageFont.FreeTypeFont: + """Bold type font (lazy-loaded).""" + if self._bold_type_font is None: + self._bold_type_font = ImageFont.truetype(self.bold_font_path, TYPE_FONT_SIZE) + return self._bold_type_font + + @property + def bold_name_font(self) -> ImageFont.FreeTypeFont: + """Bold name font (lazy-loaded).""" + if self._bold_name_font is None: + self._bold_name_font = ImageFont.truetype(self.bold_font_path, NAME_FONT_SIZE) + return self._bold_name_font + + +# Singleton instance +paths = PathManager() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..32ddbbc --- /dev/null +++ b/config/settings.py @@ -0,0 +1,86 @@ +""" +Settings module using Pydantic for type-safe configuration management. +Loads configuration from environment variables with .env file support. +""" +from typing import Optional +from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import Field + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=True, + extra="ignore" + ) + + # Edition Configuration + EDITION: str = Field(default="2025", description="Event edition year") + TEST: bool = Field(default=False, description="Test mode flag") + + # Database Paths + DB_PATH_T: str = Field( + default="hackeps-2025/dev/users", + description="Test/development database path" + ) + DB_PATH: str = Field( + default="hackeps-2025/prod/users", + description="Production database path" + ) + + # API Configuration + BASE_URL: str = Field( + default="http://localhost:8000", + description="Backend API base URL" + ) + SERVICE_TOKEN: str = Field( + default="dummy_token", + description="Service authentication token" + ) + + # Folder Paths (with sensible defaults) + EDITIONS_FOLDER: str = Field(default="editions", description="Editions folder name") + OUT_FOLDER: str = Field(default="_out_", description="Output folder name") + RES_FOLDER: str = Field(default="resources", description="Resources folder name") + FONT_FOLDER: str = Field(default="fonts", description="Fonts folder name") + + # File Names + DATA_FILE: str = Field(default="data.json", description="Data file name") + DB_CERT: str = Field( + default="2019_firebase_cert.json", + description="Firebase certificate file name" + ) + FONT_FILE: str = Field( + default="SpaceMono-Regular.ttf", + description="Regular font file name" + ) + BOLD_FONT_FILE: str = Field( + default="SpaceMono-Bold.ttf", + description="Bold font file name" + ) + + # Template Files (relative paths within edition folder) + BAK_FILE_CONTESTANT: str = Field( + default="plantilles/participant.png", + description="Contestant card template" + ) + BAK_FILE_STAFF: str = Field( + default="plantilles/organitzador.png", + description="Staff card template" + ) + BAK_FILE_EMPRESA: str = Field( + default="plantilles/patrocinador.png", + description="Company card template" + ) + + @property + def db_path(self) -> str: + """Get the appropriate database path based on TEST mode.""" + return self.DB_PATH_T if self.TEST else self.DB_PATH + + +# Singleton instance +settings = Settings() diff --git a/dependencies.py b/dependencies.py new file mode 100644 index 0000000..48079aa --- /dev/null +++ b/dependencies.py @@ -0,0 +1,37 @@ +from services.api_service import APIService +from services.pdf_service import PDFService +from services.card_generator_service import CardGeneratorService +from repositories.contestant_repository import ContestantRepository +from repositories.company_repository import CompanyRepository +from repositories.guest_repository import GuestRepository +from repositories.mentor_repository import MentorRepository +from repositories.organizer_repository import OrganizerRepository +from repositories.volunteer_repository import VolunteerRepository +from fastapi import Depends + +def get_api_service(): + return APIService() + +def get_pdf_service(): + return PDFService() + +def get_card_generator_service(): + return CardGeneratorService() + +def get_contestant_repository(api_service: APIService = Depends(get_api_service)): + return ContestantRepository(api_service) + +def get_company_repository(api_service: APIService = Depends(get_api_service)): + return CompanyRepository(api_service) + +def get_guest_repository(): + return GuestRepository() + +def get_mentor_repository(): + return MentorRepository() + +def get_organizer_repository(): + return OrganizerRepository() + +def get_volunteer_repository(): + return VolunteerRepository() diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..959d97c --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,30 @@ +version: '3.8' + +services: + api: + build: + context: . + dockerfile: Dockerfile.dev + container_name: identification-generator-dev + ports: + - "8000:8000" + volumes: + # Mount source code for hot-reload + - .:/app + # Exclude virtual environment + - /app/.venv + # Mount output directory + - ./generated_cards:/app/_out_ + environment: + - EDITION=2025 + - TEST=True + restart: unless-stopped + networks: + - app-network + # Enable debugging + stdin_open: true + tty: true + +networks: + app-network: + driver: bridge diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example new file mode 100644 index 0000000..9e50dec --- /dev/null +++ b/docker-compose.override.yml.example @@ -0,0 +1,37 @@ +# Docker Compose Override Example +# +# Copy this file to docker-compose.override.yml to customize your local setup +# docker-compose.override.yml is git-ignored and will automatically be used +# alongside docker-compose.yml when you run docker-compose commands +# +# Example customizations: + +version: '3.8' + +services: + api: + # Use a different port + # ports: + # - "8080:8000" + + # Add additional environment variables + # environment: + # - DEBUG=True + # - LOG_LEVEL=debug + + # Mount additional volumes + # volumes: + # - ./custom_resources:/app/custom_resources + + # Override the command + # command: uv run uvicorn main:app --host 0.0.0.0 --port 8000 --reload --log-level debug + + # Add resource limits + # deploy: + # resources: + # limits: + # cpus: '2' + # memory: 2G + # reservations: + # cpus: '1' + # memory: 512M diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..aeb4a73 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +version: '3.8' + +services: + api: + build: + context: . + dockerfile: Dockerfile + container_name: identification-generator-api + ports: + - "8000:8000" + volumes: + # Mount output directory for generated files + - ./generated_cards:/app/_out_ + # Mount resources if you need to update them without rebuilding + - ./resources:/app/resources:ro + # Mount Firebase certificate (create this file from your credentials) + # - ./resources/2019_firebase_cert.json:/app/resources/2019_firebase_cert.json:ro + environment: + # Override these in a .env file or docker-compose.override.yml + - EDITION=2025 + - TEST=False + restart: unless-stopped + networks: + - app-network + # Optional: Add nginx reverse proxy for production + # nginx: + # image: nginx:alpine + # container_name: identification-generator-nginx + # ports: + # - "80:80" + # - "443:443" + # volumes: + # - ./nginx.conf:/etc/nginx/nginx.conf:ro + # - ./ssl:/etc/nginx/ssl:ro + # depends_on: + # - api + # networks: + # - app-network + +networks: + app-network: + driver: bridge + +volumes: + generated_cards: diff --git a/main.py b/main.py index 23d2916..9203df1 100644 --- a/main.py +++ b/main.py @@ -1,26 +1,15 @@ -import Config -import Model -import Tools -from PIL import Image -import os - -users = [] -users += Model.Contestant.get_data() -users += Model.Organizer.get_data() -users += Model.Volunteer.get_data() -users += Model.Mentor.get_data() -users += Model.Company.get_data() -users += Model.Guest.get_data() -users += Model.Assistant.get_data() - -Tools.create_dir(Config.OUT_PATH) -Tools.empty_dir(Config.OUT_PATH) - -i = 0 -for u in users: - u.generate_card() - u.save() - i+=1 - # break - -print('Generated ' + str(i) + ' cards') \ No newline at end of file +from fastapi import FastAPI +from routers import contestants, companies, guests, mentors, organizers, volunteers + +app = FastAPI() + +app.include_router(contestants.router) +app.include_router(companies.router) +app.include_router(guests.router) +app.include_router(mentors.router) +app.include_router(organizers.router) +app.include_router(volunteers.router) + +@app.get("/") +def read_root(): + return {"message": "Identification Generator API"} diff --git a/models/assistant.py b/models/assistant.py new file mode 100644 index 0000000..dd1c631 --- /dev/null +++ b/models/assistant.py @@ -0,0 +1,62 @@ + +import os + +from config.settings import settings +from config.paths import paths +from config.constants import BROWN_COLOR +from models.card import Card +import tools + +from PIL import Image + +class Assistant(object): + __ID:int = 1 + __DATA:str = 'empty' + _DATA_FILE:str = paths.data_path + + def __init__(self, id:str, type:str='', name:str=None): + self.id = id + self.type = type + self.name = name + self.card = None + self.qr = None + self.code = None + + def show(self): + self.card.show() + + def save(self): + self.card.save(os.path.join(paths.out_path, str(self.id) + '.png')) + + def generate_qr(self, crypt_id=False): + if self.code: + self.qr = tools.generate_qr(self.code, Card.QR_PIX_SIZE, Card.QR_BORDER_SIZE) + else: + self.qr = tools.generate_qr(self.id, Card.QR_PIX_SIZE, Card.QR_BORDER_SIZE) + self.qr = tools.scale(self.qr, Card.QR_SIZE, False) + + def generate_card(self, rgb_back=(255, 255, 255), template=None): + if template is None: + template = paths.bak_path_contestant + self.card = Image.open(template) + tools.draw_text(self.card, self.type, Card.TYPE_POS, paths.type_font, BROWN_COLOR) + if self.type == '': + self.smallen() + + def smallen(self): + return + blank = Image.new('RGB', (1082, 782),(255,255,255)) + blank.paste(self.card, (0, 0)) + self.card = blank + + @staticmethod + def get_data(): + data = tools.DataFile.get_content(Assistant._DATA_FILE, 'JSON') + num = data[Assistant.__DATA] + res = [] + for _ in range(num): + res.append(Assistant('A' + str(Assistant.__ID))) + Assistant.__ID += 1 + if settings.TEST: + break + return res diff --git a/models/card.py b/models/card.py new file mode 100644 index 0000000..eac51f0 --- /dev/null +++ b/models/card.py @@ -0,0 +1,9 @@ +class Card: + QR_PIX_SIZE = 1 + QR_POS = (711*1 - 100, 315*2-900//3) + _QR_SIZE = 331*1 + QR_SIZE = (_QR_SIZE, _QR_SIZE) + QR_BORDER_SIZE = 2 + TYPE_POS = (1063*(1/2), 720*(0.8/5)) + NAME_POS = (1063*(1/2), 720*(0.2/5)) + NICK_POS = (1063*(2/4), 720*(2/5)) \ No newline at end of file diff --git a/models/company.py b/models/company.py new file mode 100644 index 0000000..709ca45 --- /dev/null +++ b/models/company.py @@ -0,0 +1,35 @@ +import base64 +import os +from config.paths import paths +from config.constants import DARK_FONT_COLOR + +import requests +from io import BytesIO +import tools +from models.assistant import Assistant + +from PIL import Image + +from models.card import Card + +class Company(Assistant): + __ID:int = 1 + __TYPE:str = 'Empresa' + __DATA:str = 'companies' + + def __init__(self, name:str, image): + super().__init__('C' + str(Company.__ID), Company.__TYPE, name) + Company.__ID += 1 + self.logo = image + + def generate_card(self, rgb_back=(255, 255, 255)): + print("Generating card for company:", self.name) + self.card = Image.open(paths.bak_path_empresa) + tools.draw_text(self.card, self.type, Card.TYPE_POS, paths.type_font, DARK_FONT_COLOR) + if self.type == '': + self.smallen() + image = tools.translate_image(self.logo) + image = tools.scale(image, Card.QR_SIZE) + self.card.paste(image, Card.QR_POS) + tools.centrate_text_relative(self.card, self.name.strip(), paths.bold_name_font, Card.NAME_POS, Card.QR_SIZE * 3, DARK_FONT_COLOR) + self.smallen() diff --git a/models/contestant.py b/models/contestant.py new file mode 100644 index 0000000..2dd22a9 --- /dev/null +++ b/models/contestant.py @@ -0,0 +1,35 @@ +from config.settings import settings +from config.paths import paths +from config.constants import WHITE_FONT_COLOR +import tools + +from models.assistant import Assistant +from models.card import Card + + +class Contestant(Assistant): + __CRYPT_ID = False + __TYPE:str = 'Participant' + __FIREBASE = None + __FIRE_PATH:str = settings.db_path + + def __init__(self, id, data): + super().__init__(id, Contestant.__TYPE) + self.code = data['code'] + self.generate_qr() + self.name = data['name'] + self.nick = '\"' + data['nickname'] + '\"' + + def generate_card(self, rgb_back=(255, 255, 255)): + super().generate_card(rgb_back, paths.bak_path_contestant) + self.card.paste(self.qr, Card.QR_POS) + tools.centrate_text_relative(self.card, " ".join(self.name.split(" ")[:2]).strip(), paths.bold_name_font, Card.NAME_POS, Card.QR_SIZE * 3, WHITE_FONT_COLOR) + self.smallen() + + # @staticmethod + # def __firebase_init(cred): + # if Contestant.__FIREBASE is None: + # Contestant.__FIREBASE = firebase_admin.initialize_app(cred) + # return Contestant.__FIREBASE + + diff --git a/models/guest.py b/models/guest.py new file mode 100644 index 0000000..9aed8af --- /dev/null +++ b/models/guest.py @@ -0,0 +1,49 @@ +import os +from config.settings import settings +from config.paths import paths +from config.constants import DARK_FONT_COLOR +from models.card import Card +import tools +from models.assistant import Assistant +from PIL import Image + +class Guest(Assistant): + __ID:int = 1 + __TYPE:str = 'Convidat' + __DATA:str = 'guests' + + def __init__(self, name:str, mtype:str='', logo:str='', has_qr:bool=False): + super().__init__('HackEPS_Guest_' + str(Guest.__ID), (mtype, Guest.__TYPE)[mtype == ''], name) + Guest.__ID += 1 + self.has_qr = has_qr + self.logo = logo + if has_qr: + self.generate_qr(True) + if not logo == '': + self.logopath = os.path.join(paths.res_path, settings.EDITIONS_FOLDER, settings.EDITION, 'images', logo) + + def generate_card(self, rgb_back=(255, 255, 255)): + self.card = Image.open(paths.bak_path_staff) + tools.draw_text(self.card, self.type, Card.TYPE_POS, paths.type_font, DARK_FONT_COLOR) + if self.type == '': + self.smallen() + if self.logo != '': + logo = Image.open(self.logopath).convert("RGBA") + logo = tools.scale(logo, Card.QR_SIZE) + self.card.paste(logo, Card.QR_POS) + elif self.has_qr: + self.card.paste(self.qr, Card.QR_POS) + if self.name != '': + tools.centrate_text_relative(self.card, " ".join(self.name.split(" ")[:2]).strip(), paths.bold_name_font, Card.NAME_POS, Card.QR_SIZE * 3, DARK_FONT_COLOR) + self.smallen() + + @staticmethod + def get_data(name=None): + res = [] + data = tools.DataFile.get_content(Guest._DATA_FILE, 'JSON') + for u in data[Guest.__DATA]: + if name is None or u['name'] == name: + res.append(Guest(u['name'], u['type'], u['logo'], u['qr'])) + if settings.TEST or (name is not None and u['name'] == name): + break + return res \ No newline at end of file diff --git a/models/mentor.py b/models/mentor.py new file mode 100644 index 0000000..e9d5e96 --- /dev/null +++ b/models/mentor.py @@ -0,0 +1,42 @@ +import os +from config.settings import settings +from config.paths import paths +from config.constants import DARK_FONT_COLOR +import tools +from models.assistant import Assistant +from models.card import Card + +from PIL import Image + + +class Mentor(Assistant): + __ID:int = 1 + __LOGO_PATH:str = os.path.join(paths.res_path, 'editions', settings.EDITION, 'images', 'logogran.png') + __TYPE:str = 'Mentor/a' + __DATA:str = 'mentors' + + def __init__(self, name:str): + super().__init__('M' + str(Mentor.__ID), Mentor.__TYPE, name) + Mentor.__ID += 1 + + def generate_card(self, rgb_back=(255, 255, 255)): + self.card = Image.open(paths.bak_path_staff) + tools.draw_text(self.card, self.type, Card.TYPE_POS, paths.type_font, DARK_FONT_COLOR) + if self.type == '': + self.smallen() + image = Image.open(Mentor.__LOGO_PATH).convert("RGBA") + image = tools.scale(image, Card.QR_SIZE) + self.card.paste(image, Card.QR_POS) + tools.centrate_text_relative(self.card, " ".join(self.name.split(" ")[:2]).strip(), paths.bold_name_font, Card.NAME_POS, Card.QR_SIZE * 3, DARK_FONT_COLOR) + self.smallen() + + @staticmethod + def get_data(name=None): + res = [] + data = tools.DataFile.get_content(Mentor._DATA_FILE, 'JSON') + for u in data[Mentor.__DATA]: + if name is None or name == u['name']: + res.append(Mentor(u['name'])) + if settings.TEST or (name is not None and name == u['name']): + break + return res \ No newline at end of file diff --git a/models/organizer.py b/models/organizer.py new file mode 100644 index 0000000..435e8d5 --- /dev/null +++ b/models/organizer.py @@ -0,0 +1,42 @@ +import os +from PIL import Image +from config.settings import settings +from config.paths import paths +from config.constants import DARK_FONT_COLOR +import tools +from models.assistant import Assistant +from models.card import Card + + +class Organizer(Assistant): + __ID:int = 1 + __LOGO_PATH:str = os.path.join(paths.res_path, 'editions', settings.EDITION, 'images', 'logogran.png') + __TYPE:str = 'Staff' + __DATA:str = 'organizers' + + def __init__(self, name): + super().__init__('O' + str(Organizer.__ID), Organizer.__TYPE, name) + Organizer.__ID += 1 + + def generate_card(self, rgb_back=(255, 255, 255)): + self.card = Image.open(paths.bak_path_staff) + tools.draw_text(self.card, self.type, Card.TYPE_POS, paths.type_font, DARK_FONT_COLOR) + if self.type == '': + self.smallen() + image = Image.open(Organizer.__LOGO_PATH).convert("RGBA") + image = tools.scale(image, Card.QR_SIZE) + self.card.paste(image, Card.QR_POS) + tools.centrate_text_relative(self.card, " ".join(self.name.split(" ")[:2]).strip(), paths.bold_name_font, Card.NAME_POS, Card.QR_SIZE * 3, DARK_FONT_COLOR) + # Tools.draw_text(self.card, self.name, Card.NAME_POS, Config.NAME_FONT, Config.WHITE_FONT_COLOR, False) + self.smallen() + + @staticmethod + def get_data(name=None): + res = [] + data = tools.DataFile.get_content(Organizer._DATA_FILE, 'JSON') + for u in data[Organizer.__DATA]: + if name is None or u['name'] == name: + res.append(Organizer(u['name'])) + if settings.TEST or (name is not None and u['name'] == name): + break + return res \ No newline at end of file diff --git a/models/volunteer.py b/models/volunteer.py new file mode 100644 index 0000000..84e2b3d --- /dev/null +++ b/models/volunteer.py @@ -0,0 +1,41 @@ +import os + +from config.settings import settings +from config.paths import paths +from config.constants import DARK_FONT_COLOR +import tools +from models.assistant import Assistant +from models.card import Card +from PIL import Image + +class Volunteer(Assistant): + __ID:int = 1 + __LOGO_PATH:str = os.path.join(paths.res_path, 'editions', settings.EDITION, 'images', 'logogran.png') + __TYPE:str = 'Voluntari/a' + __DATA:str = 'volunteers' + + def __init__(self, name:str): + super().__init__('V' + str(Volunteer.__ID), Volunteer.__TYPE, name) + Volunteer.__ID += 1 + + def generate_card(self, rgb_back=(255, 255, 255)): + self.card = Image.open(paths.bak_path_staff) + tools.draw_text(self.card, self.type, Card.TYPE_POS, paths.type_font, DARK_FONT_COLOR) + if self.type == '': + self.smallen() + image = Image.open(Volunteer.__LOGO_PATH).convert("RGBA") + image = tools.scale(image, Card.QR_SIZE) + self.card.paste(image, Card.QR_POS) + tools.centrate_text_relative(self.card, " ".join(self.name.split(" ")[:2]).strip(), paths.bold_name_font, Card.NAME_POS, Card.QR_SIZE * 3, DARK_FONT_COLOR) + self.smallen() + + @staticmethod + def get_data(name=None): + res = [] + data = tools.DataFile.get_content(Volunteer._DATA_FILE, 'JSON') + for u in data[Volunteer.__DATA]: + if name is None or name == u['name']: + res.append(Volunteer(u['name'])) + if settings.TEST or (name is not None and name == u['name']): + break + return res diff --git a/old.requirements.txt b/old.requirements.txt deleted file mode 100644 index 57004d6..0000000 --- a/old.requirements.txt +++ /dev/null @@ -1,32 +0,0 @@ -CacheControl==0.12.5 -cachetools==3.1.1 -certifi==2019.9.11 -chardet==3.0.4 -firebase-admin==3.2.0 -google-api-core==1.14.3 -google-api-python-client==1.7.11 -google-auth==1.7.1 -google-auth-httplib2==0.0.3 -google-cloud-core==1.0.3 -google-cloud-firestore==1.6.0 -google-cloud-storage==1.23.0 -google-resumable-media==0.5.0 -googleapis-common-protos==1.6.0 -# grpcio==1.25.0 -grpcio==1.50.0 -httplib2==0.19.0 -idna==2.8 -msgpack==0.6.2 -Pillow==8.3.2 -# pkg-resources==0.0.0 -# protobuf==3.15.0 -protobuf==4.21.6 -pyasn1==0.4.7 -pyasn1-modules==0.2.7 -pytz==2019.3 -qrcode==6.1 -requests==2.22.0 -rsa==4.0 -six==1.13.0 -uritemplate==3.0.0 -urllib3==1.22.0 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..619cc70 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "identificationgenerator" +version = "0.1.0" +description = "" +readme = "README.md" +authors = [ + { name = "Ton", email = "tonlls1999@gmail.com" } +] +requires-python = ">=3.12" +dependencies = [ + "pillow>=11.0.0", + "qrcode>=8.0", + "requests>=2.32.3", + "isort>=5.13.2", + "fpdf>=1.7.2", + "reportlab>=4.4.5", + "fastapi>=0.123.10", + "uvicorn>=0.38.0", + "pydantic-settings>=2.0.0", + "python-dotenv>=1.0.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv] +package = false + +[dependency-groups] +dev = [ + "pytest>=9.0.1", +] diff --git a/repositories/company_repository.py b/repositories/company_repository.py new file mode 100644 index 0000000..59f0b0b --- /dev/null +++ b/repositories/company_repository.py @@ -0,0 +1,24 @@ +from typing import List + +from services.api_service import APIService +from config.settings import settings +from models.company import Company + +class CompanyRepository: + def __init__(self, api_service: APIService): + self.api_service = api_service + + def get_all(self) -> List[Company]: + res = [] + tiers_config = [(1, 8), (2, 5), (3, 5)] + + for tier_num, count in tiers_config: + comps = self.api_service.get_companies_by_tier(tier_num) + for comp in comps: + for _ in range(count): + res.append(Company(comp['name'], comp['image'])) + if settings.TEST: + break + if settings.TEST: + break + return res diff --git a/repositories/contestant_repository.py b/repositories/contestant_repository.py new file mode 100644 index 0000000..506efb7 --- /dev/null +++ b/repositories/contestant_repository.py @@ -0,0 +1,18 @@ +from typing import List, Optional +from models.contestant import Contestant +from services.api_service import APIService + +class ContestantRepository: + def __init__(self, api_service: APIService): + self.api_service = api_service + + def get_all(self) -> List[Contestant]: + usrs = self.api_service.get_accepted_hackers() + return [Contestant(usr['id'], usr) for usr in usrs] + + def get_by_id(self, user_id: str) -> Optional[Contestant]: + usrs = self.api_service.get_accepted_hackers() + for usr in usrs: + if str(usr['id']) == str(user_id): + return Contestant(usr['id'], usr) + return None diff --git a/repositories/guest_repository.py b/repositories/guest_repository.py new file mode 100644 index 0000000..548c5e7 --- /dev/null +++ b/repositories/guest_repository.py @@ -0,0 +1,21 @@ +from typing import List, Optional +from models.guest import Guest +import tools + +class GuestRepository: + def __init__(self): + pass + + def get_all(self) -> List[Guest]: + res = [] + data = tools.DataFile.get_content(Guest._DATA_FILE, 'JSON') + for u in data['guests']: + res.append(Guest(u['name'], u['type'], u['logo'], u['qr'])) + return res + + def get_by_name(self, name: str) -> Optional[Guest]: + data = tools.DataFile.get_content(Guest._DATA_FILE, 'JSON') + for u in data['guests']: + if u['name'] == name: + return Guest(u['name'], u['type'], u['logo'], u['qr']) + return None diff --git a/repositories/mentor_repository.py b/repositories/mentor_repository.py new file mode 100644 index 0000000..a031251 --- /dev/null +++ b/repositories/mentor_repository.py @@ -0,0 +1,21 @@ +from typing import List, Optional +from models.mentor import Mentor +import tools + +class MentorRepository: + def __init__(self): + pass + + def get_all(self) -> List[Mentor]: + res = [] + data = tools.DataFile.get_content(Mentor._DATA_FILE, 'JSON') + for u in data['mentors']: + res.append(Mentor(u['name'])) + return res + + def get_by_name(self, name: str) -> Optional[Mentor]: + data = tools.DataFile.get_content(Mentor._DATA_FILE, 'JSON') + for u in data['mentors']: + if u['name'] == name: + return Mentor(u['name']) + return None diff --git a/repositories/organizer_repository.py b/repositories/organizer_repository.py new file mode 100644 index 0000000..42a9fb6 --- /dev/null +++ b/repositories/organizer_repository.py @@ -0,0 +1,21 @@ +from typing import List, Optional +from models.organizer import Organizer +import tools + +class OrganizerRepository: + def __init__(self): + pass + + def get_all(self) -> List[Organizer]: + res = [] + data = tools.DataFile.get_content(Organizer._DATA_FILE, 'JSON') + for u in data['organizers']: + res.append(Organizer(u['name'])) + return res + + def get_by_name(self, name: str) -> Optional[Organizer]: + data = tools.DataFile.get_content(Organizer._DATA_FILE, 'JSON') + for u in data['organizers']: + if u['name'] == name: + return Organizer(u['name']) + return None diff --git a/repositories/volunteer_repository.py b/repositories/volunteer_repository.py new file mode 100644 index 0000000..d7ab66b --- /dev/null +++ b/repositories/volunteer_repository.py @@ -0,0 +1,21 @@ +from typing import List, Optional +from models.volunteer import Volunteer +import tools + +class VolunteerRepository: + def __init__(self): + pass + + def get_all(self) -> List[Volunteer]: + res = [] + data = tools.DataFile.get_content(Volunteer._DATA_FILE, 'JSON') + for u in data['volunteers']: + res.append(Volunteer(u['name'])) + return res + + def get_by_name(self, name: str) -> Optional[Volunteer]: + data = tools.DataFile.get_content(Volunteer._DATA_FILE, 'JSON') + for u in data['volunteers']: + if u['name'] == name: + return Volunteer(u['name']) + return None diff --git a/requirements.txt b/requirements.txt index 3ce8d51..59ba41e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,11 +13,11 @@ google-cloud-storage==2.8.0 google-resumable-media==2.4.1 googleapis-common-protos==1.59.0 # grpcio==1.25.0 -grpcio==1.53.0 +grpcio==1.76.0 httplib2==0.22.0 idna==3.4 msgpack==1.0.5 -Pillow==9.5.0 +Pillow==12.0.0 # pkg-resources==0.0.0 # protobuf==3.15.0 protobuf==4.22.1 @@ -29,4 +29,5 @@ requests==2.28.2 rsa==4.9 six==1.16.0 uritemplate==3.0.0 -urllib3==1.26.15 \ No newline at end of file +urllib3==1.26.15 +cairosvg>=2.0.0 \ No newline at end of file diff --git a/resources/editions/2023/plantilles/organitzador.png b/resources/editions/2023/plantilles/organitzador.png new file mode 100644 index 0000000..87b4a00 Binary files /dev/null and b/resources/editions/2023/plantilles/organitzador.png differ diff --git a/resources/editions/2023/plantilles/participant.png b/resources/editions/2023/plantilles/participant.png new file mode 100644 index 0000000..ad0f5bc Binary files /dev/null and b/resources/editions/2023/plantilles/participant.png differ diff --git a/resources/editions/2023/plantilles/patrocinador.png b/resources/editions/2023/plantilles/patrocinador.png new file mode 100644 index 0000000..edf6d04 Binary files /dev/null and b/resources/editions/2023/plantilles/patrocinador.png differ diff --git a/resources/editions/2024/images/logogran.png b/resources/editions/2024/images/logogran.png new file mode 100644 index 0000000..7d8aa36 Binary files /dev/null and b/resources/editions/2024/images/logogran.png differ diff --git a/resources/editions/2024/images/paeria.png b/resources/editions/2024/images/paeria.png new file mode 100644 index 0000000..90c91ed Binary files /dev/null and b/resources/editions/2024/images/paeria.png differ diff --git a/resources/editions/2024/images/udl.png b/resources/editions/2024/images/udl.png new file mode 100644 index 0000000..aeb6971 Binary files /dev/null and b/resources/editions/2024/images/udl.png differ diff --git a/resources/editions/2024/plantilles/organitzador.png b/resources/editions/2024/plantilles/organitzador.png new file mode 100644 index 0000000..ac4388d Binary files /dev/null and b/resources/editions/2024/plantilles/organitzador.png differ diff --git a/resources/editions/2024/plantilles/participant.png b/resources/editions/2024/plantilles/participant.png new file mode 100644 index 0000000..3d1fd88 Binary files /dev/null and b/resources/editions/2024/plantilles/participant.png differ diff --git a/resources/editions/2024/plantilles/patrocinador.png b/resources/editions/2024/plantilles/patrocinador.png new file mode 100644 index 0000000..8b73c2c Binary files /dev/null and b/resources/editions/2024/plantilles/patrocinador.png differ diff --git a/resources/editions/2025/images/agrotecnio.png b/resources/editions/2025/images/agrotecnio.png new file mode 100644 index 0000000..e2be4a3 Binary files /dev/null and b/resources/editions/2025/images/agrotecnio.png differ diff --git a/resources/editions/2025/images/bonarea.png b/resources/editions/2025/images/bonarea.png new file mode 100644 index 0000000..adb7996 Binary files /dev/null and b/resources/editions/2025/images/bonarea.png differ diff --git a/resources/editions/2025/images/eurecat.png b/resources/editions/2025/images/eurecat.png new file mode 100644 index 0000000..846a6c5 Binary files /dev/null and b/resources/editions/2025/images/eurecat.png differ diff --git a/resources/editions/2025/images/ingroup.png b/resources/editions/2025/images/ingroup.png new file mode 100644 index 0000000..750da9a Binary files /dev/null and b/resources/editions/2025/images/ingroup.png differ diff --git a/resources/editions/2025/images/leitat.png b/resources/editions/2025/images/leitat.png new file mode 100644 index 0000000..ea5723d Binary files /dev/null and b/resources/editions/2025/images/leitat.png differ diff --git a/resources/editions/2025/images/logogran.png b/resources/editions/2025/images/logogran.png new file mode 100644 index 0000000..7d8aa36 Binary files /dev/null and b/resources/editions/2025/images/logogran.png differ diff --git a/resources/editions/2025/images/paeria.png b/resources/editions/2025/images/paeria.png new file mode 100644 index 0000000..90c91ed Binary files /dev/null and b/resources/editions/2025/images/paeria.png differ diff --git a/resources/editions/2025/images/restbai.png b/resources/editions/2025/images/restbai.png new file mode 100644 index 0000000..cf2ffe1 Binary files /dev/null and b/resources/editions/2025/images/restbai.png differ diff --git a/resources/editions/2025/images/udl.png b/resources/editions/2025/images/udl.png new file mode 100644 index 0000000..aeb6971 Binary files /dev/null and b/resources/editions/2025/images/udl.png differ diff --git a/resources/editions/2025/plantilles/mentor.png b/resources/editions/2025/plantilles/mentor.png new file mode 100644 index 0000000..23b8721 Binary files /dev/null and b/resources/editions/2025/plantilles/mentor.png differ diff --git a/resources/editions/2025/plantilles/organitzador.png b/resources/editions/2025/plantilles/organitzador.png new file mode 100644 index 0000000..1c464cd Binary files /dev/null and b/resources/editions/2025/plantilles/organitzador.png differ diff --git a/resources/editions/2025/plantilles/participant.png b/resources/editions/2025/plantilles/participant.png new file mode 100644 index 0000000..7c75300 Binary files /dev/null and b/resources/editions/2025/plantilles/participant.png differ diff --git a/resources/editions/2025/plantilles/patrocinador.png b/resources/editions/2025/plantilles/patrocinador.png new file mode 100644 index 0000000..56f0d63 Binary files /dev/null and b/resources/editions/2025/plantilles/patrocinador.png differ diff --git a/resources/editions/data_sample.json b/resources/editions/data_sample.json index 411efb8..632f4c1 100644 --- a/resources/editions/data_sample.json +++ b/resources/editions/data_sample.json @@ -1,15 +1,53 @@ -{ - "empty": 5, - "organizers": [ - {"name":"Ton Llucià Senserrich"} - ], - "volunteers": [ - {"name":"Ton Llucià Senserrich"} - ], - "companies": [ - {"name":"Ton Llucià Senserrich","number_of_cards":5,"logo":"logo.png"} - ], - "guests": [ - {"name":"Ton Llucià Senserrich","type":"HACKER","qr":true} - ] -} +{ + "empty": 5, + "organizers": [ + {"name":"Pol Llinàs"}, + {"name":"Naïm Saadi"}, + {"name":"Lluc Vivet"}, + {"name":"Lotfi Bouakel (LOLO)"}, + {"name":"Ton Llucià "}, + {"name":"Joel Ros "}, + {"name":"Gerard Tersa "}, + {"name":"Alba Serrano "}, + {"name":"Aida Chavero "}, + {"name":"Martina López "}, + {"name":"Anaïs Garrofé "}, + {"name":"Júlia Calvo "}, + {"name":"Aleix Rosinach "}, + {"name":"Andreu Puig-gròs "}, + {"name":"Enric Zhang "}, + {"name":"David Garcia "}, + {"name":"Nataly Jaya "}, + {"name":"Toni López "}, + {"name":"Òscar van de Crommert"}, + {"name":"Carles Sànchez"}, + {"name":"Gerard Ballebrera"}, + {"name":"Albert Sorribes"}, + {"name":"Bru Pallàs "}, + {"name":"Norbert Aguilera"}, + {"name":"Aleix Bertran"} + + ], + "volunteers": [ + {"name":"Ton Llucià Senserrich"} + ], + "companies": [ + {"name":"Ton Llucià Senserrich","number_of_cards":5,"logo":"logo.png"} + ], + "guests": [ + {"name":"Ton Llucià Senserrich","type":"HACKER","qr":true} + ], + "mentors": [ + {"name":"David Sarrat","qr":true}, + {"name":"Sergi Vila","qr":true}, + {"name":"Alba Lamas","qr":true}, + {"name":"Josep","qr":true}, + {"name":"Jordi Onrubia","qr":true}, + {"name":"Cangrejo #1"}, + {"name":"Cangrejo #2"}, + {"name":"Cangrejo #3"}, + {"name":"ivansaureu15"}, + {"name":"Josep Maria Salvia"}, + {"name":"Salce"} + ] +} diff --git a/routers/companies.py b/routers/companies.py new file mode 100644 index 0000000..5a56c04 --- /dev/null +++ b/routers/companies.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter, Depends, HTTPException, Response +from typing import List, Dict, Any +from repositories.company_repository import CompanyRepository +from dependencies import get_company_repository, get_pdf_service, get_card_generator_service +from services.pdf_service import PDFService +from services.card_generator_service import CardGeneratorService + +router = APIRouter(prefix="/companies", tags=["companies"]) + +@router.get("") +def get_companies(repo: CompanyRepository = Depends(get_company_repository)) -> List[Dict[str, Any]]: + companies = repo.get_all() + return [{"name": c.name, "id": c.id, "type": c.type} for c in companies] + +@router.get("/pdf") +def get_companies_pdf( + repo: CompanyRepository = Depends(get_company_repository), + pdf_service: PDFService = Depends(get_pdf_service), + card_service: CardGeneratorService = Depends(get_card_generator_service) +): + companies = repo.get_all() + if not companies: + raise HTTPException(status_code=404, detail="No companies found") + + try: + images = card_service.generate_multiple_cards(companies) + pdf_bytes = pdf_service.create_pdf(images) + return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": "attachment; filename=companies.pdf"}) + except ValueError as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/routers/contestants.py b/routers/contestants.py new file mode 100644 index 0000000..a57b1c9 --- /dev/null +++ b/routers/contestants.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Depends, HTTPException, Response +from typing import List, Dict, Any +from repositories.contestant_repository import ContestantRepository +from dependencies import get_contestant_repository, get_pdf_service, get_card_generator_service +from services.pdf_service import PDFService +from services.card_generator_service import CardGeneratorService + +router = APIRouter(prefix="/contestants", tags=["contestants"]) + +@router.get("") +def get_contestants(repo: ContestantRepository = Depends(get_contestant_repository)) -> List[Dict[str, Any]]: + users = repo.get_all() + return [{"name": u.name, "id": u.id, "type": u.type} for u in users] + +@router.get("/pdf") +def get_contestants_pdf( + repo: ContestantRepository = Depends(get_contestant_repository), + pdf_service: PDFService = Depends(get_pdf_service), + card_service: CardGeneratorService = Depends(get_card_generator_service) +): + users = repo.get_all() + if not users: + raise HTTPException(status_code=404, detail="No contestants found") + + try: + images = card_service.generate_multiple_cards(users) + pdf_bytes = pdf_service.create_pdf(images) + return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": "attachment; filename=contestants.pdf"}) + except ValueError as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/{user_id}/card") +def get_contestant_card( + user_id: str, + repo: ContestantRepository = Depends(get_contestant_repository), + card_service: CardGeneratorService = Depends(get_card_generator_service) +): + user = repo.get_by_id(user_id) + if not user: + raise HTTPException(status_code=404, detail="Contestant not found") + + try: + img_bytes = card_service.generate_card_png_bytes(user) + return Response(content=img_bytes, media_type="image/png") + except ValueError as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/routers/guests.py b/routers/guests.py new file mode 100644 index 0000000..b5333aa --- /dev/null +++ b/routers/guests.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Depends, HTTPException, Response +from typing import List, Dict, Any +from repositories.guest_repository import GuestRepository +from dependencies import get_guest_repository, get_pdf_service, get_card_generator_service +from services.pdf_service import PDFService +from services.card_generator_service import CardGeneratorService + +router = APIRouter(prefix="/guests", tags=["guests"]) + +@router.get("") +def get_guests(repo: GuestRepository = Depends(get_guest_repository)) -> List[Dict[str, Any]]: + guests = repo.get_all() + return [{"name": g.name, "id": g.id, "type": g.type} for g in guests] + +@router.get("/pdf") +def get_guests_pdf( + repo: GuestRepository = Depends(get_guest_repository), + pdf_service: PDFService = Depends(get_pdf_service), + card_service: CardGeneratorService = Depends(get_card_generator_service) +): + guests = repo.get_all() + if not guests: + raise HTTPException(status_code=404, detail="No guests found") + + try: + images = card_service.generate_multiple_cards(guests) + pdf_bytes = pdf_service.create_pdf(images) + return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": "attachment; filename=guests.pdf"}) + except ValueError as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/{name}/card") +def get_guest_card( + name: str, + repo: GuestRepository = Depends(get_guest_repository), + card_service: CardGeneratorService = Depends(get_card_generator_service) +): + guest = repo.get_by_name(name) + if not guest: + raise HTTPException(status_code=404, detail="Guest not found") + + try: + img_bytes = card_service.generate_card_png_bytes(guest) + return Response(content=img_bytes, media_type="image/png") + except ValueError as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/routers/mentors.py b/routers/mentors.py new file mode 100644 index 0000000..c1b1beb --- /dev/null +++ b/routers/mentors.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Depends, HTTPException, Response +from typing import List, Dict, Any +from repositories.mentor_repository import MentorRepository +from dependencies import get_mentor_repository, get_pdf_service, get_card_generator_service +from services.pdf_service import PDFService +from services.card_generator_service import CardGeneratorService + +router = APIRouter(prefix="/mentors", tags=["mentors"]) + +@router.get("") +def get_mentors(repo: MentorRepository = Depends(get_mentor_repository)) -> List[Dict[str, Any]]: + mentors = repo.get_all() + return [{"name": m.name, "id": m.id, "type": m.type} for m in mentors] + +@router.get("/pdf") +def get_mentors_pdf( + repo: MentorRepository = Depends(get_mentor_repository), + pdf_service: PDFService = Depends(get_pdf_service), + card_service: CardGeneratorService = Depends(get_card_generator_service) +): + mentors = repo.get_all() + if not mentors: + raise HTTPException(status_code=404, detail="No mentors found") + + try: + images = card_service.generate_multiple_cards(mentors) + pdf_bytes = pdf_service.create_pdf(images) + return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": "attachment; filename=mentors.pdf"}) + except ValueError as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/{name}/card") +def get_mentor_card( + name: str, + repo: MentorRepository = Depends(get_mentor_repository), + card_service: CardGeneratorService = Depends(get_card_generator_service) +): + mentor = repo.get_by_name(name) + if not mentor: + raise HTTPException(status_code=404, detail="Mentor not found") + + try: + img_bytes = card_service.generate_card_png_bytes(mentor) + return Response(content=img_bytes, media_type="image/png") + except ValueError as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/routers/organizers.py b/routers/organizers.py new file mode 100644 index 0000000..e6745b0 --- /dev/null +++ b/routers/organizers.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Depends, HTTPException, Response +from typing import List, Dict, Any +from repositories.organizer_repository import OrganizerRepository +from dependencies import get_organizer_repository, get_pdf_service, get_card_generator_service +from services.pdf_service import PDFService +from services.card_generator_service import CardGeneratorService + +router = APIRouter(prefix="/organizers", tags=["organizers"]) + +@router.get("") +def get_organizers(repo: OrganizerRepository = Depends(get_organizer_repository)) -> List[Dict[str, Any]]: + organizers = repo.get_all() + return [{"name": o.name, "id": o.id, "type": o.type} for o in organizers] + +@router.get("/pdf") +def get_organizers_pdf( + repo: OrganizerRepository = Depends(get_organizer_repository), + pdf_service: PDFService = Depends(get_pdf_service), + card_service: CardGeneratorService = Depends(get_card_generator_service) +): + organizers = repo.get_all() + if not organizers: + raise HTTPException(status_code=404, detail="No organizers found") + + try: + images = card_service.generate_multiple_cards(organizers) + pdf_bytes = pdf_service.create_pdf(images) + return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": "attachment; filename=organizers.pdf"}) + except ValueError as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/{name}/card") +def get_organizer_card( + name: str, + repo: OrganizerRepository = Depends(get_organizer_repository), + card_service: CardGeneratorService = Depends(get_card_generator_service) +): + organizer = repo.get_by_name(name) + if not organizer: + raise HTTPException(status_code=404, detail="Organizer not found") + + try: + img_bytes = card_service.generate_card_png_bytes(organizer) + return Response(content=img_bytes, media_type="image/png") + except ValueError as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/routers/volunteers.py b/routers/volunteers.py new file mode 100644 index 0000000..e038950 --- /dev/null +++ b/routers/volunteers.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Depends, HTTPException, Response +from typing import List, Dict, Any +from repositories.volunteer_repository import VolunteerRepository +from dependencies import get_volunteer_repository, get_pdf_service, get_card_generator_service +from services.pdf_service import PDFService +from services.card_generator_service import CardGeneratorService + +router = APIRouter(prefix="/volunteers", tags=["volunteers"]) + +@router.get("") +def get_volunteers(repo: VolunteerRepository = Depends(get_volunteer_repository)) -> List[Dict[str, Any]]: + volunteers = repo.get_all() + return [{"name": v.name, "id": v.id, "type": v.type} for v in volunteers] + +@router.get("/pdf") +def get_volunteers_pdf( + repo: VolunteerRepository = Depends(get_volunteer_repository), + pdf_service: PDFService = Depends(get_pdf_service), + card_service: CardGeneratorService = Depends(get_card_generator_service) +): + volunteers = repo.get_all() + if not volunteers: + raise HTTPException(status_code=404, detail="No volunteers found") + + try: + images = card_service.generate_multiple_cards(volunteers) + pdf_bytes = pdf_service.create_pdf(images) + return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": "attachment; filename=volunteers.pdf"}) + except ValueError as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/{name}/card") +def get_volunteer_card( + name: str, + repo: VolunteerRepository = Depends(get_volunteer_repository), + card_service: CardGeneratorService = Depends(get_card_generator_service) +): + volunteer = repo.get_by_name(name) + if not volunteer: + raise HTTPException(status_code=404, detail="Volunteer not found") + + try: + img_bytes = card_service.generate_card_png_bytes(volunteer) + return Response(content=img_bytes, media_type="image/png") + except ValueError as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/services/api_service.py b/services/api_service.py new file mode 100644 index 0000000..33c8cb3 --- /dev/null +++ b/services/api_service.py @@ -0,0 +1,32 @@ +import requests +import json +import os +from config.settings import settings + +BASE_URL = settings.BASE_URL +SERVICE_TOKEN = settings.SERVICE_TOKEN + +class APIService: + def __init__(self): + self.base_url = BASE_URL + self.token = SERVICE_TOKEN + + def _send_request(self, endpoint: str, bearer: bool = True): + url = self.base_url + endpoint + headers = {} + if bearer: + headers["Authorization"] = "Bearer " + self.token + + r = requests.get(url, headers=headers) + r.raise_for_status() # Good practice to raise on error + return r.json() + + def get_hackeps_event_id(self): + return self._send_request("/v1/event/get_hackeps")["id"] + + def get_accepted_hackers(self): + event_id = self.get_hackeps_event_id() + return self._send_request(f'/v1/event/{event_id}/get_approved_hackers') + + def get_companies_by_tier(self, tier): + return self._send_request(f'/v1/company/tier/{tier}/') diff --git a/services/card_generator_service.py b/services/card_generator_service.py new file mode 100644 index 0000000..d3069aa --- /dev/null +++ b/services/card_generator_service.py @@ -0,0 +1,39 @@ +from typing import List, Any +from PIL import Image +from io import BytesIO + +class CardGeneratorService: + """Service for generating identification cards and converting them to various formats""" + + def generate_card_image(self, user: Any) -> Image.Image: + """Generate a card image for a single user""" + user.generate_card() + if not user.card: + raise ValueError(f"Failed to generate card for user {user.id}") + return user.card + + def generate_card_png_bytes(self, user: Any) -> bytes: + """Generate a card and return it as PNG bytes""" + card_image = self.generate_card_image(user) + img_byte_arr = BytesIO() + card_image.save(img_byte_arr, format='PNG') + return img_byte_arr.getvalue() + + def generate_multiple_cards(self, users: List[Any]) -> List[Image.Image]: + """Generate cards for multiple users and return list of images""" + if not users: + raise ValueError("No users provided") + + images = [] + for user in users: + try: + card = self.generate_card_image(user) + images.append(card) + except ValueError: + # Skip users that fail to generate cards + continue + + if not images: + raise ValueError("Failed to generate any card images") + + return images diff --git a/services/pdf_service.py b/services/pdf_service.py new file mode 100644 index 0000000..ffca685 --- /dev/null +++ b/services/pdf_service.py @@ -0,0 +1,104 @@ +from PIL import Image +from fpdf import FPDF +import os +import tempfile +import shutil + +class PDFService: + def __init__(self): + # Config params from batch_pdfer.py + self.PIXEL_TO_MM = 0.0847 + self.IMAGES_PER_ROW = 3 + self.IMAGES_PER_COL = 3 + self.MARGIN_MM = 0 + self.GAP_MM = 1 + self.TARGET_WIDTH_MM = 95 + self.TARGET_HEIGHT_MM = 70 + self.AUTO_LAYOUT = False + self.CELL_FILL = 0.95 + self.UPSCALE = True + self.GLOBAL_SCALE = 1.0 + + def create_pdf(self, images: list[Image.Image]) -> str: + """ + Creates a PDF from a list of PIL Images. + Returns the path to the generated PDF file. + """ + # Create a temp dir to store images + temp_dir = tempfile.mkdtemp() + image_paths = [] + + try: + # Save all images to temp dir + for idx, img in enumerate(images): + img_path = os.path.join(temp_dir, f"img_{idx}.png") + img.save(img_path) + image_paths.append(img_path) + + pdf = FPDF() + + pdf_size = {'P': {'w': 210, 'h': 297}, 'L': {'w': 297, 'h': 210}} + + for index, imageFile in enumerate(image_paths): + # Retrieve dimensions from stored file to match original logic + # although we have the PIL objects, fpdf needs reading sizing sometimes or we calculate it. + # using the PIL object we have in 'images' list would be faster for size, + # but let's stick to the file loop logic for consistency with original script if needed. + # Actually, we can use the PIL image from the list for sizing. + + curr_img = images[index] + px_w, px_h = curr_img.size + + # convert pixels to mm + width_mm = float(px_w * self.PIXEL_TO_MM) + height_mm = float(px_h * self.PIXEL_TO_MM) + + # determine orientation by image aspect + orientation = 'P' if width_mm < height_mm else 'L' + + page_w = pdf_size[orientation]['w'] + page_h = pdf_size[orientation]['h'] + + # compute available cell size + cell_w = (page_w - 2 * self.MARGIN_MM - (self.IMAGES_PER_ROW - 1) * self.GAP_MM) / self.IMAGES_PER_ROW + cell_h = (page_h - 2 * self.MARGIN_MM - (self.IMAGES_PER_COL - 1) * self.GAP_MM) / self.IMAGES_PER_COL + + if self.TARGET_WIDTH_MM and self.TARGET_HEIGHT_MM: + target_w = self.TARGET_WIDTH_MM * self.GLOBAL_SCALE * self.CELL_FILL + target_h = self.TARGET_HEIGHT_MM * self.GLOBAL_SCALE * self.CELL_FILL + base_scale = min(target_w / width_mm, target_h / height_mm) + else: + base_scale = min(cell_w / width_mm, cell_h / height_mm) + + if not self.UPSCALE: + base_scale = min(1.0, base_scale) + + render_w = width_mm * base_scale + render_h = height_mm * base_scale + + # add new page when needed + images_per_page = self.IMAGES_PER_ROW * self.IMAGES_PER_COL + if index % images_per_page == 0: + pdf.add_page(orientation=orientation) + + col = index % self.IMAGES_PER_ROW + row = (index // self.IMAGES_PER_ROW) % self.IMAGES_PER_COL + + x = self.MARGIN_MM + (render_w + self.GAP_MM) * col + y = self.MARGIN_MM + (render_h + self.GAP_MM) * row + + pdf.image(imageFile, x, y, render_w, render_h) + + output_pdf_path = os.path.join(temp_dir, "output.pdf") + pdf.output(output_pdf_path, "F") + + # We need to return the file path, but the temp dir will be tricky if we want to delete it effectively. + # Ideally, we return the bytes. + + with open(output_pdf_path, "rb") as f: + pdf_bytes = f.read() + + finally: + shutil.rmtree(temp_dir) + + return pdf_bytes diff --git a/test.py b/test.py deleted file mode 100644 index ee2f8d2..0000000 --- a/test.py +++ /dev/null @@ -1,71 +0,0 @@ -from reportlab.pdfgen import canvas -from reportlab.lib.pagesizes import A4, landscape -from reportlab.lib.units import mm -from reportlab.lib.utils import ImageReader - -from PIL import Image - -import os - -path = '_out_' -filename = 'test.pdf' -title = 'Test' - -MAXY = 139 -MAXX = -5 -MARGIN = 10 - -def createPDF(path_to_images, document_name, document_title): - - def rowGen(list_of_images): #Creates a list of 4 image rows - - for i in range(0, len(list_of_images), 2): - yield list_of_images[i:i + 2] - - - def renderRow(path_to_images, row, y_pos): #Renders each row to the page - - x_pos = 27.5 #starting x position - thumb_size = 450, 400 #Thumbnail image size - - for i in row: - - img = Image.open(os.path.join(path_to_images, i)) #Opens image as a PIL object - img.thumbnail(thumb_size) #Creates thumbnail - - img = ImageReader(img) #Passes PIL object to the Reportlab ImageReader - - #Lays out the image and filename - pdf.drawImage(img, x_pos * mm , y_pos * mm, width = 317, height = 227, preserveAspectRatio=True, anchor='c') - - x_pos += 150 #Increments the x position ready for the next image - - - images = [i for i in os.listdir(path_to_images) if i.endswith('.png')] #Creates list of images filtering out non .jpgs - row_layout = list(rowGen(images)) #Creates the layout of image rows - - pdf = canvas.Canvas(document_name, pagesize=landscape(A4), pageCompression=1) #Creates the PDF object - pdf.setTitle(document_title) - - rows_rendered = 0 - - y_pos = 113.5 #Sets starting y pos - pdf.setFont('Helvetica', 10) - - for row in row_layout: #Loops through each row in the row_layout list and renders the row. For each 5 rows, makes a new page - - if rows_rendered == 2: - - pdf.showPage() - pdf.setFont('Helvetica', 10) - y_pos = -100 - rows_rendered = 0 - break - - else: - - renderRow(path_to_images, row, y_pos) - y_pos -= 100 - rows_rendered += 1 - pdf.save() -createPDF(path, filename, title) \ No newline at end of file diff --git a/tools.py b/tools.py new file mode 100644 index 0000000..5035b57 --- /dev/null +++ b/tools.py @@ -0,0 +1,231 @@ +from base64 import b64decode +import hashlib +from io import BytesIO +import json +import os + +import qrcode +from PIL import ImageDraw, Image +import requests +from config.constants import BAK_COLOR + + + + + +def draw_text(image, text, pos, font, fill, centrate=True, mayus=False): + if mayus: + text = text.upper() + draw = ImageDraw.Draw(image) + w = draw.textlength(text, font=font) + # Center horizontally around pos[0] when centrate=True + if centrate: + x = pos[0] - (w / 2) + ImageDraw.Draw(image).text((x, pos[1]), text, font=font, fill=fill) + else: + ImageDraw.Draw(image).text(pos, text, font=font, fill=fill) + +def centrate_text_relative(image, text, font, relative_pos, relative_size, fill, mayus=False): + #centrate relative on x and split in 2 lines if text width is bigger than relative_size + if mayus: + text = text.upper() + draw = ImageDraw.Draw(image) + w = draw.textlength(text, font=font) + # center the text horizontally around relative_pos[0] + x = relative_pos[0] - (w / 2) + y = relative_pos[1] + # if w > relative_size[0]: + # #split in 2 lines + # words = text.split(' ') + # line1 = '' + # line2 = '' + # for word in words: + # if draw.textsize(line1 + ' ' + word, font=font)[0] < relative_size[0]: + # line1 += ' ' + word + # else: + # line2 += ' ' + word + # x1 = relative_pos[0] + (relative_size[0] - draw.textsize(line1, font=font)[0]) / 2 + # x2 = relative_pos[0] + (relative_size[0] - draw.textsize(line2, font=font)[0]) / 2 + # draw.text((x1, y), line1, font=font) + # draw.text((x2, y + h), line2, font=font) + # else: + ImageDraw.Draw(image).text((x, y), text, font=font, fill=fill) + +def has_transparency(img): + if img.info.get("transparency", None) is not None: + return True + if img.mode == "P": + transparent = img.info.get("transparency", -1) + for _, index in img.getcolors(): + if index == transparent: + return True + elif img.mode == "RGBA": + extrema = img.getextrema() + if extrema[3][0] < 255: + return True + + return False + +def scale(image, max_size, mask_col=BAK_COLOR, method=Image.LANCZOS): + """ + resize 'image' to 'max_size' keeping the aspect ratio + and place it in center of white 'max_size' image + """ + im_aspect = float(image.size[0]) / float(image.size[1]) + out_aspect = float(max_size[0]) / float(max_size[1]) + if im_aspect >= out_aspect: + scaled = image.resize((max_size[0], int((float(max_size[0]) / im_aspect) + 0.5)), method) + else: + scaled = image.resize((int((float(max_size[1]) * im_aspect) + 0.5), max_size[1]), method) + + offset = (((max_size[0] - scaled.size[0]) / 2), ((max_size[1] - scaled.size[1]) / 2)) + back = Image.new("RGBA", max_size, mask_col) + if has_transparency(image): + try: + back.paste(scaled, (int(offset[0]), int(offset[1])), scaled) + except: + scaled = scaled.convert("RGBA") + back.paste(scaled, (int(offset[0]), int(offset[1])), scaled) + else: + back.paste(scaled, (int(offset[0]), int(offset[1]))) + return back + +def generate_qr(input, size, border_size): + qr = qrcode.QRCode( + version=2, + error_correction=qrcode.constants.ERROR_CORRECT_H, + box_size=size, + border=border_size) + qr.add_data(input) + qr.make(fit=True) + qr = qr.make_image() + return qr + + +def crypt(input, method='md5'): + m = hashlib.new(method) + m.update(bytes(input, 'utf')) + return str(m.digest()) + + +class DataFile: + __contents = {} + + @staticmethod + def get_content(filepath, type, reload_if_cached=False): + if not reload_if_cached and filepath in DataFile.__contents: + return DataFile.__contents[filepath] + else: + with open(filepath, 'r', encoding='utf-8') as json_file: + if type == 'JSON': + data = json.load(json_file) + DataFile.__contents[filepath] = data + return data + else: + raise Exception(NotImplemented) + + @staticmethod + def clear_cached_content(filepath): + if filepath in DataFile.__contents: + del DataFile.__contents[filepath] + return True + else: + return False + + @staticmethod + def clear_cache(): + DataFile.__contents = {} + + +def translate_image(image) -> Image: + # handle bytes directly + if isinstance(image, (bytes, bytearray)): + return Image.open(BytesIO(image)) + + s = str(image) + # remote URL + if s[:4].lower() == "http": + resp = requests.get(s, timeout=10) + try: + resp.raise_for_status() + except Exception as e: + raise RuntimeError(f"Failed to download image from URL {s}: {e}") + + ct = resp.headers.get('Content-Type', '').lower() + content = resp.content + + # handle SVG responses specifically (PIL doesn't support SVG) + if 'svg' in ct or (content.lstrip().startswith(b'<') and b'