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 %} +
+
+
+

À propos

+

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.

+
+
+ +

Compétences

+
+
Python
+
FastAPI
+
Docker
+
Agentique
+
SQL
+
DevOps
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 7cc53ac..6be4061 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -12,10 +12,10 @@
@@ -26,7 +26,7 @@ diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..4fe750c --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block title %}Geekbrain.io - Accueil{% endblock %} + +{% block content %} +
+

Geekbrain.io

+

Développeur Python / Agentique - Freelance

+ Me contacter +
+ +
+
+

Services

+

Développement Python, agents autonomes, automatisation, solutions IA sur mesure.

+
+
+

Compétences

+

Python, FastAPI, Django, Docker, Machine Learning, Agents, SQL, NoSQL.

+
+
+

Projets

+

Découvrez mes réalisations sur GitHub, des scripts aux applications complètes.

+ Voir les projets → +
+
+{% endblock %} diff --git a/app/templates/projects.html b/app/templates/projects.html new file mode 100644 index 0000000..d37b4c0 --- /dev/null +++ b/app/templates/projects.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block title %}Geekbrain.io - Projets{% endblock %} + +{% block content %} +

Mes Projets

+ +{% if repos %} +
+ {% for repo in repos %} +
+

{{ repo.name }}

+

{{ repo.description or "Pas de description" }}

+
+ {% endfor %} +
+{% else %} +

