parking-app

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
"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
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, PointField et PolygonField GeoDjango.
  • 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).
🌐 Voir le projet

Technologies utilisées

Backend
Django
Django REST Framework
Python
DevOps
Docker
docker-compose
GitHub Actions
Bases de donnees (SGBD & SQL)
PostGIS
PostgreSQL
Frontend
TypeScript
Vue.js