Autosave: 20260202-033511

This commit is contained in:
Flatlogic Bot 2026-02-02 03:35:13 +00:00
parent 9f0927a406
commit 8ac308c73f
17 changed files with 2208 additions and 643 deletions

View File

@ -1,7 +1,7 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import User
from .models import Profile, Parcel, Country, Governate, City, PlatformProfile, Testimonial, DriverRating, NotificationTemplate, PricingRule, DriverReport, DriverRejection, ParcelType
from .models import Profile, Parcel, Country, Governate, City, PlatformProfile, Testimonial, DriverRating, NotificationTemplate, PricingRule, DriverReport, DriverRejection, ParcelType, DriverWarning
from django.utils.translation import gettext_lazy as _
from django.urls import path, reverse
from django.shortcuts import render, redirect
@ -18,19 +18,23 @@ from django.template.loader import render_to_string
import weasyprint
from django.db.models import Sum
class DriverWarningInline(admin.TabularInline):
model = DriverWarning
extra = 1
class ProfileInline(admin.StackedInline):
model = Profile
can_delete = False
verbose_name_plural = _('Profiles')
fieldsets = (
(None, {'fields': ('role', 'is_approved', 'is_banned', 'ban_reason', 'phone_number', 'profile_picture', 'address')}),
(None, {'fields': ('role', 'is_approved', 'is_banned', 'ban_reason', 'phone_number', 'profile_picture', 'address', 'language')}),
(_('Driver Assessment'), {'fields': ('driver_grade', 'is_recommended')}),
(_('Driver Info'), {'fields': ('license_front_image', 'license_back_image', 'car_plate_number', 'bank_account_number'), 'classes': ('collapse',)}),
(_('Location'), {'fields': ('country', 'governate', 'city'), 'classes': ('collapse',)}),
)
class CustomUserAdmin(UserAdmin):
inlines = (ProfileInline,)
inlines = (ProfileInline, DriverWarningInline)
list_display = ('username', 'email', 'get_role', 'get_driver_grade', 'get_approval_status', 'get_ban_status', 'is_active', 'is_staff', 'send_whatsapp_link')
list_filter = ('is_active', 'is_staff', 'profile__role', 'profile__is_approved', 'profile__is_banned', 'profile__driver_grade')
@ -208,9 +212,12 @@ class PlatformProfileAdmin(admin.ModelAdmin):
'fields': ('accepting_shipments', 'maintenance_message_en', 'maintenance_message_ar'),
'description': _('Toggle to allow or stop receiving new parcel shipments. If stopped, buttons will turn red and an alert will be shown.')
}),
(_('Driver Rejection / Auto-Ban'), {
'fields': ('auto_ban_on_rejections', 'rejection_limit'),
'description': _('Configure automatic banning for drivers who reject too many shipments.')
(_('Driver Warning & Rejection / Auto-Ban'), {
'fields': (
'enable_auto_ban_on_warnings', 'max_warnings_before_ban',
'auto_ban_on_rejections', 'rejection_limit'
),
'description': _('Configure automatic banning for drivers who exceed warning or rejection limits.')
}),
(_('Testing / Development'), {
'fields': ('auto_mark_paid',),
@ -403,3 +410,10 @@ class NotificationTemplateAdmin(admin.ModelAdmin):
admin.site.register(NotificationTemplate, NotificationTemplateAdmin)
admin.site.register(ParcelType)
class DriverWarningAdmin(admin.ModelAdmin):
list_display = ('driver', 'reason', 'created_at')
list_filter = ('created_at',)
search_fields = ('driver__username', 'reason')
admin.site.register(DriverWarning, DriverWarningAdmin)

View File

@ -115,6 +115,7 @@ class UserRegistrationForm(forms.ModelForm):
profile.car_plate_number = self.cleaned_data['car_plate_number']
if 'bank_account_number' in self.cleaned_data:
profile.bank_account_number = self.cleaned_data['bank_account_number']
profile.language = get_language()
profile.save()
return user
@ -158,16 +159,18 @@ class UserProfileForm(forms.ModelForm):
class Meta:
model = Profile
fields = ['profile_picture', 'phone_number', 'address', 'country', 'governate', 'city', 'bank_account_number']
fields = ['profile_picture', 'phone_number', 'address', 'country', 'governate', 'city', 'bank_account_number', 'language']
widgets = {
'country': forms.Select(attrs={'class': 'form-control'}),
'governate': forms.Select(attrs={'class': 'form-control'}),
'city': forms.Select(attrs={'class': 'form-control'}),
'language': forms.Select(attrs={'class': 'form-control'}),
}
labels = {
'country': _('Country'),
'governate': _('Governate'),
'city': _('City'),
'language': _('Language'),
}
def __init__(self, *args, **kwargs):

View File

@ -0,0 +1,40 @@
# Generated by Django 5.2.7 on 2026-02-02 02:52
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0032_parceltype_parcel_parcel_type'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='platformprofile',
name='enable_auto_ban_on_warnings',
field=models.BooleanField(default=False, help_text='Automatically ban drivers who exceed a certain number of warnings.', verbose_name='Enable Auto-Ban on Warnings'),
),
migrations.AddField(
model_name='platformprofile',
name='max_warnings_before_ban',
field=models.PositiveIntegerField(default=3, help_text='Number of warnings allowed before auto-ban.', verbose_name='Max Warnings Before Ban'),
),
migrations.CreateModel(
name='DriverWarning',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reason', models.TextField(verbose_name='Reason for Warning')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('driver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='warnings', to=settings.AUTH_USER_MODEL, verbose_name='Driver')),
],
options={
'verbose_name': 'Driver Warning',
'verbose_name_plural': 'Driver Warnings',
'ordering': ['-created_at'],
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-02-02 03:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0033_platformprofile_enable_auto_ban_on_warnings_and_more'),
]
operations = [
migrations.AddField(
model_name='profile',
name='language',
field=models.CharField(choices=[('en', 'English'), ('ar', 'Arabic')], default='ar', max_length=10, verbose_name='Language'),
),
]

View File

@ -115,6 +115,7 @@ class Profile(models.Model):
is_recommended = models.BooleanField(_("Recommended by Shippers"), default=False)
is_approved = models.BooleanField(_('Approved'), default=False, help_text=_("Designates whether this user is approved to use the platform (mainly for drivers)."))
language = models.CharField(_("Language"), max_length=10, choices=[("en", _("English")), ("ar", _("Arabic"))], default="ar")
# Ban Status
is_banned = models.BooleanField(_('Banned'), default=False)
@ -205,6 +206,10 @@ class PlatformProfile(models.Model):
maintenance_message_ar = models.TextField(_("Maintenance Message (Arabic)"), blank=True, help_text=_("Message to show when shipments are stopped."))
# Driver Rejection / Auto-Ban
# Driver Warning / Auto-Ban
enable_auto_ban_on_warnings = models.BooleanField(_("Enable Auto-Ban on Warnings"), default=False, help_text=_("Automatically ban drivers who exceed a certain number of warnings."))
max_warnings_before_ban = models.PositiveIntegerField(_("Max Warnings Before Ban"), default=3, help_text=_("Number of warnings allowed before auto-ban."))
auto_ban_on_rejections = models.BooleanField(_("Enable Auto-Ban on Rejections"), default=False, help_text=_("Automatically ban drivers who exceed a certain number of rejections."))
rejection_limit = models.PositiveIntegerField(_("Rejection Limit"), default=5, help_text=_("Number of rejections allowed before auto-ban."))
# Live Activity Ticker
@ -523,4 +528,35 @@ class DriverRejection(models.Model):
class Meta:
verbose_name = _('Driver Rejection')
verbose_name_plural = _('Driver Rejections')
ordering = ['-created_at']
ordering = ['-created_at']
class DriverWarning(models.Model):
driver = models.ForeignKey(User, on_delete=models.CASCADE, related_name='warnings', verbose_name=_('Driver'))
reason = models.TextField(_('Reason for Warning'))
created_at = models.DateTimeField(_('Created At'), auto_now_add=True)
def __str__(self):
return f"Warning for {self.driver.username} on {self.created_at.strftime('%Y-%m-%d')}"
def save(self, *args, **kwargs):
is_new = self.pk is None
super().save(*args, **kwargs)
if is_new:
# Check for auto-ban
try:
platform_profile = PlatformProfile.objects.first()
if platform_profile and platform_profile.enable_auto_ban_on_warnings:
warning_count = DriverWarning.objects.filter(driver=self.driver).count()
if warning_count >= platform_profile.max_warnings_before_ban:
profile = self.driver.profile
profile.is_banned = True
profile.ban_reason = _("Automatically banned due to exceeding warning limit (%(limit)d warnings).") % {
'limit': platform_profile.max_warnings_before_ban
}
profile.save()
except Exception:
pass
class Meta:
verbose_name = _('Driver Warning')
verbose_name_plural = _('Driver Warnings')
ordering = ['-created_at']

View File

@ -5,9 +5,33 @@
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="mb-0">{% trans "Driver Dashboard" %}</h1>
<a href="{% url 'scan_qr' %}" class="btn btn-primary rounded-pill px-4">
<i class="bi bi-qr-code-scan me-2"></i> {% trans "Scan Parcel" %}
</a>
<div class="d-flex gap-2">
<a href="{% url 'scan_qr' %}" class="btn btn-primary rounded-pill px-4">
<i class="bi bi-qr-code-scan me-2"></i> {% trans "Scan Parcel" %}
</a>
</div>
</div>
<!-- Search Section -->
<div class="row mb-4">
<div class="col-md-12">
<form method="GET" class="position-relative">
<div class="input-group shadow-sm" style="border-radius: 12px; overflow: hidden;">
<span class="input-group-text bg-white border-end-0">
<i class="bi bi-search text-muted"></i>
</span>
<input type="text" name="q" class="form-control border-start-0 ps-0 py-3"
placeholder="{% trans 'Search by Parcel Number (e.g. MSX12345)...' %}"
value="{{ search_query }}">
<button class="btn btn-masarx-primary px-4" type="submit">{% trans "Search" %}</button>
{% if search_query %}
<a href="{% url 'dashboard' %}" class="btn btn-outline-secondary d-flex align-items-center">
<i class="bi bi-x-lg"></i>
</a>
{% endif %}
</div>
</form>
</div>
</div>
{% if not is_approved %}
@ -15,10 +39,58 @@
<i class="bi bi-hourglass-split fs-3 me-3 text-warning"></i>
<div>
<h5 class="alert-heading mb-1">{% trans "Account Pending Approval" %}</h5>
<p class="mb-0 text-muted">{% trans "Your driver account is currently under review by our administrators. You will be notified once your documents are verified and your account is approved to accept shipments." %}</p>
<p class="mb-0 text-muted">{% trans "We are currently revising your documents. Please be patient. We will inform once we finish." %}</p>
</div>
</div>
{% endif %}
<!-- Statistics Section -->
<div class="row g-4 mb-5">
<div class="col-6 col-md-4 col-lg">
<div class="card border-0 shadow-sm text-center p-3 h-100" style="border-radius: 15px; background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);">
<div class="text-primary fs-3 mb-1"><i class="bi bi-box-seam"></i></div>
<div class="fw-bold fs-4">{{ stats.accepted_count }}</div>
<div class="text-muted small">{% trans "Accepted Parcels" %}</div>
</div>
</div>
<div class="col-6 col-md-4 col-lg">
<div class="card border-0 shadow-sm text-center p-3 h-100" style="border-radius: 15px; background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);">
<div class="text-danger fs-3 mb-1"><i class="bi bi-x-circle"></i></div>
<div class="fw-bold fs-4">{{ stats.rejected_count }}</div>
<div class="text-muted small">{% trans "Rejected Parcels" %}</div>
</div>
</div>
<div class="col-6 col-md-4 col-lg">
<div class="card border-0 shadow-sm text-center p-3 h-100" style="border-radius: 15px; background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);">
<div class="text-warning fs-3 mb-1">
{% if stats.average_rating %}
{{ stats.average_rating|floatformat:1 }} <i class="bi bi-star-fill"></i>
{% else %}
N/A
{% endif %}
</div>
<div class="fw-bold fs-4">{{ stats.rating_count }}</div>
<div class="text-muted small">{% trans "Shippers Rating" %}</div>
</div>
</div>
<div class="col-6 col-md-4 col-lg">
<div class="card border-0 shadow-sm text-center p-3 h-100" style="border-radius: 15px; background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);">
<div class="text-info fs-3 mb-1"><i class="bi bi-shield-check"></i></div>
<div class="fw-bold fs-5">{{ stats.driver_grade }}</div>
<div class="text-muted small">{% trans "Admin Rate" %}</div>
{% if stats.is_recommended %}
<span class="badge bg-success rounded-pill mt-2" style="font-size: 0.6rem;">{% trans "Recommended" %}</span>
{% endif %}
</div>
</div>
<div class="col-6 col-md-4 col-lg">
<div class="card border-0 shadow-sm text-center p-3 h-100" style="border-radius: 15px; background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);">
<div class="text-danger fs-3 mb-1"><i class="bi bi-exclamation-triangle-fill"></i></div>
<div class="fw-bold fs-4">{{ stats.warning_count }} / {{ stats.max_warnings }}</div>
<div class="text-muted small">{% trans "Warnings" %}</div>
</div>
</div>
</div>
<ul class="nav nav-pills mb-4" id="pills-tab" role="tablist">
<li class="nav-item" role="presentation">
@ -176,7 +248,7 @@
<ul class="pagination justify-content-center">
{% if available_parcels.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ available_parcels.previous_page_number }}" aria-label="Previous">
<a class="page-link" href="?page={{ available_parcels.previous_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
@ -190,13 +262,13 @@
{% if available_parcels.number == i %}
<li class="page-item active"><span class="page-link">{{ i }}</span></li>
{% else %}
<li class="page-item"><a class="page-link" href="?page={{ i }}">{{ i }}</a></li>
<li class="page-item"><a class="page-link" href="?page={{ i }}{% if search_query %}&q={{ search_query }}{% endif %}">{{ i }}</a></li>
{% endif %}
{% endfor %}
{% if available_parcels.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ available_parcels.next_page_number }}" aria-label="Next">
<a class="page-link" href="?page={{ available_parcels.next_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
@ -210,7 +282,12 @@
{% endif %}
{% else %}
<p class="text-center py-5">{% trans "No shipments available at the moment." %}</p>
<div class="text-center py-5">
<p class="text-muted">{% trans "No shipments available matching your criteria." %}</p>
{% if search_query %}
<a href="{% url 'dashboard' %}" class="btn btn-link">{% trans "Clear Search" %}</a>
{% endif %}
</div>
{% endif %}
{% else %}
@ -267,7 +344,12 @@
{% endfor %}
</div>
{% else %}
<p class="text-center py-5">{% trans "You haven't accepted any shipments yet." %}</p>
<div class="text-center py-5">
<p class="text-muted">{% trans "No active deliveries matching your criteria." %}</p>
{% if search_query %}
<a href="{% url 'dashboard' %}" class="btn btn-link">{% trans "Clear Search" %}</a>
{% endif %}
</div>
{% endif %}
</div>
@ -312,7 +394,10 @@
</div>
{% else %}
<div class="text-center py-5">
<p class="lead">{% trans "No completed deliveries yet." %}</p>
<p class="lead">{% trans "No matching transaction history." %}</p>
{% if search_query %}
<a href="{% url 'dashboard' %}" class="btn btn-link">{% trans "Clear Search" %}</a>
{% endif %}
</div>
{% endif %}
</div>
@ -358,7 +443,10 @@
</div>
{% else %}
<div class="text-center py-5">
<p class="lead">{% trans "No cancelled shipments." %}</p>
<p class="lead">{% trans "No matching cancelled shipments." %}</p>
{% if search_query %}
<a href="{% url 'dashboard' %}" class="btn btn-link">{% trans "Clear Search" %}</a>
{% endif %}
</div>
{% endif %}
</div>