+ 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 + + + + + + {% block title %}Geekbrain.io{% endblock %} + + + + +
+
+ + +
+
+ +
+ {% block content %}{% endblock %} +
+ + + + + {% block scripts %}{% endblock %} + + +``` + +- [ ] **Step 3: Write app/static/css/style.css (steampunk theme)** + +```css +:root { + --primary: #D4AF37; + --secondary: #4a4a4a; + --bg-light: #F5F5DC; + --bg-dark: #1a1a1a; + --accent: #B87333; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: Georgia, 'Times New Roman', serif; + background: var(--bg-dark); + color: #e0d0b0; + line-height: 1.6; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; +} + +/* Header */ +.header { + background: var(--bg-dark); + border-bottom: 2px solid var(--primary); + padding: 15px 0; +} + +.header-content { + display: flex; + justify-content: space-between; + align-items: center; +} + +.logo { + height: 50px; + width: auto; +} + +.nav { + display: flex; + gap: 25px; +} + +.nav a { + color: var(--primary); + text-decoration: none; + font-weight: bold; + transition: color 0.3s; +} + +.nav a:hover { + color: #fff; +} + +/* Main */ +.main { + padding: 40px 0; + min-height: calc(100vh - 200px); +} + +/* Footer */ +.footer { + background: #0d0d0d; + border-top: 2px solid var(--primary); + padding: 20px 0; + text-align: center; + color: var(--secondary); +} + +.footer a { + color: var(--primary); +} + +/* Hero */ +.hero { + background: linear-gradient(135deg, var(--primary), var(--accent)); + color: var(--bg-dark); + padding: 60px; + border-radius: 8px; + margin-bottom: 40px; + text-align: center; +} + +.hero h1 { + font-size: 2.5em; + margin-bottom: 10px; +} + +/* Grid */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 25px; + margin: 30px 0; +} + +/* Card */ +.card { + background: var(--bg-light); + color: #333; + border: 2px solid var(--primary); + border-radius: 8px; + padding: 20px; +} + +.card h3 { + color: var(--accent); + margin-bottom: 10px; +} + +/* Buttons */ +.btn { + background: var(--primary); + color: var(--bg-dark); + border: none; + padding: 12px 24px; + border-radius: 4px; + cursor: pointer; + font-weight: bold; + font-size: 1em; +} + +.btn:hover { + background: var(--accent); +} + +/* Forms */ +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + color: var(--primary); +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 10px; + border: 1px solid var(--secondary); + border-radius: 4px; + background: #fff; + color: #333; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--primary); +} + +/* Responsive */ +@media (max-width: 768px) { + .nav { + gap: 10px; + font-size: 0.9em; + } + .hero { + padding: 30px; + } +} +``` + +- [ ] **Step 4: Write app/static/js/main.js (frontend validation + reCAPTCHA)** + +```javascript +document.addEventListener('DOMContentLoaded', () => { + const form = document.getElementById('contact-form'); + if (!form) return; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + + // Basic validation + const name = form.name.value.trim(); + const email = form.email.value.trim(); + const subject = form.subject.value.trim(); + const message = form.message.value.trim(); + const recaptchaResponse = form['g-recaptcha-response'].value; + + if (!name || !email || !subject || !message) { + alert('Tous les champs sont obligatoires.'); + return; + } + + if (!recaptchaResponse) { + alert('Veuillez valider le reCAPTCHA.'); + return; + } + + // Submit via fetch + try { + const response = await fetch('/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, email, subject, message, + recaptcha_token: recaptchaResponse + }) + }); + + const data = await response.json(); + if (data.success) { + alert('Message envoyé avec succès!'); + form.reset(); + grecaptcha.reset(); + } else { + alert('Erreur: ' + data.message); + } + } catch (err) { + alert('Erreur réseau: ' + err.message); + } + }); +}); +``` + +- [ ] **Step 5: Commit** + +```bash +git add app/static/ app/templates/base.html app/static/assets/images/Geekbrain-io.png +git commit -m "feat: static assets, base template, steampunk CSS, JS validation" +``` + +--- + +#### Task 7: Home and About Routes + +**Files:** +- Create: `app/routes/__init__.py` +- Create: `app/routes/home.py` +- Create: `app/routes/about.py` +- Create: `app/templates/index.html` +- Create: `app/templates/about.html` +- Create: `tests/test_routes/test_home.py` +- Create: `tests/test_routes/test_about.py` + +- [ ] **Step 1: Write tests for home route** + +`tests/test_routes/test_home.py`: + +```python +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + +def test_home_page_loads(): + response = client.get("/") + assert response.status_code == 200 + assert "Geekbrain" in response.text + assert "Accueil" in response.text +``` + +- [ ] **Step 2: Write app/routes/home.py** + +```python +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}) +``` + +- [ ] **Step 3: Write app/templates/index.html** + +```html +{% extends "base.html" %} + +{% block title %}Geekbrain.io - Accueil{% endblock %} + +{% block content %} +
+

Geekbrain.io

+

Développeur Python / Agentique - Freelance

+ Me contacter +
+ +
+
+

Services

+

Développement Python, agents autonomes, automatisation, solutions IA sur mesure.

+
+
+

Compétences

+

Python, FastAPI, Django, Docker, Machine Learning, Agents, SQL, NoSQL.

+
+
+

Projets

+

Découvrez mes réalisations sur GitHub, from scripts to full apps.

+ Voir les projets → +
+
+{% endblock %} +``` + +- [ ] **Step 4: Run tests** + +```bash +pytest tests/test_routes/test_home.py -v +``` +Expected: FAIL (app.main not ready yet) + +- [ ] **Step 5: Continue with about route (same pattern)** + +`tests/test_routes/test_about.py`: + +```python +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + +def test_about_page_loads(): + response = client.get("/about") + assert response.status_code == 200 + assert "À propos" in response.text or "Geekbrain" in response.text +``` + +`app/routes/about.py`: + +```python +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}) +``` + +`app/templates/about.html`: + +```html +{% extends "base.html" %} + +{% block title %}Geekbrain.io - À propos{% endblock %} + +{% block content %} +
+
+
+

À propos

+

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.

+
+
+ +

Compétences

