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:
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
Files:
requirements.txtapp/__init__.pyapp/config.pydocker/.env.exampleCreate: README.md
Step 1: Write requirements.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
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()
app/__init__.py (empty, marks package) tests/__init__.py (empty) tests/conftest.py (basic pytest config)
# Database DATABASE_URL=mysql+aiomysql://geekbrain_app:[email protected]:3306/geekbrain_portfolio # SMTP (Gmail) SMTP_HOST=smtp.gmail.com SMTP_PORT=587 [email protected] 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
# 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
* task: : **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"
Files:
app/database.pyapp/models.pytests/test_config.pyCreate: tests/test_models.py
Step 1: Write failing test for app/models.py Contact model
tests/test_models.py:
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="[email protected]",
subject="Test Subject",
message="Test message"
)
assert contact.name == "Test User"
assert contact.email == "[email protected]"
assert contact.subject == "Test Subject"
assert contact.message == "Test message"
assert contact.submitted_at is None # default not set yet
Run: pytest tests/test_models.py -v Expected: Import error or attribute error for Contact
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)
Run: pytest tests/test_models.py::test_contact_model_columns -v Expected: PASS
tests/test_config.py (add after existing if any):
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", "[email protected]")
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
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()
Run: pytest tests/test_config.py -v Expected: PASS
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"
Files:
app/schemas.pyapp/crud.pytests/test_schemas.pyCreate: tests/test_crud.py
Step 1: Write failing test for ContactCreate schema
tests/test_schemas.py:
from app.schemas import ContactCreate
def test_contact_create_valid():
data = {
"name": "Jean Dupont",
"email": "[email protected]",
"subject": "Test",
"message": "Hello"
}
contact = ContactCreate(**data)
assert contact.name == "Jean Dupont"
assert contact.email == "[email protected]"
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()
Run: pytest tests/test_schemas.py -v Expected: First test fails (module not found), second not yet runnable
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
Run: pytest tests/test_schemas.py -v Expected: PASS
tests/test_crud.py:
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="[email protected]",
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="[email protected]", subject="Test", message="Hello")
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
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
Run: pytest tests/test_crud.py -v Expected: PASS (may need pytest-mock installed)
Add pytest-mock==3.12.0 to requirements.txt and commit if needed.
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"
Files:
app/services/__init__.pyapp/services/gitbucket.pyapp/exceptions.pyCreate: tests/test_services/test_gitbucket.py
Step 1: Write failing test for fetch_repos
tests/test_services/test_gitbucket.py:
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 == []
Run: pytest tests/test_services/test_gitbucket.py -v
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 []
Run: pytest tests/test_services/test_gitbucket.py -v Expected: PASS
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"
Files:
app/services/email.pyCreate: tests/test_services/test_email.py
Step 1: Write failing test for send_contact_email
tests/test_services/test_email.py:
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="[email protected]",
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", "[email protected]", "Test", "Hello")
Step 2: Run test, fails (module not found)
Step 3: Write app/services/email.py
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
Run: pytest tests/test_services/test_email.py -v Expected: PASS
git add app/services/email.py tests/test_services/test_email.py git commit -m "feat: email service with SMTP async send"
Files:
app/static/css/style.cssapp/static/js/main.jspublic/assets/images/Geekbrain-io.png → app/static/assets/images/Geekbrain-io.pngCreate: app/templates/base.html
Step 1: Copy logo image
mkdir -p app/static/assets/images cp public/assets/images/Geekbrain-io.png app/static/assets/images/
app/templates/base.html:
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Geekbrain.io{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', path='css/style.css') }}">
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
</head>
<body>
<header class="header">
<div class="container header-content">
<img src="{{ url_for('static', path='assets/images/Geekbrain-io.png') }}" alt="Geekbrain.io" class="logo">
<nav class="nav">
<a href="{{ url_for('home') }}">Accueil</a>
<a href="{{ url_for('about') }}">À propos</a>
<a href="{{ url_for('projects') }}">Projets</a>
<a href="{{ url_for('contact') }}">Contact</a>
</nav>
</div>
</header>
<main class="main">
{% block content %}{% endblock %}
</main>
<footer class="footer">
<div class="container">
<p>© {% now "Y" %} Geekbrain.io - <a href="https://www.linkedin.com/in/eric-chicoine-2440893b7" target="_blank">LinkedIn</a></p>
</div>
</footer>
<script src="{{ url_for('static', path='js/main.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
: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;
}
}
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);
}
});
});
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"
Files:
app/routes/__init__.pyapp/routes/home.pyapp/routes/about.pyapp/templates/index.htmlapp/templates/about.htmltests/test_routes/test_home.pyCreate: tests/test_routes/test_about.py
Step 1: Write tests for home route
tests/test_routes/test_home.py:
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
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})
{% extends "base.html" %}
{% block title %}Geekbrain.io - Accueil{% endblock %}
{% block content %}
<div class="hero">
<h1>Geekbrain.io</h1>
<p>Développeur Python / Agentique - Freelance</p>
<a href="{{ url_for('contact') }}" class="btn">Me contacter</a>
</div>
<div class="grid">
<div class="card">
<h3>Services</h3>
<p>Développement Python, agents autonomes, automatisation, solutions IA sur mesure.</p>
</div>
<div class="card">
<h3>Compétences</h3>
<p>Python, FastAPI, Django, Docker, Machine Learning, Agents, SQL, NoSQL.</p>
</div>
<div class="card">
<h3>Projets</h3>
<p>Découvrez mes réalisations sur GitHub, from scripts to full apps.</p>
<a href="{{ url_for('projects') }}">Voir les projets →</a>
</div>
</div>
{% endblock %}
pytest tests/test_routes/test_home.py -v
Expected: FAIL (app.main not ready yet)
tests/test_routes/test_about.py:
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:
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:
{% extends "base.html" %}
{% block title %}Geekbrain.io - À propos{% endblock %}
{% block content %}
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 30px; align-items: start;">
<div style="background: var(--bg-light); width: 150px; height: 150px; border-radius: 50%; margin: 0 auto;"></div>
<div>
<h1>À propos</h1>
<p><strong>Geekbrain.io</strong> - Développeur Python et expert en agents autonomes.</p>
<p>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.</p>
</div>
</div>
<h2 style="margin-top: 40px;">Compétences</h2>
<div class="grid">
<div class="card">Python</div>
<div class="card">FastAPI</div>
<div class="card">Docker</div>
<div class="card">Agentique</div>
<div class="card">SQL</div>
<div class="card">DevOps</div>
</div>
{% endblock %}
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"
Files:
app/routes/projects.pyapp/templates/projects.htmlCreate: tests/test_routes/test_projects.py
Step 1: Write tests for projects route (mocked)
tests/test_routes/test_projects.py:
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()
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}
)
{% extends "base.html" %}
{% block title %}Geekbrain.io - Projets{% endblock %}
{% block content %}
<h1>Mes Projets</h1>
{% if repos %}
<div class="grid">
{% for repo in repos %}
<div class="card">
<h3>{{ repo.name }}</h3>
<p>{{ repo.description or "Pas de description" }}</p>
</div>
{% endfor %}
</div>
{% else %}
<p style="text-align: center; padding: 40px; font-size: 1.2em;">
Projets temporairement indisponibles.
</p>
{% endif %}
{% endblock %}
pytest tests/test_routes/test_projects.py -v
Expected: FAIL (main app not wired yet, but we'll run in isolation soon)
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"
Files:
app/routes/contact.pyapp/templates/contact.htmlapp/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:
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": "[email protected]",
"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
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.
{% extends "base.html" %}
{% block title %}Geekbrain.io - Contact{% endblock %}
{% block content %}
<h1>Contactez-moi</h1>
<div class="grid" style="grid-template-columns: 1fr 1fr; gap: 30px;">
<div>
<form id="contact-form" method="post">
<div class="form-group">
<label for="name">Nom *</label>
<input type="text" id="name" name="name" required>
</div>
<div class="form-group">
<label for="email">Email *</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-group">
<label for="subject">Sujet *</label>
<input type="text" id="subject" name="subject" required>
</div>
<div class="form-group">
<label for="message">Message *</label>
<textarea id="message" name="message" rows="6" required></textarea>
</div>
<div class="form-group">
<div class="g-recaptcha" data-sitekey="{{ recaptcha_site_key }}"></div>
</div>
<button type="submit" class="btn">Envoyer</button>
</form>
</div>
<div style="background: var(--bg-light); color: #333; padding: 20px; border-radius: 8px;">
<h3>Coordonnées</h3>
<p>Email: <a href="mailto:[email protected]">[email protected]</a></p>
<p>Disponible pour missions freelance.</p>
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
{% endblock %}
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"}
pytest tests/test_routes/ -v
Expected: Many failures due to missing dependencies but structure correct.
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"
Files:
app/config.py (add RECAPTCHA_SITE_KEY)app/routes/contact.py (inject settings and site key)docker/.env.exampleCreate: .env.example at project root
Step 1: Update config.py
class Settings(BaseSettings):
# ... existing fields
recaptcha_secret: str = Field(..., description="reCAPTCHA secret key")
recaptcha_site_key: str = Field(..., description="reCAPTCHA site key (frontend)")
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
docker/.env.example:
RECAPTCHA_SITE_KEY=your_site_key_here RECAPTCHA_SECRET=your_secret_key_here
Create .env.example at root:
# Copy from docker/.env.example and fill in actual values DATABASE_URL=mysql+aiomysql://geekbrain_app:[email protected]:3306/geekbrain_portfolio SMTP_HOST=smtp.gmail.com SMTP_PORT=587 [email protected] 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
git add app/config.py app/routes/contact.py docker/.env.example .env.example git commit -m "feat: wire recaptcha keys and update config"
Files:
docker/DockerfileCreate: docker/docker-compose.yml
Step 1: Write Dockerfile (see spec)
docker/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"]
docker/docker-compose.yml:
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
cd docker docker-compose build
Check for errors.
git add docker/Dockerfile docker/docker-compose.yml git commit -m "feat: docker configuration"
Files:
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:
-- 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.
Include:
Health check
Step 3: Commit
git add docker/init-db.sql README.md git commit -m "docs: add database setup script and deployment guide"
Files:
Create: tests/test_integration/test_contact_flow.py
Step 1: Write comprehensive integration test
tests/test_integration/test_contact_flow.py:
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": "[email protected]",
"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 '<form' in response.text
assert 'g-recaptcha' in response.text
pytest tests/test_integration/ -v
Expected: PASS (with mocks)
git add tests/test_integration/test_contact_flow.py git commit -m "test: add end-to-end contact flow integration tests"
Files:
Create: MANUAL_TESTING.md (optional but helpful)
Step 1: Write manual testing scenarios
MANUAL_TESTING.md:
# Manual Testing Checklist ## Site Navigation * task: : All pages load (Accueil, À propos, Projets, Contact) * task: : Navigation links work correctly * task: : Logo displays properly ## Contact Form * task: : Form fields accept input * task: : Frontend validation blocks empty submission * task: : reCAPTCHA appears and can be checked * task: : Submission sends email (check inbox) * task: : Success message displays * task: : Invalid email shows error ## Projects Page * task: : Projects list displays from GitBucket * task: : Names and descriptions shown * task: : "Indisponible" message shows if API fails ## Responsive Design * task: : Test on mobile viewport (< 768px) * task: : Nav stacks or hamburger? (current design stacks) * task: : Cards wrap correctly * task: : Form usable on mobile ## Docker * task: : Container builds without errors * task: : Container starts and runs on port 8000 * task: : Health endpoint returns 200 * task: : Logs show no errors ## Security * task: : reCAPTCHA prevents basic bot submission * task: : No SQL injection possible (ORM used) * task: : Sensitive data in .env, not committed
git add MANUAL_TESTING.md git commit -m "docs: add manual testing checklist"
pytest tests/ -v --tb=short
Fix any failures.
Step 2: Ensure code quality
Check that no secrets are committed (use .gitignore)
app/__pycache__, .pytest_cache, __pycache__ to .gitignoreCreate .gitignore:
__pycache__/ *.py[cod] *$py.class *.so .Python env/ venv/ env.bak/ venv.bak/ .env *.log .pytest_cache/ mypy_cache/
git add .gitignore git commit -m "chore: add gitignore and final cleanup"
Cross-check with docs/superpowers/specs/2025-03-21-geekbrain-portfolio-design.md acceptance criteria.
Add "Implementation Complete" section with notes on what was built, how to deploy, and any deviations from spec (if any).
git add README.md git commit -m "docs: summarize implementation"
Plan complete and saved to docs/superpowers/plans/2025-03-21-geekbrain-portfolio-implementation.md.
Two execution options:
1. Subagent-Driven (recommended) - I dispatch a fresh subagent per task, review between tasks, fast iteration
2. Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints
Which approach?
(If Subagent-Driven chosen, I will invoke: superpowers:subagent-driven-development)