From 72be356bbdf7641ab501a32c412581005de9e6ee Mon Sep 17 00:00:00 2001 From: Ahmed Nassar Date: Sun, 23 Feb 2025 02:29:49 +0200 Subject: [PATCH] feat: Add authentication app with password reset functionality and update train line constants --- apps/authentication/__init__.py | 0 apps/authentication/admin.py | 0 apps/authentication/apps.py | 6 + .../authentication/migrations/0001_initial.py | 55 +++ apps/authentication/migrations/__init__.py | 0 apps/authentication/models.py | 27 ++ apps/authentication/serializers.py | 30 ++ apps/authentication/services.py | 102 +++++ apps/authentication/signals.py | 0 .../templates/reset_password_email.html | 28 ++ apps/authentication/tests.py | 0 apps/authentication/urls.py | 15 + apps/authentication/views.py | 100 +++++ apps/trains/admin.py | 31 +- apps/trains/api/views.py | 45 +- apps/trains/constants.py | 14 +- .../management/commands/initialize_trains.py | 167 ++++++-- apps/trains/migrations/0001_initial.py | 402 ++++++++++++++++++ .../migrations/0002_alter_train_train_id.py | 23 + ...wdmeasurement_confidence_score_and_more.py | 39 ++ apps/trains/migrations/__init__.py | 0 apps/trains/models/crowd.py | 19 +- apps/trains/models/train.py | 28 +- apps/trains/services/crowd_service.py | 36 ++ backup.sql | 0 cert.crt | 19 + cert.key | 27 ++ metro/settings.py | 118 +++-- metro/urls.py | 3 +- render.yaml | 4 + requirements.txt | 8 + 31 files changed, 1248 insertions(+), 98 deletions(-) create mode 100644 apps/authentication/__init__.py create mode 100644 apps/authentication/admin.py create mode 100644 apps/authentication/apps.py create mode 100644 apps/authentication/migrations/0001_initial.py create mode 100644 apps/authentication/migrations/__init__.py create mode 100644 apps/authentication/models.py create mode 100644 apps/authentication/serializers.py create mode 100644 apps/authentication/services.py create mode 100644 apps/authentication/signals.py create mode 100644 apps/authentication/templates/reset_password_email.html create mode 100644 apps/authentication/tests.py create mode 100644 apps/authentication/urls.py create mode 100644 apps/authentication/views.py create mode 100644 apps/trains/migrations/0001_initial.py create mode 100644 apps/trains/migrations/0002_alter_train_train_id.py create mode 100644 apps/trains/migrations/0003_alter_crowdmeasurement_confidence_score_and_more.py create mode 100644 apps/trains/migrations/__init__.py create mode 100644 backup.sql create mode 100644 cert.crt create mode 100644 cert.key diff --git a/apps/authentication/__init__.py b/apps/authentication/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/authentication/admin.py b/apps/authentication/admin.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/authentication/apps.py b/apps/authentication/apps.py new file mode 100644 index 00000000..975015de --- /dev/null +++ b/apps/authentication/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthenticationConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.authentication" diff --git a/apps/authentication/migrations/0001_initial.py b/apps/authentication/migrations/0001_initial.py new file mode 100644 index 00000000..aee6fd19 --- /dev/null +++ b/apps/authentication/migrations/0001_initial.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.18 on 2025-02-22 20:30 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="PasswordResetToken", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "token", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("expires_at", models.DateTimeField()), + ("is_used", models.BooleanField(default=False)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "indexes": [ + models.Index(fields=["token"], name="authenticat_token_3288c7_idx"), + models.Index( + fields=["user", "is_used"], + name="authenticat_user_id_9751b1_idx", + ), + ], + }, + ), + ] diff --git a/apps/authentication/migrations/__init__.py b/apps/authentication/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/authentication/models.py b/apps/authentication/models.py new file mode 100644 index 00000000..88d5fdec --- /dev/null +++ b/apps/authentication/models.py @@ -0,0 +1,27 @@ +# apps/authentication/models.py +from django.db import models +from django.contrib.auth import get_user_model +import uuid +from django.utils import timezone + +User = get_user_model() + + +class PasswordResetToken(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField() + is_used = models.BooleanField(default=False) + + def __str__(self): + return f"Password reset token for {self.user.email}" + + def is_valid(self): + return not self.is_used and self.expires_at > timezone.now() + + class Meta: + indexes = [ + models.Index(fields=['token']), + models.Index(fields=['user', 'is_used']), + ] \ No newline at end of file diff --git a/apps/authentication/serializers.py b/apps/authentication/serializers.py new file mode 100644 index 00000000..383903b9 --- /dev/null +++ b/apps/authentication/serializers.py @@ -0,0 +1,30 @@ +# authentication/serializers.py +from rest_framework import serializers +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError + +User = get_user_model() + + +class RequestPasswordResetSerializer(serializers.Serializer): + email = serializers.EmailField() + + def validate_email(self, value): + if not User.objects.filter(email=value).exists(): + raise ValidationError("No user found with this email address.") + return value + + +class ValidateTokenSerializer(serializers.Serializer): + token = serializers.UUIDField() + + +class ResetPasswordSerializer(serializers.Serializer): + token = serializers.UUIDField() + new_password = serializers.CharField(min_length=8, max_length=128) + confirm_password = serializers.CharField(min_length=8, max_length=128) + + def validate(self, data): + if data['new_password'] != data['confirm_password']: + raise ValidationError("Passwords don't match.") + return data \ No newline at end of file diff --git a/apps/authentication/services.py b/apps/authentication/services.py new file mode 100644 index 00000000..9864cc58 --- /dev/null +++ b/apps/authentication/services.py @@ -0,0 +1,102 @@ +# authentication/services.py +from django.core.mail import send_mail +from django.conf import settings +from django.utils import timezone +from django.template.loader import render_to_string +from datetime import timedelta +import logging + +from .models import PasswordResetToken + +logger = logging.getLogger(__name__) + + +class PasswordResetService: + TOKEN_EXPIRY_HOURS = 24 + + @staticmethod + def create_reset_token(user): + """Create a new password reset token""" + # Invalidate any existing tokens + PasswordResetToken.objects.filter(user=user, is_used=False).update(is_used=True) + + # Create new token + token = PasswordResetToken.objects.create( + user=user, + expires_at=timezone.now() + timedelta(hours=PasswordResetService.TOKEN_EXPIRY_HOURS) + ) + return token + + @staticmethod + def validate_token(token_str): + """Validate a password reset token""" + try: + token = PasswordResetToken.objects.get(token=token_str, is_used=False) + return token.is_valid() + except PasswordResetToken.DoesNotExist: + return False + + @staticmethod + def get_valid_token(token_str): + """Get a valid token object""" + try: + token = PasswordResetToken.objects.get(token=token_str, is_used=False) + if token.is_valid(): + return token + return None + except PasswordResetToken.DoesNotExist: + return None + + @staticmethod + def send_reset_email(user, token): + """Send password reset email""" + try: + reset_url = f"{settings.FRONTEND_URL}/reset-password?token={token.token}" + + # Render email template + context = { + 'user': user, + 'reset_url': reset_url, + 'expires_in': PasswordResetService.TOKEN_EXPIRY_HOURS + } + + html_message = render_to_string('authentication/reset_password_email.html', context) + plain_message = render_to_string('authentication/reset_password_email.txt', context) + + send_mail( + subject='Reset Your Password', + message=plain_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + html_message=html_message + ) + + logger.info(f"Password reset email sent to {user.email}") + return True + + except Exception as e: + logger.error(f"Error sending password reset email: {str(e)}") + return False + + @staticmethod + def reset_password(token, new_password): + """Reset user's password""" + token_obj = PasswordResetService.get_valid_token(token) + if not token_obj: + return False + + try: + user = token_obj.user + user.set_password(new_password) + user.save() + + # Mark token as used + token_obj.is_used = True + token_obj.save() + + logger.info(f"Password reset successful for user {user.email}") + return True + + except Exception as e: + logger.error(f"Error resetting password: {str(e)}") + return False \ No newline at end of file diff --git a/apps/authentication/signals.py b/apps/authentication/signals.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/authentication/templates/reset_password_email.html b/apps/authentication/templates/reset_password_email.html new file mode 100644 index 00000000..02870f11 --- /dev/null +++ b/apps/authentication/templates/reset_password_email.html @@ -0,0 +1,28 @@ + + + + + + + +
+

Reset Your Password

+

Hello {{ user.get_full_name|default:user.email }},

+

We received a request to reset your password. Click the button below to reset it:

+

+ Reset Password +

+

This link will expire in {{ expires_in }} hours.

+

If you didn't request this, please ignore this email.

+
+ + \ No newline at end of file diff --git a/apps/authentication/tests.py b/apps/authentication/tests.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/authentication/urls.py b/apps/authentication/urls.py new file mode 100644 index 00000000..57fc27ba --- /dev/null +++ b/apps/authentication/urls.py @@ -0,0 +1,15 @@ +# authentication/urls.py +from django.urls import path +from .views import RequestPasswordResetView, ValidateTokenView, ResetPasswordView + +urlpatterns = [ + path('password/reset/request/', + RequestPasswordResetView.as_view(), + name='password-reset-request'), + path('password/reset/validate/', + ValidateTokenView.as_view(), + name='password-reset-validate'), + path('password/reset/confirm/', + ResetPasswordView.as_view(), + name='password-reset-confirm'), +] diff --git a/apps/authentication/views.py b/apps/authentication/views.py new file mode 100644 index 00000000..8d61b322 --- /dev/null +++ b/apps/authentication/views.py @@ -0,0 +1,100 @@ +# apps/authentication/views.py +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.response import Response +from django.contrib.auth import get_user_model +from drf_spectacular.utils import extend_schema +import logging + +from .serializers import ( + RequestPasswordResetSerializer, + ValidateTokenSerializer, + ResetPasswordSerializer +) +from .services import PasswordResetService + +logger = logging.getLogger(__name__) +User = get_user_model() + + +class RequestPasswordResetView(APIView): + serializer_class = RequestPasswordResetSerializer + + @extend_schema( + summary="Request password reset", + description="Send a password reset email to the user", + responses={200: None} + ) + def post(self, request): + serializer = self.serializer_class(data=request.data) + if serializer.is_valid(): + try: + user = User.objects.get(email=serializer.validated_data['email']) + token = PasswordResetService.create_reset_token(user) + + if PasswordResetService.send_reset_email(user, token): + return Response( + {"message": "Password reset email sent successfully"}, + status=status.HTTP_200_OK + ) + else: + return Response( + {"error": "Failed to send reset email"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + except Exception as e: + logger.error(f"Password reset request error: {str(e)}") + return Response( + {"error": "An error occurred processing your request"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ValidateTokenView(APIView): + serializer_class = ValidateTokenSerializer + + @extend_schema( + summary="Validate reset token", + description="Check if a password reset token is valid", + responses={200: None} + ) + def post(self, request): + serializer = self.serializer_class(data=request.data) + if serializer.is_valid(): + token = serializer.validated_data['token'] + is_valid = PasswordResetService.validate_token(token) + + return Response({"is_valid": is_valid}, status=status.HTTP_200_OK) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ResetPasswordView(APIView): + serializer_class = ResetPasswordSerializer + + @extend_schema( + summary="Reset password", + description="Reset user's password using a valid token", + responses={200: None} + ) + def post(self, request): + serializer = self.serializer_class(data=request.data) + if serializer.is_valid(): + token = serializer.validated_data['token'] + new_password = serializer.validated_data['new_password'] + + if PasswordResetService.reset_password(token, new_password): + return Response( + {"message": "Password reset successful"}, + status=status.HTTP_200_OK + ) + else: + return Response( + {"error": "Invalid or expired token"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file diff --git a/apps/trains/admin.py b/apps/trains/admin.py index 10bdc3be..5e21dd78 100644 --- a/apps/trains/admin.py +++ b/apps/trains/admin.py @@ -64,8 +64,7 @@ class TrainAdmin(ImportExportModelAdmin): "status_badge", "current_station_link", "direction", - "speed", - "car_count", + "ac_status", "total_passengers", "last_updated", ) @@ -125,6 +124,18 @@ def status_badge(self, obj): status_badge.short_description = "Status" + def ac_status(self, obj): + if obj.has_air_conditioning: + return format_html( + 'AC' + ) + return format_html( + 'Non-AC' + ) + ac_status.short_description = "AC Status" + def car_count(self, obj): return obj.cars.count() @@ -329,12 +340,19 @@ class CrowdMeasurementAdmin(admin.ModelAdmin): "passenger_count", "crowd_percentage", "confidence_score", - "measurement_method", ) list_filter = ("measurement_method", ("timestamp", DateRangeFilter), "train_car__train__line") search_fields = ("train_car__train__train_id",) readonly_fields = ("timestamp",) + def formatted_crowd_percentage(self, obj): + return f"{obj.crowd_percentage:.2f}%" + formatted_crowd_percentage.short_description = "Crowd Percentage" + + def formatted_confidence_score(self, obj): + return f"{obj.confidence_score:.2f}" + formatted_confidence_score.short_description = "Confidence Score" + def train_car_link(self, obj): url = reverse("admin:trains_traincar_change", args=[obj.train_car.id]) return format_html( @@ -343,11 +361,4 @@ def train_car_link(self, obj): obj.train_car.train.train_id, obj.train_car.car_number, ) - train_car_link.short_description = "Train Car" - - -# Register any additional models or customize admin site -admin.site.site_header = "Train Management System" -admin.site.site_title = "Train Management System" -admin.site.index_title = "Administration" diff --git a/apps/trains/api/views.py b/apps/trains/api/views.py index 9a3d86bd..118efe3a 100644 --- a/apps/trains/api/views.py +++ b/apps/trains/api/views.py @@ -243,26 +243,49 @@ class TrainCrowdView(APIView): @extend_schema(summary="Get crowd levels", responses={200: CrowdLevelSerializer}) def get(self, request, train_id): - """Get current crowd levels for a train with historical data""" + """Get current crowd levels for a train""" try: - train = get_object_or_404(Train, train_id=train_id) + train = get_object_or_404(Train, id=train_id) # Using integer ID + crowd_service = CrowdService() + + # Get current crowd data + crowd_data = crowd_service.get_crowd_levels(train) + + if not crowd_data: + return Response( + {"error": "Failed to retrieve crowd levels"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) - # Get current and historical crowd data - current_data = self.crowd_service.get_crowd_levels(train) - historical_data = self.crowd_service.get_historical_data(train) + # Format response for Flutter + response_data = { + "train_id": int(train_id), + "crowd_data": [ + { + "car_id": data["car_id"], + "crowd_level": data["crowd_level"], + "timestamp": data["timestamp"] + } for data in crowd_data["crowd_data"] + ], + "is_ac": train.has_air_conditioning + } + + # Cache the response + cache_key = f"crowd_data_{train_id}" + cache.set(cache_key, response_data, timeout=30) # Cache for 30 seconds + + return Response(response_data) + except Train.DoesNotExist: return Response( - { - "status": "success", - "data": {"current": current_data, "historical": historical_data}, - "timestamp": timezone.now(), - } + {"error": "Train not found"}, + status=status.HTTP_404_NOT_FOUND ) except Exception as e: logger.error(f"Error getting crowd levels: {str(e)}") return Response( {"error": "Failed to retrieve crowd levels"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_500_INTERNAL_SERVER_ERROR ) diff --git a/apps/trains/constants.py b/apps/trains/constants.py index ad80a6c1..3f187ef9 100644 --- a/apps/trains/constants.py +++ b/apps/trains/constants.py @@ -3,7 +3,7 @@ # Line configurations with actual station counts LINE_CONFIG = { - "LINE_1": { + "LINE_First_Line": { "has_ac_percentage": 50, "total_trains": 30, "speed_limit": 80, @@ -15,7 +15,7 @@ ("MARG", "El-Marg"), ], }, - "LINE_2": { + "LINE_Second_Line": { "has_ac_percentage": 50, "total_trains": 20, "speed_limit": 80, @@ -27,7 +27,7 @@ ("MONIB", "El-Monib"), ], }, - "LINE_3": { + "LINE_Third_Line": { "has_ac_percentage": 100, "total_trains": 25, "speed_limit": 100, @@ -50,8 +50,12 @@ ] DIRECTION_CHOICES = [ - ("NORTHBOUND", "Northbound"), - ("SOUTHBOUND", "Southbound"), + ("HELWAN", "Helwan"), + ("MARG", "El-Marg"), + ("SHOBRA", "Shobra El Kheima"), + ("MONIB", "El-Monib"), + ("ADLY", "Adly Mansour"), + ("KIT_KAT", "Kit Kat"), ] # Train types diff --git a/apps/trains/management/commands/initialize_trains.py b/apps/trains/management/commands/initialize_trains.py index f29ed571..43572e91 100644 --- a/apps/trains/management/commands/initialize_trains.py +++ b/apps/trains/management/commands/initialize_trains.py @@ -1,6 +1,8 @@ +from decimal import ROUND_DOWN, Decimal, DecimalException import logging import random +from dj_database_url import config from django.core.management.base import BaseCommand from django.db import transaction from django.utils import timezone @@ -20,86 +22,150 @@ def handle(self, *args, **kwargs): with transaction.atomic(): self.stdout.write("Initializing trains...") - # Delete existing trains + # Clear existing data Train.objects.all().delete() + TrainCar.objects.all().delete() # Get all lines and log their names lines = Line.objects.all() self.stdout.write(f"Found lines: {', '.join([line.name for line in lines])}") for line in lines: - line_name = f"LINE_{line.name}" - config = LINE_CONFIG.get(line_name) + # Remove spaces and special characters from line name + formatted_line_name = line.name.replace(" ", "_") + line_config_key = f"LINE_{formatted_line_name}" + config = LINE_CONFIG.get(line_config_key) if not config: self.stdout.write( self.style.WARNING( - f"No config for line {line.name}. " f'Available configs: {", ".join(LINE_CONFIG.keys())}' + f"No config for line {line.name}. " + f'Available configs: {", ".join(LINE_CONFIG.keys())}' ) ) continue # Get actual stations for this line stations = Station.objects.filter(lines=line).order_by("station_lines__order") + station_count = stations.count() - self.stdout.write(f"Processing {line.name}: Found {stations.count()} stations") + self.stdout.write(f"Processing {line.name}: Found {station_count} stations") - if not stations.exists(): + if not station_count: self.stdout.write(self.style.WARNING(f"No stations found for {line.name}")) continue # Calculate trains needed total_trains = config["total_trains"] ac_trains = int((config["has_ac_percentage"] / 100) * total_trains) - station_spacing = max(1, stations.count() // total_trains) + station_spacing = max(1, station_count // total_trains) self.stdout.write( - f"Creating {ac_trains} AC and {total_trains - ac_trains} " f"non-AC trains for {line.name}" + f"Creating {ac_trains} AC and {total_trains - ac_trains} " + f"non-AC trains for {line.name}" ) try: # Create AC trains for i in range(ac_trains): - station_index = (i * station_spacing) % stations.count() + station_index = (i * station_spacing) % station_count train = self._create_train(line, i + 1, True, stations, station_index, config) self._create_cars(train, is_peak=self._is_peak_hour()) # Create non-AC trains for i in range(total_trains - ac_trains): - station_index = ((i + ac_trains) * station_spacing) % stations.count() - train = self._create_train(line, i + ac_trains + 1, False, stations, station_index, config) + station_index = ((i + ac_trains) * station_spacing) % station_count + train = self._create_train( + line, i + ac_trains + 1, False, stations, station_index, config + ) self._create_cars(train, is_peak=self._is_peak_hour()) self.stdout.write(self.style.SUCCESS(f"Created {total_trains} trains for {line.name}")) except Exception as e: self.stdout.write(self.style.ERROR(f"Error creating trains for {line.name}: {str(e)}")) - def _create_train(self, line, number, has_ac, stations, station_index, config): - """Create a train with realistic data""" + def _create_train(self, line, number, has_ac, station_list, station_index, config): + """ + Create a train with comprehensive error handling and validation. + + Args: + line: Line object + number: Train number within the line + has_ac: Boolean indicating AC status + stations: QuerySet of stations + station_index: Current station index + config: Line configuration dictionary + + Returns: + Train object + """ try: - current_station = stations[station_index] - next_station = stations[station_index + 1] if station_index < len(stations) - 1 else stations[0] + # Validate input parameters + if not all([line, station_list, config]): + raise ValueError("Missing required parameters") - # Get direction based on station order - direction = self._determine_direction(line, current_station, next_station) + # Get current and next stations with validation + try: + current_station = station_list[station_index] + next_station = station_list[station_index + 1] if station_index < len(station_list) - 1 else station_list[0] + except IndexError as e: + raise ValueError(f"Invalid station index: {station_index}") from e - # Determine status - is_peak = self._is_peak_hour() - status = "IN_SERVICE" if (is_peak or random.random() > 0.1) else random.choice(["DELAYED", "MAINTENANCE"]) + # Get direction based on station order + current_order = current_station.get_station_order(line) + next_order = next_station.get_station_order(line) - # Convert and round coordinates to Decimal with 6 decimal places - from decimal import ROUND_DOWN, Decimal + # Use directions from the passed config + directions = config["directions"] + if current_order is None or next_order is None: + self.stdout.write(self.style.WARNING( + f"Station order not found for line {line.name}: " + f"Current: {current_station.name}, Next: {next_station.name}" + )) + direction = directions[0][0] # Default direction + else: + direction = directions[0][0] if next_order > current_order else directions[1][0] + + # Generate train_id with validation + line_mapping = { + 'First': '1', + 'Second': '2', + 'Third': '3' + } + + line_number = line_mapping.get(line.name.split()[0]) + if not line_number: + raise ValueError(f"Invalid line name format: {line.name}") + + # Convert train_id to string format + train_id = f"{line_number}{number:03d}" + + # Handle coordinates try: - lat = Decimal(str(current_station.latitude)).quantize(Decimal("0.000001"), rounding=ROUND_DOWN) - lon = Decimal(str(current_station.longitude)).quantize(Decimal("0.000001"), rounding=ROUND_DOWN) - except Exception as e: + lat = Decimal(str(current_station.latitude or 0)).quantize( + Decimal("0.000001"), + rounding=ROUND_DOWN + ) + lon = Decimal(str(current_station.longitude or 0)).quantize( + Decimal("0.000001"), + rounding=ROUND_DOWN + ) + except (TypeError, DecimalException) as e: self.stdout.write(self.style.WARNING(f"Error converting coordinates: {e}")) - lat = Decimal("0.000000") - lon = Decimal("0.000000") + lat, lon = Decimal("0.000000"), Decimal("0.000000") + # Determine status + is_peak = self._is_peak_hour() + status = ( + "IN_SERVICE" + if (is_peak or random.random() > 0.1) + else random.choice(["DELAYED", "MAINTENANCE"]) + ) + + # Create train train = Train.objects.create( - train_id=f'{line.name}_{number:03d}_{"AC" if has_ac else "NONAC"}', + train_id=train_id, line=line, has_air_conditioning=has_ac, number_of_cars=CARS_PER_TRAIN, @@ -113,11 +179,34 @@ def _create_train(self, line, number, has_ac, stations, station_index, config): last_updated=timezone.now(), ) - self.stdout.write(f"Created train: {train.train_id}") + self.stdout.write( + self.style.SUCCESS( + f"Created train {train.train_id} on line {line.name}\n" + f"Direction: {direction} ({current_station.name} → {next_station.name})\n" + f"Status: {status}, AC: {has_ac}" + ) + ) return train except Exception as e: - self.stdout.write(self.style.ERROR(f"Error creating train: {str(e)}")) + self.stdout.write( + self.style.ERROR( + f"Error creating train for line {line.name}:\n" + f"Error type: {type(e).__name__}\n" + f"Error message: {str(e)}" + ) + ) + raise + + except Exception as e: + # Log detailed error information + self.stdout.write( + self.style.ERROR( + f"Error creating train for line {line.name}:\n" + f"Error type: {type(e).__name__}\n" + f"Error message: {str(e)}" + ) + ) raise def _create_cars(self, train, is_peak): @@ -177,17 +266,23 @@ def _determine_direction(self, line, current_station, next_station): try: current_order = current_station.get_station_order(line) next_order = next_station.get_station_order(line) + directions = config["directions"] - line_config = LINE_CONFIG[f"LINE_{line.name}"] - directions = line_config["directions"] + # Log station orders for debugging + self.stdout.write( + f"Determining direction for line {line.name}: " + f"Current station ({current_station.name}) order: {current_order}, " + f"Next station ({next_station.name}) order: {next_order}" + ) - # Default to first direction if order comparison fails if current_order is None or next_order is None: + self.stdout.write(f"Using default direction {directions[0][0]}") return directions[0][0] - return directions[0][0] if next_order > current_order else directions[1][0] + direction = directions[0][0] if next_order > current_order else directions[1][0] + self.stdout.write(f"Selected direction: {direction}") + return direction except Exception as e: self.stdout.write(self.style.ERROR(f"Error determining direction: {str(e)}")) - # Return default direction - return LINE_CONFIG[f"LINE_{line.name}"]["directions"][0][0] + return config["directions"][0][0] diff --git a/apps/trains/migrations/0001_initial.py b/apps/trains/migrations/0001_initial.py new file mode 100644 index 00000000..343606d9 --- /dev/null +++ b/apps/trains/migrations/0001_initial.py @@ -0,0 +1,402 @@ +# Generated by Django 4.2.18 on 2025-02-22 23:47 + +from decimal import Decimal +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("stations", "0005_connectingstation"), + ] + + operations = [ + migrations.CreateModel( + name="Train", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "train_id", + models.BigIntegerField( + db_index=True, + help_text="Unique numeric identifier for the train", + unique=True, + ), + ), + ("number_of_cars", models.IntegerField(default=10)), + ("has_air_conditioning", models.BooleanField(default=False)), + ( + "train_type", + models.CharField( + choices=[ + ("AC", "Air Conditioned"), + ("NON_AC", "Non Air Conditioned"), + ], + default="NON_AC", + max_length=10, + ), + ), + ( + "direction", + models.CharField( + choices=[ + ("HELWAN", "Helwan"), + ("MARG", "El-Marg"), + ("SHOBRA", "Shobra El Kheima"), + ("MONIB", "El-Monib"), + ("ADLY", "Adly Mansour"), + ("KIT_KAT", "Kit Kat"), + ], + max_length=20, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("IN_SERVICE", "In Service"), + ("DELAYED", "Delayed"), + ("MAINTENANCE", "Under Maintenance"), + ("OUT_OF_SERVICE", "Out of Service"), + ], + default="IN_SERVICE", + max_length=20, + ), + ), + ( + "latitude", + models.DecimalField( + decimal_places=6, + default=Decimal("0.000000"), + max_digits=9, + validators=[django.core.validators.DecimalValidator(9, 6)], + ), + ), + ( + "longitude", + models.DecimalField( + decimal_places=6, + default=Decimal("0.000000"), + max_digits=9, + validators=[django.core.validators.DecimalValidator(9, 6)], + ), + ), + ("speed", models.FloatField(default=0)), + ("last_updated", models.DateTimeField(auto_now=True)), + ( + "current_station", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="current_trains", + to="stations.station", + ), + ), + ( + "line", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="stations.line" + ), + ), + ( + "next_station", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="incoming_trains", + to="stations.station", + ), + ), + ], + options={ + "ordering": ["train_id"], + }, + ), + migrations.CreateModel( + name="TrainCar", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "car_number", + models.IntegerField( + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(10), + ] + ), + ), + ("capacity", models.IntegerField(default=180)), + ( + "current_load", + models.IntegerField( + default=0, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ("is_operational", models.BooleanField(default=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ( + "train", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="cars", + to="trains.train", + ), + ), + ], + options={ + "ordering": ["car_number"], + }, + ), + migrations.CreateModel( + name="Schedule", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("arrival_time", models.TimeField()), + ("departure_time", models.TimeField()), + ( + "day_type", + models.CharField( + choices=[ + ("WEEKDAY", "Weekday"), + ("SATURDAY", "Saturday"), + ("SUNDAY", "Sunday"), + ("HOLIDAY", "Holiday"), + ], + max_length=10, + ), + ), + ( + "sequence_number", + models.PositiveIntegerField( + validators=[django.core.validators.MinValueValidator(1)] + ), + ), + ("is_active", models.BooleanField(default=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ( + "station", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="stations.station", + ), + ), + ( + "train", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="schedules", + to="trains.train", + ), + ), + ], + options={ + "ordering": ["sequence_number"], + }, + ), + migrations.CreateModel( + name="CrowdMeasurement", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ("passenger_count", models.IntegerField()), + ( + "crowd_percentage", + models.FloatField( + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(100), + ] + ), + ), + ( + "confidence_score", + models.FloatField( + help_text="Confidence level of the measurement (0-1)", + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(1), + ], + ), + ), + ( + "measurement_method", + models.CharField( + choices=[ + ("AI_CAMERA", "AI Camera Detection"), + ("WEIGHT_SENSOR", "Weight Sensor"), + ("MANUAL", "Manual Count"), + ("ESTIMATED", "AI Estimated"), + ], + help_text="Method used to measure crowd levels", + max_length=20, + ), + ), + ( + "train_car", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="measurements", + to="trains.traincar", + ), + ), + ], + options={ + "ordering": ["-timestamp"], + }, + ), + migrations.CreateModel( + name="ActualSchedule", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("actual_arrival", models.DateTimeField(null=True)), + ("actual_departure", models.DateTimeField(null=True)), + ("delay_minutes", models.IntegerField(default=0)), + ( + "status", + models.CharField( + choices=[ + ("ON_TIME", "On Time"), + ("DELAYED", "Delayed"), + ("CANCELLED", "Cancelled"), + ("SKIPPED", "Station Skipped"), + ("DIVERTED", "Train Diverted"), + ], + default="ON_TIME", + max_length=20, + ), + ), + ("reason", models.TextField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "schedule", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="trains.schedule", + ), + ), + ], + ), + migrations.AddIndex( + model_name="traincar", + index=models.Index( + fields=["train", "car_number"], name="trains_trai_train_i_429d09_idx" + ), + ), + migrations.AddIndex( + model_name="traincar", + index=models.Index( + fields=["is_operational"], name="trains_trai_is_oper_329dcd_idx" + ), + ), + migrations.AlterUniqueTogether( + name="traincar", + unique_together={("train", "car_number")}, + ), + migrations.AddIndex( + model_name="train", + index=models.Index( + fields=["train_id"], name="trains_trai_train_i_be36ab_idx" + ), + ), + migrations.AddIndex( + model_name="train", + index=models.Index( + fields=["line", "status"], name="trains_trai_line_id_1cbd44_idx" + ), + ), + migrations.AddIndex( + model_name="train", + index=models.Index( + fields=["current_station"], name="trains_trai_current_a486a7_idx" + ), + ), + migrations.AddIndex( + model_name="schedule", + index=models.Index( + fields=["train", "day_type"], name="trains_sche_train_i_3f8118_idx" + ), + ), + migrations.AddIndex( + model_name="schedule", + index=models.Index( + fields=["station", "arrival_time"], + name="trains_sche_station_74b613_idx", + ), + ), + migrations.AddIndex( + model_name="crowdmeasurement", + index=models.Index( + fields=["timestamp"], name="trains_crow_timesta_b9189c_idx" + ), + ), + migrations.AddIndex( + model_name="crowdmeasurement", + index=models.Index( + fields=["train_car", "timestamp"], name="trains_crow_train_c_6778e1_idx" + ), + ), + migrations.AddIndex( + model_name="crowdmeasurement", + index=models.Index( + fields=["measurement_method"], name="trains_crow_measure_c34a27_idx" + ), + ), + migrations.AddIndex( + model_name="actualschedule", + index=models.Index( + fields=["schedule", "status"], name="trains_actu_schedul_bd871b_idx" + ), + ), + migrations.AddIndex( + model_name="actualschedule", + index=models.Index( + fields=["created_at"], name="trains_actu_created_f45a0a_idx" + ), + ), + ] diff --git a/apps/trains/migrations/0002_alter_train_train_id.py b/apps/trains/migrations/0002_alter_train_train_id.py new file mode 100644 index 00000000..85ca270f --- /dev/null +++ b/apps/trains/migrations/0002_alter_train_train_id.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.18 on 2025-02-22 23:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("trains", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="train", + name="train_id", + field=models.CharField( + db_index=True, + help_text="Unique numeric identifier for the train", + max_length=10, + unique=True, + ), + ), + ] diff --git a/apps/trains/migrations/0003_alter_crowdmeasurement_confidence_score_and_more.py b/apps/trains/migrations/0003_alter_crowdmeasurement_confidence_score_and_more.py new file mode 100644 index 00000000..7b72c03c --- /dev/null +++ b/apps/trains/migrations/0003_alter_crowdmeasurement_confidence_score_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.18 on 2025-02-23 00:26 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("trains", "0002_alter_train_train_id"), + ] + + operations = [ + migrations.AlterField( + model_name="crowdmeasurement", + name="confidence_score", + field=models.DecimalField( + decimal_places=2, + help_text="Confidence level of the measurement (0-1)", + max_digits=3, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(1), + ], + ), + ), + migrations.AlterField( + model_name="crowdmeasurement", + name="crowd_percentage", + field=models.DecimalField( + decimal_places=2, + max_digits=5, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(100), + ], + ), + ), + ] diff --git a/apps/trains/migrations/__init__.py b/apps/trains/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/trains/models/crowd.py b/apps/trains/models/crowd.py index 1f8334d1..eb5896c9 100644 --- a/apps/trains/models/crowd.py +++ b/apps/trains/models/crowd.py @@ -49,7 +49,10 @@ def save(self, *args, **kwargs): @property def load_percentage(self): """Calculate the current load percentage""" - return (self.current_load / self.capacity) * 100 if self.capacity else 0 + if self.capacity: + percentage = (self.current_load / self.capacity) * 100 + return round(percentage, 2) # Round to 2 decimal places + return 0 @property def crowd_status(self): @@ -91,8 +94,14 @@ class CrowdMeasurement(models.Model): train_car = models.ForeignKey(TrainCar, on_delete=models.CASCADE, related_name="measurements") timestamp = models.DateTimeField(auto_now_add=True) passenger_count = models.IntegerField() - crowd_percentage = models.FloatField(validators=[MinValueValidator(0), MaxValueValidator(100)]) - confidence_score = models.FloatField( + crowd_percentage = models.DecimalField( + max_digits=5, + decimal_places=2, # Store only 2 decimal places + validators=[MinValueValidator(0), MaxValueValidator(100)] + ) + confidence_score = models.DecimalField( + max_digits=3, + decimal_places=2, # Store only 2 decimal places validators=[MinValueValidator(0), MaxValueValidator(1)], help_text="Confidence level of the measurement (0-1)", ) @@ -116,6 +125,10 @@ def is_reliable(self): """Check if measurement is considered reliable""" return self.confidence_score >= 0.8 + @property + def formatted_crowd_percentage(self): + return round(self.crowd_percentage, 2) + @classmethod def get_recent_measurements(cls, train_car, minutes=15): """Get recent measurements for a train car""" diff --git a/apps/trains/models/train.py b/apps/trains/models/train.py index 20bbb17c..6e04a0ea 100644 --- a/apps/trains/models/train.py +++ b/apps/trains/models/train.py @@ -9,7 +9,12 @@ class Train(models.Model): - train_id = models.CharField(max_length=50, unique=True) + train_id = models.CharField( + max_length=10, + unique=True, + db_index=True, + help_text="Unique numeric identifier for the train" + ) line = models.ForeignKey("stations.Line", on_delete=models.CASCADE) number_of_cars = models.IntegerField(default=CARS_PER_TRAIN) has_air_conditioning = models.BooleanField(default=False) @@ -43,9 +48,12 @@ class Meta: models.Index(fields=["line", "status"]), models.Index(fields=["current_station"]), ] + ordering = ['train_id'] def __str__(self): - return f"{self.train_id} ({self.line.name})" + station_info = f" at {self.current_station.name}" if self.current_station else "" + ac_status = "AC" if self.has_air_conditioning else "Non-AC" + return f"Train {self.train_id} - Line {self.line.name} ({ac_status}){station_info}" def clean(self): if self.line_id: @@ -70,7 +78,16 @@ def clean(self): if line_config and self.speed > line_config["speed_limit"]: raise ValidationError(f"Speed exceeds line limit of {line_config['speed_limit']} km/h") + def get_train_number(self): + """Extract the train number from train_id""" + return int(str(self.train_id)[-3:]) + + def get_line_number(self): + """Extract the line number from train_id""" + return int(str(self.train_id)[:-3]) + def save(self, *args, **kwargs): + # Update train_type based on AC status self.train_type = "AC" if self.has_air_conditioning else "NON_AC" self.full_clean() super().save(*args, **kwargs) @@ -98,3 +115,10 @@ def is_peak_hour(self): if start <= current_time <= end: return True return False + + @property + def formatted_id(self): + """Return a formatted version of the train ID""" + line_num = self.get_line_number() + train_num = self.get_train_number() + return f"{line_num}-{train_num:03d}" diff --git a/apps/trains/services/crowd_service.py b/apps/trains/services/crowd_service.py index a0f4cc1b..50fdef38 100644 --- a/apps/trains/services/crowd_service.py +++ b/apps/trains/services/crowd_service.py @@ -6,6 +6,8 @@ from django.db.models import Avg from django.utils import timezone +from apps.trains.constants import CARS_PER_TRAIN + from ..models import CrowdMeasurement, TrainCar logger = logging.getLogger(__name__) @@ -41,6 +43,40 @@ def update_crowd_level(train_car_id, passenger_count, method="AI_CAMERA", confid logger.error(f"Error updating crowd level: {e}") raise + def get_crowd_levels(self, train): + """Get current crowd levels for all cars in a train""" + try: + # Get all cars ordered by number + cars = train.cars.all().order_by('car_number') + crowd_data = [] + + # Ensure we have data for all 10 cars + for car_number in range(1, CARS_PER_TRAIN + 1): + car = next((c for c in cars if c.car_number == car_number), None) + + if car: + crowd_data.append({ + "car_id": car.car_number, + "crowd_level": car.load_percentage / 100, + "timestamp": car.last_updated.isoformat() + }) + else: + # Add default data for missing cars + crowd_data.append({ + "car_id": car_number, + "crowd_level": 0.0, + "timestamp": timezone.now().isoformat() + }) + + return { + "train_id": train.id, + "crowd_data": crowd_data, + "is_ac": train.has_air_conditioning + } + except Exception as e: + logger.error(f"Error getting crowd levels: {e}") + return None + @staticmethod def get_crowd_history(train_car_id, hours=24): """Get crowd level history for a train car""" diff --git a/backup.sql b/backup.sql new file mode 100644 index 00000000..e69de29b diff --git a/cert.crt b/cert.crt new file mode 100644 index 00000000..4512f342 --- /dev/null +++ b/cert.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDKDCCAhCgAwIBAgIULdhNfLSzPt+yZYW0/9+H1+1czhEwDQYJKoZIhvcNAQEL +BQAwMDEaMBgGA1UECgwRRHVtbXkgQ2VydGlmaWNhdGUxEjAQBgNVBAMMCWxvY2Fs +aG9zdDAeFw0yNTAyMjIyMjQyMDhaFw0yNjAyMjIyMjQyMDhaMDAxGjAYBgNVBAoM +EUR1bW15IENlcnRpZmljYXRlMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCoGyKC6MwxdcWwjvXl7aWKBip182bk895q +njRWL+tESt0K7y5IOGo3yONnW14tHz2AHuazdsUf9pwrw9PpsQU02zs+k4AtLXFX +5G6HUYJ/u/QiX7ynr0FfZnG0QIzwtGymj/mMEVU3qV1zkKZ0l7dMpTouZ8KQtmiD +IJWu8M/B/O0gJ3BSzufNsQ/OpkHqhBFqMjpDKPO518rYy4LJZVbDgNTqjqWW6t7Z +ejhlWINuyR0OQI2owMuxAtTSh3k1PWhvVHcs1sLHZR+rgRF6TRQwcXQK0A5Kgg5y +nJuzQGdm5tVc/eVKj56JxkQ2dUKTbDhWIlwjcBpx/v8r8QrLEyZ/AgMBAAGjOjA4 +MBMGA1UdJQQMMAoGCCsGAQUFBwMBMCEGA1UdEQQaMBiCCWxvY2FsaG9zdIILKi5s +b2NhbGhvc3QwDQYJKoZIhvcNAQELBQADggEBABgK7C6ozwtFq7yZWovL+iQjBSIY +k0nJ6Ccga9VpcjqXa8DO9m7H4uj8itUSPddJa5WBf68SstLZEM2qt/D1Jh9Fr0qq +ScCb0Blra4DaMhIt5BE+tBA534R2+uTagqctPWw1iQdcetrV5QSerU/s6Y0dQrw3 +giJjuIYEQwhnqzy77CLRQaYdLS+14PAtD3M2ARAmyaS9rUWZ9AvAKwxUxOiRgADi +9/71Z2f8JEVI2KoXPwfQ5Nh2PnYMQceOfJC52flA2+Rt+MWg8zB4EWCCG8F319T6 +BdS+un6N6xipxw9X+1vknczuKZOJ24Rh8N5mbRDCVEkd9SImjlk7VQ/Ufd0= +-----END CERTIFICATE----- diff --git a/cert.key b/cert.key new file mode 100644 index 00000000..19429736 --- /dev/null +++ b/cert.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAqBsigujMMXXFsI715e2ligYqdfNm5PPeap40Vi/rRErdCu8u +SDhqN8jjZ1teLR89gB7ms3bFH/acK8PT6bEFNNs7PpOALS1xV+Ruh1GCf7v0Il+8 +p69BX2ZxtECM8LRspo/5jBFVN6ldc5CmdJe3TKU6LmfCkLZogyCVrvDPwfztICdw +Us7nzbEPzqZB6oQRajI6QyjzudfK2MuCyWVWw4DU6o6llure2Xo4ZViDbskdDkCN +qMDLsQLU0od5NT1ob1R3LNbCx2Ufq4ERek0UMHF0CtAOSoIOcpybs0BnZubVXP3l +So+eicZENnVCk2w4ViJcI3Aacf7/K/EKyxMmfwIDAQABAoIBAFK8tDIpIOLQ+8EH +w7fsTJbPrRNLfl9+NYBVKB4pTIparEtG6AkMlx6opV/H5OShZRQLYXF/qcqvBjM2 +GTUB9rWKlzCXyTSzEn93Qv0f3XI/0AshAomnZK3vDlRwls2bh4Q9BU3uc/pGxeNs +lLh6uXEkOyv+zpCJ7otnPLSS+jYXmT5616HDK2LcnukfFcE+8x3J//Mcg9YnOGUH +7Q2n9qWmXhw5/n60QEnT53orWBzZn7fLTFVUiFZIpTj2pgCAk4DjtsVRotxjPujf +2EHByX6Tjj4YYT0q5SGQzYHI9bZqQtDu0m3Fkzaf2Afoo0Lczq4OdqhaTeg02R3E +GnxiDi0CgYEA2DqaNGyC3NU5DF/vdNOjWA3D0ChRaY6YEyx+z32Vex6AahspGSTW +vRLKD1D3JyWWrxUPyxGD8lUPCAuNP6aDYsSHH1PA7Nj1uCGbJXa5NO9oy5bVFnbv +9R9RQNrWIxQ0LNLq/fxGnW1rtXqXZniU2doOTYl5WAarJusgUoNie9UCgYEAxwab +aXFwjdx675bHORaiVtqKG4HsVow55EBIkpm81NuaADly9rctTZLkY5Z+ryGDD63O +54fipd4nJVlkuATDeYCqMcupReWwK0FJsDTpT9gbcQW9VFz8BdZYMyE42HMILHju +/9NFdfPG+of4vWPcmVcAUSK6bVWienMBPAY1ZwMCgYEAvTNh9Rcx1ouYNHTJcV8y +YX0B5uUwf32vIa/gcixAHRAOa0nCx2aNo7mKJqbA7Hceh6qVPCniW6wD0BVTjetk +sYqndUjy2gvpzilRuRFOc7w85U/gupxt0P2LOoWBtAYctVAFISKao2u4QH65dYdz +L8B2nW/6RoTnQ9GfYbPm+9UCgYAMdpV03O23dH8eUcrYAFraJYVQg6pRISbe7Eef +JpF0ouvZ578TRBVKLlCvC9WTEf4rFS9HwHKpAGhjJl3wSBwVl8EQrThEcEW/7661 +ohqaLWZKhZl9lvURSILtcL8mjs2MO2Z8Drbi1Ak51xn0gjRmRdR3QD18a0hpcpo5 +MApYEQKBgEZyMa7HJ0WAJ8fp1VcxQfyFXb45DeTnuxglZmbShr7Gz6wjx0U/gu2r +2v+EEhtW3AIBPALHQ8tI+HlawJHEYcc8DUJh31gfJO9/c3Z+zDz/F90Ly/96rc41 +gRuauxUyeNx5wuWJIEnTI4fv+aLi7byUw7rPU99pt4OGF4wK6in1 +-----END RSA PRIVATE KEY----- diff --git a/metro/settings.py b/metro/settings.py index a705f08d..8a1477b4 100644 --- a/metro/settings.py +++ b/metro/settings.py @@ -34,20 +34,10 @@ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = os.getenv("SECRET_KEY") # Secret key for Django -DEBUG = os.getenv("DEBUG", "False") == "True" # Default to False -ALLOWED_HOSTS = [ - "127.0.0.1", - "localhost", - "backend-54v5.onrender.com", # Your production domain -] -DEBUG = True +ENVIRONMENT = os.getenv("ENVIRONMENT", "dev") # Environment (dev, test, prod) +DEBUG = ENVIRONMENT == "dev" # Debug mode based on environment BASE_URL = os.getenv("BASE_URL") # Base URL for the project JWT_SECRET = os.getenv("JWT_SECRET") # Secret key for JWT tokens -CSRF_TRUSTED_ORIGINS = [ - "https://backend-54v5.onrender.com", - "http://127.0.0.1:8000", - "http://localhost:8000", -] # Set API start time to the application's boot time API_START_TIME = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") @@ -56,6 +46,7 @@ INSTALLED_APPS = [ "django.contrib.admin", # Admin panel "django.contrib.auth", # Authentication framework + 'django_rest_passwordreset', # Password reset "django.contrib.contenttypes", # Content types framework "django.contrib.sessions", # Sessions framework "django.contrib.messages", # Messages framework @@ -74,11 +65,14 @@ "channels", # Channels "import_export", # Import and export data "rangefilter", # Range filter for Django admin + 'sslserver', # SSL server for development + 'django_extensions', # Django extensions # Custom apps "apps.users.apps.UsersConfig", # Users app "apps.stations.apps.StationsConfig", # Stations app "apps.routes.apps.RoutesConfig", # Routes app "apps.trains.apps.TrainsConfig", # Trains app + "apps.authentication.apps.AuthenticationConfig", ] # Middleware configuration @@ -107,6 +101,33 @@ AI_SERVICE_API_KEY = "your-api-key" # API key for authentication AI_SERVICE_TIMEOUT = 30 # seconds +# Email Configuration (Production-focused with Mailgun) +EMAIL_BACKEND = 'anymail.backends.mailgun.EmailBackend' +ANYMAIL = { + "MAILGUN_API_KEY": os.getenv("MAILGUN_API_KEY"), + "MAILGUN_SENDER_DOMAIN": os.getenv("MAILGUN_DOMAIN"), + "MAILGUN_API_URL": "https://api.eu.mailgun.net/v3", # Use EU endpoint if in Europe +} + +# Email settings +DEFAULT_FROM_EMAIL = f"Metro App " +EMAIL_TIMEOUT = 30 +EMAIL_SUBJECT_PREFIX = '[Metro] ' + +# Password Reset Settings +DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG = { + "CLASS": "django_rest_passwordreset.tokens.RandomStringTokenGenerator", + "OPTIONS": { + "min_length": 20, + "max_length": 30 + } +} +DJANGO_REST_PASSWORDRESET_NO_INFORMATION_LEAKAGE = True +DJANGO_REST_MULTITOKENAUTH_RESET_TOKEN_EXPIRY_TIME = 24 # Hours + +# Define REQUIRED_ENV_VARS and add to it +REQUIRED_ENV_VARS = ["MAILGUN_API_KEY", "MAILGUN_DOMAIN"] + # For production Redis # if ENVIRONMENT == 'prod': # REDIS_HOST = os.getenv('REDIS_HOST', 'your-production-redis-host') @@ -115,13 +136,48 @@ # REDIS_HOST = os.getenv('REDIS_HOST', 'redis') # REDIS_PORT = int(os.getenv('REDIS_PORT', 6379)) +# Security Settings based on environment +if ENVIRONMENT == 'prod': + # Production settings + SECURE_SSL_REDIRECT = True + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +else: + # Development settings + SECURE_SSL_REDIRECT = False + SESSION_COOKIE_SECURE = False + CSRF_COOKIE_SECURE = False + SECURE_PROXY_SSL_HEADER = None + +# Update ALLOWED_HOSTS and CORS settings +ALLOWED_HOSTS = [ + "127.0.0.1", + "localhost", + "backend-54v5.onrender.com", +] + +CORS_ALLOWED_ORIGINS = [ + "http://127.0.0.1:8000", + "http://localhost:8000", + "https://backend-54v5.onrender.com", +] if ENVIRONMENT == "prod" else [ + "http://127.0.0.1:8000", + "http://localhost:8000", + "http://127.0.0.1:3000", + "http://localhost:3000", +] + +# Add development URLs to CSRF trusted origins +CSRF_TRUSTED_ORIGINS = [ + "http://127.0.0.1:8000", + "http://localhost:8000", + "http://127.0.0.1:3000", + "http://localhost:3000", + "https://backend-54v5.onrender.com", +] + # CORS settings -CORS_ALLOW_ALL_ORIGINS = os.getenv("CORS_ALLOW_ALL_ORIGINS", "False") == "True" -if not CORS_ALLOW_ALL_ORIGINS: - CORS_ALLOWED_ORIGINS = [ - "https://backend-54v5.onrender.com", - "http://localhost:8000", - ] CORS_ALLOW_HEADERS = list(default_headers) + [ "Authorization", # Authorization header "Content-Type", # Content type header @@ -243,17 +299,18 @@ # Security settings Production CSRF_COOKIE_SECURE = True # Ensure CSRF cookies are only sent over HTTPS SESSION_COOKIE_SECURE = True # Ensure session cookies are only sent over HTTPS - # SECURE_BROWSER_XSS_FILTER = True # Enable XSS protection for browsers - # SECURE_CONTENT_TYPE_NOSNIFF = True # Prevent content type sniffing - # SECURE_HSTS_SECONDS = 31536000 # 1 year in seconds - # SECURE_HSTS_INCLUDE_SUBDOMAINS = True # Include subdomains for HSTS - # SECURE_HSTS_PRELOAD = True # Enable HSTS preload list + SECURE_BROWSER_XSS_FILTER = True # Enable XSS protection for browsers + SECURE_CONTENT_TYPE_NOSNIFF = True # Prevent content type sniffing + SECURE_HSTS_SECONDS = 31536000 # 1 year in seconds + SECURE_HSTS_INCLUDE_SUBDOMAINS = True # Include subdomains for HSTS + SECURE_HSTS_PRELOAD = True # Enable HSTS preload list SECURE_SSL_REDIRECT = True # Redirect HTTP to HTTPS - # # Proxy Settings - # USE_X_FORWARDED_HOST = True + + # Proxy Settings + USE_X_FORWARDED_HOST = True SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") - # SECURE_REFERRER_POLICY = "same-origin" # Referrer policy - # X_FRAME_OPTIONS = "DENY" # Prevent framing of site content + SECURE_REFERRER_POLICY = "same-origin" # Referrer policy + X_FRAME_OPTIONS = "DENY" # Prevent framing of site content REQUIRED_ENV_VARS = ["SECRET_KEY", "DATABASE_URL", "JWT_SECRET", "BASE_URL"] @@ -301,6 +358,7 @@ "DEFAULT_PERMISSION_CLASSES": ( "rest_framework.permissions.IsAuthenticated", # Default to authenticated users "rest_framework.permissions.AllowAny", # Allow any user + 'rest_framework_simplejwt.authentication.JWTAuthentication', ), "DEFAULT_RENDERER_CLASSES": [ "rest_framework.renderers.JSONRenderer", # Default renderer @@ -318,8 +376,8 @@ "rest_framework.throttling.AnonRateThrottle", ], "DEFAULT_THROTTLE_RATES": { - "anon": "60/minute", # Anonymous users can make 60 requests per minute - "user": "120/minute", # Authenticated users can make 120 requests per minute + 'anon': '100/day', + 'user': '1000/day', "station_lookup": "10/second", # For specific station lookup endpoints "route_planning": "30/minute", # For route and trip planning APIs "ticket_booking": "15/minute", # For ticket booking and QR code generation @@ -420,7 +478,7 @@ "DEFAULT_TIMEOUT": (30, "Default timeout for user actions."), } -# Session Settings +# Session Configuration SESSION_ENGINE = "django.contrib.sessions.backends.cached_db" # Cached database session engine SESSION_CACHE_ALIAS = "default" # Cache alias for sessions SESSION_COOKIE_AGE = 3600 # Session cookie age in seconds (1 hour) diff --git a/metro/urls.py b/metro/urls.py index 8ac410fd..abd52559 100644 --- a/metro/urls.py +++ b/metro/urls.py @@ -69,7 +69,8 @@ path("api/users/", include("apps.users.urls")), # User path("api/stations/", include("apps.stations.urls")), # Stations path("api/routes/", include("apps.routes.urls")), # Routes - path("api/trains/", include("apps.routes.urls")), # Routes + path("api/trains/", include("apps.routes.urls")), # Trains + path("api/auth/", include("apps.authentication.urls")), # Tickets # Miscellaneous path("health/", health_check, name="health_check"), # Health check # Documentation diff --git a/render.yaml b/render.yaml index 727774d4..3b1996f0 100644 --- a/render.yaml +++ b/render.yaml @@ -28,6 +28,10 @@ services: value: "False" - key: PYTHON_VERSION # Specify the Python version value: "3.11.10" + - key: MAILGUN_API_KEY + sync: false + - key: MAILGUN_DOMAIN + sync: false disk: # Persistent storage (optional) name: persistent-data mountPath: /var/lib/egypt-metro diff --git a/requirements.txt b/requirements.txt index 1252b039..522967e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,6 +33,7 @@ dj-database-url==2.3.0 Django==4.2.18 django-admin-rangefilter==0.13.2 django-allauth==65.3.0 +django-anymail==12.0 django-constance==4.1.3 django-cors-headers==4.6.0 django-db-geventpool==4.0.7 @@ -41,8 +42,11 @@ django-environ==0.11.2 django-extensions==3.2.3 django-filter==25.1 django-import-export==4.3.5 +django-mailgun-provider==0.2.3 django-redis==5.4.0 +django-rest-passwordreset==1.5.0 django-silk==5.3.2 +django-sslserver==0.22 djangorestframework==3.15.2 djangorestframework-simplejwt==5.3.1 docker==7.1.0 @@ -123,6 +127,7 @@ pydantic==1.10.19 pyflakes==3.2.0 Pygments==2.18.0 PyJWT==2.10.1 +pyOpenSSL==25.0.0 pyparsing==3.1.4 pyproject_hooks==1.2.0 pytest==6.2.5 @@ -145,6 +150,7 @@ requests-toolbelt==1.0.0 rfc3986==1.5.0 rpds-py==0.22.3 rsa==4.9 +setuptools==75.8.0 shellingham==1.5.4 simplejwt==2.0.1 six==1.16.0 @@ -169,5 +175,7 @@ uvicorn==0.15.0 virtualenv==20.29.1 waitress==3.0.2 wcwidth==0.2.13 +Werkzeug==3.1.3 +wheel==0.45.1 whitenoise==6.7.0 yarl==1.18.3