+
+
Python
+
FastAPI
+
Docker
+
Agentique
+
SQL
+
DevOps
+
+{% endblock %} +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/routes/__init__.py app/routes/home.py app/routes/about.py \ + app/templates/index.html app/templates/about.html \ + tests/test_routes/test_home.py tests/test_routes/test_about.py +git commit -m "feat: home and about routes with templates" +``` + +--- + +#### Task 8: Projects Route with GitBucket Integration + +**Files:** +- Create: `app/routes/projects.py` +- Create: `app/templates/projects.html` +- Create: `tests/test_routes/test_projects.py` + +- [ ] **Step 1: Write tests for projects route (mocked)** + +`tests/test_routes/test_projects.py`: + +```python +from fastapi.testclient import TestClient +from app.main import app +from unittest.mock import patch + +client = TestClient(app) + +@patch("app.routes.projects.fetch_repos") +def test_projects_page_shows_repos(mock_fetch): + mock_fetch.return_value = [ + {"name": "Project1", "description": "Desc 1"}, + {"name": "Project2", "description": "Desc 2"} + ] + response = client.get("/projects") + assert response.status_code == 200 + assert "Project1" in response.text + assert "Project2" in response.text + +@patch("app.routes.projects.fetch_repos") +def test_projects_shows_message_when_empty(mock_fetch): + mock_fetch.return_value = [] + response = client.get("/projects") + assert response.status_code == 200 + assert "indisponibles" in response.text.lower() +``` + +- [ ] **Step 2: Write app/routes/projects.py** + +```python +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} + ) +``` + +- [ ] **Step 3: Write app/templates/projects.html** + +```html +{% extends "base.html" %} + +{% block title %}Geekbrain.io - Projets{% endblock %} + +{% block content %} +

Mes Projets

+ +{% if repos %} +
+ {% for repo in repos %} +
+

{{ repo.name }}

+

{{ repo.description or "Pas de description" }}

+
+ {% endfor %} +
+{% else %} +

+ 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 %} +

Contactez-moi

+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+

Coordonnées

+

Email: rcairbum@gmail.com

+

Disponible pour missions freelance.

