diff --git a/.agents/rules/django-code-gen.md b/.agents/rules/django-code-gen.md new file mode 100644 index 00000000..9e0e48ba --- /dev/null +++ b/.agents/rules/django-code-gen.md @@ -0,0 +1,37 @@ +--- +trigger: always_on +--- + +# Django & DRF Version Constraints + +## Target Framework Versions +* **Django**: `4.2.16` (LTS) +* **Django REST Framework (DRF)**: `3.15.2` (or latest compatible `3.15.x`) +* **Python**: `3.10` / `3.11` / `3.12` + +## Guidelines for Code Generation +1. **Django 4.2 Compatibility**: + - Use `django.urls.path` and `re_path` instead of legacy `url()`. + - Prefer `async` view functions and `os.path` / `pathlib.Path` standards compatible with Django 4.2+. + - Use modern field options and class-based views (CBVs) or `@api_view` decorators conforming to Django 4.2 API standards. + - Do NOT use features introduced in Django 5.0+ (such as `GeneratedField` or database-computed defaults). + +2. **Django REST Framework 3.15 Compatibility**: + - Write serializers using `serializers.ModelSerializer` or `serializers.Serializer`. + - **Ninja-Ready DRF Endpoints**: + - **View Structure**: Prefer `@api_view` function views or explicit `APIView` methods over `ModelViewSet` magic. This aligns directly with Django Ninja's route functions (`@api.get`, `@api.post`). + - **Strict Type Annotations**: Require full Python type hints on all view arguments (e.g., `def list_items(request: Request, item_id: int) -> Response:`) and function return types. + - **Decoupled Business Logic**: Move query filters, mutations, and domain logic into standalone service/selector functions rather than putting logic inside DRF Serializer `create()`/`update()` methods or ViewSet hooks. + - **Symmetric Schemas**: Separate input payload serializers from output serializers (e.g., `ItemCreateSerializer` vs `ItemSchemaSerializer`) to mirror Django Ninja's Pydantic input schemas and `response=` models. + - Standardize responses using `rest_framework.response.Response` and `rest_framework.status`. + +## Authentication & User Model Guidelines + +1. **Ninja-Ready Auth & Permissions**: + - Use standard HTTP Bearer token headers (`Authorization: Bearer `) rather than session cookies or custom headers. + - Isolate permission logic into dedicated `BasePermission` classes or helper properties on the User model (e.g., `user.is_admin_user`) rather than inline `if` statements inside views. + - Avoid overriding `request.user` or attaching custom attributes directly to `request`; rely on standard user model properties. + +2. **Custom User Model**: + - Always reference the custom user model using `settings.AUTH_USER_MODEL` for ForeignKeys/OneToOneFields. + - Use `django.contrib.auth.get_user_model()` in application code and serializers. diff --git a/.agents/rules/python-env.md b/.agents/rules/python-env.md new file mode 100644 index 00000000..da00dab4 --- /dev/null +++ b/.agents/rules/python-env.md @@ -0,0 +1,11 @@ +--- +trigger: always_on +--- + +# Python Execution Constraints + +- Python commands, tests, migrations, and scripts in this repository must be executed inside the Docker `web` container. +- When running terminal execution (e.g., executing scripts, running tests, or managing Django): + - Use `docker compose exec web python manage.py ...` for running commands against active services. + - Or use `docker compose run --rm web python ...` if services are not already running. +- Ensure all Django management commands and test suites run through the Docker environment. \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..86a7bf30 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitignore +.idea +venv +__pycache__ +*.pyc +*.pyo +*.pyd +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..6f0034ef --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +POSTGRES_DB=postgis_34_sample +POSTGRES_USER=padam_user +POSTGRES_PASSWORD=padam_p@ssw0rd +POSTGRES_HOST=db +POSTGRES_PORT=5432 +SECRET_KEY=django-insecure-change-me-in-production +DEBUG=True +ALLOWED_HOSTS=* diff --git a/.gitignore b/.gitignore index d7d26693..5c377005 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,14 @@ ENV/ # pipenv: https://github.com/kennethreitz/pipenv /Pipfile +# dotenv environment variable files +.env +.env.* +!.env.example + # Database /db.sqlite3 # Editors stuff -.idea +.idea/ .vscode diff --git a/.idea/misc.xml b/.idea/misc.xml index 574ec96e..0e45b509 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/.idea/padam-django-tech-test.iml b/.idea/padam-django-tech-test.iml index c7ffe09b..d5ef4731 100644 --- a/.idea/padam-django-tech-test.iml +++ b/.idea/padam-django-tech-test.iml @@ -14,7 +14,7 @@ - + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..90a83daa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Install system dependencies for GeoDjango and psycopg2 +RUN apt-get update && apt-get install -y --no-install-recommends \ + binutils \ + gdal-bin \ + libgdal-dev \ + libpq-dev \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt /app/ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /app/ + +EXPOSE 8000 + +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] diff --git a/Makefile b/Makefile index 4062f4c4..4c51e63d 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,25 @@ -run: ## Run the test server. - python manage.py runserver_plus +.PHONY: build up down logs migrate makemigrations test shell -install: ## Install the python requirements. - pip install -r requirements.txt +build: ## Build docker images + docker compose build + +up: ## Start containers + docker compose up -d + +down: ## Stop containers + docker compose down + +logs: ## Tail container logs + docker compose logs -f web + +migrate: ## Run database migrations + docker compose exec web python manage.py migrate + +makemigrations: ## Create database migrations + docker compose exec web python manage.py makemigrations + +test: ## Run test suite inside container + docker compose exec web python manage.py test + +shell: ## Open Django shell inside web container + docker compose exec web python manage.py shell diff --git a/README.md b/README.md index f99d629d..892c4319 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,53 @@ more time-consuming than others... - You can reduce the scope of the project if you're short of time. A draft response is already a good thing. - Be ready to present the subject, justify your choices and talk about how you would have done the parts you left out. - +# Deliverable - Tahina + +I chose PostGIS as the database backend to accurately model the domain requirements for Padam Mobility's route calculations. + +I focused on 1 of the 2 trip constraints: + +> The same bus cannot be assigned to several routes at the same time, with overlapping start and end times. + +Here is how I designed the application's database models: + +```mermaid +erDiagram + Place ||--o| BusStop : locates + User ||--o{ Bus : drives + User ||--o{ BusShift : works + Bus ||--o{ BusShift : works + BusShift ||--|{ BusStop : includes + + User { + int id PK + boolean is_driver + time shift_start_time "e.g., 08:00:00" + time shift_end_time "e.g., 17:00:00" + string shift_days "e.g., Mon-Fri" + } + Bus { + int id PK + string licence_plate "e.g., AA-001-AA" + } + Place { + int id PK + Point location PK + } + BusStop { + int id PK + Point place_id FK + string name + datetime departure_time "optional" + } + BusShift { + int id PK + datetime departure_time + datetime arrival_time + float duration + BusStop[] stops + } +``` @@ -193,3 +239,53 @@ en temps que d'autres ... - Privilégier la qualité et les bonnes pratiques. - Vous pouvez réduire le périmètre du projet si vous manquez de temps. Une ébauche de réponse est déjà une bonne chose. - Soyez prêt à présenter le sujet, à justifier vos choix et à parler de comment vous auriez fait les parties que vous avez laisser de côté. + +# Livrable - Tahina + + +J'ai choisi PostGIS comme backend pour une bonne modélisation du besoin autour des calculs d'itinéraires de Padam Mobility. + +Je me suis concentré sur 1/2 des contraintes d'un trajet : + +> Un même bus ne peut être assigné, en même temps, à plusieurs trajets dont les heures de début et fin se + chevaucheraient. + +Voilà comment j'ai vu les modèles de base de donées de l'application : + +```mermaid +erDiagram + Place ||--o| BusStop : locates + User ||--o{ Bus : drives + User ||--o{ BusShift : works + Bus ||--o{ BusShift : works + BusShift ||--|{ BusStop : includes + + User { + int id PK + boolean is_driver + time shift_start_time "e.g., 08:00:00" + time shift_end_time "e.g., 17:00:00" + string shift_days "e.g., Lun-Ven" + } + Bus { + int id PK + string licence_plate "e.g., AA-001-AA" + } + Place { + int id PK + Point location PK + } + BusStop { + int id PK + Point place_id FK + string name + datetime departure_time "optional" + } + BusShift { + int id PK + datetime departure_time + datetime arrival_time + float duration + BusStop[] stops + } +``` \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..fd52d78e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +services: + db: + image: postgis/postgis:15-3.4-alpine + container_name: padam_postgis + restart: always + env_file: + - .env + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 5 + + web: + build: . + container_name: padam_web + restart: always + command: python manage.py runserver 0.0.0.0:8000 + env_file: + - .env + volumes: + - .:/app + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + +volumes: + postgres_data: diff --git a/padam_django/apps/fleet/admin.py b/padam_django/apps/fleet/admin.py index 3fba5023..7313f24e 100644 --- a/padam_django/apps/fleet/admin.py +++ b/padam_django/apps/fleet/admin.py @@ -1,8 +1,26 @@ +from django import forms from django.contrib import admin from . import models +class BusStopInlineFormSet(forms.BaseInlineFormSet): + def clean(self): + super().clean() + active_stops = 0 + for form in self.forms: + if form.cleaned_data and not form.cleaned_data.get('DELETE', False): + active_stops += 1 + if active_stops < 2: + raise forms.ValidationError("A bus shift must have at least 2 stops.") + + +class BusStopInline(admin.TabularInline): + model = models.BusStop + formset = BusStopInlineFormSet + extra = 2 + + @admin.register(models.Bus) class BusAdmin(admin.ModelAdmin): pass @@ -11,3 +29,12 @@ class BusAdmin(admin.ModelAdmin): @admin.register(models.Driver) class DriverAdmin(admin.ModelAdmin): pass + + +@admin.register(models.BusShift) +class BusShiftAdmin(admin.ModelAdmin): + list_display = ('__str__', 'bus', 'driver', 'departure_time', 'arrival_time', 'duration') + readonly_fields = ('departure_time', 'arrival_time', 'duration') + inlines = [BusStopInline] + + diff --git a/padam_django/apps/fleet/factories.py b/padam_django/apps/fleet/factories.py index c78c832e..08a2e238 100644 --- a/padam_django/apps/fleet/factories.py +++ b/padam_django/apps/fleet/factories.py @@ -19,3 +19,22 @@ class BusFactory(factory.django.DjangoModelFactory): class Meta: model = models.Bus + + +class BusShiftFactory(factory.django.DjangoModelFactory): + bus = factory.SubFactory(BusFactory) + driver = factory.SubFactory(DriverFactory) + + class Meta: + model = models.BusShift + + +class BusStopFactory(factory.django.DjangoModelFactory): + place = factory.SubFactory('padam_django.apps.geography.factories.PlaceFactory') + bus_shift = factory.SubFactory(BusShiftFactory) + name = factory.LazyFunction(fake.street_name) + departure_time = factory.LazyFunction(fake.date_time) + + class Meta: + model = models.BusStop + diff --git a/padam_django/apps/fleet/migrations/0003_busshift_busstop.py b/padam_django/apps/fleet/migrations/0003_busshift_busstop.py new file mode 100644 index 00000000..30640fd8 --- /dev/null +++ b/padam_django/apps/fleet/migrations/0003_busshift_busstop.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.16 on 2026-08-09 17:09 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('geography', '0002_alter_place_unique_together_place_location_and_more'), + ('fleet', '0002_auto_20211109_1456'), + ] + + operations = [ + migrations.CreateModel( + name='BusShift', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('departure_time', models.DateTimeField(blank=True, editable=False, null=True, verbose_name='Departure time')), + ('arrival_time', models.DateTimeField(blank=True, editable=False, null=True, verbose_name='Arrival time')), + ('duration', models.DurationField(blank=True, editable=False, null=True, verbose_name='Duration')), + ('bus', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shifts', to='fleet.bus')), + ('driver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shifts', to='fleet.driver')), + ], + ), + migrations.CreateModel( + name='BusStop', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, verbose_name='Name of the stop')), + ('departure_time', models.DateTimeField(blank=True, null=True)), + ('bus_shift', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='stops', to='fleet.busshift')), + ('place', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bus_stops', to='geography.place')), + ], + ), + ] + diff --git a/padam_django/apps/fleet/models.py b/padam_django/apps/fleet/models.py index 4cd3f19d..df2ed439 100644 --- a/padam_django/apps/fleet/models.py +++ b/padam_django/apps/fleet/models.py @@ -1,4 +1,7 @@ +from django.core.exceptions import ValidationError from django.db import models +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver class Driver(models.Model): @@ -16,3 +19,79 @@ class Meta: def __str__(self): return f"Bus: {self.licence_plate} (id: {self.pk})" + + +class BusShift(models.Model): + bus = models.ForeignKey(Bus, on_delete=models.CASCADE, related_name='shifts') + driver = models.ForeignKey(Driver, on_delete=models.CASCADE, related_name='shifts') + departure_time = models.DateTimeField("Departure time", null=True, blank=True, editable=False) + arrival_time = models.DateTimeField("Arrival time", null=True, blank=True, editable=False) + duration = models.DurationField("Duration", null=True, blank=True, editable=False) + + def __str__(self): + return f"BusShift #{self.pk} - Bus: {self.bus.licence_plate} (Driver: {self.driver.user.username})" + + def update_times(self): + """Calculate and update departure_time, arrival_time, and duration from related stops.""" + stops = self.stops.filter(departure_time__isnull=False).order_by('departure_time') + first_stop = stops.first() + last_stop = stops.last() + + if first_stop and last_stop and first_stop != last_stop: + self.departure_time = first_stop.departure_time + self.arrival_time = last_stop.departure_time + self.duration = self.arrival_time - self.departure_time + elif first_stop: + self.departure_time = first_stop.departure_time + self.arrival_time = first_stop.departure_time + self.duration = self.arrival_time - self.departure_time + else: + self.departure_time = None + self.arrival_time = None + self.duration = None + + self.save(update_fields=['departure_time', 'arrival_time', 'duration']) + + def clean(self): + super().clean() + if self.bus_id and self.departure_time and self.arrival_time: + overlapping = BusShift.objects.filter( + bus=self.bus, + departure_time__lt=self.arrival_time, + arrival_time__gt=self.departure_time, + ).exclude(pk=self.pk) + if overlapping.exists(): + raise ValidationError({'bus': "This bus is already assigned to another shift during this time window."}) + + +class BusStop(models.Model): + place = models.ForeignKey( + 'geography.Place', + on_delete=models.CASCADE, + related_name='bus_stops', + ) + + bus_shift = models.ForeignKey( + BusShift, + on_delete=models.CASCADE, + related_name='stops', + null=True, + blank=True, + ) + name = models.CharField("Name of the stop", max_length=50) + # If departure_time is null then this BusStop will be the dropoff location. + # Otherwise it will be the pickup location. + departure_time = models.DateTimeField(null=True, blank=True) + + def __str__(self): + return f"Stop: {self.name} ({self.departure_time})" + + +@receiver([post_save, post_delete], sender=BusStop) +def update_shift_times_on_stop_change(sender, instance, **kwargs): + if instance.bus_shift_id: + shift = BusShift.objects.filter(pk=instance.bus_shift_id).first() + if shift: + shift.update_times() + + diff --git a/padam_django/apps/fleet/tests/__init__.py b/padam_django/apps/fleet/tests/__init__.py new file mode 100644 index 00000000..87de65ce --- /dev/null +++ b/padam_django/apps/fleet/tests/__init__.py @@ -0,0 +1 @@ +# Fleet tests module diff --git a/padam_django/apps/fleet/tests/test_bus_shift.py b/padam_django/apps/fleet/tests/test_bus_shift.py new file mode 100644 index 00000000..f276d134 --- /dev/null +++ b/padam_django/apps/fleet/tests/test_bus_shift.py @@ -0,0 +1,109 @@ +from datetime import timedelta +from django.core.exceptions import ValidationError +from django.forms.models import inlineformset_factory +from django.test import TestCase +from django.utils import timezone + +from padam_django.apps.fleet.admin import BusStopInlineFormSet +from padam_django.apps.fleet.factories import BusFactory, BusShiftFactory, BusStopFactory, DriverFactory +from padam_django.apps.fleet.models import BusShift, BusStop + + +class BusShiftTestCase(TestCase): + + + def setUp(self): + self.bus = BusFactory() + self.driver = DriverFactory() + self.now = timezone.now() + + def test_shift_timing_and_duration_calculation(self): + shift = BusShiftFactory(bus=self.bus, driver=self.driver) + + stop1_time = self.now + stop2_time = self.now + timedelta(hours=2) + + BusStopFactory(bus_shift=shift, departure_time=stop1_time) + BusStopFactory(bus_shift=shift, departure_time=stop2_time) + + shift.refresh_from_db() + self.assertEqual(shift.departure_time, stop1_time) + self.assertEqual(shift.arrival_time, stop2_time) + self.assertEqual(shift.duration, timedelta(hours=2)) + + def test_bus_overlap_validation(self): + shift1 = BusShiftFactory(bus=self.bus, driver=self.driver) + BusStopFactory(bus_shift=shift1, departure_time=self.now) + BusStopFactory(bus_shift=shift1, departure_time=self.now + timedelta(hours=3)) + + shift1.refresh_from_db() + + # Create shift2 for the same bus that overlaps with shift1 + shift2 = BusShiftFactory(bus=self.bus, driver=DriverFactory()) + BusStopFactory(bus_shift=shift2, departure_time=self.now + timedelta(hours=1)) + BusStopFactory(bus_shift=shift2, departure_time=self.now + timedelta(hours=4)) + + shift2.refresh_from_db() + + with self.assertRaises(ValidationError): + shift2.clean() + + def test_non_overlapping_bus_shifts_allowed(self): + shift1 = BusShiftFactory(bus=self.bus, driver=self.driver) + BusStopFactory(bus_shift=shift1, departure_time=self.now) + BusStopFactory(bus_shift=shift1, departure_time=self.now + timedelta(hours=2)) + shift1.refresh_from_db() + + shift2 = BusShiftFactory(bus=self.bus, driver=DriverFactory()) + BusStopFactory(bus_shift=shift2, departure_time=self.now + timedelta(hours=3)) + BusStopFactory(bus_shift=shift2, departure_time=self.now + timedelta(hours=5)) + shift2.refresh_from_db() + + # clean() should pass without raising ValidationError + try: + shift2.clean() + except ValidationError: + self.fail("clean() raised ValidationError unexpectedly for non-overlapping shifts.") + + def test_minimum_two_stops_validation(self): + + shift = BusShiftFactory(bus=self.bus, driver=self.driver) + formset_class = inlineformset_factory( + BusShift, + BusStop, + formset=BusStopInlineFormSet, + fields=['place', 'name', 'departure_time'] + ) + formset_data = { + 'stops-TOTAL_FORMS': '1', + 'stops-INITIAL_FORMS': '0', + 'stops-MIN_NUM_FORMS': '0', + 'stops-MAX_NUM_FORMS': '1000', + 'stops-0-name': 'Stop 1', + } + formset = formset_class(data=formset_data, instance=shift, prefix='stops') + self.assertFalse(formset.is_valid()) + + def test_duplicate_bus_stops_same_place_allowed(self): + from padam_django.apps.geography.factories import PlaceFactory + place = PlaceFactory() + shift = BusShiftFactory(bus=self.bus, driver=self.driver) + + stop1 = BusStopFactory(bus_shift=shift, place=place, departure_time=self.now) + stop2 = BusStopFactory(bus_shift=shift, place=place, departure_time=self.now + timedelta(hours=1)) + + self.assertEqual(shift.stops.count(), 2) + self.assertEqual(stop1.place, stop2.place) + + def test_delete_bus_shift_with_stops(self): + shift = BusShiftFactory(bus=self.bus, driver=self.driver) + BusStopFactory(bus_shift=shift, departure_time=self.now) + BusStopFactory(bus_shift=shift, departure_time=self.now + timedelta(hours=1)) + + shift_id = shift.pk + shift.delete() + + self.assertFalse(BusShift.objects.filter(pk=shift_id).exists()) + + + diff --git a/padam_django/apps/geography/admin.py b/padam_django/apps/geography/admin.py index e0334458..ddae0bc9 100644 --- a/padam_django/apps/geography/admin.py +++ b/padam_django/apps/geography/admin.py @@ -1,8 +1,8 @@ -from django.contrib import admin +from django.contrib.gis import admin from . import models @admin.register(models.Place) -class PlaceAdmin(admin.ModelAdmin): +class PlaceAdmin(admin.GISModelAdmin): pass diff --git a/padam_django/apps/geography/factories.py b/padam_django/apps/geography/factories.py index b134a30c..3d0736ac 100644 --- a/padam_django/apps/geography/factories.py +++ b/padam_django/apps/geography/factories.py @@ -1,17 +1,21 @@ import factory +from django.contrib.gis.geos import Point from faker import Faker from . import models - fake = Faker(['fr']) class PlaceFactory(factory.django.DjangoModelFactory): name = factory.LazyFunction(fake.street_name) - - longitude = factory.LazyFunction(fake.longitude) - latitude = factory.LazyFunction(fake.latitude) + location = factory.LazyFunction( + lambda: Point( + float(fake.longitude()), + float(fake.latitude()), + srid=4326 + ) + ) class Meta: model = models.Place diff --git a/padam_django/apps/geography/migrations/0002_alter_place_unique_together_place_location_and_more.py b/padam_django/apps/geography/migrations/0002_alter_place_unique_together_place_location_and_more.py new file mode 100644 index 00000000..746fda85 --- /dev/null +++ b/padam_django/apps/geography/migrations/0002_alter_place_unique_together_place_location_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.16 on 2026-08-09 17:09 + +import django.contrib.gis.db.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('geography', '0001_initial'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='place', + unique_together=set(), + ), + migrations.AddField( + model_name='place', + name='location', + field=django.contrib.gis.db.models.fields.PointField(default='POINT(0 0)', srid=4326, unique=True, verbose_name='Location'), + ), + migrations.RemoveField( + model_name='place', + name='latitude', + ), + migrations.RemoveField( + model_name='place', + name='longitude', + ), + ] diff --git a/padam_django/apps/geography/models.py b/padam_django/apps/geography/models.py index e566ee2b..c8e61ddf 100644 --- a/padam_django/apps/geography/models.py +++ b/padam_django/apps/geography/models.py @@ -1,15 +1,21 @@ +from django.contrib.gis.db import models as gis_models from django.db import models class Place(models.Model): name = models.CharField("Name of the place", max_length=50) - longitude = models.DecimalField("Longitude", max_digits=9, decimal_places=6) - latitude = models.DecimalField("Latitude", max_digits=9, decimal_places=6) + # Spatial location point field (longitude, latitude) in WGS84 projection + location = gis_models.PointField("Location", srid=4326, spatial_index=True, unique=True, default='POINT(0 0)') - class Meta: - # Two places cannot be located at the same coordinates. - unique_together = (("longitude", "latitude"), ) + + @property + def longitude(self): + return self.location.x if self.location else None + + @property + def latitude(self): + return self.location.y if self.location else None def __str__(self): return f"Place: {self.name} (id: {self.pk})" diff --git a/padam_django/apps/users/models.py b/padam_django/apps/users/models.py index 672f6a15..7511b96f 100644 --- a/padam_django/apps/users/models.py +++ b/padam_django/apps/users/models.py @@ -3,6 +3,8 @@ class User(AbstractUser): + # TODO: add Shift hours informations + @property def is_driver(self) -> bool: """Define if the user is related to a driver.""" diff --git a/padam_django/settings.py b/padam_django/settings.py index 129e922c..286f0345 100644 --- a/padam_django/settings.py +++ b/padam_django/settings.py @@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/3.2/ref/settings/ """ +import os from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -20,12 +21,13 @@ # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-&r2)+_fdqxe2dtc@1vizr6tsh6!1cesaptlfgj@ug*%3=fnq=i' +SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-&r2)+_fdqxe2dtc@1vizr6tsh6!1cesaptlfgj@ug*%3=fnq=i') # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = os.environ.get('DEBUG', 'True').lower() in ('true', '1', 't') + +ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',') -ALLOWED_HOSTS = [] # Application definition @@ -38,6 +40,7 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'django.contrib.gis', # Third party apps 'django_extensions', # Internal apps @@ -83,12 +86,15 @@ DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + 'ENGINE': 'django.contrib.gis.db.backends.postgis', + 'NAME': os.environ.get('POSTGRES_DB', 'postgis_34_sample'), + 'USER': os.environ.get('POSTGRES_USER', 'padam_user'), + 'PASSWORD': os.environ.get('POSTGRES_PASSWORD', 'padam_p@ssw0rd'), + 'HOST': os.environ.get('POSTGRES_HOST', 'db'), + 'PORT': os.environ.get('POSTGRES_PORT', '5432'), } } - # Password validation # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators diff --git a/requirements.txt b/requirements.txt index 863fd63d..a02037d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ Django==4.2.16 +psycopg2-binary==2.9.9 django-extensions==3.2.1 Werkzeug==3.1.3 -ipython==8.29.0 factory-boy==3.2.0 Faker==8.10.1