parking-app
Application fullstack de réservation de places de parking avec backend Django REST, frontend Vue 3, orchestration Docker et pipeline CI/CD. Le projet couvre les sujets techniques recherchés en recrutement : API documentée (drf-spectacular), auth token, permissions, géodonnées PostGIS, routage protégé TypeScript, tests backend et déploiement automatisé.
🎯 Contexte et objectifs
- Construire une application permettant à un utilisateur authentifié de consulter des parkings, filtrer les places disponibles, réserver une place et gérer ses réservations.
- Structurer une architecture de livraison exploitable en développement et en production avec Docker Compose, Nginx TLS, qualité de code et déploiement CI.
- Poser des bases de maintenabilité (modélisation métier explicite, pagination/filtrage API, gestion d’erreurs typée côté frontend, tests automatisés côté backend).
🛠️ Réalisations
🧩 Conception
- Le backend est conçu autour de Django + DRF + drf-spectacular + PostGIS + CORS/auth, avec séparation claire entre dépendances runtime et dev. Source: parking-app/backend/requirements.txt
Django>=4.2
wheel
setuptools
djangorestframework
drf-spectacular
gunicorn
psycopg2-binary
django-allauth
dj-rest-auth
requests
jwt
django-cors-headers
djangorestframework-gis
python-decouple
- Le frontend est structuré en Vue 3 + TypeScript + Vue Router + Tailwind + Headless UI, avec lint/format intégrés. Source: parking-app/frontend/package.json
"scripts": {
"dev": "vite --host",
"build": "vite build",
"format:check": "prettier --config .prettierrc --ignore-path .prettierignore --check 'src/**/*.{vue,ts,js,json,css,scss,html}'",
"lint": "eslint . --ext .ts,.tsx"
},
"dependencies": {
"@headlessui/vue": "^1.7.23",
"vue": "^3.4.0",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.3",
"@vitejs/plugin-vue": "^5.2.3",
"eslint": "^9.24.0",
"prettier": "^3.3.3",
"tailwindcss": "^4.1.3",
"typescript": "^5.0.0",
"vite": "^5.0.0"
}
- La configuration backend formalise les choix d’architecture: base PostGIS, auth DRF token, documentation OpenAPI/Swagger et politique CORS selon environnement. Source: parking-app/backend/config/settings.py
INSTALLED_APPS = [
"corsheaders",
"django.contrib.sites",
"allauth",
"allauth.account",
"allauth.socialaccount",
"dj_rest_auth",
"dj_rest_auth.registration",
"parking",
"rest_framework",
"rest_framework.authtoken",
"drf_spectacular",
"django.contrib.gis",
]
DATABASES = {
"default": {
"ENGINE": "django.contrib.gis.db.backends.postgis",
"NAME": os.environ.get("POSTGRES_DB", "parking_db"),
"HOST": os.environ.get("DB_HOST", "db"),
"PORT": os.environ.get("DB_PORT", "5432"),
}
}
💻 Développement
- Backend : Le modèle métier implémente un utilisateur custom, des entités de parking/réservation et une logique géospatiale (point dans polygone) pour rattacher automatiquement un parking à sa ville. Source: parking-app/backend/parking/models.py
class CustomUserManager(BaseUserManager):
def create_user(self, email, password=None, **extra_fields):
if not email:
raise ValueError("Email obligatoire")
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
class User(AbstractBaseUser, PermissionsMixin):
email = models.EmailField(unique=True)
USERNAME_FIELD = "email"
objects = CustomUserManager()
class ParkingLot(models.Model):
name = models.CharField(max_length=100)
position = gis_models.PointField(srid=4326, null=True)
city = models.ForeignKey("City", null=True, blank=True, on_delete=models.SET_NULL, related_name="parking_lots")
def save(self, *args, **kwargs):
if self.position:
matching_city = City.objects.filter(geom__contains=self.position).first()
if matching_city:
self.city = matching_city
super().save(*args, **kwargs)
La sérialisation expose un pattern expand pour récupérer des relations enrichies à la demande sans gonfler toutes les réponses API.
Source: parking-app/backend/parking/serializers.py
class ParkingSpotSerializer(serializers.ModelSerializer):
parking_lot = serializers.SerializerMethodField()
class Meta:
model = ParkingSpot
fields = ["id", "number", "available", "price", "parking_lot"]
def get_parking_lot(self, obj):
request = self.context.get("request")
if is_expanded(request, "lot"):
return ParkingLotSerializer(obj.parking_lot, context=self.context).data
return obj.parking_lot.id
class ReservationSerializer(serializers.ModelSerializer):
parking_spot_detail = serializers.SerializerMethodField()
def get_parking_spot_detail(self, obj):
request = self.context.get("request")
if is_expanded(request, "spot"):
return ParkingSpotSerializer(obj.parking_spot, context=self.context).data
return None
Les ViewSets implémentent pagination, filtres query param et règles transactionnelles métier (réservation = indisponible, suppression = disponibilité restaurée). Source: parking-app/backend/parking/views.py
class DefaultPagination(PageNumberPagination):
page_size = 5
page_size_query_param = "page_size"
max_page_size = 50
class ParkingSpotViewSet(viewsets.ModelViewSet):
serializer_class = ParkingSpotSerializer
pagination_class = DefaultPagination
def get_queryset(self):
queryset = ParkingSpot.objects.all()
parking_lot_id = self.request.query_params.get("parking_lot")
available = self.request.query_params.get("available")
if parking_lot_id:
queryset = queryset.filter(parking_lot=parking_lot_id)
if available == "true":
queryset = queryset.filter(available=True)
elif available == "false":
queryset = queryset.filter(available=False)
return queryset
class ReservationViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer):
reservation = serializer.save(user=self.request.user)
reservation.parking_spot.available = False
reservation.parking_spot.save()
def perform_destroy(self, instance):
spot = instance.parking_spot
instance.delete()
spot.available = True
spot.save()
La logique métier critique est couverte par des tests API de comportement, notamment la bascule d’état d’une place après réservation. Source: parking-app/backend/parking/tests/test_views.py
@pytest.mark.django_db
def test_reservation_sets_spot_unavailable():
client = APIClient()
user = User.objects.create_user(email="test@example.com", password="pass123")
client.force_authenticate(user=user)
lot = ParkingLot.objects.create(name="Lot A", position=Point(4, 4))
spot = ParkingSpot.objects.create(parking_lot=lot, number=1, available=True, price=10)
response = client.post(
reverse("reservations-list"),
{"parking_spot": str(spot.id)},
format="json",
)
assert response.status_code == 201
reservation = Reservation.objects.first()
assert reservation is not None
assert reservation.parking_spot.id == spot.id
spot.refresh_from_db()
assert spot.available is False
- Frontend :
Le routage implémente des guards d’authentification (
requiresAuth/guestOnly) pour sécuriser les parcours utilisateur. Source: parking-app/frontend/src/router/index.ts
const routes = [
{
path: '/',
component: DefaultLayout,
children: [
{ path: '', redirect: '/dashboard' },
{ path: 'login', component: LoginPage, meta: { guestOnly: true } },
{ path: 'register', component: RegisterPage, meta: { guestOnly: true } },
{ path: 'dashboard', component: DashboardPage, meta: { requiresAuth: true } },
{ path: 'reservation', component: ReservationForm, meta: { requiresAuth: true } },
],
},
];
router.beforeEach((to, from, next) => {
const isAuthenticated = AuthService.isAuthenticated();
if (to.meta.requiresAuth && !isAuthenticated) {
return next({ name: 'Login' });
}
if (to.meta.guestOnly && isAuthenticated) {
return next({ name: 'Dashboard' });
}
return next();
});
Le client HTTP est factorisé dans une fonction générique typée qui gère token, erreurs de validation backend et erreurs réseau, ce qui améliore la robustesse UX et la maintenabilité. Source: parking-app/frontend/src/services/api.ts
export class ApiError extends Error {
status: number;
details?: ErrorDetails;
constructor(message: string, status: number, details?: ErrorDetails) {
super(message);
this.name = 'ApiError';
this.status = status;
this.details = details;
}
}
export async function apiFetch<T>(
endpoint: string,
options: RequestInit = {},
includeAuthHeader: boolean = true
): Promise<T> {
const token = includeAuthHeader ? AuthService.getToken() : null;
const headers = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Token ${token}` } : {}),
...(options.headers || {}),
};
const url = endpoint.startsWith('http') ? endpoint : `${API_BASE_URL}${endpoint}`;
const res = await fetch(url, { ...options, headers });
if (!res.ok) {
const errorBody = await res.json();
throw new ApiError(errorBody.detail || `HTTP ${res.status}`, res.status, errorBody);
}
return (await res.json()) as T;
}
Le formulaire de réservation implémente des composants Headless UI, du chargement paginé, du filtrage dynamique par parking et une remontée d’erreurs structurée. Source: parking-app/frontend/src/views/ReservationForm.vue
async function loadLots(url: string = '/parkinglots/?page_size=10') {
try {
const response = await apiFetch<PaginatedResponse<ParkingLot>>(url);
lots.value.push(...response.results);
lotsPagination.value.next = response.next;
} catch (e) {
errors.value = { global: ['Erreur lors du chargement des parkings.'] };
}
}
watch(selectedLotId, async newLotId => {
if (newLotId) {
availableSpots.value = [];
const initialUrl = `/parkingspots/?parking_lot=${newLotId}&available=true&page_size=10`;
await loadSpots(initialUrl);
}
});
async function submitReservation() {
const user = AuthService.getUser();
try {
await apiFetch('/reservations/', {
method: 'POST',
body: JSON.stringify({
parking_spot: selectedSpotId.value,
user: user.pk,
}),
});
success.value = true;
errors.value = {};
} catch (err: any) {
if (err instanceof ApiError && err.details) {
errors.value = err.details;
}
}
}
🏗️ DevOps & Qualité
- La stack est orchestrée avec Docker Compose (backend, frontend, PostGIS), puis étendue en production avec Nginx TLS en reverse proxy. Source: parking-app/docker-compose.yml
services:
backend:
build:
context: ./backend
container_name: parking_backend
depends_on:
- db
frontend:
build:
context: ./frontend
container_name: parking_frontend
depends_on:
- backend
db:
image: postgis/postgis:15-3.3
container_name: parking_db
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
- Le déploiement production est automatisé via GitHub Actions + SSH, avec
git pull, rebuild compose, migration Django et vérification de santé des services. Source: parking-app/.github/workflows/deploy.yml
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
cd $BASE_DIR_APP/parking_app
git pull origin master
docker compose -f docker-compose.yml -f docker-compose.prod.yml down
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend python manage.py migrate
- name: ✅ Vérification des containers Docker
uses: appleboy/ssh-action@v1.0.0
with:
script: |
RUNNING=$(docker compose -f docker-compose.yml -f docker-compose.prod.yml ps --services --filter "status=running" | wc -l)
TOTAL=$( docker compose -f docker-compose.yml -f docker-compose.prod.yml config --services | wc -l)
if [ "$RUNNING" -ne "$TOTAL" ]; then
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps
exit 1
fi
📈 Résultats
- Sur la base de l’historique Git du dépôt analysé, le travail couvre la période 2025-04-11 -> 2025-04-14, avec 15 commits réalisés par 2 contributeurs. Le code livré met en place une chaîne fonctionnelle complète: authentification token, API CRUD paginées/filtrées, géolocalisation PostGIS, interface SPA sécurisée et processus de réservation avec règles de disponibilité cohérentes. L’industrialisation est présente via conteneurisation, workflows CI/CD et tests backend ciblant les comportements critiques. Le bénéfice technique est une base fullstack directement déployable et maintenable, alignée avec les standards attendus pour un projet de production.
🔧 Environnement technique
- Backend: Django 5, Django REST Framework, dj-rest-auth, django-allauth, drf-spectacular (OpenAPI/Swagger), djangorestframework-gis, Gunicorn.
- Frontend: Vue 3, TypeScript strict, Vue Router, Headless UI, Vite, Tailwind CSS.
- Base de données et géo: PostgreSQL/PostGIS,
PointFieldetPolygonFieldGeoDjango. - Qualité: Pytest/pytest-django, Black, Flake8, ESLint, Prettier.
- Infrastructure: Docker, Docker Compose, Nginx reverse proxy TLS.
- CI/CD: GitHub Actions (tests Django, lint, déploiement SSH).