+
+
+{% endblock %} + +{% block scripts %} +{{ super() }} +{% endblock %} +``` + +- [ ] **Step 4: Update app/main.py to wire routes** + +```python +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from app.routes import home, about, projects, contact +from app.config import settings + +app = FastAPI(title="Geekbrain Portfolio") + +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"} +``` + +- [ ] **Step 5: Run tests** + +```bash +pytest tests/test_routes/ -v +``` +Expected: Many failures due to missing dependencies but structure correct. + +- [ ] **Step 6: Commit** + +```bash +git add app/routes/contact.py app/templates/contact.html app/main.py tests/test_routes/test_contact.py +git commit -m "feat: contact route with form, email, recaptcha validation" +``` + +--- + +#### Task 10: Wire reCAPTCHA Site Key and Finalize Contact + +**Files:** +- Update `app/config.py` (add RECAPTCHA_SITE_KEY) +- Update `app/routes/contact.py` (inject settings and site key) +- Update `docker/.env.example` +- Create: `.env.example` at project root + +- [ ] **Step 1: Update config.py** + +```python +class Settings(BaseSettings): + # ... existing fields + recaptcha_secret: str = Field(..., description="reCAPTCHA secret key") + recaptcha_site_key: str = Field(..., description="reCAPTCHA site key (frontend)") +``` + +- [ ] **Step 2: Update contact.py to use settings** + +```python +from app.config import settings + +# In contact_page: +return templates.TemplateResponse("contact.html", { + "request": request, + "recaptcha_site_key": settings.recaptcha_site_key +}) + +# In submit_contact, use settings.recaptcha_secret +``` + +- [ ] **Step 3: Add keys to env files** + +`docker/.env.example`: +```env +RECAPTCHA_SITE_KEY=your_site_key_here +RECAPTCHA_SECRET=your_secret_key_here +``` + +Create `.env.example` at root: + +```env +# Copy from docker/.env.example and fill in actual values +DATABASE_URL=mysql+aiomysql://geekbrain_app:YOUR_PASSWORD@10.0.0.16:3306/geekbrain_portfolio +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=rcairbum@gmail.com +SMTP_PASSWORD=your_gmail_app_password +RECAPTCHA_SITE_KEY=your_site_key +RECAPTCHA_SECRET=your_recaptcha_secret +GITBUCKET_URL=http://10.0.0.16:8080/api/v3/users/rcairbum/repos +CACHE_TTL=300 +LOG_LEVEL=INFO +``` + +- [ ] **Step 4: Commit** + +```bash +git add app/config.py app/routes/contact.py docker/.env.example .env.example +git commit -m "feat: wire recaptcha keys and update config" +``` + +--- + +### Phase 3: Docker and Deployment + +#### Task 11: Docker Configuration + +**Files:** +- Create: `docker/Dockerfile` +- Create: `docker/docker-compose.yml` + +- [ ] **Step 1: Write Dockerfile** (see spec) + +`docker/Dockerfile`: + +```dockerfile +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +- [ ] **Step 2: Write docker-compose.yml** + +`docker/docker-compose.yml`: + +```yaml +version: '3.8' + +services: + web: + build: + context: .. + dockerfile: docker/Dockerfile + ports: + - "8000:8000" + environment: + DATABASE_URL: ${DATABASE_URL} + SMTP_HOST: ${SMTP_HOST} + SMTP_PORT: ${SMTP_PORT} + SMTP_USER: ${SMTP_USER} + SMTP_PASSWORD: ${SMTP_PASSWORD} + RECAPTCHA_SECRET: ${RECAPTCHA_SECRET} + RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY} + GITBUCKET_URL: ${GITBUCKET_URL} + CACHE_TTL: ${CACHE_TTL:-300} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + volumes: + - ../app/static:/app/static + restart: unless-stopped +``` + +- [ ] **Step 3: Test build locally (optional, but good)** + +```bash +cd docker +docker-compose build +``` +Check for errors. + +- [ ] **Step 4: Commit** + +```bash +git add docker/Dockerfile docker/docker-compose.yml +git commit -m "feat: docker configuration" +``` + +--- + +#### Task 12: Database Setup Script and Deployment Docs + +**Files:** +- Create: `docker/init-db.sql` (or just document in README) +- Update: `README.md` with full deployment steps + +- [ ] **Step 1: Create database initialization SQL** + +`docker/init-db.sql`: + +```sql +-- Run this on MariaDB server as root +CREATE DATABASE IF NOT EXISTS geekbrain_portfolio CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- Create user (adjust password) +-- CREATE USER 'geekbrain_app'@'%' IDENTIFIED BY 'YOUR_PASSWORD'; +-- GRANT SELECT, INSERT, UPDATE, DELETE ON geekbrain_portfolio.* TO 'geekbrain_app'@'%'; +-- FLUSH PRIVILEGES; +``` + +*Note: Actual user creation should be done manually securely.* + +- [ ] **Step 2: Update README.md with full deployment instructions** + +Include: +- Create DB and user +- Configure .env +- docker-compose up -d +- NPM proxy config steps +- Health check + +- [ ] **Step 3: Commit** + +```bash +git add docker/init-db.sql README.md +git commit -m "docs: add database setup script and deployment guide" +``` + +--- + +### Phase 4: Integration Testing and Refinement + +#### Task 13: End-to-End Contact Flow Test + +**Files:** +- Create: `tests/test_integration/test_contact_flow.py` + +- [ ] **Step 1: Write comprehensive integration test** + +`tests/test_integration/test_contact_flow.py`: + +```python +import pytest +from fastapi.testclient import TestClient +from app.main import app +from unittest.mock import patch, AsyncMock + +client = TestClient(app) + +@patch("app.routes.contact.fetch_repos", return_value=[]) +@patch("app.routes.contact.send_contact_email", new_callable=AsyncMock) +@patch("app.routes.contact.create_contact", new_callable=AsyncMock) +@patch("app.routes.contact.httpx.AsyncClient.post") +def test_full_contact_submission(mock_recaptcha, mock_create, mock_send, mock_fetch): + # Mock reCAPTCHA verification + mock_recaptcha.return_value = AsyncMock() + mock_recaptcha.return_value.json.return_value = {"success": True} + + response = client.post("/contact", json={ + "name": "Jean Dupont", + "email": "jean@example.com", + "subject": "Test", + "message": "Hello world", + "recaptcha_token": "valid-token" + }) + + assert response.status_code == 200 + assert response.json()["success"] is True + mock_create.assert_awaited_once() + mock_send.assert_awaited_once() + +@patch("app.routes.contact.fetch_repos", return_value=[]) +def test_contact_page_contains_form_elements(_): + response = client.get("/contact") + assert response.status_code == 200 + assert ' +docker-compose down +docker-compose up -d --build +``` + +--- + +## 11. Maintenance et Monitoring + +### 11.1 Logs +- FastAPI logs structurés (JSON si possible) +- Docker logs (`docker-compose logs -f web`) +- Configuration niveau log via variable env (`LOG_LEVEL`) + +### 11.2 Health check +Endpoint `/health` retourne 200 si DB connectée. + +### 11.3 Sauvegardes +- Backup MariaDB automatique (géré au niveau NAS) +- Backup code Git (hors périmètre) + +### 11.4 Monitoring +- Métriques FastAPI (optionnel) +- Monitoring Docker via Portainer ou Watchtower + +--- + +## 12. Évolution future (backlog) + +- Interface admin pour gérer projets (au lieu de GitBucket API) +- Blog technique avec articles markdown +- Multilanguage (i18n) +- Stats de visites (Plausible/Umami) +- Notification Slack/Discord pour nouveaux contacts +- Export contacts CSV +- Thème sombre/clair + +--- + +## 13. Risques et Contraintes + +### 13.1 Risques +- GitBucket API non accessible depuis container (réseau) → utiliser SSHFS ou API publique? +- Spam malgré reCAPTCHA → prévoir rate limiting +- SMTP Gmail limitations (quotas) → prévoir alternative +- dépendandance à GitBucket privé → considérer copie locale + +### 13.2 Contraintes +- Doit fonctionner sur NAS avec ressources limitées (RAM/CPU) +- Version MariaDB existante (compatibilité SQLAlchemy) +- Accès réseau local à GitBucket (10.0.0.16) + +--- + +## 14. Tests + +### 14.1 Tests unitaires +- Modèles Pydantic +- Fonctions de validation +- Crud opérations + +### 14.2 Tests d'intégration +- Endpoints API (TestClient) +- Connexion DB +- Envoi email (mock SMTP) + +### 14.3 Tests manuels +- Formulaire soumission avec/erreurs +- Affichage projets (mocked API) +- Responsive design + +--- + +## 15. Suivi et Acceptation + +### Critères d'acceptation +- [ ] Site visible sur portfolio.geekbrain.io avec HTTPS +- [ ] Pages Accueil, À propos, Projets, Contact fonctionnelles +- [ ] Formulaire envoie email + stocke en BD +- [ ] reCAPTCHA fonctionnel (pas de spam) +- [ ] Liste projets dynamique depuis GitBucket +- [ ] Design steampunk pro (validé) +- [ ] Responsive mobile/desktop +- [ ] Dockerisé, logs, health check +- [ ] Variables d'env configurées +- [ ] Code documenté, structure claire + +--- + +**Prochaines étapes:** +1. Valider cette spécification +2. Créer le plan d'implémentation détaillé (writing-plans) +3. Implémenter par phases (setup, DB, routes, templates, intégrations, Docker) +4. Tester et déployer + +--- + +*Document generated by Claude Code - superpowers:brainstorming* diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..c5ac036 --- /dev/null +++ b/install.sh @@ -0,0 +1,200 @@ +#!/bin/bash + +# ============================================ +# Script d'installation automatique du portfolio Geekbrain.io +# À exécuter SUR LE NAS dans le dossier du projet +# ============================================ + +set -e # Arrête le script en cas d'erreur + +echo "==========================================" +echo "Installation du portfolio Geekbrain.io" +echo "==========================================" +echo "" + +# Couleurs pour l'affichage +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Vérifier que Docker est installé et en cours d'exécution +echo "1. Vérification de Docker..." +if ! command -v docker &> /dev/null; then + echo -e "${RED}❌ Docker n'est pas installé. Installez Docker d'abord.${NC}" + exit 1 +fi + +if ! docker info &> /dev/null; then + echo -e "${RED}❌ Docker n'est pas en cours d'exécution ou vous n'avez pas les permissions.${NC}" + echo " Démarrez Docker : systemctl start docker (ou via l'interface de votre NAS)" + exit 1 +fi + +echo -e "${GREEN}✓ Docker est opérationnel${NC}" +echo "" + +# Vérifier que le docker-compose.yml existe +if [ ! -f "docker/docker-compose.yml" ]; then + echo -e "${RED}❌ Fichier docker/docker-compose.yml introuvable.${NC}" + echo " Assurez-vous d'exécuter ce script depuis la racine du projet." + exit 1 +fi + +echo "2. Configuration de l'environnement..." +echo "" + +# Vérifier si .env existe déjà +if [ -f ".env" ]; then + read -p "Un fichier .env existe déjà. Le remplacer ? (o/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Oo]$ ]]; then + echo "Installation annulée. Conservez votre fichier .env existant." + exit 0 + fi +fi + +# Créer le fichier .env +ENV_FILE=".env" + +# === Demander les valeurs === + +echo "Veuillez renseigner les variables d'environnement :" +echo "" + +# DATABASE_URL +read -p "DATABASE_URL (ex: mysql+aiomysql://geekbrain_app:2025Supp@rt2026@10.0.0.16:3306/geekbrain_portfolio) : " DATABASE_URL + +# SMTP +read -p "SMTP_HOST (ex: smtp.gmail.com) : " SMTP_HOST +read -p "SMTP_PORT (ex: 587) : " SMTP_PORT +read -p "SMTP_USER (ex: rcairbum@gmail.com) : " SMTP_USER +read -s -p "SMTP_PASSWORD (mot de passe d'application Gmail) : " SMTP_PASSWORD +echo "" + +# reCAPTCHA +read -p "RECAPTCHA_SITE_KEY (depuis Google) : " RECAPTCHA_SITE_KEY +read -s -p "RECAPTCHA_SECRET (depuis Google) : " RECAPTCHA_SECRET +echo "" + +# GitBucket +read -p "GITBUCKET_URL (ex: http://10.0.0.16:8080/api/v3/users/rcairbum/repos) : " GITBUCKET_URL + +# Optionnels avec défauts +read -p "CACHE_TTL en secondes (défaut: 300) : " CACHE_TTL +CACHE_TTL=${CACHE_TTL:-300} + +read -p "LOG_LEVEL (défaut: INFO) : " LOG_LEVEL +LOG_LEVEL=${LOG_LEVEL:-INFO} + +echo "" +echo "3. Création du fichier .env..." + +# Écrire le fichier .env +cat > "$ENV_FILE" << EOF +# Configuration de l'application Geekbrain.io +# Généré automatiquement le $(date) + +DATABASE_URL=$DATABASE_URL +SMTP_HOST=$SMTP_HOST +SMTP_PORT=$SMTP_PORT +SMTP_USER=$SMTP_USER +SMTP_PASSWORD=$SMTP_PASSWORD +RECAPTCHA_SITE_KEY=$RECAPTCHA_SITE_KEY +RECAPTCHA_SECRET=$RECAPTCHA_SECRET +GITBUCKET_URL=$GITBUCKET_URL +CACHE_TTL=$CACHE_TTL +LOG_LEVEL=$LOG_LEVEL +EOF + +echo -e "${GREEN}✓ Fichier .env créé${NC}" +echo "" + +# Vérifier que le fichier a été créé +if [ ! -f "$ENV_FILE" ]; then + echo -e "${RED}❌ Erreur : fichier .env non créé${NC}" + exit 1 +fi + +echo "4. Construction de l'image Docker..." +echo " Cela peut prendre plusieurs minutes (téléchargement de Python, pip install...)" +echo "" + +if docker-compose -f docker/docker-compose.yml build; then + echo -e "${GREEN}✓ Image Docker construite avec succès${NC}" +else + echo -e "${RED}❌ Erreur lors de la construction de l'image.${NC}" + echo " Consultez les erreurs ci-dessus." + exit 1 +fi + +echo "" +echo "5. Démarrage du container..." +echo "" + +if docker-compose -f docker/docker-compose.yml up -d; then + echo -e "${GREEN}✓ Container démarré${NC}" +else + echo -e "${RED}❌ Erreur lors du démarrage du container.${NC}" + exit 1 +fi + +echo "" +echo "6. Vérification..." +sleep 3 + +# Vérifier que le container est en cours d'exécution +CONTAINER_NAME=$(docker-compose -f docker/docker-compose.yml ps -q web 2>/dev/null) + +if [ -z "$CONTAINER_NAME" ]; then + echo -e "${RED}❌ Container non détecté ou arrêté.${NC}" + echo " Vérifiez avec : docker-compose -f docker/docker-compose.yml logs" + exit 1 +fi + +CONTAINER_STATUS=$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null) + +if [ "$CONTAINER_STATUS" != "true" ]; then + echo -e "${RED}❌ Container en état d'arrêt.${NC}" + echo " Logs :" + docker-compose -f docker/docker-compose.yml logs web --tail=20 + exit 1 +fi + +echo -e "${GREEN}✓ Container est en cours d'exécution${NC}" +echo "" + +# Afficher les logs récents +echo "Logs récents :" +docker-compose -f docker/docker-compose.yml logs --tail=10 web +echo "" + +# Test HTTP +echo "7. Test de l'application..." +if curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/health | grep -q "200"; then + echo -e "${GREEN}✓ L'application répond correctement (health check 200)${NC}" + echo "" + echo "✅ INSTALLATION RÉUSSIE !" + echo "" + echo "Prochaines étapes :" + echo " 1. Testez dans votre navigateur : http://$(hostname -I | awk '{print $1}'):8000" + echo " (ou http://localhost:8000 depuis le NAS)" + echo " 2. Soumettez le formulaire de contact pour tester" + echo " 3. Configurez Nginx Proxy Manager pour portfolio.geekbrain.io" + echo " 4. Ajoutez le logo dans app/static/assets/images/ si ce n'est pas déjà fait" + echo "" + echo "Pour voir les logs en temps réel :" + echo " docker-compose -f docker/docker-compose.yml logs -f web" + echo "" + echo "Pour arrêter :" + echo " docker-compose -f docker/docker-compose.yml down" +else + echo -e "${YELLOW}⚠ Health check ne répond pas 200.${NC}" + echo " L'application peut être en cours de démarrage, ou il y a un problème." + echo " Vérifiez les logs complets :" + echo " docker-compose -f docker/docker-compose.yml logs web" +fi + +echo "" +echo "==========================================" +echo "Fin du script d'installation" +echo "==========================================" diff --git a/public/assets/images/Geekbrain-io.png b/public/assets/images/Geekbrain-io.png new file mode 100644 index 0000000..6058fac --- /dev/null +++ b/public/assets/images/Geekbrain-io.png Binary files differ diff --git a/public/assets/images/README.md b/public/assets/images/README.md new file mode 100644 index 0000000..6fb5231 --- /dev/null +++ b/public/assets/images/README.md @@ -0,0 +1,5 @@ +# Logo + +Place the GeekBrain.io logo file here as `Geekbrain-io.png`. + +Recommended: Transparent PNG, ~200-300px width. diff --git a/requirements.txt b/requirements.txt index 0ee2bf7..79dcd26 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ email-validator==2.1.0 aiosmtplib==3.0.1 httpx==0.25.1 +jinja2 pytest==7.4.3 pytest-asyncio==0.21.1 pytest-mock==3.12.0 diff --git a/tests/conftest.py b/tests/conftest.py index 5871ed8..70f68d3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1 +1,20 @@ import pytest +import os + +@pytest.fixture(autouse=True) +def setup_env(monkeypatch): + """Setup required environment variables for all tests.""" + env_vars = { + "DATABASE_URL": "mysql+aiomysql://user:pass@localhost/db", + "SMTP_HOST": "smtp.test.com", + "SMTP_PORT": "587", + "SMTP_USER": "test@test.com", + "SMTP_PASSWORD": "password", + "RECAPTCHA_SECRET": "secret", + "RECAPTCHA_SITE_KEY": "site_key", + "GITBUCKET_URL": "http://test.com/api", + "CACHE_TTL": "300", + "LOG_LEVEL": "INFO" + } + for key, value in env_vars.items(): + monkeypatch.setenv(key, value) diff --git a/tests/test_integration/__init__.py b/tests/test_integration/__init__.py new file mode 100644 index 0000000..a265048 --- /dev/null +++ b/tests/test_integration/__init__.py @@ -0,0 +1 @@ +# Integration tests package diff --git a/tests/test_integration/test_contact_flow.py b/tests/test_integration/test_contact_flow.py new file mode 100644 index 0000000..a7bd04f --- /dev/null +++ b/tests/test_integration/test_contact_flow.py @@ -0,0 +1,38 @@ +import pytest +from fastapi.testclient import TestClient +from app.main import app +from unittest.mock import patch, AsyncMock + +client = TestClient(app) + +@patch("app.routes.contact.fetch_repos", return_value=[]) +@patch("app.routes.contact.send_contact_email", new_callable=AsyncMock) +@patch("app.routes.contact.create_contact", new_callable=AsyncMock) +@patch("app.routes.contact.httpx.AsyncClient.post") +def test_full_contact_submission(mock_recaptcha, mock_create, mock_send, mock_fetch): + # Mock reCAPTCHA verification + mock_resp = AsyncMock() + mock_resp.json.return_value = {"success": True} + mock_recaptcha.return_value = mock_resp + + response = client.post("/contact", json={ + "name": "Jean Dupont", + "email": "jean@example.com", + "subject": "Test contact", + "message": "Hello, I'd like to discuss a project.", + "recaptcha_token": "valid-token" + }) + + assert response.status_code == 200 + assert response.json()["success"] is True + mock_create.assert_awaited_once() + mock_send.assert_awaited_once() + +@patch("app.routes.contact.fetch_repos", return_value=[]) +def test_contact_page_contains_form_elements(_): + response = client.get("/contact") + assert response.status_code == 200 + assert ' + + + + + Design Steampunk Pro - Geekbrain.io + + + +
+

