diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..f221f59 --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +max-line-length = 88 +ignore = E203, E266, E501, W503 +exclude = .git,__pycache__,docs/source/conf.py,old,build,dista \ No newline at end of file diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index 9766b45..37163c2 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -8,23 +8,42 @@ on: jobs: build: - runs-on: ubuntu-latest strategy: - max-parallel: 4 matrix: - python-version: [3.7, 3.8, 3.9] + python-version: [3.9] steps: - - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} - - name: Install Dependencies + + - name: Install Poetry run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install poetry + + - name: Install Dependencies + run: | + poetry install + + - name: Export requirements.txt for Deployment + run: | + poetry export --without-hashes -o requirements.txt + + - name: Deploy to Render + env: + SECRET_KEY: ${{ secrets.SECRET_KEY }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + run: | + echo "Starting deployment to Render..." + render deploy --service-name egypt-metro + echo "Deployment complete!" + - name: Run Tests run: | python manage.py test diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6063867 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +# .pre-commit-config.yaml + +# Repositories that contain hooks +repos: + # Black: Code formatter for Python + - repo: https://github.com/psf/black + rev: 23.3.0 + hooks: + - id: black + args: ['--line-length=88'] # Customize the line length here if needed + + # Flake8: Linter for Python + - repo: https://github.com/PyCQA/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + args: + - --max-line-length=88 # Customize the max line length if needed + # additional_dependencies: + # - flake8-bugbear # Optional: Adds additional linting checks + # - flake8-docstrings # Optional: Adds docstring linting checks + + # Isort: Sorts imports alphabetically and automatically separates them into sections + # - repo: https://github.com/pre-commit/mirrors-isort + # rev: v5.12.0 # Use the latest valid version + # hooks: + # - id: isort + # args: ['--profile=black'] # Align with Black's formatting style + # additional_dependencies: + # - isort[app] # Optional: Install extra dependencies for app-specific sorting + + # Pydocstyle: Checks compliance with Python docstring conventions + # - repo: https://github.com/PyCQA/pydocstyle + # rev: 6.1.1 + # hooks: + # - id: pydocstyle + # args: + # - --convention=google # Use the Google style for docstrings (customize as needed) diff --git a/Procfile b/Procfile deleted file mode 100644 index 7dbda4d..0000000 --- a/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: gunicorn egypt_metro.wsgi diff --git a/apps/stations/admin.py b/apps/stations/admin.py index be4187d..ccb7783 100644 --- a/apps/stations/admin.py +++ b/apps/stations/admin.py @@ -3,14 +3,16 @@ from django.contrib import admin from .models import Line, Station, LineStation + class LineStationInline(admin.TabularInline): """ Inline admin for LineStation to manage the relationship between lines and stations. """ - model = LineStation # model to display - extra = 1 # number of extra fields to display - fields = ["station", "order"] # fields to display - ordering = ["order"] # order by order + + model = LineStation # model to display + extra = 1 # number of extra fields to display + fields = ["station", "order"] # fields to display + ordering = ["order"] # order by order @admin.register(Line) @@ -18,9 +20,10 @@ class LineAdmin(admin.ModelAdmin): """ Admin configuration for the Line model. """ + list_display = ("name", "total_stations") # fields to display - search_fields = ("name",) # fields to search - inlines = [LineStationInline] # inline to display + search_fields = ("name",) # fields to search + inlines = [LineStationInline] # inline to display @admin.register(Station) @@ -28,13 +31,17 @@ class StationAdmin(admin.ModelAdmin): """ Admin configuration for the Station model. """ + list_display = ("name", "is_interchange") # fields to display - search_fields = ("name",) # fields to search - inlines = [LineStationInline] # inline to display + search_fields = ("name",) # fields to search + inlines = [LineStationInline] # inline to display fieldsets = ( - (None, { - "fields": ("name", "latitude", "longitude"), # fields to display - }), + ( + None, + { + "fields": ("name", "latitude", "longitude"), # fields to display + }, + ), ) @@ -43,6 +50,7 @@ class LineStationAdmin(admin.ModelAdmin): """ Admin configuration for the LineStation model. """ + list_display = ("line", "station", "order") # fields to display - list_filter = ("line",) # fields to filter - ordering = ["line", "order"] # order by line and order + list_filter = ("line",) # fields to filter + ordering = ["line", "order"] # order by line and order diff --git a/apps/stations/apps.py b/apps/stations/apps.py index 0141eba..dcd98e4 100644 --- a/apps/stations/apps.py +++ b/apps/stations/apps.py @@ -2,6 +2,7 @@ from django.apps import AppConfig + class StationsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.stations' + default_auto_field = "django.db.models.BigAutoField" + name = "apps.stations" diff --git a/apps/stations/management/commands/populate_metro_data.py b/apps/stations/management/commands/populate_metro_data.py index a73bd32..7590d68 100644 --- a/apps/stations/management/commands/populate_metro_data.py +++ b/apps/stations/management/commands/populate_metro_data.py @@ -4,6 +4,7 @@ from django.db import transaction from apps.stations.models import Line, Station, LineStation, ConnectingStation + class Command(BaseCommand): help = "Populate metro lines, stations, and connections data" @@ -41,7 +42,11 @@ class Command(BaseCommand): {"name": "Hammamat El-Qobba", "latitude": 30.090, "longitude": 31.298}, {"name": "Saray El-Qobba", "latitude": 30.098, "longitude": 31.303}, {"name": "Hadayek El-Zaitoun", "latitude": 30.105, "longitude": 31.310}, - {"name": "Helmeyet El-Zaitoun", "latitude": 30.115, "longitude": 31.314}, + { + "name": "Helmeyet El-Zaitoun", + "latitude": 30.115, + "longitude": 31.314, + }, {"name": "El-Matareyya", "latitude": 30.121, "longitude": 31.315}, {"name": "Ain Shams", "latitude": 30.131, "longitude": 31.318}, {"name": "Ezbet El-Nakhl", "latitude": 30.139, "longitude": 31.324}, @@ -54,10 +59,18 @@ class Command(BaseCommand): "stations": [ {"name": "El-Mounib", "latitude": 29.98139, "longitude": 31.21194}, {"name": "Sakiat Mekky", "latitude": 29.99556, "longitude": 31.20861}, - {"name": "Omm El-Masryeen", "latitude": 30.00528, "longitude": 31.20806}, + { + "name": "Omm El-Masryeen", + "latitude": 30.00528, + "longitude": 31.20806, + }, {"name": "El Giza", "latitude": 30.01056, "longitude": 31.20722}, {"name": "Faisal", "latitude": 30.01722, "longitude": 31.20389}, - {"name": "Cairo University", "latitude": 30.02611, "longitude": 31.20111}, + { + "name": "Cairo University", + "latitude": 30.02611, + "longitude": 31.20111, + }, {"name": "El Bohoth", "latitude": 30.03583, "longitude": 31.20028}, {"name": "Dokki", "latitude": 30.03833, "longitude": 31.21194}, {"name": "Opera", "latitude": 30.04222, "longitude": 31.22528}, @@ -70,8 +83,16 @@ class Command(BaseCommand): {"name": "St. Teresa", "latitude": 30.08833, "longitude": 31.24556}, {"name": "Khalafawy", "latitude": 30.09806, "longitude": 31.24528}, {"name": "Mezallat", "latitude": 30.10500, "longitude": 31.24667}, - {"name": "Kolleyyet El-Zeraa", "latitude": 30.11389, "longitude": 31.24861}, - {"name": "Shubra El-Kheima", "latitude": 30.12250, "longitude": 31.24472}, + { + "name": "Kolleyyet El-Zeraa", + "latitude": 30.11389, + "longitude": 31.24861, + }, + { + "name": "Shubra El-Kheima", + "latitude": 30.12250, + "longitude": 31.24472, + }, ], }, { @@ -79,16 +100,28 @@ class Command(BaseCommand): "stations": [ {"name": "Adly Mansour", "latitude": 30.14694, "longitude": 31.42139}, {"name": "El Haykestep", "latitude": 30.14389, "longitude": 31.40472}, - {"name": "Omar Ibn El-Khattab", "latitude": 30.14056, "longitude": 31.39417}, + { + "name": "Omar Ibn El-Khattab", + "latitude": 30.14056, + "longitude": 31.39417, + }, {"name": "Qobaa", "latitude": 30.13472, "longitude": 31.38389}, {"name": "Hesham Barakat", "latitude": 30.13111, "longitude": 31.37389}, {"name": "El-Nozha", "latitude": 30.12833, "longitude": 31.36000}, {"name": "Nadi El-Shams", "latitude": 30.12222, "longitude": 31.34389}, {"name": "Alf Maskan", "latitude": 30.11806, "longitude": 31.33972}, - {"name": "Heliopolis Square", "latitude": 30.10806, "longitude": 31.33722}, + { + "name": "Heliopolis Square", + "latitude": 30.10806, + "longitude": 31.33722, + }, {"name": "Haroun", "latitude": 30.10000, "longitude": 31.33278}, {"name": "Al-Ahram", "latitude": 30.09139, "longitude": 31.32639}, - {"name": "Koleyet El-Banat", "latitude": 30.08361, "longitude": 31.32889}, + { + "name": "Koleyet El-Banat", + "latitude": 30.08361, + "longitude": 31.32889, + }, {"name": "Stadium", "latitude": 30.07306, "longitude": 31.31750}, {"name": "Fair Zone", "latitude": 30.07333, "longitude": 31.30111}, {"name": "Abbassia", "latitude": 30.06972, "longitude": 31.28083}, @@ -103,14 +136,34 @@ class Command(BaseCommand): {"name": "Sudan Street", "latitude": 30.06972, "longitude": 31.20472}, {"name": "Imbaba", "latitude": 30.07583, "longitude": 31.20750}, {"name": "El-Bohy", "latitude": 30.08222, "longitude": 31.21056}, - {"name": "Al-Qawmeya Al-Arabiya", "latitude": 30.09333, "longitude": 31.20889}, + { + "name": "Al-Qawmeya Al-Arabiya", + "latitude": 30.09333, + "longitude": 31.20889, + }, {"name": "Ring Road", "latitude": 30.09639, "longitude": 31.19972}, - {"name": "Rod al-Farag Axis", "latitude": 30.10194, "longitude": 31.18417}, + { + "name": "Rod al-Farag Axis", + "latitude": 30.10194, + "longitude": 31.18417, + }, {"name": "El-Tawfikeya", "latitude": 30.06528, "longitude": 31.20250}, {"name": "Wadi El-Nil", "latitude": 30.05833, "longitude": 31.20000}, - {"name": "Gamaat El Dowal Al-Arabiya", "latitude": 30.05083, "longitude": 31.19972}, - {"name": "Bulaq El-Dakroor", "latitude": 30.03611, "longitude": 31.19639}, - {"name": "Cairo University", "latitude": 30.02611, "longitude": 31.20111}, + { + "name": "Gamaat El Dowal Al-Arabiya", + "latitude": 30.05083, + "longitude": 31.19972, + }, + { + "name": "Bulaq El-Dakroor", + "latitude": 30.03611, + "longitude": 31.19639, + }, + { + "name": "Cairo University", + "latitude": 30.02611, + "longitude": 31.20111, + }, ], }, ] @@ -130,7 +183,9 @@ def handle(self, *args, **kwargs): self.populate_lines_and_stations() self.populate_connecting_stations() - self.stdout.write(self.style.SUCCESS("Metro data population completed successfully.")) + self.stdout.write( + self.style.SUCCESS("Metro data population completed successfully.") + ) def clear_existing_data(self): """Clear existing LineStation and ConnectingStation data.""" @@ -177,10 +232,15 @@ def populate_connecting_stations(self): lines = Line.objects.filter(name__in=conn_station["lines"]) for line in lines: # Check if the ConnectingStation already exists - if not ConnectingStation.objects.filter(station=station, lines=line).exists(): - connecting_station, created = ConnectingStation.objects.get_or_create( - station=station - ) + if not ConnectingStation.objects.filter( + station=station, lines=line + ).exists(): + ( + connecting_station, + created, + ) = ConnectingStation.objects.get_or_create(station=station) connecting_station.lines.add(line) action = "Created" if created else "Already exists" - self.stdout.write(f"{action} ConnectingStation: {station.name} - {line.name}") \ No newline at end of file + self.stdout.write( + f"{action} ConnectingStation: {station.name} - {line.name}" + ) diff --git a/apps/stations/migrations/0001_initial.py b/apps/stations/migrations/0001_initial.py index 1702baf..31ae089 100644 --- a/apps/stations/migrations/0001_initial.py +++ b/apps/stations/migrations/0001_initial.py @@ -5,49 +5,97 @@ class Migration(migrations.Migration): - initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='Line', + name="Line", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, unique=True)), - ('color_code', models.CharField(blank=True, help_text='Format: #RRGGBB', max_length=10, null=True)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, unique=True)), + ( + "color_code", + models.CharField( + blank=True, + help_text="Format: #RRGGBB", + max_length=10, + null=True, + ), + ), ], ), migrations.CreateModel( - name='LineStation', + name="LineStation", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('order', models.PositiveIntegerField()), - ('line', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='line_stations', to='stations.line')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("order", models.PositiveIntegerField()), + ( + "line", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="line_stations", + to="stations.line", + ), + ), ], options={ - 'ordering': ['order'], + "ordering": ["order"], }, ), migrations.CreateModel( - name='Station', + name="Station", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, unique=True)), - ('latitude', models.FloatField(blank=True, null=True)), - ('longitude', models.FloatField(blank=True, null=True)), - ('lines', models.ManyToManyField(related_name='stations', through='stations.LineStation', to='stations.line')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, unique=True)), + ("latitude", models.FloatField(blank=True, null=True)), + ("longitude", models.FloatField(blank=True, null=True)), + ( + "lines", + models.ManyToManyField( + related_name="stations", + through="stations.LineStation", + to="stations.line", + ), + ), ], ), migrations.AddField( - model_name='linestation', - name='station', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='station_lines', to='stations.station'), + model_name="linestation", + name="station", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="station_lines", + to="stations.station", + ), ), migrations.AlterUniqueTogether( - name='linestation', - unique_together={('line', 'station')}, + name="linestation", + unique_together={("line", "station")}, ), ] diff --git a/apps/stations/migrations/0002_connectingstation.py b/apps/stations/migrations/0002_connectingstation.py index bd7fc55..9a5bd34 100644 --- a/apps/stations/migrations/0002_connectingstation.py +++ b/apps/stations/migrations/0002_connectingstation.py @@ -5,18 +5,36 @@ class Migration(migrations.Migration): - dependencies = [ - ('stations', '0001_initial'), + ("stations", "0001_initial"), ] operations = [ migrations.CreateModel( - name='ConnectingStation', + name="ConnectingStation", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('lines', models.ManyToManyField(related_name='connecting_stations', to='stations.line')), - ('station', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='stations.station')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "lines", + models.ManyToManyField( + related_name="connecting_stations", to="stations.line" + ), + ), + ( + "station", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="stations.station", + ), + ), ], ), ] diff --git a/apps/stations/models.py b/apps/stations/models.py index 4d2f5cb..2674347 100644 --- a/apps/stations/models.py +++ b/apps/stations/models.py @@ -1,11 +1,13 @@ # apps/stations/models.py from django.db import models -from geopy.distance import geodesic # type: ignore +from geopy.distance import geodesic # type: ignore + # Create your models here. class Line(models.Model): """Represents a metro line with stations connected in order.""" + name = models.CharField(max_length=255, unique=True, null=False) # Line name color_code = models.CharField( max_length=10, null=True, blank=True, help_text="Format: #RRGGBB" @@ -17,7 +19,7 @@ def __str__(self): def total_stations(self): """Returns the total number of stations on this line.""" return self.line_stations.count() - + def ordered_stations(self): """Returns all stations in order.""" return self.line_stations.order_by("order") @@ -28,24 +30,23 @@ class Station(models.Model): latitude = models.FloatField(null=True, blank=True) # GPS latitude longitude = models.FloatField(null=True, blank=True) # GPS longitude lines = models.ManyToManyField(Line, through="LineStation", related_name="stations") - + # Optimized method to get stations by line or name @staticmethod def get_stations_by_query(query): return Station.objects.filter( - models.Q(name__icontains=query) | - models.Q(lines__name__icontains=query) + models.Q(name__icontains=query) | models.Q(lines__name__icontains=query) ).distinct() def __str__(self): return self.name - + def connected_lines(self): """Returns all lines the station is connected to.""" return self.lines.all() - + def is_interchange(self): - """ Checks if the station connects to more than one line. """ + """Checks if the station connects to more than one line.""" return self.lines.count() > 1 def get_station_order(self, line): @@ -55,9 +56,9 @@ def get_station_order(self, line): """ line_station = self.station_lines.filter(line=line).first() return line_station.order if line_station else None - + def distance_to(self, other_station): - """ Calculate distance (in meters) between two stations using lat-long.""" + """Calculate distance (in meters) between two stations using lat-long.""" start = (self.latitude, self.longitude) end = (other_station.latitude, other_station.longitude) return geodesic(start, end).meters @@ -71,26 +72,39 @@ def get_distance(self, user_latitude, user_longitude): def get_next_station(self, line): """Returns the next station on the same line, based on the order field.""" current_order = self.get_station_order(line) - return Station.objects.filter( - station_lines__line=line, - station_lines__order__gt=current_order, - ).order_by("station_lines__order").first() + return ( + Station.objects.filter( + station_lines__line=line, + station_lines__order__gt=current_order, + ) + .order_by("station_lines__order") + .first() + ) def get_previous_station(self, line): """Returns the previous station on the same line, based on the order field.""" current_order = self.get_station_order(line) - return Station.objects.filter( - station_lines__line=line, - station_lines__order__lt=current_order, - ).order_by("-station_lines__order").first() - - + return ( + Station.objects.filter( + station_lines__line=line, + station_lines__order__lt=current_order, + ) + .order_by("-station_lines__order") + .first() + ) + + class LineStation(models.Model): """ Through model to handle Line-Station relationship with additional metadata. """ - line = models.ForeignKey(Line, on_delete=models.CASCADE, related_name="line_stations") - station = models.ForeignKey(Station, on_delete=models.CASCADE, related_name="station_lines") + + line = models.ForeignKey( + Line, on_delete=models.CASCADE, related_name="line_stations" + ) + station = models.ForeignKey( + Station, on_delete=models.CASCADE, related_name="station_lines" + ) order = models.PositiveIntegerField() # Order of station on the line class Meta: @@ -105,8 +119,9 @@ class ConnectingStation(models.Model): """ Model to represent stations that connect multiple lines. """ + station = models.ForeignKey(Station, on_delete=models.CASCADE) lines = models.ManyToManyField(Line, related_name="connecting_stations") def __str__(self): - return f"Connecting Station: {self.station.name} between lines {', '.join([line.name for line in self.lines.all()])}" \ No newline at end of file + return f"Connecting Station: {self.station.name} between lines {', '.join([line.name for line in self.lines.all()])}" diff --git a/apps/stations/pagination.py b/apps/stations/pagination.py index c87066e..09bf5a6 100644 --- a/apps/stations/pagination.py +++ b/apps/stations/pagination.py @@ -6,16 +6,18 @@ class StandardResultsSetPagination(PageNumberPagination): page_size = 10 - page_size_query_param = 'page_size' + page_size_query_param = "page_size" max_page_size = 100 def get_paginated_response(self, data): try: - return Response({ - 'count': self.page.paginator.count, - 'total_pages': self.page.paginator.num_pages, - 'current_page': self.page.number, - 'results': data - }) + return Response( + { + "count": self.page.paginator.count, + "total_pages": self.page.paginator.num_pages, + "current_page": self.page.number, + "results": data, + } + ) except Exception as e: return Response({"error": str(e)}, status=500) diff --git a/apps/stations/serializers.py b/apps/stations/serializers.py index a85b55a..36a1056 100644 --- a/apps/stations/serializers.py +++ b/apps/stations/serializers.py @@ -1,14 +1,16 @@ from rest_framework import serializers from .models import Station, Line + class LineSerializer(serializers.ModelSerializer): class Meta: model = Line - fields = ['id', 'name'] + fields = ["id", "name"] + class StationSerializer(serializers.ModelSerializer): lines = LineSerializer(many=True) # Serialize associated lines class Meta: model = Station - fields = ['id', 'name', 'lines'] + fields = ["id", "name", "lines"] diff --git a/apps/stations/services/__init__.py b/apps/stations/services/__init__.py index b1eb959..0145fca 100644 --- a/apps/stations/services/__init__.py +++ b/apps/stations/services/__init__.py @@ -1 +1 @@ -# This file marks the directory as a Python package. \ No newline at end of file +# This file marks the directory as a Python package. diff --git a/apps/stations/services/route_service.py b/apps/stations/services/route_service.py index 0c755ab..cab3333 100644 --- a/apps/stations/services/route_service.py +++ b/apps/stations/services/route_service.py @@ -3,6 +3,7 @@ from collections import defaultdict import heapq + class RouteService: def __init__(self, stations): self.stations = stations @@ -22,7 +23,7 @@ def find_best_route(self, start_station, end_station): if (start_station, end_station) in self.cached_routes: return self.cached_routes[(start_station, end_station)] - distances = {station: float('inf') for station in self.graph} + distances = {station: float("inf") for station in self.graph} previous = {station: None for station in self.graph} distances[start_station] = 0 priority_queue = [(0, start_station)] diff --git a/apps/stations/services/ticket_service.py b/apps/stations/services/ticket_service.py index 7f58797..843027d 100644 --- a/apps/stations/services/ticket_service.py +++ b/apps/stations/services/ticket_service.py @@ -1,5 +1,6 @@ # apps/stations/services/ticket_service.py + def calculate_ticket_price(start_station, end_station): """ Calculate the ticket price based on the number of stations between start and end. @@ -20,6 +21,7 @@ def calculate_ticket_price(start_station, end_station): # Return a structured error response for better debugging return {"error": f"Failed to calculate ticket price: {str(e)}"} + def calculate_total_stations(start_station, end_station): """ Calculate the total number of stations between start and end stations. @@ -41,6 +43,7 @@ def calculate_total_stations(start_station, end_station): # Case 2: Multi-line (transfer required) return calculate_transfer_cost(start_station, end_station) + def calculate_transfer_cost(start_station, end_station): """ Calculate the total stations for routes requiring a transfer. @@ -57,6 +60,7 @@ def calculate_transfer_cost(start_station, end_station): # Add logic for actual interchange station determination if required return abs(end_order - start_order) + 1 + def calculate_price(station_count): """ Determine ticket price based on the number of stations. diff --git a/apps/stations/tests.py b/apps/stations/tests.py index 7ce503c..a79ca8b 100644 --- a/apps/stations/tests.py +++ b/apps/stations/tests.py @@ -1,3 +1,3 @@ -from django.test import TestCase +# from django.test import TestCase # Create your tests here. diff --git a/apps/stations/urls.py b/apps/stations/urls.py index 3a3c4ab..4c59eb6 100644 --- a/apps/stations/urls.py +++ b/apps/stations/urls.py @@ -4,7 +4,11 @@ from .views import TripDetailsView, NearestStationView, StationListView urlpatterns = [ - path('stations-list/', StationListView.as_view(), name='stations-list'), - path("trip-details///", TripDetailsView.as_view(), name="trip-details"), - path('nearest-station/', NearestStationView.as_view(), name='nearest-station'), + path("stations-list/", StationListView.as_view(), name="stations-list"), + path( + "trip-details///", + TripDetailsView.as_view(), + name="trip-details", + ), + path("nearest-station/", NearestStationView.as_view(), name="nearest-station"), ] diff --git a/apps/stations/utils/location_helpers.py b/apps/stations/utils/location_helpers.py index 30925fd..5a8462f 100644 --- a/apps/stations/utils/location_helpers.py +++ b/apps/stations/utils/location_helpers.py @@ -1,6 +1,6 @@ # apps/stations/utils/location_helpers.py -from geopy.distance import geodesic # type: ignore +from geopy.distance import geodesic # type: ignore from apps.stations.models import Station @@ -13,7 +13,11 @@ def find_nearest_station(latitude, longitude): nearest_station = min( stations, - key=lambda station: geodesic(user_location, (station.latitude, station.longitude)).meters, + key=lambda station: geodesic( + user_location, (station.latitude, station.longitude) + ).meters, ) - distance = geodesic(user_location, (nearest_station.latitude, nearest_station.longitude)).meters + distance = geodesic( + user_location, (nearest_station.latitude, nearest_station.longitude) + ).meters return nearest_station, distance diff --git a/apps/stations/views.py b/apps/stations/views.py index 1f3f340..96dee73 100644 --- a/apps/stations/views.py +++ b/apps/stations/views.py @@ -1,47 +1,58 @@ # apps/stations/views.py -from rest_framework import generics, status # Import generics for ListAPIView -from rest_framework.permissions import AllowAny # Import AllowAny for public access -from rest_framework.views import APIView # Import APIView for creating API views -from rest_framework.response import Response # Import Response for sending JSON responses +from rest_framework import generics, status # Import generics for ListAPIView +from rest_framework.permissions import AllowAny # Import AllowAny for public access +from rest_framework.views import APIView # Import APIView for creating API views +from rest_framework.response import ( + Response, +) # Import Response for sending JSON responses from django.db.models import Q # Import Q for complex queries -from apps.stations.models import Station # Import the Station model +from apps.stations.models import Station # Import the Station model from .serializers import StationSerializer # Import the StationSerializer -from .pagination import StandardResultsSetPagination # Import the pagination class -from apps.stations.services.ticket_service import calculate_ticket_price # Import the ticket price calculation service -from apps.stations.utils.location_helpers import find_nearest_station # Import the find_nearest_station function -from geopy.distance import geodesic # type: ignore +from .pagination import StandardResultsSetPagination # Import the pagination class +from apps.stations.services.ticket_service import ( + calculate_ticket_price, +) # Import the ticket price calculation service +from apps.stations.utils.location_helpers import ( + find_nearest_station, +) # Import the find_nearest_station function + # Create your views here. class StationListView(generics.ListAPIView): - queryset = Station.objects.all() # Get all stations - serializer_class = StationSerializer # Use the StationSerializer + queryset = Station.objects.all() # Get all stations + serializer_class = StationSerializer # Use the StationSerializer pagination_class = StandardResultsSetPagination # Apply pagination - permission_classes = [AllowAny] # Allow access + permission_classes = [AllowAny] # Allow access def get_queryset(self): """ Retrieves a paginated list of stations, with optional search filtering (name or line name). """ queryset = Station.objects.all() - search_term = self.request.query_params.get('search', None) - + search_term = self.request.query_params.get("search", None) + if search_term: - queryset = queryset.filter( # Filter stations by name or line name - Q(name__icontains=search_term) | # Case-insensitive search by station name - Q(lines__name__icontains=search_term) # Case-insensitive search by line name - ).distinct() # Remove duplicates - - queryset = queryset.prefetch_related('lines') # Optimize querying of related lines - return queryset # Return the filtered queryset + queryset = queryset.filter( # Filter stations by name or line name + Q(name__icontains=search_term) + | Q( # Case-insensitive search by station name + lines__name__icontains=search_term + ) # Case-insensitive search by line name + ).distinct() # Remove duplicates + + queryset = queryset.prefetch_related( + "lines" + ) # Optimize querying of related lines + return queryset # Return the filtered queryset class TripDetailsView(APIView): """ Provides trip details between two stations, including ticket price, travel time, and distance. """ + permission_classes = [AllowAny] # Public access - + def get(self, request, start_station_id, end_station_id): try: # Get stations @@ -50,11 +61,16 @@ def get(self, request, start_station_id, end_station_id): # Calculate ticket price using the service ticket_price = calculate_ticket_price(start_station, end_station) - + # Number of stations between start and end - num_stations = abs(start_station.get_station_order(start_station.lines.first()) - - end_station.get_station_order(end_station.lines.first())) + 1 - + num_stations = ( + abs( + start_station.get_station_order(start_station.lines.first()) + - end_station.get_station_order(end_station.lines.first()) + ) + + 1 + ) + # Estimated travel time (in minutes) travel_time = num_stations * 2 # 2 minutes per station (can be customized) @@ -64,43 +80,57 @@ def get(self, request, start_station_id, end_station_id): "end_station": end_station.name, "ticket_price": ticket_price, "number_of_stations": num_stations, - "estimated_travel_time": travel_time + "estimated_travel_time": travel_time, } return Response(data) - + except Exception as e: return Response({"error": str(e)}, status=400) - + class NearestStationView(APIView): """ Finds the nearest station to the user's location. """ + permission_classes = [AllowAny] # Public access - + def get(self, request): try: latitude = request.query_params.get("latitude") longitude = request.query_params.get("longitude") if not latitude or not longitude: - return Response({"error": "Latitude and Longitude are required."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Latitude and Longitude are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) latitude, longitude = float(latitude), float(longitude) nearest_station, distance = find_nearest_station(latitude, longitude) if nearest_station is None: - return Response({"error": "No stations available."}, status=status.HTTP_404_NOT_FOUND) - - return Response({ - "nearest_station": nearest_station.name, - "distance": round(distance, 2), - }) + return Response( + {"error": "No stations available."}, + status=status.HTTP_404_NOT_FOUND, + ) + + return Response( + { + "nearest_station": nearest_station.name, + "distance": round(distance, 2), + } + ) except ValueError: - return Response({"error": "Invalid Latitude or Longitude."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Invalid Latitude or Longitude."}, + status=status.HTTP_400_BAD_REQUEST, + ) except Exception as e: - return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - + return Response( + {"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + def post(self, request): """ Handle POST request for nearest station based on latitude and longitude. @@ -111,19 +141,32 @@ def post(self, request): longitude = request.data.get("longitude") if not latitude or not longitude: - return Response({"error": "Latitude and Longitude are required."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Latitude and Longitude are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) latitude, longitude = float(latitude), float(longitude) nearest_station, distance = find_nearest_station(latitude, longitude) if nearest_station is None: - return Response({"error": "No stations available."}, status=status.HTTP_404_NOT_FOUND) - - return Response({ - "nearest_station": nearest_station.name, - "distance": round(distance, 2), - }) + return Response( + {"error": "No stations available."}, + status=status.HTTP_404_NOT_FOUND, + ) + + return Response( + { + "nearest_station": nearest_station.name, + "distance": round(distance, 2), + } + ) except ValueError: - return Response({"error": "Invalid Latitude or Longitude."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Invalid Latitude or Longitude."}, + status=status.HTTP_400_BAD_REQUEST, + ) except Exception as e: - return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) \ No newline at end of file + return Response( + {"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) diff --git a/apps/users/admin.py b/apps/users/admin.py index 765e3ba..8fcacf5 100644 --- a/apps/users/admin.py +++ b/apps/users/admin.py @@ -1,15 +1,24 @@ from django.contrib import admin from .models import User + @admin.register(User) class UserAdmin(admin.ModelAdmin): - list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'is_active') - search_fields = ('username', 'email', 'first_name', 'last_name') - ordering = ('username',) + list_display = ( + "username", + "email", + "first_name", + "last_name", + "is_staff", + "is_active", + ) + search_fields = ("username", "email", "first_name", "last_name") + ordering = ("username",) def has_delete_permission(self, request, obj=None): return False # Disable user deletion + admin.site.site_header = "Egypt Metro" admin.site.site_title = "Egypt Metro Admin Portal" -admin.site.index_title = "Welcome to Egypt Metro Admin Portal" \ No newline at end of file +admin.site.index_title = "Welcome to Egypt Metro Admin Portal" diff --git a/apps/users/apps.py b/apps/users/apps.py index 2bb189c..37ba421 100644 --- a/apps/users/apps.py +++ b/apps/users/apps.py @@ -2,5 +2,5 @@ class UsersConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.users' + default_auto_field = "django.db.models.BigAutoField" + name = "apps.users" diff --git a/apps/users/migrations/0001_initial.py b/apps/users/migrations/0001_initial.py index df73511..4c638b9 100644 --- a/apps/users/migrations/0001_initial.py +++ b/apps/users/migrations/0001_initial.py @@ -7,45 +7,133 @@ class Migration(migrations.Migration): - initial = True dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), + ("auth", "0012_alter_user_first_name_max_length"), ] operations = [ migrations.CreateModel( - name='User', + name="User", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('email', models.EmailField(max_length=254, unique=True)), - ('national_id', models.CharField(max_length=20, unique=True)), - ('phone_number', models.CharField(blank=True, max_length=15, null=True)), - ('subscription_type', models.BooleanField(default=False)), - ('payment_method', models.CharField(blank=True, max_length=50, null=True)), - ('balance', models.IntegerField(default=0)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ("email", models.EmailField(max_length=254, unique=True)), + ("national_id", models.CharField(max_length=20, unique=True)), + ( + "phone_number", + models.CharField(blank=True, max_length=15, null=True), + ), + ("subscription_type", models.BooleanField(default=False)), + ( + "payment_method", + models.CharField(blank=True, max_length=50, null=True), + ), + ("balance", models.IntegerField(default=0)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), ], options={ - 'verbose_name': 'User', - 'verbose_name_plural': 'Users', - 'db_table': 'users', + "verbose_name": "User", + "verbose_name_plural": "Users", + "db_table": "users", }, managers=[ - ('objects', django.contrib.auth.models.UserManager()), + ("objects", django.contrib.auth.models.UserManager()), ], ), ] diff --git a/apps/users/migrations/0002_alter_user_balance_alter_user_national_id_and_more.py b/apps/users/migrations/0002_alter_user_balance_alter_user_national_id_and_more.py index da1b772..686baa1 100644 --- a/apps/users/migrations/0002_alter_user_balance_alter_user_national_id_and_more.py +++ b/apps/users/migrations/0002_alter_user_balance_alter_user_national_id_and_more.py @@ -5,25 +5,42 @@ class Migration(migrations.Migration): - dependencies = [ - ('users', '0001_initial'), + ("users", "0001_initial"), ] operations = [ migrations.AlterField( - model_name='user', - name='balance', + model_name="user", + name="balance", field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10), ), migrations.AlterField( - model_name='user', - name='national_id', - field=models.CharField(max_length=14, unique=True, validators=[django.core.validators.RegexValidator('^\\d{14}$', 'National ID must be exactly 14 digits.')]), + model_name="user", + name="national_id", + field=models.CharField( + max_length=14, + unique=True, + validators=[ + django.core.validators.RegexValidator( + "^\\d{14}$", "National ID must be exactly 14 digits." + ) + ], + ), ), migrations.AlterField( - model_name='user', - name='phone_number', - field=models.CharField(blank=True, max_length=15, null=True, validators=[django.core.validators.RegexValidator('^(0|20|\\+20)\\d{9,10}$', "Phone number must start with '0', '20', or '+20', followed by 9-10 digits.")]), + model_name="user", + name="phone_number", + field=models.CharField( + blank=True, + max_length=15, + null=True, + validators=[ + django.core.validators.RegexValidator( + "^(0|20|\\+20)\\d{9,10}$", + "Phone number must start with '0', '20', or '+20', followed by 9-10 digits.", + ) + ], + ), ), ] diff --git a/apps/users/migrations/0003_alter_user_username.py b/apps/users/migrations/0003_alter_user_username.py index 282ea7a..cfb9bc9 100644 --- a/apps/users/migrations/0003_alter_user_username.py +++ b/apps/users/migrations/0003_alter_user_username.py @@ -4,15 +4,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('users', '0002_alter_user_balance_alter_user_national_id_and_more'), + ("users", "0002_alter_user_balance_alter_user_national_id_and_more"), ] operations = [ migrations.AlterField( - model_name='user', - name='username', + model_name="user", + name="username", field=models.CharField(max_length=150, unique=True), ), ] diff --git a/apps/users/migrations/0004_alter_user_phone_number.py b/apps/users/migrations/0004_alter_user_phone_number.py index f90cc44..7f3b187 100644 --- a/apps/users/migrations/0004_alter_user_phone_number.py +++ b/apps/users/migrations/0004_alter_user_phone_number.py @@ -5,15 +5,24 @@ class Migration(migrations.Migration): - dependencies = [ - ('users', '0003_alter_user_username'), + ("users", "0003_alter_user_username"), ] operations = [ migrations.AlterField( - model_name='user', - name='phone_number', - field=models.CharField(blank=True, max_length=11, null=True, validators=[django.core.validators.RegexValidator('^01\\d{9}$', "Phone number must start with '01', followed by exactly 9 digits.")]), + model_name="user", + name="phone_number", + field=models.CharField( + blank=True, + max_length=11, + null=True, + validators=[ + django.core.validators.RegexValidator( + "^01\\d{9}$", + "Phone number must start with '01', followed by exactly 9 digits.", + ) + ], + ), ), ] diff --git a/apps/users/migrations/0005_alter_user_phone_number.py b/apps/users/migrations/0005_alter_user_phone_number.py index 4fd12c4..65ecb8a 100644 --- a/apps/users/migrations/0005_alter_user_phone_number.py +++ b/apps/users/migrations/0005_alter_user_phone_number.py @@ -5,15 +5,24 @@ class Migration(migrations.Migration): - dependencies = [ - ('users', '0004_alter_user_phone_number'), + ("users", "0004_alter_user_phone_number"), ] operations = [ migrations.AlterField( - model_name='user', - name='phone_number', - field=models.CharField(blank=True, max_length=11, null=True, validators=[django.core.validators.RegexValidator('^01\\d{9}$', "Phone number must start with '01' followed by 9 digits.")]), + model_name="user", + name="phone_number", + field=models.CharField( + blank=True, + max_length=11, + null=True, + validators=[ + django.core.validators.RegexValidator( + "^01\\d{9}$", + "Phone number must start with '01' followed by 9 digits.", + ) + ], + ), ), ] diff --git a/apps/users/models.py b/apps/users/models.py index 0d29930..7dbc7e1 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -2,23 +2,24 @@ from django.contrib.auth.models import AbstractUser from django.core.validators import RegexValidator + # Create your models here. class User(AbstractUser): username = models.CharField(max_length=150, unique=True, null=False, blank=False) email = models.EmailField(unique=True, null=False, blank=False) national_id = models.CharField( - max_length=14, - unique=True, + max_length=14, + unique=True, validators=[ - RegexValidator(r'^\d{14}$', "National ID must be exactly 14 digits.") + RegexValidator(r"^\d{14}$", "National ID must be exactly 14 digits.") ], ) phone_number = models.CharField( max_length=11, # Ensures max length of 11 validators=[ RegexValidator( - r'^01\d{9}$', # Starts with '01' and followed by exactly 9 digits - "Phone number must start with '01' followed by 9 digits." + r"^01\d{9}$", # Starts with '01' and followed by exactly 9 digits + "Phone number must start with '01' followed by 9 digits.", ) ], blank=True, @@ -29,14 +30,17 @@ class User(AbstractUser): balance = models.DecimalField(max_digits=10, decimal_places=2, default=0.00) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - - + # Optionally add meta information for better management class Meta: - db_table = 'users' # Ensure it matches the SQL table name - verbose_name = db_table[:-1].capitalize() # Generate singular name ('users' -> 'User') - verbose_name_plural = db_table.capitalize() # Capitalize the table name ('users' -> 'Users') - + db_table = "users" # Ensure it matches the SQL table name + verbose_name = db_table[ + :-1 + ].capitalize() # Generate singular name ('users' -> 'User') + verbose_name_plural = ( + db_table.capitalize() + ) # Capitalize the table name ('users' -> 'Users') + # Define __str__ for better object representation def __str__(self): - return f"{self.first_name} {self.last_name} ({self.email})" \ No newline at end of file + return f"{self.first_name} {self.last_name} ({self.email})" diff --git a/apps/users/serializers.py b/apps/users/serializers.py index d27905e..5a77576 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from .models import User + # 1. Registration Serializer class RegisterSerializer(serializers.ModelSerializer): password = serializers.CharField(write_only=True) @@ -8,31 +9,44 @@ class RegisterSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ['email', 'username', 'password', 'confirm_password', 'national_id', 'phone_number'] + fields = [ + "email", + "username", + "password", + "confirm_password", + "national_id", + "phone_number", + ] def validate(self, data): # Ensure passwords match - if data['password'] != data['confirm_password']: + if data["password"] != data["confirm_password"]: raise serializers.ValidationError({"password": "Passwords do not match."}) # Best Practice: Check that email is unique as well - if User.objects.filter(email=data['email']).exists(): - raise serializers.ValidationError({"email": "This email is already registered."}) + if User.objects.filter(email=data["email"]).exists(): + raise serializers.ValidationError( + {"email": "This email is already registered."} + ) # Ensure username is unique - if User.objects.filter(username=data['username']).exists(): - raise serializers.ValidationError({"username": "This username is already taken."}) + if User.objects.filter(username=data["username"]).exists(): + raise serializers.ValidationError( + {"username": "This username is already taken."} + ) # Best Practice: Validate phone number format (example: phone number should be 10 digits) - if data.get('phone_number') and len(data['phone_number']) != 11: - raise serializers.ValidationError({"phone_number": "Phone number must be 11 digits."}) + if data.get("phone_number") and len(data["phone_number"]) != 11: + raise serializers.ValidationError( + {"phone_number": "Phone number must be 11 digits."} + ) return data def create(self, validated_data): - validated_data.pop('confirm_password', None) + validated_data.pop("confirm_password", None) return User.objects.create_user( - username=validated_data['username'], - email=validated_data['email'], - password=validated_data['password'], - national_id=validated_data['national_id'], - phone_number=validated_data.get('phone_number', None) + username=validated_data["username"], + email=validated_data["email"], + password=validated_data["password"], + national_id=validated_data["national_id"], + phone_number=validated_data.get("phone_number", None), ) @@ -43,21 +57,34 @@ class LoginSerializer(serializers.Serializer): def validate(self, data): # Check if the user exists with the provided username - if not User.objects.filter(username=data['username']).exists(): - raise serializers.ValidationError({"username": "User with this username does not exist."}) + if not User.objects.filter(username=data["username"]).exists(): + raise serializers.ValidationError( + {"username": "User with this username does not exist."} + ) return data + # 3. User Serializer class UserSerializer(serializers.ModelSerializer): class Meta: model = User fields = [ - 'id', 'email', 'first_name', 'last_name', - 'phone_number', 'national_id', 'subscription_type', - 'payment_method', 'balance', 'created_at', 'updated_at' + "id", + "email", + "first_name", + "last_name", + "phone_number", + "national_id", + "subscription_type", + "payment_method", + "balance", + "created_at", + "updated_at", ] # Ensure sensitive fields (like password) are not exposed - read_only_fields = ['password'] # Password should not be returned in the response + read_only_fields = [ + "password" + ] # Password should not be returned in the response # 4. Update User Serializer @@ -65,13 +92,16 @@ class UpdateUserSerializer(serializers.ModelSerializer): class Meta: model = User fields = [ - 'first_name', 'last_name', 'phone_number', - 'subscription_type', 'payment_method' + "first_name", + "last_name", + "phone_number", + "subscription_type", + "payment_method", ] - read_only_fields = ['balance'] # Prevent direct updates to the balance field + read_only_fields = ["balance"] # Prevent direct updates to the balance field # Add validation for certain fields (optional) def validate_phone_number(self, value): if len(value) != 11: # Example: Check if phone number is valid raise serializers.ValidationError("Phone number must be 11 digits.") - return value \ No newline at end of file + return value diff --git a/apps/users/tests.py b/apps/users/tests.py index 7ce503c..a79ca8b 100644 --- a/apps/users/tests.py +++ b/apps/users/tests.py @@ -1,3 +1,3 @@ -from django.test import TestCase +# from django.test import TestCase # Create your tests here. diff --git a/apps/users/urls.py b/apps/users/urls.py index 4c51a82..22b295c 100644 --- a/apps/users/urls.py +++ b/apps/users/urls.py @@ -5,17 +5,13 @@ urlpatterns = [ # User registration endpoint (POST) - path('register/', RegisterView.as_view(), name='register'), - + path("register/", RegisterView.as_view(), name="register"), # User login endpoint (POST), provides access and refresh tokens - path('login/', LoginView.as_view(), name='login'), - + path("login/", LoginView.as_view(), name="login"), # User profile view (GET) for authenticated users - path('profile/', UserProfileView.as_view(), name='user-profile'), - + path("profile/", UserProfileView.as_view(), name="user-profile"), # Update user profile (PATCH) for authenticated users - path('profile/update/', UpdateUserView.as_view(), name='update-profile'), - + path("profile/update/", UpdateUserView.as_view(), name="update-profile"), # Token refresh endpoint (POST), required to get a new access token - path('token/refresh/', TokenRefreshView.as_view(), name='token-refresh'), + path("token/refresh/", TokenRefreshView.as_view(), name="token-refresh"), ] diff --git a/apps/users/views.py b/apps/users/views.py index f735df9..cadc846 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -4,10 +4,13 @@ from rest_framework.views import APIView from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework_simplejwt.tokens import RefreshToken -from rest_framework.throttling import UserRateThrottle from django.contrib.auth import authenticate -from .models import User -from .serializers import RegisterSerializer, LoginSerializer, UserSerializer, UpdateUserSerializer +from .serializers import ( + RegisterSerializer, + LoginSerializer, + UserSerializer, + UpdateUserSerializer, +) logger = logging.getLogger(__name__) @@ -17,6 +20,7 @@ class RegisterView(APIView): """ Handles user registration. Creates a new user and returns JWT tokens. """ + permission_classes = [AllowAny] def post(self, request): @@ -26,11 +30,14 @@ def post(self, request): user = serializer.save() logger.info(f"User registered successfully: {user.email}") refresh = RefreshToken.for_user(user) - return Response({ - 'message': 'User registered successfully', - 'refresh': str(refresh), - 'access': str(refresh.access_token), - }, status=status.HTTP_201_CREATED) + return Response( + { + "message": "User registered successfully", + "refresh": str(refresh), + "access": str(refresh.access_token), + }, + status=status.HTTP_201_CREATED, + ) logger.warning(f"Registration failed: {serializer.errors}") return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -41,6 +48,7 @@ class LoginView(APIView): """ Handles user login. Authenticates credentials and returns JWT tokens. """ + permission_classes = [AllowAny] def post(self, request): @@ -48,29 +56,38 @@ def post(self, request): serializer = LoginSerializer(data=request.data) if serializer.is_valid(): user = authenticate( - username=serializer.validated_data['username'], - password=serializer.validated_data['password'] + username=serializer.validated_data["username"], + password=serializer.validated_data["password"], ) if user: logger.info(f"User logged in successfully: {user.username}") refresh = RefreshToken.for_user(user) - return Response({ - 'message': 'Login successful', - 'refresh': str(refresh), - 'access': str(refresh.access_token), - }, status=status.HTTP_200_OK) - - logger.warning(f"Invalid credentials for username: {request.data.get('username')}") - return Response({"error": "Invalid credentials"}, status=status.HTTP_401_UNAUTHORIZED) + return Response( + { + "message": "Login successful", + "refresh": str(refresh), + "access": str(refresh.access_token), + }, + status=status.HTTP_200_OK, + ) + + logger.warning( + f"Invalid credentials for username: {request.data.get('username')}" + ) + return Response( + {"error": "Invalid credentials"}, status=status.HTTP_401_UNAUTHORIZED + ) logger.error(f"Validation error during login: {serializer.errors}") return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + # 3. User Profile View class UserProfileView(APIView): """ Retrieves the profile of the authenticated user. """ + permission_classes = [IsAuthenticated] def get(self, request): @@ -85,6 +102,7 @@ class UpdateUserView(APIView): """ Updates the profile of the authenticated user. """ + permission_classes = [IsAuthenticated] def patch(self, request): @@ -95,5 +113,7 @@ def patch(self, request): logger.info(f"Profile updated successfully for user: {updated_user.email}") return Response(serializer.data, status=status.HTTP_200_OK) - logger.warning(f"Failed to update profile for user: {user.email} - {serializer.errors}") + logger.warning( + f"Failed to update profile for user: {user.email} - {serializer.errors}" + ) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/egypt_metro/asgi.py b/egypt_metro/asgi.py index 5c11147..5107c66 100644 --- a/egypt_metro/asgi.py +++ b/egypt_metro/asgi.py @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'egypt_metro.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "egypt_metro.settings") application = get_asgi_application() diff --git a/egypt_metro/settings.py b/egypt_metro/settings.py index badae47..c517e4a 100644 --- a/egypt_metro/settings.py +++ b/egypt_metro/settings.py @@ -10,88 +10,98 @@ https://docs.djangoproject.com/en/5.1/ref/settings/ """ -from pathlib import Path -import environ -import os -from dotenv import load_dotenv -from datetime import timedelta +from pathlib import Path # File path helper +import os # Operating system dependent functionality +from dotenv import load_dotenv # Load environment variables from .env file +from datetime import timedelta # Time delta for JWT tokens +from corsheaders.defaults import default_headers # Default headers for CORS -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent # Base directory for the project -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent # Base directory for the project # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.getenv("SECRET_KEY") # Secret key for Django +SECRET_KEY = os.getenv("SECRET_KEY") # Secret key for Django -ALLOWED_HOSTS = [os.getenv("BASE_URL"), "127.0.0.1", "localhost", ".herokuapp.com"] +ALLOWED_HOSTS = [ + os.getenv("BASE_URL"), + "127.0.0.1", + "localhost", + "https://backend-54v5.onrender.com", +] # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', # Admin panel - 'django.contrib.auth', # Authentication framework - 'django.contrib.contenttypes', # Content types framework - 'django.contrib.sessions', # Sessions framework - 'django.contrib.messages', # Messages framework - 'django.contrib.staticfiles', # Static files + "django.contrib.admin", # Admin panel + "django.contrib.auth", # Authentication framework + "django.contrib.contenttypes", # Content types framework + "django.contrib.sessions", # Sessions framework + "django.contrib.messages", # Messages framework + "django.contrib.staticfiles", # Static files # External packages - 'allauth', # Authentication - 'allauth.account', # Account management - 'allauth.socialaccount', # Social authentication - 'allauth.socialaccount.providers.google', # Google OAuth provider - 'rest_framework', # REST framework - 'rest_framework_simplejwt', # JWT authentication - 'corsheaders', # CORS headers - 'debug_toolbar', # Debug toolbar + "allauth", # Authentication + "allauth.account", # Account management + "allauth.socialaccount", # Social authentication + "allauth.socialaccount.providers.google", # Google OAuth provider + "rest_framework", # REST framework + "rest_framework_simplejwt", # JWT authentication + "corsheaders", # CORS headers + "debug_toolbar", # Debug toolbar # Custom apps - 'apps.users.apps.UsersConfig', # Users app - 'apps.stations.apps.StationsConfig', # Stations app + "apps.users.apps.UsersConfig", # Users app + "apps.stations.apps.StationsConfig", # Stations app ] MIDDLEWARE = [ - 'whitenoise.middleware.WhiteNoiseMiddleware', # WhiteNoise middleware - 'django.middleware.security.SecurityMiddleware', # Security middleware - 'django.contrib.sessions.middleware.SessionMiddleware', # Session middleware - 'django.middleware.common.CommonMiddleware', # Common middleware - 'django.middleware.csrf.CsrfViewMiddleware', # CSRF middleware - 'django.contrib.auth.middleware.AuthenticationMiddleware', # Authentication middleware - 'django.contrib.messages.middleware.MessageMiddleware', # Messages middleware - 'django.middleware.clickjacking.XFrameOptionsMiddleware', # Clickjacking middleware - 'corsheaders.middleware.CorsMiddleware', # CORS middleware - 'debug_toolbar.middleware.DebugToolbarMiddleware', # Debug toolbar middleware - "allauth.account.middleware.AccountMiddleware", # Account middleware + "django.middleware.security.SecurityMiddleware", # Security middleware + "whitenoise.middleware.WhiteNoiseMiddleware", # WhiteNoise middleware + "django.contrib.sessions.middleware.SessionMiddleware", # Session middleware + "django.middleware.common.CommonMiddleware", # Common middleware + "django.middleware.csrf.CsrfViewMiddleware", # CSRF middleware + "django.contrib.auth.middleware.AuthenticationMiddleware", # Authentication middleware + "django.contrib.messages.middleware.MessageMiddleware", # Messages middleware + "django.middleware.clickjacking.XFrameOptionsMiddleware", # Clickjacking middleware + "corsheaders.middleware.CorsMiddleware", # CORS middleware + "debug_toolbar.middleware.DebugToolbarMiddleware", # Debug toolbar middleware + "allauth.account.middleware.AccountMiddleware", # Account middleware ] -ROOT_URLCONF = 'egypt_metro.urls' # Root URL configuration +ROOT_URLCONF = "egypt_metro.urls" # Root URL configuration # CORS settings -CORS_ALLOW_ALL_ORIGINS = os.getenv("CORS_ALLOW_ALL_ORIGINS", "False") == "True" # Default to False +CORS_ALLOW_ALL_ORIGINS = ( + os.getenv("CORS_ALLOW_ALL_ORIGINS", "False") == "True" +) # Default to False +CORS_ALLOW_HEADERS = list(default_headers) + [ # Default headers + custom headers + "Authorization", # Authorization header + "Content-Type", # Content type header +] +CORS_ALLOW_CREDENTIALS = True # Allow credentials TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], # Add template directories here - 'APP_DIRS': True, # Enable app templates - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', # Debug context processor - 'django.template.context_processors.request', # Request context processor - 'django.contrib.auth.context_processors.auth', # Auth context processor - 'django.contrib.messages.context_processors.messages', # Messages context processor - 'django.template.context_processors.request', # Request context processor + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], # Add template directories here + "APP_DIRS": True, # Enable app templates + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", # Debug context processor + "django.template.context_processors.request", # Request context processor + "django.contrib.auth.context_processors.auth", # Auth context processor + "django.contrib.messages.context_processors.messages", # Messages context processor + "django.template.context_processors.request", # Request context processor ], }, }, ] -WSGI_APPLICATION = 'egypt_metro.wsgi.application' # WSGI application +WSGI_APPLICATION = "egypt_metro.wsgi.application" # WSGI application # Database # https://docs.djangoproject.com/en/5.1/ref/settings/#databases -AUTH_USER_MODEL = 'users.User' +AUTH_USER_MODEL = "users.User" # Load the appropriate .env file based on an environment variable ENVIRONMENT = os.getenv("ENVIRONMENT", "dev") # Default to dev @@ -100,163 +110,197 @@ # Load secret file if in production if ENVIRONMENT == "prod": - load_dotenv("/etc/secrets/env.prod") # Load production secrets + load_dotenv("/etc/secrets/env.prod") # Load production secrets # General settings -DEBUG = os.getenv("DEBUG", "False") == "True" # Default to False -SECRET_KEY = os.getenv("SECRET_KEY") # Secret key for Django -BASE_URL = os.getenv("BASE_URL", "http://127.0.0.1:8000") # Base URL for the project -JWT_SECRET = os.getenv("JWT_SECRET") # Secret key for JWT tokens +DEBUG = os.getenv("DEBUG", "False") == "True" # Default to False +SECRET_KEY = os.getenv("SECRET_KEY") # Secret key for Django +BASE_URL = os.getenv("BASE_URL", "http://127.0.0.1:8000") # Base URL for the project +JWT_SECRET = os.getenv("JWT_SECRET") # Secret key for JWT tokens # Database configuration DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql', # Database engine - 'CONN_MAX_AGE': 500, # Maximum connection age - 'OPTIONS': { - 'options': '-c search_path=public', # Set the default schema + "default": { + "ENGINE": "django.db.backends.postgresql", # Database engine + "CONN_MAX_AGE": 500, # Maximum connection age + "OPTIONS": { + "options": "-c search_path=public", # Set the default schema }, - 'DISABLE_SERVER_SIDE_CURSORS': True, # Disable server-side cursors - "NAME": os.getenv("DB_NAME"), # Database name - "USER": os.getenv("DB_USER"), # Database user - "PASSWORD": os.getenv("DB_PASSWORD"), # Database password - "HOST": os.getenv("DB_HOST"), # Database host - "PORT": os.getenv("DB_PORT"), # Database port + "DISABLE_SERVER_SIDE_CURSORS": True, # Disable server-side cursors + "NAME": os.getenv("DB_NAME"), # Database name + "USER": os.getenv("DB_USER"), # Database user + "PASSWORD": os.getenv("DB_PASSWORD"), # Database password + "HOST": os.getenv("DB_HOST"), # Database host + "PORT": os.getenv("DB_PORT"), # Database port } } -REQUIRED_ENV_VARS = ["SECRET_KEY", "DATABASE_URL", "JWT_SECRET", "BASE_URL"] +REQUIRED_ENV_VARS = ["SECRET_KEY", "DATABASE_URL", "JWT_SECRET", "BASE_URL"] for var in REQUIRED_ENV_VARS: if not os.getenv(var): raise ValueError(f"{var} is not set in environment variables.") +if not DEBUG: # Enable only in production + SECURE_SSL_REDIRECT = os.getenv("SECURE_SSL_REDIRECT", "True") == "True" + SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "31536000")) + SECURE_HSTS_PRELOAD = os.getenv("SECURE_HSTS_PRELOAD", "True") == "True" + SECURE_HSTS_INCLUDE_SUBDOMAINS = ( + os.getenv("SECURE_HSTS_INCLUDE_SUBDOMAINS", "True") == "True" + ) + CSRF_COOKIE_SECURE = True + SESSION_COOKIE_SECURE = True + # Password validation # https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # User attribute similarity validator + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", # User attribute similarity validator }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', # Minimum length validator + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", # Minimum length validator }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', # Common password validator + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", # Common password validator }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', # Numeric password validator + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", # Numeric password validator }, ] AUTHENTICATION_BACKENDS = [ - 'django.contrib.auth.backends.ModelBackend', # For admin logins - 'allauth.account.auth_backends.AuthenticationBackend', # For allauth + "django.contrib.auth.backends.ModelBackend", # For admin logins + "allauth.account.auth_backends.AuthenticationBackend", # For allauth ] SOCIALACCOUNT_PROVIDERS = { - 'google': { - 'APP': { - 'client_id': 'your-client-id', - 'secret': 'your-secret', - 'key': '' - } + "google": { + "APP": {"client_id": "your-client-id", "secret": "your-secret", "key": ""} } } REST_FRAMEWORK = { - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework_simplejwt.authentication.JWTAuthentication', - 'rest_framework.authentication.TokenAuthentication', - 'rest_framework.authentication.SessionAuthentication', # For session-based authentication + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.SessionAuthentication", # For session-based authentication ), - 'DEFAULT_PERMISSION_CLASSES': ( - 'rest_framework.permissions.IsAuthenticated', # Default to authenticated users + "DEFAULT_PERMISSION_CLASSES": ( + "rest_framework.permissions.IsAuthenticated", # Default to authenticated users ), - 'DEFAULT_THROTTLE_CLASSES': [ - 'rest_framework.throttling.UserRateThrottle', - 'rest_framework.throttling.AnonRateThrottle', + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.UserRateThrottle", + "rest_framework.throttling.AnonRateThrottle", ], - 'DEFAULT_THROTTLE_RATES': { - 'user': '1000/day', # 1000 requests per day - 'anon': '100/day', # 100 requests per day + "DEFAULT_THROTTLE_RATES": { + "user": "1000/day", # 1000 requests per day + "anon": "100/day", # 100 requests per day }, } SIMPLE_JWT = { - 'SIGNING_KEY': os.getenv("JWT_SECRET"), # Secret key for JWT tokens - 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), # Access token lifetime - 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), # Refresh token lifetime + "SIGNING_KEY": os.getenv("JWT_SECRET"), # Secret key for JWT tokens + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30), # Access token lifetime + "REFRESH_TOKEN_LIFETIME": timedelta(days=7), # Refresh token lifetime } LOGGING = { - 'version': 1, # Log version - 'disable_existing_loggers': False, # Don't disable existing loggers - 'formatters': { - 'verbose': { - 'format': '{levelname} {asctime} {module} {message}', # Log format - 'style': '{', # Use {} for formatting + "version": 1, # Log version + "disable_existing_loggers": False, # Don't disable existing loggers + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {module} {message}", # Log format + "style": "{", # Use {} for formatting }, }, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', # Log to console - 'formatter': 'verbose', # Use the verbose formatter + "handlers": { + "console": { + "class": "logging.StreamHandler", # Log to console + "formatter": "verbose", # Use the verbose formatter }, - 'file': { - 'level': 'DEBUG', # Log debug messages - 'class': 'logging.FileHandler', # Log to file - 'filename': BASE_DIR / 'logs/debug.log', # File where logs are saved - 'formatter': 'verbose', # Use the verbose formatter + "file": { + "level": "DEBUG", # Log debug messages + "class": "logging.FileHandler", # Log to file + "filename": BASE_DIR / "logs/debug.log", # File where logs are saved + "formatter": "verbose", # Use the verbose formatter }, }, - 'loggers': { - 'django': { - 'handlers': ['console', 'file'], # Log to console and file - 'level': 'INFO', # Log info messages - 'propagate': True, + "loggers": { + "django": { + "handlers": ["console", "file"], # Log to console and file + "level": "INFO", # Log info messages + "propagate": True, }, - '__main__': { - 'handlers': ['console', 'file'], # Log to console and file - 'level': 'DEBUG', # Log all messages - 'propagate': True, + "__main__": { + "handlers": ["console", "file"], # Log to console and file + "level": "DEBUG", # Log all messages + "propagate": True, }, }, } CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', # In-memory cache + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", # Local memory cache + "LOCATION": "unique-snowflake", } } +SESSION_ENGINE = "django.contrib.sessions.backends.cache" +SESSION_CACHE_ALIAS = "default" + INTERNAL_IPS = [ - '127.0.0.1', # Localhost + "127.0.0.1", # Localhost ] +HANDLER404 = "egypt_metro.views.custom_404" # Custom 404 handler +HANDLER500 = "egypt_metro.views.custom_500" # Custom 500 handler + # Internationalization # https://docs.djangoproject.com/en/5.1/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True USE_TZ = True +# Initialize Sentry for Error Tracking +# SENTRY_DSN = os.getenv("SENTRY_DSN") # Use environment variable + +# if SENTRY_DSN: +# import sentry_sdk # type: ignore +# from sentry_sdk.integrations.django import DjangoIntegration # type: ignore + +# sentry_sdk.init( +# dsn=SENTRY_DSN, +# integrations=[DjangoIntegration()], +# send_default_pii=True, +# ) +# else: +# print("Sentry DSN not configured. Skipping Sentry initialization.") + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ + # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.1/howto/static-files/ -STATIC_URL = 'static/' -STATIC_ROOT = BASE_DIR / 'staticfiles' # Folder where static files will be collected +STATIC_URL = "/static/" # URL for static files +STATIC_ROOT = BASE_DIR / "staticfiles" # Folder where static files will be collected +STATICFILES_STORAGE = ( + "whitenoise.storage.CompressedManifestStaticFilesStorage" # Static files storage +) # Media files (optional, if your project uses media uploads) -MEDIA_URL = '/media/' -MEDIA_ROOT = BASE_DIR / 'mediafiles' # Folder where media files will be uploaded +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "mediafiles" # Folder where media files will be uploaded # Default primary key field type # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # Default primary key field type +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # Default primary key field type diff --git a/egypt_metro/urls.py b/egypt_metro/urls.py index cb26ff1..690396a 100644 --- a/egypt_metro/urls.py +++ b/egypt_metro/urls.py @@ -19,16 +19,48 @@ from .views import health_check from django.conf import settings from django.conf.urls.static import static +from drf_yasg.views import get_schema_view +from drf_yasg import openapi +from rest_framework.permissions import AllowAny +# OpenAPI schema view +schema_view = get_schema_view( + openapi.Info( + title="Your API", + default_version="v1", + description="API documentation for the Flutter app", + ), + public=True, + permission_classes=(AllowAny,), +) + +# Core URL patterns urlpatterns = [ - path('admin/', admin.site.urls), # Django admin panel - path('accounts/', include('allauth.urls')), # Allauth authentication routes - path('api/users/', include('apps.users.urls')), # User-related API routes - path('api/stations/', include('apps.stations.urls')), # Station-related API routes - path('health/', health_check, name='health_check'), # Health check endpoint + # Admin + path("admin/", admin.site.urls), # Admin panel + # Authentication + path("accounts/", include("allauth.urls")), # Allauth authentication + # API Routes + path("api/users/", include("apps.users.urls")), # User authentication + path("api/stations/", include("apps.stations.urls")), # Stations and trips + # Miscellaneous + path("health/", health_check, name="health_check"), # Health check + # Documentation + path( + "swagger/", + schema_view.with_ui("swagger", cache_timeout=0), + name="schema-swagger-ui", + ), # Swagger UI ] +# Debug Toolbar (only for development) if settings.DEBUG: urlpatterns += [ - path('__debug__/', include('debug_toolbar.urls')), - ] \ No newline at end of file + path("__debug__/", include("debug_toolbar.urls")), # Debug toolbar + ] + +# Static and media files (if DEBUG is enabled) +if settings.DEBUG: + urlpatterns += static( + settings.MEDIA_URL, document_root=settings.MEDIA_ROOT + ) # Media files diff --git a/egypt_metro/views.py b/egypt_metro/views.py index 745a3df..87da6eb 100644 --- a/egypt_metro/views.py +++ b/egypt_metro/views.py @@ -1,4 +1,18 @@ from django.http import JsonResponse +from django.db import connection + def health_check(request): - return JsonResponse({"status": "ok"}) \ No newline at end of file + try: + connection.ensure_connection() # Check DB connection + return JsonResponse({"status": "ok"}) + except Exception as e: + return JsonResponse({"status": "error", "details": str(e)}, status=500) + + +def custom_404(request, exception=None): + return JsonResponse({"error": "Resource not found"}, status=404) + + +def custom_500(request): + return JsonResponse({"error": "Internal server error"}, status=500) diff --git a/egypt_metro/wsgi.py b/egypt_metro/wsgi.py index ed5147a..45edea9 100644 --- a/egypt_metro/wsgi.py +++ b/egypt_metro/wsgi.py @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'egypt_metro.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "egypt_metro.settings") application = get_wsgi_application() diff --git a/manage.py b/manage.py index 9167df7..2150cde 100644 --- a/manage.py +++ b/manage.py @@ -6,7 +6,7 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'egypt_metro.settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "egypt_metro.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -18,5 +18,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..649acd3 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,602 @@ +# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. + +[[package]] +name = "anyio" +version = "3.7.1" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.7" +files = [ + {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"}, + {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"}, +] + +[package.dependencies] +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] +test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (<0.22)"] + +[[package]] +name = "asgiref" +version = "3.8.1" +description = "ASGI specs, helper code, and adapters" +optional = false +python-versions = ">=3.8" +files = [ + {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, + {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} + +[package.extras] +tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] + +[[package]] +name = "asyncpg" +version = "0.24.0" +description = "An asyncio PostgreSQL driver" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "asyncpg-0.24.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c4fc0205fe4ddd5aeb3dfdc0f7bafd43411181e1f5650189608e5971cceacff1"}, + {file = "asyncpg-0.24.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a7095890c96ba36f9f668eb552bb020dddb44f8e73e932f8573efc613ee83843"}, + {file = "asyncpg-0.24.0-cp310-cp310-win_amd64.whl", hash = "sha256:8ff5073d4b654e34bd5eaadc01dc4d68b8a9609084d835acd364cd934190a08d"}, + {file = "asyncpg-0.24.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e36c6806883786b19551bb70a4882561f31135dc8105a59662e0376cf5b2cbc5"}, + {file = "asyncpg-0.24.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ddffcb85227bf39cd1bedd4603e0082b243cf3b14ced64dce506a15b05232b83"}, + {file = "asyncpg-0.24.0-cp37-cp37m-win_amd64.whl", hash = "sha256:41704c561d354bef01353835a7846e5606faabbeb846214dfcf666cf53319f18"}, + {file = "asyncpg-0.24.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:29ef6ae0a617fc13cc2ac5dc8e9b367bb83cba220614b437af9b67766f4b6b20"}, + {file = "asyncpg-0.24.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:eed43abc6ccf1dc02e0d0efc06ce46a411362f3358847c6b0ec9a43426f91ece"}, + {file = "asyncpg-0.24.0-cp38-cp38-win_amd64.whl", hash = "sha256:129d501f3d30616afd51eb8d3142ef51ba05374256bd5834cec3ef4956a9b317"}, + {file = "asyncpg-0.24.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a458fc69051fbb67d995fdda46d75a012b5d6200f91e17d23d4751482640ed4c"}, + {file = "asyncpg-0.24.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:556b0e92e2b75dc028b3c4bc9bd5162ddf0053b856437cf1f04c97f9c6837d03"}, + {file = "asyncpg-0.24.0-cp39-cp39-win_amd64.whl", hash = "sha256:a738f4807c853623d3f93f0fea11f61be6b0e5ca16ea8aeb42c2c7ee742aa853"}, + {file = "asyncpg-0.24.0.tar.gz", hash = "sha256:dd2fa063c3344823487d9ddccb40802f02622ddf8bf8a6cc53885ee7a2c1c0c6"}, +] + +[package.extras] +dev = ["Cython (>=0.29.24,<0.30.0)", "Sphinx (>=4.1.2,<4.2.0)", "flake8 (>=3.9.2,<3.10.0)", "pycodestyle (>=2.7.0,<2.8.0)", "pytest (>=6.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "uvloop (>=0.15.3)"] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["flake8 (>=3.9.2,<3.10.0)", "pycodestyle (>=2.7.0,<2.8.0)", "uvloop (>=0.15.3)"] + +[[package]] +name = "atomicwrites" +version = "1.4.1" +description = "Atomic file writes." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, +] + +[[package]] +name = "attrs" +version = "24.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, +] + +[package.extras] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] + +[[package]] +name = "certifi" +version = "2024.8.30" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, + {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, + {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "fastapi" +version = "0.68.2" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.6.1" +files = [ + {file = "fastapi-0.68.2-py3-none-any.whl", hash = "sha256:36bcdd3dbea87c586061005e4a40b9bd0145afd766655b4e0ec1d8870b32555c"}, + {file = "fastapi-0.68.2.tar.gz", hash = "sha256:38526fc46bda73f7ec92033952677323c16061e70a91d15c95f18b11895da494"}, +] + +[package.dependencies] +pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0" +starlette = "0.14.2" + +[package.extras] +all = ["aiofiles (>=0.5.0,<0.8.0)", "async_exit_stack (>=1.0.1,<2.0.0)", "async_generator (>=1.10,<2.0.0)", "email_validator (>=1.1.1,<2.0.0)", "graphene (>=2.1.8,<3.0.0)", "itsdangerous (>=1.1.0,<2.0.0)", "jinja2 (>=2.11.2,<3.0.0)", "orjson (>=3.2.1,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "pyyaml (>=5.3.1,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "ujson (>=4.0.1,<5.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] +dev = ["autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "graphene (>=2.1.8,<3.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "python-jose[cryptography] (>=3.3.0,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] +doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mkdocs-material (>=7.1.9,<8.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "typer-cli (>=0.0.12,<0.0.13)"] +test = ["aiofiles (>=0.5.0,<0.8.0)", "async_exit_stack (>=1.0.1,<2.0.0)", "async_generator (>=1.10,<2.0.0)", "black (==21.9b0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "email_validator (>=1.1.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "flask (>=1.1.2,<2.0.0)", "httpx (>=0.14.0,<0.19.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "orjson (>=3.2.1,<4.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest (>=6.2.4,<7.0.0)", "pytest-asyncio (>=0.14.0,<0.16.0)", "pytest-cov (>=2.12.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "requests (>=2.24.0,<3.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "types-dataclasses (==0.1.7)", "types-orjson (==3.6.0)", "types-ujson (==0.1.1)", "ujson (>=4.0.1,<5.0.0)"] + +[[package]] +name = "h11" +version = "0.12.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.6" +files = [ + {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"}, + {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"}, +] + +[[package]] +name = "httpcore" +version = "0.14.7" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.6" +files = [ + {file = "httpcore-0.14.7-py3-none-any.whl", hash = "sha256:47d772f754359e56dd9d892d9593b6f9870a37aeb8ba51e9a88b09b3d68cfade"}, + {file = "httpcore-0.14.7.tar.gz", hash = "sha256:7503ec1c0f559066e7e39bc4003fd2ce023d01cf51793e3c173b864eb456ead1"}, +] + +[package.dependencies] +anyio = "==3.*" +certifi = "*" +h11 = ">=0.11,<0.13" +sniffio = "==1.*" + +[package.extras] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + +[[package]] +name = "httpx" +version = "0.21.3" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.6" +files = [ + {file = "httpx-0.21.3-py3-none-any.whl", hash = "sha256:df9a0fd43fa79dbab411d83eb1ea6f7a525c96ad92e60c2d7f40388971b25777"}, + {file = "httpx-0.21.3.tar.gz", hash = "sha256:7a3eb67ef0b8abbd6d9402248ef2f84a76080fa1c839f8662e6eb385640e445a"}, +] + +[package.dependencies] +certifi = "*" +charset-normalizer = "*" +httpcore = ">=0.14.0,<0.15.0" +rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (==10.*)"] +http2 = ["h2 (>=3,<5)"] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "packaging" +version = "24.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] + +[[package]] +name = "pydantic" +version = "1.10.19" +description = "Data validation and settings management using python type hints" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pydantic-1.10.19-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a415b9e95fa602b10808113967f72b2da8722061265d6af69268c111c254832d"}, + {file = "pydantic-1.10.19-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:11965f421f7eb026439d4eb7464e9182fe6d69c3d4d416e464a4485d1ba61ab6"}, + {file = "pydantic-1.10.19-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5bb81fcfc6d5bff62cd786cbd87480a11d23f16d5376ad2e057c02b3b44df96"}, + {file = "pydantic-1.10.19-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83ee8c9916689f8e6e7d90161e6663ac876be2efd32f61fdcfa3a15e87d4e413"}, + {file = "pydantic-1.10.19-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0399094464ae7f28482de22383e667625e38e1516d6b213176df1acdd0c477ea"}, + {file = "pydantic-1.10.19-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8b2cf5e26da84f2d2dee3f60a3f1782adedcee785567a19b68d0af7e1534bd1f"}, + {file = "pydantic-1.10.19-cp310-cp310-win_amd64.whl", hash = "sha256:1fc8cc264afaf47ae6a9bcbd36c018d0c6b89293835d7fb0e5e1a95898062d59"}, + {file = "pydantic-1.10.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d7a8a1dd68bac29f08f0a3147de1885f4dccec35d4ea926e6e637fac03cdb4b3"}, + {file = "pydantic-1.10.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07d00ca5ef0de65dd274005433ce2bb623730271d495a7d190a91c19c5679d34"}, + {file = "pydantic-1.10.19-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad57004e5d73aee36f1e25e4e73a4bc853b473a1c30f652dc8d86b0a987ffce3"}, + {file = "pydantic-1.10.19-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dce355fe7ae53e3090f7f5fa242423c3a7b53260747aa398b4b3aaf8b25f41c3"}, + {file = "pydantic-1.10.19-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:0d32227ea9a3bf537a2273fd2fdb6d64ab4d9b83acd9e4e09310a777baaabb98"}, + {file = "pydantic-1.10.19-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e351df83d1c9cffa53d4e779009a093be70f1d5c6bb7068584086f6a19042526"}, + {file = "pydantic-1.10.19-cp311-cp311-win_amd64.whl", hash = "sha256:d8d72553d2f3f57ce547de4fa7dc8e3859927784ab2c88343f1fc1360ff17a08"}, + {file = "pydantic-1.10.19-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d5b5b7c6bafaef90cbb7dafcb225b763edd71d9e22489647ee7df49d6d341890"}, + {file = "pydantic-1.10.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:570ad0aeaf98b5e33ff41af75aba2ef6604ee25ce0431ecd734a28e74a208555"}, + {file = "pydantic-1.10.19-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0890fbd7fec9e151c7512941243d830b2d6076d5df159a2030952d480ab80a4e"}, + {file = "pydantic-1.10.19-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec5c44e6e9eac5128a9bfd21610df3b8c6b17343285cc185105686888dc81206"}, + {file = "pydantic-1.10.19-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6eb56074b11a696e0b66c7181da682e88c00e5cebe6570af8013fcae5e63e186"}, + {file = "pydantic-1.10.19-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9d7d48fbc5289efd23982a0d68e973a1f37d49064ccd36d86de4543aff21e086"}, + {file = "pydantic-1.10.19-cp312-cp312-win_amd64.whl", hash = "sha256:fd34012691fbd4e67bdf4accb1f0682342101015b78327eaae3543583fcd451e"}, + {file = "pydantic-1.10.19-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4a5d5b877c7d3d9e17399571a8ab042081d22fe6904416a8b20f8af5909e6c8f"}, + {file = "pydantic-1.10.19-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c46f58ef2df958ed2ea7437a8be0897d5efe9ee480818405338c7da88186fb3"}, + {file = "pydantic-1.10.19-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d8a38a44bb6a15810084316ed69c854a7c06e0c99c5429f1d664ad52cec353c"}, + {file = "pydantic-1.10.19-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a82746c6d6e91ca17e75f7f333ed41d70fce93af520a8437821dec3ee52dfb10"}, + {file = "pydantic-1.10.19-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:566bebdbe6bc0ac593fa0f67d62febbad9f8be5433f686dc56401ba4aab034e3"}, + {file = "pydantic-1.10.19-cp37-cp37m-win_amd64.whl", hash = "sha256:22a1794e01591884741be56c6fba157c4e99dcc9244beb5a87bd4aa54b84ea8b"}, + {file = "pydantic-1.10.19-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:076c49e24b73d346c45f9282d00dbfc16eef7ae27c970583d499f11110d9e5b0"}, + {file = "pydantic-1.10.19-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5d4320510682d5a6c88766b2a286d03b87bd3562bf8d78c73d63bab04b21e7b4"}, + {file = "pydantic-1.10.19-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e66aa0fa7f8aa9d0a620361834f6eb60d01d3e9cea23ca1a92cda99e6f61dac"}, + {file = "pydantic-1.10.19-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d216f8d0484d88ab72ab45d699ac669fe031275e3fa6553e3804e69485449fa0"}, + {file = "pydantic-1.10.19-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9f28a81978e936136c44e6a70c65bde7548d87f3807260f73aeffbf76fb94c2f"}, + {file = "pydantic-1.10.19-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d3449633c207ec3d2d672eedb3edbe753e29bd4e22d2e42a37a2c1406564c20f"}, + {file = "pydantic-1.10.19-cp38-cp38-win_amd64.whl", hash = "sha256:7ea24e8614f541d69ea72759ff635df0e612b7dc9d264d43f51364df310081a3"}, + {file = "pydantic-1.10.19-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:573254d844f3e64093f72fcd922561d9c5696821ff0900a0db989d8c06ab0c25"}, + {file = "pydantic-1.10.19-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ff09600cebe957ecbb4a27496fe34c1d449e7957ed20a202d5029a71a8af2e35"}, + {file = "pydantic-1.10.19-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4739c206bfb6bb2bdc78dcd40bfcebb2361add4ceac6d170e741bb914e9eff0f"}, + {file = "pydantic-1.10.19-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bfb5b378b78229119d66ced6adac2e933c67a0aa1d0a7adffbe432f3ec14ce4"}, + {file = "pydantic-1.10.19-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7f31742c95e3f9443b8c6fa07c119623e61d76603be9c0d390bcf7e888acabcb"}, + {file = "pydantic-1.10.19-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c6444368b651a14c2ce2fb22145e1496f7ab23cbdb978590d47c8d34a7bc0289"}, + {file = "pydantic-1.10.19-cp39-cp39-win_amd64.whl", hash = "sha256:945407f4d08cd12485757a281fca0e5b41408606228612f421aa4ea1b63a095d"}, + {file = "pydantic-1.10.19-py3-none-any.whl", hash = "sha256:2206a1752d9fac011e95ca83926a269fb0ef5536f7e053966d058316e24d929f"}, + {file = "pydantic-1.10.19.tar.gz", hash = "sha256:fea36c2065b7a1d28c6819cc2e93387b43dd5d3cf5a1e82d8132ee23f36d1f10"}, +] + +[package.dependencies] +typing-extensions = ">=4.2.0" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] + +[[package]] +name = "pytest" +version = "6.2.5" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, + {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, +] + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +toml = "*" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.15.1" +description = "Pytest support for asyncio." +optional = false +python-versions = ">= 3.6" +files = [ + {file = "pytest-asyncio-0.15.1.tar.gz", hash = "sha256:2564ceb9612bbd560d19ca4b41347b54e7835c2f792c504f698e05395ed63f6f"}, + {file = "pytest_asyncio-0.15.1-py3-none-any.whl", hash = "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea"}, +] + +[package.dependencies] +pytest = ">=5.4.0" + +[package.extras] +testing = ["coverage", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pytest-mock" +version = "3.14.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, + {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + +[[package]] +name = "rfc3986" +version = "1.5.0" +description = "Validating URI References per RFC 3986" +optional = false +python-versions = "*" +files = [ + {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, + {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, +] + +[package.dependencies] +idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} + +[package.extras] +idna2008 = ["idna"] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "starlette" +version = "0.14.2" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.6" +files = [ + {file = "starlette-0.14.2-py3-none-any.whl", hash = "sha256:3c8e48e52736b3161e34c9f0e8153b4f32ec5d8995a3ee1d59410d92f75162ed"}, + {file = "starlette-0.14.2.tar.gz", hash = "sha256:7d49f4a27f8742262ef1470608c59ddbc66baf37c148e938c7038e6bc7a998aa"}, +] + +[package.extras] +full = ["aiofiles", "graphene", "itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"] + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "uvicorn" +version = "0.15.0" +description = "The lightning-fast ASGI server." +optional = false +python-versions = "*" +files = [ + {file = "uvicorn-0.15.0-py3-none-any.whl", hash = "sha256:17f898c64c71a2640514d4089da2689e5db1ce5d4086c2d53699bf99513421c1"}, + {file = "uvicorn-0.15.0.tar.gz", hash = "sha256:d9a3c0dd1ca86728d3e235182683b4cf94cd53a867c288eaeca80ee781b2caff"}, +] + +[package.dependencies] +asgiref = ">=3.4.0" +click = ">=7.0" +h11 = ">=0.8" + +[package.extras] +standard = ["PyYAML (>=5.1)", "colorama (>=0.4)", "httptools (==0.2.*)", "python-dotenv (>=0.13)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchgod (>=0.6)", "websockets (>=9.1)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.9" +content-hash = "a169ae6ee5ed0666819dc22a593b05564188b3c298a9a09e337bc23ee200c973" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..944db20 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[tool.black] +line-length = 88 +target-version = ["py38", "py39"] + +[tool.poetry] +name = "egypt-metro-backend" +version = "0.1.0" +description = "Backend for Egypt Metro project" +authors = ["Ahmed Nassar "] + +[tool.poetry.dependencies] +python = "^3.9" +fastapi = "^0.68.1" +uvicorn = "^0.15.0" +pydantic = "^1.8.2" +httpx = "^0.21.1" +starlette = "^0.14.2" +asyncpg = "^0.24.0" + +pywin32 = { version = "306", optional = true, markers = "sys_platform == 'win32'" } + +[tool.poetry.dev-dependencies] +pytest = "^6.2.4" +pytest-asyncio = "^0.15.1" +httpx = "^0.21.1" +pytest-mock = "^3.6.1" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/render.yaml b/render.yaml index 2c58a09..4b53fbe 100644 --- a/render.yaml +++ b/render.yaml @@ -4,14 +4,20 @@ services: env: python region: frankfurt # Adjust based on your location rootDirectory: backend # Root directory of the project - buildCommand: "pip install -r requirements.txt" + plan: free + buildCommand: + - pip install --upgrade pip + - pip install -r requirements.txt + - python manage.py collectstatic --noinput + - python manage.py migrate --noinput startCommand: "gunicorn egypt_metro.wsgi:application --bind 0.0.0.0:8000" envVars: - key: ENVIRONMENT # Environment for loading specific config value: prod - - key: DATABASE_URL # Add the database URL environment variable - value: postgres://postgres:123@localhost:5432/egypt_metro - - fromGroup: egypt-metro-env-group # Link generic variables from environment group + - key: SECRET_KEY + value: ${SECRET_KEY} + - key: DATABASE_URL + value: ${DATABASE_URL} - key: CORS_ALLOW_ALL_ORIGINS # Set CORS settings value: "True" disk: # Persistent storage (optional) diff --git a/requirements.txt b/requirements.txt index 0c6ffc1..0818863 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,82 +1,19 @@ -asgiref==3.8.1 -asttokens==2.4.1 -beautifulsoup4==4.12.3 -cachetools==5.5.0 -certifi==2024.8.30 -cffi==1.17.1 -charset-normalizer==3.4.0 -colorama==0.4.6 -comm==0.2.2 -cryptography==44.0.0 -debugpy==1.8.5 -decorator==5.1.1 -distlib==0.3.9 -dj-database-url==2.3.0 -Django==5.1.3 -django-allauth==65.3.0 -django-cors-headers==4.6.0 -django-debug-toolbar==4.4.6 -django-environ==0.11.2 -django-redis==5.4.0 -djangorestframework==3.15.2 -djangorestframework-simplejwt==5.3.1 -drf-yasg==1.21.8 -executing==2.1.0 -filelock==3.16.1 -geographiclib==2.0 -geopy==2.4.1 -git-filter-repo==2.45.0 -google-auth==2.36.0 -google-auth-httplib2==0.2.0 -google-auth-oauthlib==1.2.1 -gunicorn==23.0.0 -httplib2==0.22.0 -idna==3.10 -inflection==0.5.1 -ipykernel==6.29.5 -ipython==8.27.0 -jedi==0.19.1 -jupyter_client==8.6.3 -jupyter_core==5.7.2 -matplotlib-inline==0.1.7 -nest-asyncio==1.6.0 -oauthlib==3.2.2 -packaging==24.2 -parso==0.8.4 -platformdirs==4.3.6 -prompt_toolkit==3.0.47 -psutil==6.0.0 -psycopg2==2.9.10 -psycopg2-binary==2.9.10 -pure_eval==0.2.3 -pyasn1==0.6.1 -pyasn1_modules==0.4.1 -pycparser==2.22 -Pygments==2.18.0 -PyJWT==2.10.1 -pyparsing==3.1.4 -python-dateutil==2.9.0.post0 -python-decouple==3.8 -python-dotenv==1.0.1 -pytz==2024.2 -pywin32==306 -PyYAML==6.0.2 -pyzmq==26.2.0 -redis==5.2.1 -requests==2.32.3 -requests-oauthlib==2.0.0 -rsa==4.9 -six==1.16.0 -soupsieve==2.6 -sqlparse==0.5.2 -stack-data==0.6.3 -tornado==6.4.1 -traitlets==5.14.3 -typing_extensions==4.12.2 -tzdata==2024.2 -uritemplate==4.1.1 -urllib3==2.2.3 -virtualenv==20.28.0 -waitress==3.0.2 -wcwidth==0.2.13 -whitenoise==6.7.0 +anyio==3.7.1 ; python_version >= "3.9" and python_version < "4.0" +asgiref==3.8.1 ; python_version >= "3.9" and python_version < "4.0" +asyncpg==0.24.0 ; python_version >= "3.9" and python_version < "4.0" +certifi==2024.8.30 ; python_version >= "3.9" and python_version < "4.0" +charset-normalizer==3.4.0 ; python_version >= "3.9" and python_version < "4.0" +click==8.1.7 ; python_version >= "3.9" and python_version < "4.0" +colorama==0.4.6 ; python_version >= "3.9" and python_version < "4.0" and platform_system == "Windows" +exceptiongroup==1.2.2 ; python_version >= "3.9" and python_version < "3.11" +fastapi==0.68.2 ; python_version >= "3.9" and python_version < "4.0" +h11==0.12.0 ; python_version >= "3.9" and python_version < "4.0" +httpcore==0.14.7 ; python_version >= "3.9" and python_version < "4.0" +httpx==0.21.3 ; python_version >= "3.9" and python_version < "4.0" +idna==3.10 ; python_version >= "3.9" and python_version < "4.0" +pydantic==1.10.19 ; python_version >= "3.9" and python_version < "4.0" +rfc3986[idna2008]==1.5.0 ; python_version >= "3.9" and python_version < "4.0" +sniffio==1.3.1 ; python_version >= "3.9" and python_version < "4.0" +starlette==0.14.2 ; python_version >= "3.9" and python_version < "4.0" +typing-extensions==4.12.2 ; python_version >= "3.9" and python_version < "4.0" +uvicorn==0.15.0 ; python_version >= "3.9" and python_version < "4.0" diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 0000000..425359e --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.9.9