diff --git a/Archive.tar.gz b/Archive.tar.gz new file mode 100644 index 0000000..eb34bb1 --- /dev/null +++ b/Archive.tar.gz Binary files differ diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..d2cb991 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,195 @@ +# Guide de déploiement automatique + +Ce dossier contient l'application portfolio Geekbrain.io prête à être déployée sur votre NAS. + +## Prérequis + +- Un NAS avec Docker et Docker Compose installés +- Accès SSH au NAS depuis votre machine locale +- MariaDB accessible (10.0.0.16:3306) avec la base `geekbrain_portfolio` créée +- Compte Gmail (avec 2FA et mot de passe d'application si 2FA activé) +- Clés reCAPTCHA v2 obtenues depuis https://www.google.com/recaptcha/admin +- GitBucket API accessible (10.0.0.16:8080) + +--- + +## Étape 1 : Copier le projet sur le NAS + +Depuis votre **machine locale** (pas sur le NAS) : + +```bash +# Remplacez user@nas_ip par vos identifiants SSH +# Exemple : scp -r ./Geekbrain_web_site root@192.168.1.100:/volume1/Docker/geekbrain-portfolio/ + +scp -r /home/rcairbum/Documents/Claude_Projects/Geekbrain_web_site/* user@nas_ip:/volume1/Docker/geekbrain-portfolio/ +``` + +> **Note** : Si vous avez déjà copié les fichiers, passez à l'étape 2. + +--- + +## Étape 2 : Exécuter le script d'installation sur le NAS + +Connectez-vous en SSH à votre NAS : + +```bash +ssh user@nas_ip +``` + +Naviguez vers le dossier du projet : + +```bash +cd /volume1/Docker/geekbrain-portfolio +``` + +Rendez le script exécutable : + +```bash +chmod +x install.sh +``` + +Lancez l'installation : + +```bash +./install.sh +``` + +Le script va : +1. Vérifier Docker +2. Vous demander toutes les configurations (avec masquage des mots de passe) +3. Créer le fichier `.env` +4. Construire l'image Docker +5. Démarrer le container +6. Vérifier que tout fonctionne + +--- + +## Étape 3 : Vérifier le déploiement + +Après l'installation, testez depuis votre navigateur : + +``` +http://nas_ip:8000 +``` + +Ou depuis le NAS lui-même : + +```bash +curl http://localhost:8000 +``` + +Le health check doit retourner `200` : + +```bash +curl http://localhost:8000/health +``` + +--- + +## Étape 4 : Configurer Nginx Proxy Manager + +1. Connectez-vous à l'interface de NPM +2. Ajouter un **Proxy Host** : + - **Domain names** : `portfolio.geekbrain.io` + - **Scheme** : `http` + - **Hostname/IP** : IP de votre NAS + - **Port** : `8000` + - **SSL** : Activer (Let's Encrypt) + - **Force SSL** : Oui + - **WebSockets** : Non +3. Enregistrer +4. Attendre quelques instants que le certificat SSL soit émis + +--- + +## Étape 5 : Tester en production + +Visitez : `https://portfolio.geekbrain.io` + +Vérifiez : +- [ ] Les pages s'affichent (Accueil, À propos, Projets, Contact) +- [ ] Le formulaire de contact fonctionne (vous recevrez un email) +- [ ] Les projets s'affichent depuis GitBucket +- [ ] Le design steampunk est correct (couleurs or, fond sombre) +- [ ] Le site est responsive (mobile/tablette) + +--- + +## Commandes utiles + +```bash +# Voir les logs en temps réel +docker-compose -f docker/docker-compose.yml logs -f web + +# Arrêter le container +docker-compose -f docker/docker-compose.yml down + +# Redémarrer +docker-compose -f docker/docker-compose.yml up -d + +# Reconstruire (après modification du code) +docker-compose -f docker/docker-compose.yml build +docker-compose -f docker/docker-compose.yml up -d + +# Voir l'état des containers +docker-compose -f docker/docker-compose.yml ps + +# Accéder au container (debug) +docker-compose -f docker/docker-compose.yml exec web bash +``` + +--- + +## Dépannage + +### Erreur de connexion à la base de données +- Vérifiez que `10.0.0.16:3306` est accessible depuis le container : + ```bash + docker-compose -f docker/docker-compose.yml exec web ping 10.0.0.16 + ``` +- Vérifiez les credentials dans `.env` +- Assurez-vous que l'utilisateur `geekbrain_app` existe et a les droits + +### Erreur de connexion à GitBucket +```bash +docker-compose -f docker/docker-compose.yml exec web curl http://10.0.0.16:8080/api/v3/users/rcairbum/repos +``` + +### Email non envoyé +- Vérifiez les logs (`docker-compose logs -f web`) +- Pour Gmail, utilisez un **mot de passe d'application** (pas votre mot de passe normal) +- Activez "Accès des applications moins sécurisées" si 2FA désactivé (non recommandé) + +### reCAPTCHA ne fonctionne pas +- Vérifiez que les clés dans `.env` correspondent à `portfolio.geekbrain.io` +- Dans la console Google reCAPTCHA, ajoutez `portfolio.geekbrain.io` aux domaines autorisés + +--- + +## Structure du projet sur le NAS + +``` +/volume1/Docker/geekbrain-portfolio/ +├── app/ +│ ├── static/ # Fichiers statiques (CSS, js, images) +│ ├── templates/ # Templates HTML +│ ├── main.py +│ ├── config.py +│ ├── database.py +│ ├── models.py +│ ├── schemas.py +│ ├── crud.py +│ └── services/ +├── docker/ +│ ├── Dockerfile +│ └── docker-compose.yml +├── tests/ +├── requirements.txt +├── .env # Vos secrets (NE PAS PARTAGER) +├── install.sh # Script d'installation +└── README.md +``` + +--- + +**L'installation est maintenant entièrement automatisée !** Exécutez simplement `./install.sh` après avoir copié les fichiers. diff --git a/app/database.py b/app/database.py index 863d9b3..14babc0 100644 --- a/app/database.py +++ b/app/database.py @@ -1,8 +1,9 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker -from app.config import settings +from app.config import get_settings from app.models import Base +settings = get_settings() engine: AsyncEngine = create_async_engine(settings.database_url, echo=False) AsyncSessionLocal = sessionmaker( bind=engine, class_=AsyncSession, expire_on_commit=False diff --git a/app/exceptions.py b/app/exceptions.py new file mode 100644 index 0000000..146f721 --- /dev/null +++ b/app/exceptions.py @@ -0,0 +1,7 @@ +class GitBucketError(Exception): + """Raised when GitBucket API call fails.""" + pass + +class EmailError(Exception): + """Raised when email sending fails.""" + pass diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..dfe009c --- /dev/null +++ b/app/main.py @@ -0,0 +1,22 @@ +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from app.routes import home, about, projects, contact +from app.config import get_settings +from app.database import init_db + +app = FastAPI(title="Geekbrain Portfolio") + +@app.on_event("startup") +async def startup_event(): + await init_db() + +app.mount("/static", StaticFiles(directory="app/static"), name="static") + +app.include_router(home.router) +app.include_router(about.router) +app.include_router(projects.router) +app.include_router(contact.router) + +@app.get("/health") +async def health(): + return {"status": "ok"} diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..d212dab --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1 @@ +# Routes package diff --git a/app/routes/about.py b/app/routes/about.py new file mode 100644 index 0000000..bc6fc64 --- /dev/null +++ b/app/routes/about.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + +@router.get("/about", response_class=HTMLResponse) +async def about(request: Request): + return templates.TemplateResponse("about.html", {"request": request}) diff --git a/app/routes/home.py b/app/routes/home.py new file mode 100644 index 0000000..172e8ca --- /dev/null +++ b/app/routes/home.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + +@router.get("/", response_class=HTMLResponse) +async def home(request: Request): + return templates.TemplateResponse("index.html", {"request": request}) diff --git a/app/routes/projects.py b/app/routes/projects.py new file mode 100644 index 0000000..32abc9a --- /dev/null +++ b/app/routes/projects.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from app.services.gitbucket import fetch_repos + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + +@router.get("/projects", response_class=HTMLResponse) +async def projects(request: Request): + repos = await fetch_repos() + return templates.TemplateResponse( + "projects.html", + {"request": request, "repos": repos} + ) diff --git a/app/schemas.py b/app/schemas.py index bdebeef..805872b 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -5,6 +5,7 @@ email: EmailStr subject: str = Field(..., min_length=1, max_length=255) message: str = Field(..., min_length=1) + recaptcha_token: str = Field(..., description="reCAPTCHA response token") @field_validator('name', 'subject', 'message') @classmethod diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..a70b302 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +# Services package diff --git a/app/services/email.py b/app/services/email.py new file mode 100644 index 0000000..be7e276 --- /dev/null +++ b/app/services/email.py @@ -0,0 +1,41 @@ +import asyncio +from email.message import EmailMessage +from app.config import get_settings +import aiosmtplib + +async def send_contact_email(name: str, email: str, subject: str, message: str) -> None: + """ + Send contact form email via SMTP. + + Args: + name: Sender name + email: Sender email + subject: Email subject + message: Message body + + Raises: + Exception: If email sending fails + """ + settings = get_settings() + + msg = EmailMessage() + msg["From"] = settings.smtp_user + msg["To"] = settings.smtp_user # Send to self + msg["Subject"] = f"[Geekbrain Contact] {subject}" + + body = f"""Name: {name} +Email: {email} +Subject: {subject} +Message: +{message} +""" + msg.set_content(body) + + try: + async with aiosmtplib.SMTP(hostname=settings.smtp_host, port=settings.smtp_port, start_tls=True) as server: + await server.login(settings.smtp_user, settings.smtp_password) + await server.send_message(msg) + except Exception as e: + # In production, use proper logging + print(f"Email send error: {e}") + # Don't raise - allow contact submission to succeed even if email fails diff --git a/app/services/gitbucket.py b/app/services/gitbucket.py new file mode 100644 index 0000000..061038a --- /dev/null +++ b/app/services/gitbucket.py @@ -0,0 +1,43 @@ +import asyncio +from typing import List, Dict, Any +from app.config import get_settings +import httpx + +# Simple in-memory cache: { 'data': [...], 'timestamp': float } +_cache: Dict[str, Any] = {"data": [], "timestamp": 0.0} + +async def fetch_repos(force_refresh: bool = False) -> List[Dict[str, Any]]: + """ + Fetch public repositories from GitBucket API with caching. + + Returns list of dicts with 'name' and 'description' keys. + On error, returns cached data if available, else empty list. + """ + settings = get_settings() + now = asyncio.get_running_loop().time() + + # Check cache validity + if not force_refresh and (now - _cache["timestamp"]) < settings.cache_ttl: + return _cache["data"] + + try: + async with httpx.AsyncClient() as client: + response = await client.get(str(settings.gitbucket_url), timeout=10.0) + response.raise_for_status() + data = response.json() + + # Extract only name and description + repos = [ + {"name": repo["name"], "description": repo.get("description", "")} + for repo in data + ] + + # Update cache + _cache["data"] = repos + _cache["timestamp"] = now + return repos + except Exception as e: + # Log error (in real app use logging) + print(f"GitBucket API error: {e}") + # Return cached data if available, otherwise empty list + return _cache["data"] if _cache["data"] else [] diff --git a/app/templates/about.html b/app/templates/about.html new file mode 100644 index 0000000..3e680a7 --- /dev/null +++ b/app/templates/about.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block title %}Geekbrain.io - À propos{% endblock %} + +{% block content %} +
Geekbrain.io - Développeur Python et expert en agents autonomes.
+Spécialisé dans le développement de solutions logicielles robustes, d'agents IA, et d'automatisations. Mon expertise couvre le stack Python moderne, les architectures async, et le déploiement cloud.
+
Développement Python, agents autonomes, automatisation, solutions IA sur mesure.
+Python, FastAPI, Django, Docker, Machine Learning, Agents, SQL, NoSQL.
+Découvrez mes réalisations sur GitHub, des scripts aux applications complètes.
+ Voir les projets → +{{ repo.description or "Pas de description" }}
++ Projets temporairement indisponibles. +
+{% endif %} +{% endblock %} diff --git a/docker-web-logs.txt b/docker-web-logs.txt new file mode 100644 index 0000000..4283929 --- /dev/null +++ b/docker-web-logs.txt @@ -0,0 +1,552 @@ +docker compose -f docker/docker-compose.yml logs web +WARN[0000] The "SMTP_USER" variable is not set. Defaulting to a blank string. +WARN[0000] The "SMTP_PASSWORD" variable is not set. Defaulting to a blank string. +WARN[0000] The "SMTP_PORT" variable is not set. Defaulting to a blank string. +WARN[0000] The "SMTP_HOST" variable is not set. Defaulting to a blank string. +WARN[0000] The "RECAPTCHA_SECRET" variable is not set. Defaulting to a blank string. +WARN[0000] The "RECAPTCHA_SITE_KEY" variable is not set. Defaulting to a blank string. +WARN[0000] The "GITBUCKET_URL" variable is not set. Defaulting to a blank string. +WARN[0000] The "DATABASE_URL" variable is not set. Defaulting to a blank string. +WARN[0000] /volume1/Docker/geekbrain-portfolio/docker/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion +web-1 | INFO: Started server process [1] +web-1 | INFO: Waiting for application startup. +web-1 | INFO: Application startup complete. +web-1 | INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +web-1 | INFO: 172.19.0.1:39604 - "GET /health HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:43982 - "GET / HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:43982 - "GET /css/style.css HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:43994 - "GET /assets/images/Geekbrain-io.png HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:44004 - "GET /js/main.js HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:43994 - "GET /favicon.ico HTTP/1.1" 404 Not Found +web-1 | INFO: 172.19.0.1:43994 - "GET / HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:43994 - "GET /contact HTTP/1.1" 500 Internal Server Error +web-1 | ERROR: Exception in ASGI application +web-1 | Traceback (most recent call last): +web-1 | File "/usr/local/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 426, in run_asgi +web-1 | result = await app( # type: ignore[func-returns-value] +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__ +web-1 | return await self.app(scope, receive, send) +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/applications.py", line 1106, in __call__ +web-1 | await super().__call__(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/applications.py", line 122, in __call__ +web-1 | await self.middleware_stack(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 184, in __call__ +web-1 | raise exc +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 162, in __call__ +web-1 | await self.app(scope, receive, _send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 79, in __call__ +web-1 | raise exc +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 68, in __call__ +web-1 | await self.app(scope, receive, sender) +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 20, in __call__ +web-1 | raise e +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 17, in __call__ +web-1 | await self.app(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 718, in __call__ +web-1 | await route.handle(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 276, in handle +web-1 | await self.app(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 66, in app +web-1 | response = await func(request) +web-1 | ^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 274, in app +web-1 | raw_response = await run_endpoint_function( +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function +web-1 | return await dependant.call(**values) +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/app/app/routes/contact.py", line 15, in contact_page +web-1 | settings = get_settings() +web-1 | ^^^^^^^^^^^^^^ +web-1 | File "/app/app/config.py", line 21, in get_settings +web-1 | return Settings() +web-1 | ^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/pydantic_settings/main.py", line 71, in __init__ +web-1 | super().__init__( +web-1 | File "/usr/local/lib/python3.11/site-packages/pydantic/main.py", line 164, in __init__ +web-1 | __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__) +web-1 | pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings +web-1 | smtp_port +web-1 | Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='', input_type=str] +web-1 | For further information visit https://errors.pydantic.dev/2.5/v/int_parsing +web-1 | gitbucket_url +web-1 | Input should be a valid URL, input is empty [type=url_parsing, input_value='', input_type=str] +web-1 | For further information visit https://errors.pydantic.dev/2.5/v/url_parsing +web-1 | INFO: 172.19.0.1:44020 - "GET /about HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:44020 - "GET /projects HTTP/1.1" 500 Internal Server Error +web-1 | ERROR: Exception in ASGI application +web-1 | Traceback (most recent call last): +web-1 | File "/usr/local/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 426, in run_asgi +web-1 | result = await app( # type: ignore[func-returns-value] +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__ +web-1 | return await self.app(scope, receive, send) +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/applications.py", line 1106, in __call__ +web-1 | await super().__call__(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/applications.py", line 122, in __call__ +web-1 | await self.middleware_stack(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 184, in __call__ +web-1 | raise exc +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 162, in __call__ +web-1 | await self.app(scope, receive, _send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 79, in __call__ +web-1 | raise exc +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 68, in __call__ +web-1 | await self.app(scope, receive, sender) +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 20, in __call__ +web-1 | raise e +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 17, in __call__ +web-1 | await self.app(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 718, in __call__ +web-1 | await route.handle(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 276, in handle +web-1 | await self.app(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 66, in app +web-1 | response = await func(request) +web-1 | ^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 274, in app +web-1 | raw_response = await run_endpoint_function( +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function +web-1 | return await dependant.call(**values) +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/app/app/routes/projects.py", line 11, in projects +web-1 | repos = await fetch_repos() +web-1 | ^^^^^^^^^^^^^^^^^^^ +web-1 | File "/app/app/services/gitbucket.py", line 16, in fetch_repos +web-1 | settings = get_settings() +web-1 | ^^^^^^^^^^^^^^ +web-1 | File "/app/app/config.py", line 21, in get_settings +web-1 | return Settings() +web-1 | ^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/pydantic_settings/main.py", line 71, in __init__ +web-1 | super().__init__( +web-1 | File "/usr/local/lib/python3.11/site-packages/pydantic/main.py", line 164, in __init__ +web-1 | __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__) +web-1 | pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings +web-1 | smtp_port +web-1 | Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='', input_type=str] +web-1 | For further information visit https://errors.pydantic.dev/2.5/v/int_parsing +web-1 | gitbucket_url +web-1 | Input should be a valid URL, input is empty [type=url_parsing, input_value='', input_type=str] +web-1 | For further information visit https://errors.pydantic.dev/2.5/v/url_parsing +web-1 | INFO: 172.19.0.1:47116 - "GET /robots.txt HTTP/1.1" 404 Not Found +web-1 | INFO: 172.19.0.1:47124 - "GET / HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:47100 - "GET /contact HTTP/1.1" 500 Internal Server Error +web-1 | ERROR: Exception in ASGI application +web-1 | Traceback (most recent call last): +web-1 | File "/usr/local/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 426, in run_asgi +web-1 | result = await app( # type: ignore[func-returns-value] +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__ +web-1 | return await self.app(scope, receive, send) +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/applications.py", line 1106, in __call__ +web-1 | await super().__call__(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/applications.py", line 122, in __call__ +web-1 | await self.middleware_stack(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 184, in __call__ +web-1 | raise exc +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 162, in __call__ +web-1 | await self.app(scope, receive, _send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 79, in __call__ +web-1 | raise exc +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 68, in __call__ +web-1 | await self.app(scope, receive, sender) +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 20, in __call__ +web-1 | raise e +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 17, in __call__ +web-1 | await self.app(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 718, in __call__ +web-1 | await route.handle(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 276, in handle +web-1 | await self.app(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 66, in app +web-1 | response = await func(request) +web-1 | ^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 274, in app +web-1 | raw_response = await run_endpoint_function( +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function +web-1 | return await dependant.call(**values) +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/app/app/routes/contact.py", line 15, in contact_page +web-1 | settings = get_settings() +web-1 | ^^^^^^^^^^^^^^ +web-1 | File "/app/app/config.py", line 21, in get_settings +web-1 | return Settings() +web-1 | ^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/pydantic_settings/main.py", line 71, in __init__ +web-1 | super().__init__( +web-1 | File "/usr/local/lib/python3.11/site-packages/pydantic/main.py", line 164, in __init__ +web-1 | __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__) +web-1 | pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings +web-1 | smtp_port +web-1 | Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='', input_type=str] +web-1 | For further information visit https://errors.pydantic.dev/2.5/v/int_parsing +web-1 | gitbucket_url +web-1 | Input should be a valid URL, input is empty [type=url_parsing, input_value='', input_type=str] +web-1 | For further information visit https://errors.pydantic.dev/2.5/v/url_parsing +web-1 | INFO: 172.19.0.1:47136 - "GET /about HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:47136 - "GET / HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:54242 - "GET /contact HTTP/1.1" 500 Internal Server Error +web-1 | ERROR: Exception in ASGI application +web-1 | Traceback (most recent call last): +web-1 | File "/usr/local/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 426, in run_asgi +web-1 | result = await app( # type: ignore[func-returns-value] +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__ +web-1 | return await self.app(scope, receive, send) +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/applications.py", line 1106, in __call__ +web-1 | await super().__call__(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/applications.py", line 122, in __call__ +web-1 | await self.middleware_stack(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 184, in __call__ +web-1 | raise exc +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 162, in __call__ +web-1 | await self.app(scope, receive, _send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 79, in __call__ +web-1 | raise exc +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 68, in __call__ +web-1 | await self.app(scope, receive, sender) +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 20, in __call__ +web-1 | raise e +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 17, in __call__ +web-1 | await self.app(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 718, in __call__ +web-1 | await route.handle(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 276, in handle +web-1 | await self.app(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 66, in app +web-1 | response = await func(request) +web-1 | ^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 274, in app +web-1 | raw_response = await run_endpoint_function( +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function +web-1 | return await dependant.call(**values) +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/app/app/routes/contact.py", line 15, in contact_page +web-1 | settings = get_settings() +web-1 | ^^^^^^^^^^^^^^ +web-1 | File "/app/app/config.py", line 21, in get_settings +web-1 | return Settings() +web-1 | ^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/pydantic_settings/main.py", line 71, in __init__ +web-1 | super().__init__( +web-1 | File "/usr/local/lib/python3.11/site-packages/pydantic/main.py", line 164, in __init__ +web-1 | __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__) +web-1 | pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings +web-1 | smtp_port +web-1 | Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='', input_type=str] +web-1 | For further information visit https://errors.pydantic.dev/2.5/v/int_parsing +web-1 | gitbucket_url +web-1 | Input should be a valid URL, input is empty [type=url_parsing, input_value='', input_type=str] +web-1 | For further information visit https://errors.pydantic.dev/2.5/v/url_parsing +web-1 | INFO: 172.19.0.1:36944 - "GET / HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:36948 - "GET /assets/images/Geekbrain-io.png HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:36950 - "GET /js/main.js HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:36958 - "GET /css/style.css HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:36974 - "GET /favicon.ico HTTP/1.1" 404 Not Found +web-1 | INFO: 172.19.0.1:38008 - "GET /projects HTTP/1.1" 500 Internal Server Error +web-1 | ERROR: Exception in ASGI application +web-1 | Traceback (most recent call last): +web-1 | File "/usr/local/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 426, in run_asgi +web-1 | result = await app( # type: ignore[func-returns-value] +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__ +web-1 | return await self.app(scope, receive, send) +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/applications.py", line 1106, in __call__ +web-1 | await super().__call__(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/applications.py", line 122, in __call__ +web-1 | await self.middleware_stack(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 184, in __call__ +web-1 | raise exc +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 162, in __call__ +web-1 | await self.app(scope, receive, _send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 79, in __call__ +web-1 | raise exc +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 68, in __call__ +web-1 | await self.app(scope, receive, sender) +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 20, in __call__ +web-1 | raise e +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 17, in __call__ +web-1 | await self.app(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 718, in __call__ +web-1 | await route.handle(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 276, in handle +web-1 | await self.app(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 66, in app +web-1 | response = await func(request) +web-1 | ^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 274, in app +web-1 | raw_response = await run_endpoint_function( +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function +web-1 | return await dependant.call(**values) +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/app/app/routes/projects.py", line 11, in projects +web-1 | repos = await fetch_repos() +web-1 | ^^^^^^^^^^^^^^^^^^^ +web-1 | File "/app/app/services/gitbucket.py", line 16, in fetch_repos +web-1 | settings = get_settings() +web-1 | ^^^^^^^^^^^^^^ +web-1 | File "/app/app/config.py", line 21, in get_settings +web-1 | return Settings() +web-1 | ^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/pydantic_settings/main.py", line 71, in __init__ +web-1 | super().__init__( +web-1 | File "/usr/local/lib/python3.11/site-packages/pydantic/main.py", line 164, in __init__ +web-1 | __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__) +web-1 | pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings +web-1 | smtp_port +web-1 | Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='', input_type=str] +web-1 | For further information visit https://errors.pydantic.dev/2.5/v/int_parsing +web-1 | gitbucket_url +web-1 | Input should be a valid URL, input is empty [type=url_parsing, input_value='', input_type=str] +web-1 | For further information visit https://errors.pydantic.dev/2.5/v/url_parsing +web-1 | INFO: 172.19.0.1:38012 - "GET / HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:38022 - "GET /about HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:38028 - "GET /projects HTTP/1.1" 500 Internal Server Error +web-1 | ERROR: Exception in ASGI application +web-1 | Traceback (most recent call last): +web-1 | File "/usr/local/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 426, in run_asgi +web-1 | result = await app( # type: ignore[func-returns-value] +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__ +web-1 | return await self.app(scope, receive, send) +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/applications.py", line 1106, in __call__ +web-1 | await super().__call__(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/applications.py", line 122, in __call__ +web-1 | await self.middleware_stack(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 184, in __call__ +web-1 | raise exc +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 162, in __call__ +web-1 | await self.app(scope, receive, _send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 79, in __call__ +web-1 | raise exc +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 68, in __call__ +web-1 | await self.app(scope, receive, sender) +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 20, in __call__ +web-1 | raise e +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 17, in __call__ +web-1 | await self.app(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 718, in __call__ +web-1 | await route.handle(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 276, in handle +web-1 | await self.app(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 66, in app +web-1 | response = await func(request) +web-1 | ^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 274, in app +web-1 | raw_response = await run_endpoint_function( +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function +web-1 | return await dependant.call(**values) +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/app/app/routes/projects.py", line 11, in projects +web-1 | repos = await fetch_repos() +web-1 | ^^^^^^^^^^^^^^^^^^^ +web-1 | File "/app/app/services/gitbucket.py", line 16, in fetch_repos +web-1 | settings = get_settings() +web-1 | ^^^^^^^^^^^^^^ +web-1 | File "/app/app/config.py", line 21, in get_settings +web-1 | return Settings() +web-1 | ^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/pydantic_settings/main.py", line 71, in __init__ +web-1 | super().__init__( +web-1 | File "/usr/local/lib/python3.11/site-packages/pydantic/main.py", line 164, in __init__ +web-1 | __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__) +web-1 | pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings +web-1 | smtp_port +web-1 | Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='', input_type=str] +web-1 | For further information visit https://errors.pydantic.dev/2.5/v/int_parsing +web-1 | gitbucket_url +web-1 | Input should be a valid URL, input is empty [type=url_parsing, input_value='', input_type=str] +web-1 | For further information visit https://errors.pydantic.dev/2.5/v/url_parsing +web-1 | INFO: 172.19.0.1:38032 - "GET /contact HTTP/1.1" 500 Internal Server Error +web-1 | ERROR: Exception in ASGI application +web-1 | Traceback (most recent call last): +web-1 | File "/usr/local/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 426, in run_asgi +web-1 | result = await app( # type: ignore[func-returns-value] +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__ +web-1 | return await self.app(scope, receive, send) +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/applications.py", line 1106, in __call__ +web-1 | await super().__call__(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/applications.py", line 122, in __call__ +web-1 | await self.middleware_stack(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 184, in __call__ +web-1 | raise exc +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 162, in __call__ +web-1 | await self.app(scope, receive, _send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 79, in __call__ +web-1 | raise exc +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 68, in __call__ +web-1 | await self.app(scope, receive, sender) +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 20, in __call__ +web-1 | raise e +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 17, in __call__ +web-1 | await self.app(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 718, in __call__ +web-1 | await route.handle(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 276, in handle +web-1 | await self.app(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 66, in app +web-1 | response = await func(request) +web-1 | ^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 274, in app +web-1 | raw_response = await run_endpoint_function( +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function +web-1 | return await dependant.call(**values) +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/app/app/routes/contact.py", line 15, in contact_page +web-1 | settings = get_settings() +web-1 | ^^^^^^^^^^^^^^ +web-1 | File "/app/app/config.py", line 21, in get_settings +web-1 | return Settings() +web-1 | ^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/pydantic_settings/main.py", line 71, in __init__ +web-1 | super().__init__( +web-1 | File "/usr/local/lib/python3.11/site-packages/pydantic/main.py", line 164, in __init__ +web-1 | __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__) +web-1 | pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings +web-1 | smtp_port +web-1 | Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='', input_type=str] +web-1 | For further information visit https://errors.pydantic.dev/2.5/v/int_parsing +web-1 | gitbucket_url +web-1 | Input should be a valid URL, input is empty [type=url_parsing, input_value='', input_type=str] +web-1 | For further information visit https://errors.pydantic.dev/2.5/v/url_parsing +web-1 | INFO: Shutting down +web-1 | INFO: Waiting for application shutdown. +web-1 | INFO: Application shutdown complete. +web-1 | INFO: Finished server process [1] +web-1 | INFO: Started server process [1] +web-1 | INFO: Waiting for application startup. +web-1 | INFO: Application startup complete. +web-1 | INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +web-1 | INFO: 172.19.0.1:36640 - "GET /about HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:36652 - "GET /about HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:36658 - "GET /about HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:36664 - "GET /about HTTP/1.1" 200 OK +web-1 | INFO: 172.19.0.1:40722 - "GET /projects HTTP/1.1" 500 Internal Server Error +web-1 | ERROR: Exception in ASGI application +web-1 | Traceback (most recent call last): +web-1 | File "/usr/local/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 426, in run_asgi +web-1 | result = await app( # type: ignore[func-returns-value] +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__ +web-1 | return await self.app(scope, receive, send) +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/applications.py", line 1106, in __call__ +web-1 | await super().__call__(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/applications.py", line 122, in __call__ +web-1 | await self.middleware_stack(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 184, in __call__ +web-1 | raise exc +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 162, in __call__ +web-1 | await self.app(scope, receive, _send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 79, in __call__ +web-1 | raise exc +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 68, in __call__ +web-1 | await self.app(scope, receive, sender) +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 20, in __call__ +web-1 | raise e +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 17, in __call__ +web-1 | await self.app(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 718, in __call__ +web-1 | await route.handle(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 276, in handle +web-1 | await self.app(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 66, in app +web-1 | response = await func(request) +web-1 | ^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 274, in app +web-1 | raw_response = await run_endpoint_function( +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function +web-1 | return await dependant.call(**values) +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/app/app/routes/projects.py", line 11, in projects +web-1 | repos = await fetch_repos() +web-1 | ^^^^^^^^^^^^^^^^^^^ +web-1 | File "/app/app/services/gitbucket.py", line 16, in fetch_repos +web-1 | settings = get_settings() +web-1 | ^^^^^^^^^^^^^^ +web-1 | File "/app/app/config.py", line 21, in get_settings +web-1 | return Settings() +web-1 | ^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/pydantic_settings/main.py", line 71, in __init__ +web-1 | super().__init__( +web-1 | File "/usr/local/lib/python3.11/site-packages/pydantic/main.py", line 164, in __init__ +web-1 | __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__) +web-1 | pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings +web-1 | smtp_port +web-1 | Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='', input_type=str] +web-1 | For further information visit https://errors.pydantic.dev/2.5/v/int_parsing +web-1 | gitbucket_url +web-1 | Input should be a valid URL, input is empty [type=url_parsing, input_value='', input_type=str] +web-1 | For further information visit https://errors.pydantic.dev/2.5/v/url_parsing +web-1 | INFO: 172.19.0.1:40730 - "GET /contact HTTP/1.1" 500 Internal Server Error +web-1 | ERROR: Exception in ASGI application +web-1 | Traceback (most recent call last): +web-1 | File "/usr/local/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 426, in run_asgi +web-1 | result = await app( # type: ignore[func-returns-value] +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__ +web-1 | return await self.app(scope, receive, send) +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/applications.py", line 1106, in __call__ +web-1 | await super().__call__(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/applications.py", line 122, in __call__ +web-1 | await self.middleware_stack(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 184, in __call__ +web-1 | raise exc +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 162, in __call__ +web-1 | await self.app(scope, receive, _send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 79, in __call__ +web-1 | raise exc +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 68, in __call__ +web-1 | await self.app(scope, receive, sender) +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 20, in __call__ +web-1 | raise e +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 17, in __call__ +web-1 | await self.app(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 718, in __call__ +web-1 | await route.handle(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 276, in handle +web-1 | await self.app(scope, receive, send) +web-1 | File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 66, in app +web-1 | response = await func(request) +web-1 | ^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 274, in app +web-1 | raw_response = await run_endpoint_function( +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function +web-1 | return await dependant.call(**values) +web-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +web-1 | File "/app/app/routes/contact.py", line 15, in contact_page +web-1 | settings = get_settings() +web-1 | ^^^^^^^^^^^^^^ +web-1 | File "/app/app/config.py", line 21, in get_settings +web-1 | return Settings() +web-1 | ^^^^^^^^^^ +web-1 | File "/usr/local/lib/python3.11/site-packages/pydantic_settings/main.py", line 71, in __init__ +web-1 | super().__init__( +web-1 | File "/usr/local/lib/python3.11/site-packages/pydantic/main.py", line 164, in __init__ +web-1 | __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__) +web-1 | pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings +web-1 | smtp_port +web-1 | Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='', input_type=str] +web-1 | For further information visit https://errors.pydantic.dev/2.5/v/int_parsing +web-1 | gitbucket_url +web-1 | Input should be a valid URL, input is empty [type=url_parsing, input_value='', input_type=str] +web-1 | For further information visit https://errors.pydantic.dev/2.5/v/url_parsing diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 5691727..e02c07b 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -6,7 +6,7 @@ context: .. dockerfile: docker/Dockerfile ports: - - "8000:8000" + - "8700:8000" environment: DATABASE_URL: ${DATABASE_URL} SMTP_HOST: ${SMTP_HOST} diff --git a/docs/superpowers/plans/2025-03-21-geekbrain-portfolio-implementation.md b/docs/superpowers/plans/2025-03-21-geekbrain-portfolio-implementation.md new file mode 100644 index 0000000..e0278df --- /dev/null +++ b/docs/superpowers/plans/2025-03-21-geekbrain-portfolio-implementation.md @@ -0,0 +1,1742 @@ +# Geekbrain.io Portfolio Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a FastAPI portfolio website with steampunk design, GitBucket integration, contact form, Docker deployment. + +**Architecture:** Monolithic FastAPI app with Jinja2 templates, SQLAlchemy ORM, async operations. External integrations: GitBucket API (read-only), Gmail SMTP, reCAPTCHA. Docker containerized with MariaDB connection. + +**Tech Stack:** +- Python 3.11+, FastAPI 0.104+, SQLAlchemy 2.0+ (async), Pydantic v2 +- Jinja2 templates, custom CSS (steampunk theme) +- MariaDB (aiomysql async driver) +- Docker + docker-compose +- reCAPTCHA v2, aiosmtplib + +--- + +## File Structure + +``` +geekbrain-portfolio/ +├── app/ +│ ├── __init__.py +│ ├── main.py # FastAPI app instance, route includes, middleware +│ ├── config.py # Pydantic settings from env vars +│ ├── database.py # Async engine, sessionmaker, get_db dependency +│ ├── models.py # SQLAlchemy models (Contact) +│ ├── schemas.py # Pydantic schemas (ContactCreate, ContactResponse) +│ ├── crud.py # CRUD operations (create_contact) +│ ├── routes/ +│ │ ├── __init__.py +│ │ ├── home.py # GET / +│ │ ├── about.py # GET /about +│ │ ├── projects.py # GET /projects (calls GitBucket service) +│ │ └── contact.py # GET /contact, POST /contact +│ ├── services/ +│ │ ├── __init__.py +│ │ ├── gitbucket.py # fetch_repos() with caching +│ │ └── email.py # send_contact_email() +│ ├── templates/ +│ │ ├── base.html # Steampunk layout with nav blocks +│ │ ├── index.html # Accueil page +│ │ ├── about.html # À propos page +│ │ ├── projects.html # Projets page (loop over repos) +│ │ └── contact.html # Contact form with reCAPTCHA +│ ├── static/ +│ │ ├── css/ +│ │ │ └── style.css # Steampunk theme, responsive +│ │ ├── js/ +│ │ │ └── main.js # Frontend validation, reCAPTCHA integration +│ │ └── assets/ +│ │ └── images/ +│ │ └── Geekbrain-io.png # Logo (copied from ../../public/assets/images/) +│ ├── utils.py # Helper functions (if needed) +│ └── exceptions.py # Custom exceptions (GitBucketError, EmailError) +├── docker/ +│ ├── Dockerfile +│ ├── docker-compose.yml +│ └── .env.example +├── tests/ +│ ├── __init__.py +│ ├── conftest.py # Pytest fixtures (test client, db session mock) +│ ├── test_config.py +│ ├── test_models.py +│ ├── test_schemas.py +│ ├── test_crud.py +│ ├── test_routes/ +│ │ ├── test_home.py +│ │ ├── test_about.py +│ │ ├── test_projects.py (mocked GitBucket) +│ │ └── test_contact.py +│ ├── test_services/ +│ │ ├── test_gitbucket.py +│ │ └── test_email.py +│ └── test_integration/ +│ └── test_contact_flow.py +├── requirements.txt +├── .env.example +├── README.md # Deployment instructions +└── pyproject.toml (or setup.py) optional +``` + +--- + +## Implementation Tasks + +### Phase 1: Project Setup & Configuration + +#### Task 1: Initialize Project Structure and Dependencies + +**Files:** +- Create: `requirements.txt` +- Create: `app/__init__.py` +- Create: `app/config.py` +- Create: `docker/.env.example` +- Create: `README.md` + +- [ ] **Step 1: Write requirements.txt** + +```txt +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +aiomysql==0.2.0 +pydantic==2.5.0 +pydantic-settings==2.1.0 +aiosmtplib==3.0.1 +httpx==0.25.1 +pytest==7.4.3 +pytest-asyncio==0.21.1 +python-dotenv==1.0.0 +``` + +- [ ] **Step 2: Create app/config.py with Pydantic settings** + +```python +from pydantic_settings import BaseSettings +from pydantic import Field, HttpUrl, validator + +class Settings(BaseSettings): + database_url: str = Field(..., description="MariaDB async connection string") + smtp_host: str = Field(..., description="SMTP server host") + smtp_port: int = Field(587, description="SMTP port") + smtp_user: str = Field(..., description="SMTP username (email)") + smtp_password: str = Field(..., description="SMTP password/app password") + recaptcha_secret: str = Field(..., description="reCAPTCHA secret key") + gitbucket_url: HttpUrl = Field(..., description="GitBucket API URL") + cache_ttl: int = Field(300, description="Cache TTL in seconds") + log_level: str = Field("INFO", description="Logging level") + + class Config: + env_file = ".env" + +settings = Settings() +``` + +- [ ] **Step 3: Create __init__.py files** + +`app/__init__.py` (empty, marks package) +`tests/__init__.py` (empty) +`tests/conftest.py` (basic pytest config) + +- [ ] **Step 4: Create docker/.env.example** + +```env +# Database +DATABASE_URL=mysql+aiomysql://geekbrain_app:YOUR_PASSWORD@10.0.0.16:3306/geekbrain_portfolio + +# SMTP (Gmail) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=rcairbum@gmail.com +SMTP_PASSWORD=your_gmail_app_password + +# reCAPTCHA +RECAPTCHA_SECRET=your_recaptcha_secret_key + +# GitBucket +GITBUCKET_URL=http://10.0.0.16:8080/api/v3/users/rcairbum/repos +CACHE_TTL=300 +LOG_LEVEL=INFO +``` + +- [ ] **Step 5: Write README.md with project overview and setup instructions** + +```markdown +# Geekbrain.io Portfolio + +FastAPI portfolio website with steampunk design. + +## Setup + +1. Copy `.env.example` to `.env` and fill in values. +2. Ensure MariaDB is accessible and create database/user (see spec). +3. Run: `docker-compose up -d` +4. Configure Nginx Proxy Manager to proxy `portfolio.geekbrain.io` to port 8000. + +## Development + +```bash +uvicorn app.main:app --reload +``` +``` + +- [ ] **Step 6: Commit** + +```bash +git add requirements.txt app/config.py app/__init__.py tests/__init__.py tests/conftest.py docker/.env.example README.md +git commit -m "feat: initial project setup with config and dependencies" +``` + +--- + +#### Task 2: Database Connection and Models + +**Files:** +- Create: `app/database.py` +- Create: `app/models.py` +- Create: `tests/test_config.py` +- Create: `tests/test_models.py` + +- [ ] **Step 1: Write failing test for app/models.py Contact model** + +`tests/test_models.py`: + +```python +import pytest +from app.models import Contact + +def test_contact_model_columns(): + """Test that Contact model has required columns.""" + contact = Contact( + name="Test User", + email="test@example.com", + subject="Test Subject", + message="Test message" + ) + assert contact.name == "Test User" + assert contact.email == "test@example.com" + assert contact.subject == "Test Subject" + assert contact.message == "Test message" + assert contact.submitted_at is None # default not set yet +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_models.py -v` +Expected: Import error or attribute error for `Contact` + +- [ ] **Step 3: Write app/models.py** + +```python +from datetime import datetime +from sqlalchemy import Column, Integer, String, Text, DateTime, Float +from sqlalchemy.orm import declarative_base + +Base = declarative_base() + +class Contact(Base): + __tablename__ = "contacts" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + email = Column(String(255), nullable=False) + subject = Column(String(255), nullable=False) + message = Column(Text, nullable=False) + submitted_at = Column(DateTime, default=datetime.utcnow, nullable=False) + ip_address = Column(String(45), nullable=True) + recaptcha_score = Column(Float, nullable=True) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_models.py::test_contact_model_columns -v` +Expected: PASS + +- [ ] **Step 5: Write failing test for database connection (async)** + +`tests/test_config.py` (add after existing if any): + +```python +import pytest +from app.config import Settings + +def test_settings_load_from_env(monkeypatch): + """Test that Settings loads from environment.""" + monkeypatch.setenv("DATABASE_URL", "mysql+aiomysql://user:pass@localhost/db") + monkeypatch.setenv("SMTP_HOST", "smtp.test.com") + monkeypatch.setenv("SMTP_PORT", "587") + monkeypatch.setenv("SMTP_USER", "test@test.com") + monkeypatch.setenv("SMTP_PASSWORD", "password") + monkeypatch.setenv("RECAPTCHA_SECRET", "secret") + monkeypatch.setenv("GITBUCKET_URL", "http://test.com/api") + + settings = Settings() + assert settings.database_url == "mysql+aiomysql://user:pass@localhost/db" + assert settings.smtp_host == "smtp.test.com" +``` + +- [ ] **Step 6: Run test to verify it fails** (if not already passing) + +- [ ] **Step 7: Write app/database.py** + +```python +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +from app.config import settings +from app.models import Base + +engine: AsyncEngine = create_async_engine(settings.database_url, echo=False) +AsyncSessionLocal = sessionmaker( + bind=engine, class_=AsyncSession, expire_on_commit=False +) + +async def init_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() +``` + +- [ ] **Step 8: Run test again, should pass** + +Run: `pytest tests/test_config.py -v` +Expected: PASS + +- [ ] **Step 9: Commit** + +```bash +git add app/database.py app/models.py tests/test_models.py tests/test_config.py +git commit -m "feat: database models and async connection setup" +``` + +--- + +#### Task 3: Pydantic Schemas and CRUD + +**Files:** +- Create: `app/schemas.py` +- Create: `app/crud.py` +- Create: `tests/test_schemas.py` +- Create: `tests/test_crud.py` + +- [ ] **Step 1: Write failing test for ContactCreate schema** + +`tests/test_schemas.py`: + +```python +from app.schemas import ContactCreate + +def test_contact_create_valid(): + data = { + "name": "Jean Dupont", + "email": "jean@example.com", + "subject": "Test", + "message": "Hello" + } + contact = ContactCreate(**data) + assert contact.name == "Jean Dupont" + assert contact.email == "jean@example.com" + +def test_contact_create_invalid_email(): + data = { + "name": "Test", + "email": "not-an-email", + "subject": "Test", + "message": "Hello" + } + try: + ContactCreate(**data) + assert False, "Should raise validation error" + except Exception as e: + assert "email" in str(e).lower() +``` + +- [ ] **Step 2: Run test to verify failures** + +Run: `pytest tests/test_schemas.py -v` +Expected: First test fails (module not found), second not yet runnable + +- [ ] **Step 3: Write app/schemas.py** + +```python +from pydantic import BaseModel, EmailStr, Field, validator + +class ContactCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + email: EmailStr + subject: str = Field(..., min_length=1, max_length=255) + message: str = Field(..., min_length=1) + + @validator('name', 'subject', 'message') + def strip_whitespace(cls, v): + return v.strip() + +class ContactResponse(BaseModel): + success: bool + message: str +``` + +- [ ] **Step 4: Run tests, should pass** + +Run: `pytest tests/test_schemas.py -v` +Expected: PASS + +- [ ] **Step 5: Write failing test for crud.create_contact** + +`tests/test_crud.py`: + +```python +import pytest +from app.crud import create_contact +from app.models import Contact + +@pytest.mark.asyncio +async def test_create_contact(mocker): + # Mock DB session + mock_session = mocker.AsyncMock() + mock_contact = Contact( + id=1, + name="Test", + email="test@example.com", + subject="Test", + message="Hello", + submitted_at=None + ) + mock_session.add = mocker.AsyncMock() + mock_session.commit = mocker.AsyncMock() + mock_session.refresh = mocker.AsyncMock() + + result = await create_contact(mock_session, name="Test", email="test@example.com", subject="Test", message="Hello") + mock_session.add.assert_called_once() + mock_session.commit.assert_called_once() +``` + +- [ ] **Step 6: Write app/crud.py** + +```python +from app.models import Contact +from app.schemas import ContactCreate +from sqlalchemy import select + +async def create_contact(db, contact_data: ContactCreate) -> Contact: + contact = Contact(**contact_data.dict()) + db.add(contact) + await db.commit() + await db.refresh(contact) + return contact +``` + +- [ ] **Step 7: Run test, should pass** + +Run: `pytest tests/test_crud.py -v` +Expected: PASS (may need pytest-mock installed) + +- [ ] **Step 8: Ensure pytest-mock in requirements** + +Add `pytest-mock==3.12.0` to requirements.txt and commit if needed. + +- [ ] **Step 9: Commit** + +```bash +git add app/schemas.py app/crud.py tests/test_schemas.py tests/test_crud.py requirements.txt +git commit -m "feat: pydantic schemas and CRUD operations with tests" +``` + +--- + +#### Task 4: GitBucket Service + +**Files:** +- Create: `app/services/__init__.py` +- Create: `app/services/gitbucket.py` +- Create: `app/exceptions.py` +- Create: `tests/test_services/test_gitbucket.py` + +- [ ] **Step 1: Write failing test for fetch_repos** + +`tests/test_services/test_gitbucket.py`: + +```python +import pytest +from app.services.gitbucket import fetch_repos + +@pytest.mark.asyncio +async def test_fetch_repos_success(mocker): + mock_response = mocker.AsyncMock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + {"name": "repo1", "description": "Desc 1"}, + {"name": "repo2", "description": "Desc 2"} + ] + mocker.patch("httpx.AsyncClient.get", return_value=mock_response) + + repos = await fetch_repos("http://test.com/api") + assert len(repos) == 2 + assert repos[0]["name"] == "repo1" + +@pytest.mark.asyncio +async def test_fetch_repos_fallback_on_error(mocker): + """Test that empty list returned on error.""" + mocker.patch("httpx.AsyncClient.get", side_effect=Exception("Connection error")) + + repos = await fetch_repos("http://test.com/api") + assert repos == [] +``` + +- [ ] **Step 2: Run test, should fail (module not found)** + +Run: `pytest tests/test_services/test_gitbucket.py -v` + +- [ ] **Step 3: Write app/services/gitbucket.py** + +```python +import asyncio +from typing import List, Dict, Any +from app.config import settings +import httpx + +_cache: Dict[str, Any] = {"data": [], "timestamp": 0} + +async def fetch_repos(force_refresh: bool = False) -> List[Dict[str, Any]]: + """Fetch repos from GitBucket API with caching.""" + now = asyncio.get_event_loop().time() + if not force_refresh and (now - _cache["timestamp"]) < settings.cache_ttl: + return _cache["data"] + + try: + async with httpx.AsyncClient() as client: + response = await client.get(settings.gitbucket_url, timeout=10.0) + response.raise_for_status() + data = response.json() + + # Extract only name and description + repos = [ + {"name": repo["name"], "description": repo.get("description", "")} + for repo in data + ] + + _cache["data"] = repos + _cache["timestamp"] = now + return repos + except Exception as e: + # Log error in real app (print for now) + print(f"GitBucket API error: {e}") + return _cache["data"] if _cache["data"] else [] +``` + +- [ ] **Step 4: Run tests, should pass** + +Run: `pytest tests/test_services/test_gitbucket.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/services/gitbucket.py tests/test_services/test_gitbucket.py app/exceptions.py +git commit -m "feat: GitBucket API service with caching and fallback" +``` + +--- + +#### Task 5: Email Service + +**Files:** +- Create: `app/services/email.py` +- Create: `tests/test_services/test_email.py` + +- [ ] **Step 1: Write failing test for send_contact_email** + +`tests/test_services/test_email.py`: + +```python +import pytest +from app.services.email import send_contact_email + +@pytest.mark.asyncio +async def test_send_contact_email_success(mocker): + mock_smtp = mocker.AsyncMock() + mocker.patch("aiosmtplib.SMTP", return_value=mock_smtp) + + await send_contact_email( + name="Test", + email="test@example.com", + subject="Test", + message="Hello" + ) + + mock_smtp.send_message.assert_called_once() + mock_smtp.quit.assert_called_once() + +@pytest.mark.asyncio +async def test_send_contact_email_handles_error(mocker): + mocker.patch("aiosmtplib.SMTP", side_effect=Exception("SMTP error")) + # Should not raise, just log + await send_contact_email("Test", "test@example.com", "Test", "Hello") +``` + +- [ ] **Step 2: Run test, fails (module not found)** + +- [ ] **Step 3: Write app/services/email.py** + +```python +import asyncio +from email.message import EmailMessage +from app.config import settings +import aiosmtplib + +async def send_contact_email(name: str, email: str, subject: str, message: str) -> None: + """Send contact form email via SMTP.""" + msg = EmailMessage() + msg["From"] = settings.smtp_user + msg["To"] = settings.smtp_user # Send to self + msg["Subject"] = f"[Geekbrain Contact] {subject}" + body = f"""Name: {name} +Email: {email} +Subject: {subject} +Message: +{message} +""" + msg.set_content(body) + + try: + async with aiosmtplib.SMTP(hostname=settings.smtp_host, port=settings.smtp_port, start_tls=True) as server: + await server.login(settings.smtp_user, settings.smtp_password) + await server.send_message(msg) + except Exception as e: + print(f"Email send error: {e}") + raise +``` + +- [ ] **Step 4: Run tests, should pass** + +Run: `pytest tests/test_services/test_email.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/services/email.py tests/test_services/test_email.py +git commit -m "feat: email service with SMTP async send" +``` + +--- + +### Phase 2: Routes and Templates + +#### Task 6: Static Assets and Base Template + +**Files:** +- Create: `app/static/css/style.css` +- Create: `app/static/js/main.js` +- Copy: `public/assets/images/Geekbrain-io.png` → `app/static/assets/images/Geekbrain-io.png` +- Create: `app/templates/base.html` + +- [ ] **Step 1: Copy logo image** + +```bash +mkdir -p app/static/assets/images +cp public/assets/images/Geekbrain-io.png app/static/assets/images/ +``` + +- [ ] **Step 2: Write base.html with steampunk theme** + +`app/templates/base.html`: + +```html + + + + + +
+
+ Développement Python, agents autonomes, automatisation, solutions IA sur mesure.
+Python, FastAPI, Django, Docker, Machine Learning, Agents, SQL, NoSQL.
+Découvrez mes réalisations sur GitHub, from scripts to full apps.
+ Voir les projets → +Geekbrain.io - Développeur Python et expert en agents autonomes.
+Spécialisé dans le développement de solutions logicielles robustes, d'agents IA, et d'automatisations. Mon expertise couvre le stack Python moderne, les architecturesasync, et le déploiement cloud.
+{{ repo.description or "Pas de description" }}
++ Projets temporairement indisponibles. +
+{% endif %} +{% endblock %} +``` + +- [ ] **Step 4: Run tests** + +```bash +pytest tests/test_routes/test_projects.py -v +``` +Expected: FAIL (main app not wired yet, but we'll run in isolation soon) + +- [ ] **Step 5: Commit** + +```bash +git add app/routes/projects.py app/templates/projects.html tests/test_routes/test_projects.py +git commit -m "feat: projects route with GitBucket API integration" +``` + +--- + +#### Task 9: Contact Route and Form + +**Files:** +- Create: `app/routes/contact.py` +- Create: `app/templates/contact.html` +- Update: `app/main.py` (to include all routes) +- Create: `tests/test_routes/test_contact.py` + +- [ ] **Step 1: Write tests for contact route** + +`tests/test_routes/test_contact.py`: + +```python +from fastapi.testclient import TestClient +from app.main import app +from unittest.mock import patch + +client = TestClient(app) + +def test_contact_page_loads(): + response = client.get("/contact") + assert response.status_code == 200 + assert "Contact" in response.text + assert "g-recaptcha" in response.text + +@patch("app.routes.contact.create_contact") +@patch("app.routes.contact.send_contact_email") +def test_contact_post_success(mock_send, mock_create): + mock_create.return_value = None + mock_send.return_value = None + + response = client.post("/contact", json={ + "name": "Test", + "email": "test@example.com", + "subject": "Hello", + "message": "World", + "recaptcha_token": "fake-token" + }) + assert response.status_code == 200 + json = response.json() + assert json["success"] is True + +def test_contact_post_invalid_data(): + response = client.post("/contact", json={ + "name": "", + "email": "bad-email", + "subject": "Test", + "message": "Hello" + }) + assert response.status_code == 422 # Validation error +``` + +- [ ] **Step 2: Write app/routes/contact.py** + +```python +from fastapi import APIRouter, Request, Depends, HTTPException +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.templating import Jinja2Templates +from app.schemas import ContactCreate +from app.crud import create_contact +from app.services.email import send_contact_email +import httpx + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + +@router.get("/contact", response_class=HTMLResponse) +async def contact_page(request: Request): + return templates.TemplateResponse("contact.html", {"request": request, "recaptcha_site_key": ""}) # Fill from settings + +@router.post("/contact") +async def submit_contact(contact_data: ContactCreate): + # Validate reCAPTCHA + recaptcha_secret = "" # Get from settings + async with httpx.AsyncClient() as client: + resp = await client.post( + "https://www.google.com/recaptcha/api/siteverify", + data={"secret": recaptcha_secret, "response": contact_data.recaptcha_token} + ) + result = resp.json() + if not result.get("success"): + raise HTTPException(status_code=400, detail="reCAPTCHA failed") + + # Save to DB + from app.database import get_db + db = get_db() + try: + await create_contact(db, contact_data) + finally: + await db.close() + + # Send email + try: + await send_contact_email( + name=contact_data.name, + email=contact_data.email, + subject=contact_data.subject, + message=contact_data.message + ) + except Exception as e: + # Log but don't fail + print(f"Email failed: {e}") + + return JSONResponse({"success": True, "message": "Message envoyé"}) +``` + +*Note: We'll wire recaptcha_site_key properly in a later step with config injection.* + +- [ ] **Step 3: Write app/templates/contact.html** + +```html +{% extends "base.html" %} + +{% block title %}Geekbrain.io - Contact{% endblock %} + +{% block content %} +