Design Steampunk Professionnel Moderne

+

Palette de couleurs et wireframes pour Geekbrain.io

+ +
+

Palette de couleurs recommandée (Laiton doré)

+
+
Laiton Doré
#D4AF37
+
Gris Acier
#4a4a4a
+
Blanc Cassé
#F5F5DC
+
Noir Charbon
#1a1a1a
+
Cuivre
#B87333
+
+

Cette palette donne: Élégant, professionnel, avec une touche steampunk discrète via le laiton doré. Le fond sombre (#1a1a1a) contraste bien avec le texte clair.

+
+ +
+

Wireframe - Page d'Accueil

+
+ +
+

Bienvenue chez Geekbrain.io

+

Développeur Python / Agentique - Freelance

+ +
+
+
[Section: Services/Prestations]
+
[Section: Compétences Python/Agentique]
+
[Section: Call-to-Action Contact]
+
+ +
+
+ +
+

Wireframe - Page Projets

+
+ +

Mes Projets

+
+
+

Nom du repo

+

Description extraite du README...

+ Voir sur GitBucket +
+
+

Nom du repo

+

Description extraite du README...

+ Voir sur GitBucket +
+
+

Nom du repo

+

Description extraite du README...

+ Voir sur GitBucket +
+
+
+ © Geekbrain.io +
+
+
+ +
+

Wireframe - Page Contact

+
+ +

Contactez-moi

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ [Informations de contact: email, téléphone, etc.] +
+
+
+ © Geekbrain.io +
+
+
+ +
+

Design - Page À propos

+
+ +

À propos

+
+
+
+

Geekbrain.io

+

Développeur Python / Agentique freelance

+

[Parcours, compétences, expérience]

+
+
+

Compétences

+
+
Python
+
Agentique/AI
+
FastAPI
+
Docker
+
+
+ © Geekbrain.io +
+
+
+ + + + +