diff --git a/apps/authentication/__init__.py b/apps/authentication/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/authentication/admin.py b/apps/authentication/admin.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/authentication/apps.py b/apps/authentication/apps.py
new file mode 100644
index 0000000..975015d
--- /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 0000000..aee6fd1
--- /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 0000000..e69de29
diff --git a/apps/authentication/models.py b/apps/authentication/models.py
new file mode 100644
index 0000000..88d5fde
--- /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 0000000..383903b
--- /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 0000000..9864cc5
--- /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 0000000..e69de29
diff --git a/apps/authentication/templates/reset_password_email.html b/apps/authentication/templates/reset_password_email.html
new file mode 100644
index 0000000..02870f1
--- /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 0000000..e69de29
diff --git a/apps/authentication/urls.py b/apps/authentication/urls.py
new file mode 100644
index 0000000..57fc27b
--- /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 0000000..8d61b32
--- /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 10bdc3b..5e21dd7 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 9a3d86b..118efe3 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 ad80a6c..3f187ef 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 f29ed57..43572e9 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 0000000..343606d
--- /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 0000000..85ca270
--- /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 0000000..7b72c03
--- /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 0000000..e69de29
diff --git a/apps/trains/models/crowd.py b/apps/trains/models/crowd.py
index 1f8334d..eb5896c 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 20bbb17..6e04a0e 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 a0f4cc1..50fdef3 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 0000000..e69de29
diff --git a/cert.crt b/cert.crt
new file mode 100644
index 0000000..4512f34
--- /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 0000000..1942973
--- /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 a705f08..8a1477b 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 8ac410f..abd5255 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 727774d..3b1996f 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 1252b03..522967e 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