# 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:[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
```
- [ ] **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="[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
```
- [ ] **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", "[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**
```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": "[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()
```
- [ ] **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="[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()
```
- [ ] **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="[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**
```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
<!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>
```
- [ ] **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 %}
<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 %}
```
- [ ] **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 %}
<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 %}
```
- [ ] **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 %}
<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 %}
```
- [ ] **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": "[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
```
- [ ] **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 %}
<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 %}
```
- [ ] **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:[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
```
- [ ] **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": "[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
```
- [ ] **Step 2: Run integration tests**
```bash
pytest tests/test_integration/ -v
```
Expected: PASS (with mocks)
- [ ] **Step 3: Commit**
```bash
git add tests/test_integration/test_contact_flow.py
git commit -m "test: add end-to-end contact flow integration tests"
```
---
#### Task 14: Manual Testing Checklist
**Files:**
- Create: `MANUAL_TESTING.md` (optional but helpful)
- [ ] **Step 1: Write manual testing scenarios**
`MANUAL_TESTING.md`:
```markdown
# Manual Testing Checklist
## Site Navigation
- [ ] All pages load (Accueil, À propos, Projets, Contact)
- [ ] Navigation links work correctly
- [ ] Logo displays properly
## Contact Form
- [ ] Form fields accept input
- [ ] Frontend validation blocks empty submission
- [ ] reCAPTCHA appears and can be checked
- [ ] Submission sends email (check inbox)
- [ ] Success message displays
- [ ] Invalid email shows error
## Projects Page
- [ ] Projects list displays from GitBucket
- [ ] Names and descriptions shown
- [ ] "Indisponible" message shows if API fails
## Responsive Design
- [ ] Test on mobile viewport (< 768px)
- [ ] Nav stacks or hamburger? (current design stacks)
- [ ] Cards wrap correctly
- [ ] Form usable on mobile
## Docker
- [ ] Container builds without errors
- [ ] Container starts and runs on port 8000
- [ ] Health endpoint returns 200
- [ ] Logs show no errors
## Security
- [ ] reCAPTCHA prevents basic bot submission
- [ ] No SQL injection possible (ORM used)
- [ ] Sensitive data in .env, not committed
```
- [ ] **Step 2: Commit**
```bash
git add MANUAL_TESTING.md
git commit -m "docs: add manual testing checklist"
```
---
#### Task 15: Final Review and Spec Handoff
- [ ] **Step 1: Run all tests**
```bash
pytest tests/ -v --tb=short
```
Fix any failures.
- [ ] **Step 2: Ensure code quality**
- Check that no secrets are committed (use .gitignore)
- Verify imports are clean
- Add `app/__pycache__`, `.pytest_cache`, `__pycache__` to .gitignore
Create `.gitignore`:
```gitignore
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
env.bak/
venv.bak/
.env
*.log
.pytest_cache/
mypy_cache/
```
- [ ] **Step 3: Final commit**
```bash
git add .gitignore
git commit -m "chore: add gitignore and final cleanup"
```
- [ ] **Step 4: Verify spec implementation complete**
Cross-check with `docs/superpowers/specs/2025-03-21-geekbrain-portfolio-design.md` acceptance criteria.
- [ ] **Step 5: Write implementation summary in README**
Add "Implementation Complete" section with notes on what was built, how to deploy, and any deviations from spec (if any).
- [ ] **Step 6: Final commit**
```bash
git add README.md
git commit -m "docs: summarize implementation"
```
---
## Execution Handoff
**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)*