Newer
Older
geekbrain_io_web / docs / superpowers / plans / 2025-03-21-geekbrain-portfolio-implementation.md

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

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
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
# 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
# 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"

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:

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
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):

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()
  • Step 8: Run test again, should pass

Run: pytest tests/test_config.py -v Expected: PASS

  • Step 9: Commit
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:

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
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:

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
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
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:

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
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
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:

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
  • Step 4: Run tests, should pass

Run: pytest tests/test_services/test_email.py -v Expected: PASS

  • Step 5: Commit
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.pngapp/static/assets/images/Geekbrain-io.png
  • Create: 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/
  • Step 2: Write base.html with steampunk theme

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>&copy; {% 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)
: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)
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
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:

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
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
{% 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
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:

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 %}
  • Step 6: Commit
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:

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
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
{% 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
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
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:

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
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
{% 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
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
pytest tests/test_routes/ -v

Expected: Many failures due to missing dependencies but structure correct.

  • Step 6: Commit
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

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
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:

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
  • Step 4: Commit
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:

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:

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)
cd docker
docker-compose build

Check for errors.

  • Step 4: Commit
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:

-- 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

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:

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
pytest tests/test_integration/ -v

Expected: PASS (with mocks)

  • Step 3: Commit
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:

# 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
  • Step 2: Commit
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
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:

__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
env.bak/
venv.bak/
.env
*.log
.pytest_cache/
mypy_cache/
  • Step 3: Final commit
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
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)