View File

@ -80,11 +80,13 @@
<label class="text-muted small text-uppercase">{% trans "Address" %}</label>
<p class="fw-semibold">{{ profile.address|default:"-" }}</p>
</div>
</div>
<!-- Rating Section (Drivers Only) -->
<div class="col-md-6">
<label class="text-muted small text-uppercase">{% trans "Preferred Language" %}</label>
<p class="fw-semibold">{{ profile.get_language_display }}</p>
</div>
{% if profile.role == 'car_owner' %}
<hr class="my-5">
{% if profile.role == 'car_owner' %}
<div class="mb-4">
<h4 class="fw-bold">{% trans "Driver Rating" %}</h4>
<div class="d-flex align-items-center mb-3">

View File

@ -8,6 +8,7 @@ from .models import Parcel, Profile, Country, Governate, City, OTPVerification,
from .forms import UserRegistrationForm, ParcelForm, ContactForm, UserProfileForm, DriverRatingForm, ShipperRegistrationForm, DriverRegistrationForm, DriverReportForm
from django.utils.translation import gettext_lazy as _
from django.utils.translation import get_language
from django.contrib import messages
from django.http import JsonResponse, HttpResponse
from django.urls import reverse
@ -205,6 +206,9 @@ def verify_registration(request):
# Login
login(request, user)
# Set user's language preference
if hasattr(user, 'profile') and user.profile.language:
request.session["_language"] = user.profile.language
messages.success(request, _("Account verified successfully!"))
return redirect('dashboard')
@ -257,6 +261,9 @@ def dashboard(request):
})
else:
# Car Owner view
# Search logic
search_query = request.GET.get('q', '').strip()
platform_profile = PlatformProfile.objects.first()
payments_enabled = platform_profile.enable_payment if platform_profile else True
@ -270,7 +277,7 @@ def dashboard(request):
# Check Approval Status
if not profile.is_approved:
messages.warning(request, _("Your account is pending approval. You cannot accept shipments yet."))
messages.warning(request, _("We are currently revising your documents. Please be patient. We will inform once we finish."))
# Empty list if not approved
available_parcels_list = Parcel.objects.none()
@ -285,23 +292,49 @@ def dashboard(request):
except EmptyPage:
available_parcels = paginator.page(paginator.num_pages)
# Apply search filter if query exists
if search_query:
available_parcels_list = available_parcels_list.filter(tracking_number__icontains=search_query)
# We need to re-paginate if we filtered
paginator = Paginator(available_parcels_list, 9)
try:
available_parcels = paginator.page(page)
except (PageNotAnInteger, EmptyPage):
available_parcels = paginator.page(1)
# Active: Picked up or In Transit
my_parcels = Parcel.objects.filter(carrier=request.user).exclude(status__in=['delivered', 'cancelled']).order_by('-created_at')
if search_query: my_parcels = my_parcels.filter(tracking_number__icontains=search_query)
# History: Delivered
completed_parcels = Parcel.objects.filter(carrier=request.user, status='delivered').order_by('-created_at')
if search_query: completed_parcels = completed_parcels.filter(tracking_number__icontains=search_query)
# Cancelled
cancelled_parcels = Parcel.objects.filter(carrier=request.user, status='cancelled').order_by('-created_at')
if search_query: cancelled_parcels = cancelled_parcels.filter(tracking_number__icontains=search_query)
# Statistics for Driver Dashboard
stats = {
'accepted_count': Parcel.objects.filter(carrier=request.user).exclude(status='cancelled').count(),
'rejected_count': DriverRejection.objects.filter(driver=request.user).count(),
'average_rating': profile.get_average_rating(),
'rating_count': profile.get_rating_count(),
'driver_grade': profile.get_driver_grade_display(),
'is_recommended': profile.is_recommended,
'warning_count': request.user.warnings.count(),
'max_warnings': platform_profile.max_warnings_before_ban if platform_profile else 3,
}
return render(request, 'core/driver_dashboard.html', {
'available_parcels': available_parcels,
'my_parcels': my_parcels,
'completed_parcels': completed_parcels,
'cancelled_parcels': cancelled_parcels,
'is_approved': profile.is_approved # Pass to template
'is_approved': profile.is_approved,
'stats': stats,
'search_query': search_query,
})
@login_required
def shipment_request(request):
from .models import PlatformProfile
@ -369,7 +402,7 @@ def accept_parcel(request, parcel_id):
return redirect('dashboard')
if not profile.is_approved:
messages.error(request, _("Your account is pending approval. You cannot accept shipments yet."))
messages.error(request, _("We are currently revising your documents. Please be patient. We will inform once we finish."))
return redirect('dashboard')
platform_profile = PlatformProfile.objects.first()
@ -574,6 +607,7 @@ def edit_profile(request):
'country_id': data['country'].id if data['country'] else None,
'governate_id': data['governate'].id if data['governate'] else None,
'city_id': data['city'].id if data['city'] else None,
'language': data['language'],
}
request.session['pending_profile_update'] = safe_data
@ -641,6 +675,9 @@ def verify_otp_view(request):
profile.governate_id = data['governate_id']
if data.get('city_id'):
profile.city_id = data['city_id']
if data.get('language'):
profile.language = data['language']
request.session["_language"] = data['language']
profile.save()
# Cleanup
@ -784,6 +821,9 @@ def verify_login_otp(request):
# Login
login(request, user)
# Set user's language preference
if hasattr(user, 'profile') and user.profile.language:
request.session["_language"] = user.profile.language
return JsonResponse({'success': True, 'redirect_url': reverse('dashboard')})
else:
@ -1150,12 +1190,20 @@ def verify_2fa_otp(request):
is_verified=False
).latest('created_at')
if otp_record.code == code and otp_record.is_valid():
otp_record.is_verified = True
otp_record.save()
# ACTUAL LOGIN HAPPENS HERE
if otp_record.code == code and otp_record.is_valid():
otp_record.is_verified = True
otp_record.save()
# ACTUAL LOGIN HAPPENS HERE
login(request, user)
# Set user's language preference
if hasattr(user, 'profile') and user.profile.language:
request.session["_language"] = user.profile.language
# Clean up session
if 'pre_2fa_user_id' in request.session:
@ -1168,7 +1216,7 @@ def verify_2fa_otp(request):
messages.error(request, _("Invalid or expired OTP."))
except OTPVerification.DoesNotExist:
messages.error(request, _("No valid OTP found. Please request a new one."))
messages.error(request, _("No valid OTP found. Please request a new one."))
return render(request, 'core/verify_2fa_otp.html')

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB