parking-app

parking-app

Fullstack parking-reservation platform with a Django REST backend, Vue 3 frontend, Docker orchestration, and CI/CD pipeline. The project covers hiring-relevant engineering topics: documented API (drf-spectacular), token auth, permissions, PostGIS geospatial model, TypeScript protected routing, backend tests, and automated deployment.

🎯 Context and goals

  • Deliver an application where authenticated users can browse parking lots, filter available spots, create reservations, and manage their own reservations.
  • Establish a delivery architecture usable in development and production through Docker Compose, Nginx TLS reverse proxy, quality checks, and CI deployment.
  • Enforce maintainability through explicit domain modeling, paginated/filterable APIs, typed frontend error handling, and automated backend tests.

🛠️ Deliverables

🧩 Design

  • The backend stack is designed around Django + DRF + drf-spectacular + PostGIS + CORS/auth, with clear runtime/dev dependency separation. 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
  • The frontend is structured with Vue 3 + TypeScript + Vue Router + Tailwind + Headless UI, with lint/format integrated into scripts. 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"
}
  • Backend configuration formalizes architecture decisions: PostGIS database engine, DRF token auth, OpenAPI/Swagger docs, and environment-based CORS policy. 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"),
    }
}

💻 Development

  • Backend : The domain model implements a custom user entity, reservation/parking entities, and geospatial logic (point-in-polygon) to automatically attach lots to a city. 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)

Serialization uses an expand pattern to return relation details only when requested, avoiding over-fetching by default. 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

ViewSets implement pagination, query-param filtering, and business-state transitions (reservation makes spot unavailable, deletion restores availability). 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()

Critical business behavior is validated with API tests, including spot availability transitions after reservation. 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();
});

HTTP access is centralized in a typed generic client handling auth token injection, backend validation payloads, and network failures. 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;
}

The reservation UI implements Headless UI comboboxes, paginated loading, dynamic lot/spot filtering, and structured server-error rendering. 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 & Quality

  • The application is orchestrated with Docker Compose (backend, frontend, PostGIS), then extended in production with Nginx TLS 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}
  • Production deployment is automated with GitHub Actions + SSH, including git pull, compose rebuild, Django migration, and service health verification. 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

📈 Results

  • Based on the repository Git history, the delivered scope spans 2025-04-11 -> 2025-04-14, with 15 commits across 2 contributors. The implementation provides a full functional chain: token authentication, paginated/filterable CRUD APIs, PostGIS geospatial model, protected SPA navigation, and reservation workflows with coherent availability transitions. Delivery readiness is reinforced through containerization, CI/CD workflow automation, and backend tests targeting critical behavior. The technical benefit is a deployable and maintainable fullstack baseline aligned with production-oriented engineering expectations.

🔧 Technical environment

  • Backend: Django 5, Django REST Framework, dj-rest-auth, django-allauth, drf-spectacular (OpenAPI/Swagger), djangorestframework-gis, Gunicorn.
  • Frontend: Vue 3, strict TypeScript, Vue Router, Headless UI, Vite, Tailwind CSS.
  • Database and geo: PostgreSQL/PostGIS, GeoDjango PointField and PolygonField.
  • Quality: Pytest/pytest-django, Black, Flake8, ESLint, Prettier.
  • Infrastructure: Docker, Docker Compose, Nginx TLS reverse proxy.
  • CI/CD: GitHub Actions (Django tests, lint, SSH deployment).
🌐 View the project

Tech Stack

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