some features

This commit is contained in:
Flatlogic Bot 2026-02-01 13:59:50 +00:00
parent e1e791abf6
commit 3bb4e04513
25 changed files with 677 additions and 90 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

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
from .models import Profile, Parcel, Country, Governate, City, PlatformProfile, Testimonial, DriverRating, NotificationTemplate, PricingRule, DriverReport, DriverRejection
from django.utils.translation import gettext_lazy as _
from django.urls import path, reverse
from django.shortcuts import render, redirect
@ -23,7 +23,7 @@ class ProfileInline(admin.StackedInline):
can_delete = False
verbose_name_plural = _('Profiles')
fieldsets = (
(None, {'fields': ('role', 'is_approved', 'phone_number', 'profile_picture', 'address')}),
(None, {'fields': ('role', 'is_approved', 'is_banned', 'ban_reason', 'phone_number', 'profile_picture', 'address')}),
(_('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',)}),
@ -31,8 +31,8 @@ class ProfileInline(admin.StackedInline):
class CustomUserAdmin(UserAdmin):
inlines = (ProfileInline,)
list_display = ('username', 'email', 'get_role', 'get_driver_grade', 'get_approval_status', 'is_active', 'is_staff', 'send_whatsapp_link')
list_filter = ('is_active', 'is_staff', 'profile__role', 'profile__is_approved', 'profile__driver_grade')
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')
def get_role(self, obj):
return obj.profile.get_role_display()
@ -48,6 +48,11 @@ class CustomUserAdmin(UserAdmin):
return obj.profile.is_approved
get_approval_status.short_description = _('Approved')
get_approval_status.boolean = True
def get_ban_status(self, obj):
return obj.profile.is_banned
get_ban_status.short_description = _('Banned')
get_ban_status.boolean = True
def get_inline_instances(self, request, obj=None):
if not obj:
@ -203,6 +208,10 @@ 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.')
}),
(_('Testing / Development'), {
'fields': ('auto_mark_paid',),
'description': _('Enable this to automatically mark NEW parcels as "Paid" (useful for testing so drivers can see them immediately).')
@ -332,6 +341,28 @@ class TestimonialAdmin(admin.ModelAdmin):
search_fields = ('name_en', 'name_ar', 'content_en', 'content_ar')
list_editable = ('is_active',)
class DriverReportAdmin(admin.ModelAdmin):
list_display = ('driver', 'reporter', 'reason', 'status', 'created_at')
list_filter = ('status', 'reason', 'created_at')
search_fields = ('driver__username', 'reporter__username', 'description', 'admin_note')
list_editable = ('status',)
fieldsets = (
(_('Report Details'), {
'fields': ('reporter', 'driver', 'parcel', 'reason', 'description', 'created_at')
}),
(_('Investigation'), {
'fields': ('status', 'admin_note')
}),
)
readonly_fields = ('created_at',)
class DriverRejectionAdmin(admin.ModelAdmin):
list_display = ('driver', 'parcel', 'reason', 'created_at')
list_filter = ('created_at',)
search_fields = ('driver__username', 'parcel__tracking_number', 'reason')
readonly_fields = ('driver', 'parcel', 'reason', 'created_at')
admin.site.unregister(User)
admin.site.register(User, CustomUserAdmin)
admin.site.register(Parcel, ParcelAdmin)
@ -342,6 +373,9 @@ admin.site.register(PlatformProfile, PlatformProfileAdmin)
admin.site.register(Testimonial, TestimonialAdmin)
admin.site.register(DriverRating)
admin.site.register(PricingRule, PricingRuleAdmin)
admin.site.register(DriverReport, DriverReportAdmin)
admin.site.register(DriverRejection, DriverRejectionAdmin)
class NotificationTemplateAdmin(admin.ModelAdmin):
list_display = ('key', 'description')
readonly_fields = ('key', 'description', 'available_variables')

View File

@ -2,7 +2,7 @@ from django import forms
from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _
from django.utils.translation import get_language
from .models import Profile, Parcel, Country, Governate, City, DriverRating
from .models import Profile, Parcel, Country, Governate, City, DriverRating, DriverReport
class ContactForm(forms.Form):
name = forms.CharField(max_length=100, label=_("Name"), widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Your Name')}))
@ -393,4 +393,17 @@ class DriverRatingForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Reverse choices for CSS star rating logic (5 to 1) to ensure left-to-right filling
self.fields['rating'].choices = [(i, str(i)) for i in range(5, 0, -1)]
self.fields['rating'].choices = [(i, str(i)) for i in range(5, 0, -1)]
class DriverReportForm(forms.ModelForm):
class Meta:
model = DriverReport
fields = ['reason', 'description']
widgets = {
'reason': forms.Select(attrs={'class': 'form-select'}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4, 'placeholder': _('Please provide details about the incident...')}),
}
labels = {
'reason': _('Reason for Reporting'),
'description': _('Details'),
}

View File

@ -0,0 +1,36 @@
# Generated by Django 5.2.7 on 2026-02-01 13:24
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0028_platformprofile_accepting_shipments_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='DriverReport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reason', models.CharField(choices=[('unprofessional', 'Unprofessional Behavior'), ('reckless_driving', 'Reckless Driving'), ('delayed_delivery', 'Significant Delay'), ('item_damaged', 'Item Damaged'), ('item_missing', 'Item Missing'), ('other', 'Other')], max_length=50, verbose_name='Reason')),
('description', models.TextField(verbose_name='Detailed Description')),
('status', models.CharField(choices=[('pending', 'Pending Investigation'), ('investigating', 'Investigating'), ('resolved', 'Resolved'), ('dismissed', 'Dismissed')], default='pending', max_length=20, verbose_name='Status')),
('admin_note', models.TextField(blank=True, verbose_name='Admin Internal Note')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
('driver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports_received', to=settings.AUTH_USER_MODEL, verbose_name='Driver')),
('parcel', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reports', to='core.parcel', verbose_name='Related Parcel')),
('reporter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='filed_reports', to=settings.AUTH_USER_MODEL, verbose_name='Reporter')),
],
options={
'verbose_name': 'Driver Report',
'verbose_name_plural': 'Driver Reports',
'ordering': ['-created_at'],
},
),
]

View File

@ -0,0 +1,51 @@
# Generated by Django 5.2.7 on 2026-02-01 13:38
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0029_driverreport'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='platformprofile',
name='auto_ban_on_rejections',
field=models.BooleanField(default=False, help_text='Automatically ban drivers who exceed a certain number of rejections.', verbose_name='Enable Auto-Ban on Rejections'),
),
migrations.AddField(
model_name='platformprofile',
name='rejection_limit',
field=models.PositiveIntegerField(default=5, help_text='Number of rejections allowed before auto-ban.', verbose_name='Rejection Limit'),
),
migrations.AddField(
model_name='profile',
name='ban_reason',
field=models.TextField(blank=True, verbose_name='Ban Reason'),
),
migrations.AddField(
model_name='profile',
name='is_banned',
field=models.BooleanField(default=False, verbose_name='Banned'),
),
migrations.CreateModel(
name='DriverRejection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reason', models.TextField(verbose_name='Reason for Rejection')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('driver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rejections', to=settings.AUTH_USER_MODEL, verbose_name='Driver')),
('parcel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rejections', to='core.parcel', verbose_name='Parcel')),
],
options={
'verbose_name': 'Driver Rejection',
'verbose_name_plural': 'Driver Rejections',
'ordering': ['-created_at'],
},
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2026-02-01 13:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0030_platformprofile_auto_ban_on_rejections_and_more'),
]
operations = [
migrations.AddField(
model_name='platformprofile',
name='ticker_bg_color',
field=models.CharField(default='#FFFFFF', help_text='Background color for the live activity ticker (e.g. #FFFFFF or white).', max_length=20, verbose_name='Ticker Background Color'),
),
migrations.AddField(
model_name='platformprofile',
name='ticker_limit',
field=models.PositiveIntegerField(default=10, help_text='Number of recent shipments to show in the live activity ticker.', verbose_name='Ticker Shipment Limit'),
),
migrations.AddField(
model_name='platformprofile',
name='ticker_text_color',
field=models.CharField(default='#1A1A1D', help_text='Text color for the live activity ticker.', max_length=20, verbose_name='Ticker Text Color'),
),
]

View File

@ -98,6 +98,10 @@ 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)."))
# Ban Status
is_banned = models.BooleanField(_('Banned'), default=False)
ban_reason = models.TextField(_('Ban Reason'), blank=True)
def __str__(self):
return f"{self.user.username} - {self.get_role_display()}"
@ -183,6 +187,14 @@ class PlatformProfile(models.Model):
maintenance_message_en = models.TextField(_("Maintenance Message (English)"), blank=True, help_text=_("Message to show when shipments are stopped."))
maintenance_message_ar = models.TextField(_("Maintenance Message (Arabic)"), blank=True, help_text=_("Message to show when shipments are stopped."))
# Driver Rejection / 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
ticker_limit = models.PositiveIntegerField(_("Ticker Shipment Limit"), default=10, help_text=_("Number of recent shipments to show in the live activity ticker."))
ticker_bg_color = models.CharField(_("Ticker Background Color"), max_length=20, default="#FFFFFF", help_text=_("Background color for the live activity ticker (e.g. #FFFFFF or white)."))
ticker_text_color = models.CharField(_("Ticker Text Color"), max_length=20, default="#1A1A1D", help_text=_("Text color for the live activity ticker."))
@property
def maintenance_message(self):
if get_language() == "ar":
@ -442,3 +454,55 @@ class NotificationTemplate(models.Model):
class Meta:
verbose_name = _('Notification Template')
verbose_name_plural = _('Notification Templates')
class DriverReport(models.Model):
STATUS_CHOICES = (
('pending', _('Pending Investigation')),
('investigating', _('Investigating')),
('resolved', _('Resolved')),
('dismissed', _('Dismissed')),
)
REASON_CHOICES = (
('unprofessional', _('Unprofessional Behavior')),
('reckless_driving', _('Reckless Driving')),
('delayed_delivery', _('Significant Delay')),
('item_damaged', _('Item Damaged')),
('item_missing', _('Item Missing')),
('other', _('Other')),
)
reporter = models.ForeignKey(User, on_delete=models.CASCADE, related_name='filed_reports', verbose_name=_('Reporter'))
driver = models.ForeignKey(User, on_delete=models.CASCADE, related_name='reports_received', verbose_name=_('Driver'))
parcel = models.ForeignKey('Parcel', on_delete=models.SET_NULL, null=True, blank=True, related_name='reports', verbose_name=_('Related Parcel'))
reason = models.CharField(_('Reason'), max_length=50, choices=REASON_CHOICES)
description = models.TextField(_('Detailed Description'))
status = models.CharField(_('Status'), max_length=20, choices=STATUS_CHOICES, default='pending')
admin_note = models.TextField(_('Admin Internal Note'), blank=True)
created_at = models.DateTimeField(_('Created At'), auto_now_add=True)
updated_at = models.DateTimeField(_('Updated At'), auto_now=True)
def __str__(self):
return f"Report against {self.driver.username} by {self.reporter.username} - {self.get_status_display()}"
class Meta:
verbose_name = _('Driver Report')
verbose_name_plural = _('Driver Reports')
ordering = ['-created_at']
class DriverRejection(models.Model):
driver = models.ForeignKey(User, on_delete=models.CASCADE, related_name='rejections', verbose_name=_('Driver'))
parcel = models.ForeignKey(Parcel, on_delete=models.CASCADE, related_name='rejections', verbose_name=_('Parcel'))
reason = models.TextField(_('Reason for Rejection'))
created_at = models.DateTimeField(_('Created At'), auto_now_add=True)
def __str__(self):
return f"Rejection by {self.driver.username} for {self.parcel.tracking_number}"
class Meta:
verbose_name = _('Driver Rejection')
verbose_name_plural = _('Driver Rejections')
ordering = ['-created_at']

View File

@ -35,6 +35,7 @@
<!-- Custom Styles -->
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
<link rel="stylesheet" href="{% static 'css/custom_v2.css' %}?v={{ deployment_timestamp }}">
{% if LANGUAGE_BIDI %}
<style>

View File

@ -76,10 +76,15 @@
<div class="text-primary fw-bold fs-4">{{ parcel.price }} <span class="fs-6">OMR</span></div>
</div>
<form action="{% url 'accept_parcel' parcel.id %}" method="POST">
{% csrf_token %}
<button type="submit" class="btn btn-masarx-primary w-100">{% trans "Accept Shipment" %}</button>
</form>
<div class="d-grid gap-2">
<form action="{% url 'accept_parcel' parcel.id %}" method="POST">
{% csrf_token %}
<button type="submit" class="btn btn-masarx-primary w-100">{% trans "Accept Shipment" %}</button>
</form>
<button type="button" class="btn btn-outline-danger w-100" data-bs-toggle="modal" data-bs-target="#rejectModal{{ parcel.id }}">
{% trans "Reject" %}
</button>
</div>
</div>
</div>
</div>
@ -114,11 +119,16 @@
</div>
<div class="col-md-4 text-md-end">
<div class="d-flex flex-column align-items-md-end gap-2">
<div class="text-primary fw-bold fs-5">{{ parcel.price }} OMR</div>
<form action="{% url 'accept_parcel' parcel.id %}" method="POST" class="w-100 w-md-auto">
{% csrf_token %}
<button type="submit" class="btn btn-masarx-primary btn-sm w-100">{% trans "Accept" %}</button>
</form>
<div class="text-primary fw-bold fs-5 mb-1">{{ parcel.price }} OMR</div>
<div class="d-flex gap-2 w-100 w-md-auto">
<form action="{% url 'accept_parcel' parcel.id %}" method="POST" class="flex-grow-1">
{% csrf_token %}
<button type="submit" class="btn btn-masarx-primary btn-sm w-100">{% trans "Accept" %}</button>
</form>
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectModal{{ parcel.id }}">
{% trans "Reject" %}
</button>
</div>
</div>
</div>
</div>
@ -128,6 +138,38 @@
</div>
</div>
<!-- Rejection Modals -->
{% for parcel in available_parcels %}
<div class="modal fade" id="rejectModal{{ parcel.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-bold">{% trans "Reject Shipment" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url 'reject_parcel' parcel.id %}" method="POST">
{% csrf_token %}
<div class="modal-body">
<p class="text-muted small">{% trans "Please provide a reason for rejecting shipment" %} <strong>#{{ parcel.tracking_number }}</strong>. {% trans "This information is tracked by administration." %}</p>
<div class="mb-3">
<label class="form-label small fw-bold">{% trans "Reason for Rejection" %}</label>
<textarea name="reason" class="form-control" rows="3" required placeholder="{% trans 'e.g., Too far, item too large, etc.' %}"></textarea>
</div>
<div class="alert alert-info small mb-0">
<i class="bi bi-info-circle me-1"></i>
{% trans "Note: Excessive rejections may lead to automated account suspension." %}
</div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
<button type="submit" class="btn btn-danger px-4">{% trans "Submit Rejection" %}</button>
</div>
</form>
</div>
</div>
</div>
{% endfor %}
<!-- Pagination -->
{% if available_parcels.has_other_pages %}
<nav aria-label="Page navigation" class="mt-5">

View File

@ -43,6 +43,34 @@
</div>
</section>
{% if recent_shipments %}
<div class="ticker-wrap" style="background-color: {{ platform_profile.ticker_bg_color|default:'#FFFFFF' }};">
<div class="ticker" style="color: {{ platform_profile.ticker_text_color|default:'#1A1A1D' }};">
{% for parcel in recent_shipments %}
<div class="ticker-item">
<i class="bi bi-activity"></i>
<strong>{% trans "Shipment:" %}</strong> {{ parcel.pickup_city.name }}
<i class="bi {% if LANGUAGE_CODE == 'ar' %}bi-arrow-left{% else %}bi-arrow-right{% endif %} mx-2"></i>
{{ parcel.delivery_city.name }}
<span class="ticker-badge">{{ parcel.get_status_display }}</span>
<span style="opacity: 0.6; font-size: 0.8rem;" class="ms-2">{{ parcel.created_at|timesince }} {% trans "ago" %}</span>
</div>
{% endfor %}
<!-- Duplicate for seamless loop -->
{% for parcel in recent_shipments %}
<div class="ticker-item">
<i class="bi bi-activity"></i>
<strong>{% trans "Shipment:" %}</strong> {{ parcel.pickup_city.name }}
<i class="bi {% if LANGUAGE_CODE == 'ar' %}bi-arrow-left{% else %}bi-arrow-right{% endif %} mx-2"></i>
{{ parcel.delivery_city.name }}
<span class="ticker-badge">{{ parcel.get_status_display }}</span>
<span style="opacity: 0.6; font-size: 0.8rem;" class="ms-2">{{ parcel.created_at|timesince }} {% trans "ago" %}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Features Section -->
<section id="how-it-works" class="py-5 bg-white">
<div class="container py-5">

View File

@ -0,0 +1,78 @@
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
<section class="py-5">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'dashboard' %}">{% trans "Dashboard" %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% trans "Report Driver" %}</li>
</ol>
</nav>
<div class="card shadow-sm border-0">
<div class="card-body p-4">
<h2 class="h4 mb-4 text-danger">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
{% trans "Report Driver" %}
</h2>
<div class="alert alert-light border mb-4">
<div class="row align-items-center">
<div class="col-auto">
{% if parcel.carrier.profile.profile_picture %}
<img src="{{ parcel.carrier.profile.profile_picture.url }}" class="rounded-circle" width="50" height="50" style="object-fit: cover;">
{% else %}
<div class="bg-secondary rounded-circle d-flex align-items-center justify-content-center text-white" style="width: 50px; height: 50px;">
<i class="bi bi-person-fill h4 mb-0"></i>
</div>
{% endif %}
</div>
<div class="col">
<p class="mb-0 fw-bold">{{ parcel.carrier.get_full_name|default:parcel.carrier.username }}</p>
<small class="text-muted">{% trans "Tracking ID" %}: {{ parcel.tracking_number }}</small>
</div>
</div>
</div>
<form method="post">
{% csrf_token %}
{% for field in form %}
<div class="mb-3">
<label class="form-label fw-bold">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}
<div class="form-text">{{ field.help_text }}</div>
{% endif %}
{% if field.errors %}
<div class="text-danger small mt-1">
{{ field.errors }}
</div>
{% endif %}
</div>
{% endfor %}
<div class="d-grid gap-2 d-md-flex justify-content-md-end mt-4">
<a href="{% url 'dashboard' %}" class="btn btn-light px-4">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-danger px-5">
{% trans "Submit Report" %}
</button>
</div>
</form>
</div>
</div>
<div class="mt-4 p-3 bg-light rounded small text-muted">
<p class="mb-0">
<i class="bi bi-info-circle me-1"></i>
{% trans "Our administration team investigates all reports. Misuse of the reporting system may lead to account suspension." %}
</p>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -128,6 +128,7 @@
{% if parcel.carrier.profile.is_recommended %}
<span class="badge bg-success small ms-1" title="{% trans "Recommended by Shippers" %}"><i class="bi bi-hand-thumbs-up"></i></span>
{% endif %}
<a href="{% url 'report_driver' parcel.id %}" class="text-danger ms-2 small" title="{% trans "Report Driver" %}"><i class="bi bi-flag"></i></a>
{% else %}
{% trans "Waiting for pickup" %}
{% endif %}</p>
@ -177,6 +178,7 @@
{% if parcel.carrier.profile.is_recommended %}
<span class="badge bg-success small ms-1" title="{% trans "Recommended by Shippers" %}"><i class="bi bi-hand-thumbs-up"></i></span>
{% endif %}
<a href="{% url 'report_driver' parcel.id %}" class="text-danger ms-1" title="{% trans "Report Driver" %}"><i class="bi bi-flag"></i></a>
{% else %}
{% trans "Waiting" %}
{% endif %}
@ -306,6 +308,7 @@
<td>
{% if parcel.carrier %}
{{ parcel.carrier.get_full_name|default:parcel.carrier.username }}
<a href="{% url 'report_driver' parcel.id %}" class="text-danger ms-1 small" title="{% trans "Report Driver" %}"><i class="bi bi-flag"></i></a>
{% else %}
-
{% endif %}
@ -380,6 +383,7 @@
<td>
{% if parcel.carrier %}
{{ parcel.carrier.get_full_name|default:parcel.carrier.username }}
<a href="{% url 'report_driver' parcel.id %}" class="text-danger ms-1 small" title="{% trans "Report Driver" %}"><i class="bi bi-flag"></i></a>
{% else %}
-
{% endif %}
@ -454,4 +458,4 @@
if (listViewBtn) listViewBtn.addEventListener('click', () => setView('list'));
});
</script>
{% endblock %}
{% endblock %}

View File

@ -1,64 +1,28 @@
from django.urls import path
from django.contrib.auth import views as auth_views
from . import views
from . import api_views
from . import views, api_views
urlpatterns = [
path('', views.index, name='index'),
path('login/', views.CustomLoginView.as_view(), name='login'),
path('login/select-method/', views.select_2fa_method, name='select_2fa_method'),
path('login/verify-2fa/', views.verify_2fa_otp, name='verify_2fa_otp'),
path('logout/', auth_views.LogoutView.as_view(next_page='/'), name='logout'),
# Registration Flow
path('track/', views.track_parcel, name='track'),
path('register/', views.register, name='register'),
path('register/shipper/', views.register_shipper, name='register_shipper'),
path('register/driver/', views.register_driver, name='register_driver'),
path('register/verify/', views.verify_registration, name='verify_registration'),
path('verify-registration/', views.verify_registration, name='verify_registration'),
path('login/', views.CustomLoginView.as_view(), name='login'),
path('login/2fa/select/', views.select_2fa_method, name='select_2fa_method'),
path('login/2fa/verify/', views.verify_2fa_otp, name='verify_2fa_otp'),
path('logout/', views.logout, name='logout'),
# Password Reset URLs
path('password-reset/', auth_views.PasswordResetView.as_view(
template_name='core/password_reset_form.html',
email_template_name='core/emails/password_reset_email.txt',
html_email_template_name='core/emails/password_reset_email.html',
subject_template_name='core/emails/password_reset_subject.txt',
success_url='/password-reset/done/'
), name='password_reset'),
path('password-reset/done/', auth_views.PasswordResetDoneView.as_view(
template_name='core/password_reset_done.html'
), name='password_reset_done'),
path('reset/<uidb64>/<token>/', auth_views.PasswordResetConfirmView.as_view(
template_name='core/password_reset_confirm.html',
success_url='/reset/done/'
), name='password_reset_confirm'),
path('reset/done/', auth_views.PasswordResetCompleteView.as_view(
template_name='core/password_reset_complete.html'
), name='password_reset_complete'),
path('dashboard/', views.dashboard, name='dashboard'),
path('scan-qr/', views.scan_qr_view, name='scan_qr'),
path('shipment-request/', views.shipment_request, name='shipment_request'),
path('parcel/<int:parcel_id>/edit/', views.edit_parcel, name='edit_parcel'),
path('parcel/<int:parcel_id>/cancel/', views.cancel_parcel, name='cancel_parcel'),
path('track/', views.track_parcel, name='track'),
path('accept-parcel/<int:parcel_id>/', views.accept_parcel, name='accept_parcel'),
path('update-status/<int:parcel_id>/', views.update_status, name='update_status'),
path('rate-driver/<int:parcel_id>/', views.rate_driver, name='rate_driver'),
path('parcel/<int:parcel_id>/label/', views.generate_parcel_label, name='generate_parcel_label'),
path('parcel/<int:parcel_id>/invoice/', views.generate_invoice, name='generate_invoice'),
path('initiate-payment/<int:parcel_id>/', views.initiate_payment, name='initiate_payment'),
path('payment-success/', views.payment_success, name='payment_success'),
path('payment-cancel/', views.payment_cancel, name='payment_cancel'),
path('article/1/', views.article_detail, name='article_detail'),
path('article/', views.article_detail, name='article_detail'),
path('ajax/get-governates/', views.get_governates, name='get_governates'),
path('ajax/get-cities/', views.get_cities, name='get_cities'),
path('ajax/chatbot/', views.chatbot, name='chatbot'),
path('ajax/get-parcel-details/', views.get_parcel_details, name='get_parcel_details'),
path('ajax/update-parcel-status/', views.update_parcel_status_ajax, name='update_parcel_status_ajax'),
path('privacy-policy/', views.privacy_policy, name='privacy_policy'),
path('terms-conditions/', views.terms_conditions, name='terms_conditions'),
@ -66,25 +30,37 @@ urlpatterns = [
path('profile/', views.profile_view, name='profile'),
path('profile/edit/', views.edit_profile, name='edit_profile'),
path('profile/verify-otp/', views.verify_otp_view, name='verify_otp'),
path('verify-otp/', views.verify_otp_view, name='verify_otp'),
# OTP Login
path('login/request-otp/', views.request_login_otp, name='request_login_otp'),
path('login/verify-otp/', views.verify_login_otp, name='verify_login_otp'),
# API Endpoints (Standard)
path('api/auth/token/', api_views.CustomAuthToken.as_view(), name='api_token_auth'),
path('api/parcels/', api_views.ParcelListCreateView.as_view(), name='api_parcel_list'),
path('api/parcels/<int:pk>/', api_views.ParcelDetailView.as_view(), name='api_parcel_detail'),
path('api/track/<str:tracking_number>/', api_views.PublicParcelTrackView.as_view(), name='api_track_parcel'),
path('api/profile/', api_views.UserProfileView.as_view(), name='api_user_profile'),
path('api/calculate-price/', api_views.PriceCalculatorView.as_view(), name='api_calculate_price'),
path('rate-driver/<int:parcel_id>/', views.rate_driver, name='rate_driver'),
# Aliases for mobile app compatibility (API v1)
path('api/shipments/', api_views.ParcelListCreateView.as_view(), name='api_shipment_list'),
path('api/shipments/<int:pk>/', api_views.ParcelDetailView.as_view(), name='api_shipment_detail'),
# Root-level Aliases (for apps hardcoded to /shipments/)
path('shipments/', api_views.ParcelListCreateView.as_view(), name='root_shipment_list'),
path('shipments/<int:pk>/', api_views.ParcelDetailView.as_view(), name='root_shipment_detail'),
]
# OTP Login / Passwordless
path('ajax/request-login-otp/', views.request_login_otp, name='request_login_otp'),
path('ajax/verify-login-otp/', views.verify_login_otp, name='verify_login_otp'),
# Chatbot
path('ajax/chatbot/', views.chatbot, name='chatbot'),
# Document Generation
path('parcel-label/<int:parcel_id>/', views.generate_parcel_label, name='generate_parcel_label'),
path('invoice/<int:parcel_id>/', views.generate_invoice, name='generate_invoice'),
# QR Scanner
path('scan-qr/', views.scan_qr_view, name='scan_qr'),
path('ajax/get-parcel-details/', views.get_parcel_details, name='get_parcel_details'),
path('ajax/update-parcel-status/', views.update_parcel_status_ajax, name='update_parcel_status_ajax'),
path('edit-parcel/<int:parcel_id>/', views.edit_parcel, name='edit_parcel'),
path('cancel-parcel/<int:parcel_id>/', views.cancel_parcel, name='cancel_parcel'),
path('accept-parcel/<int:parcel_id>/', views.accept_parcel, name='accept_parcel'),
path('reject-parcel/<int:parcel_id>/', views.reject_parcel, name='reject_parcel'),
path('report-driver/<int:parcel_id>/', views.report_driver, name='report_driver'),
# API Endpoints (for Mobile App)
path('api/v1/parcels/', api_views.ParcelListCreateView.as_view(), name='api_parcel_list'),
path('api/v1/parcels/<int:pk>/', api_views.ParcelDetailView.as_view(), name='api_parcel_detail'),
path('api/v1/pricing/', api_views.PriceCalculatorView.as_view(), name='api_pricing'),
path('api/v1/profile/', api_views.UserProfileView.as_view(), name='api_profile'),
]

View File

@ -4,8 +4,8 @@ from django.contrib.auth import login, authenticate, logout
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from .models import Parcel, Profile, Country, Governate, City, OTPVerification, PlatformProfile, Testimonial, DriverRating
from .forms import UserRegistrationForm, ParcelForm, ContactForm, UserProfileForm, DriverRatingForm, ShipperRegistrationForm, DriverRegistrationForm
from .models import Parcel, Profile, Country, Governate, City, OTPVerification, PlatformProfile, Testimonial, DriverRating, DriverRejection
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
@ -44,6 +44,9 @@ def index(request):
return redirect(f"{reverse('track')}?tracking_number={tracking_id}")
testimonials = Testimonial.objects.filter(is_active=True)
platform_profile = PlatformProfile.objects.first()
ticker_limit = platform_profile.ticker_limit if platform_profile else 10
recent_shipments = Parcel.objects.exclude(status="cancelled").order_by("-created_at")[:ticker_limit]
# Top 5 Drivers (by Average Rating)
top_drivers = Profile.objects.filter(role='car_owner').annotate(
@ -59,7 +62,8 @@ def index(request):
return render(request, 'core/index.html', {
'testimonials': testimonials,
'top_drivers': top_drivers,
'top_shippers': top_shippers
'top_shippers': top_shippers,
'recent_shipments': recent_shipments
})
def track_parcel(request):
@ -210,6 +214,12 @@ def verify_registration(request):
def dashboard(request):
# Ensure profile exists
profile, created = Profile.objects.get_or_create(user=request.user)
# Check for Ban
if profile.is_banned:
logout(request)
messages.error(request, _("Your account is banned. Reason: ") + profile.ban_reason)
return redirect('login')
if profile.role == 'shipper':
all_parcels = Parcel.objects.filter(shipper=request.user).order_by('-created_at')
@ -245,10 +255,13 @@ def dashboard(request):
platform_profile = PlatformProfile.objects.first()
payments_enabled = platform_profile.enable_payment if platform_profile else True
# Filter out parcels this driver already rejected
rejected_parcel_ids = DriverRejection.objects.filter(driver=request.user).values_list('parcel_id', flat=True)
if payments_enabled:
available_parcels_list = Parcel.objects.filter(status='pending', payment_status='paid').order_by('-created_at')
available_parcels_list = Parcel.objects.filter(status='pending', payment_status='paid').exclude(id__in=rejected_parcel_ids).order_by('-created_at')
else:
available_parcels_list = Parcel.objects.filter(status='pending').order_by('-created_at')
available_parcels_list = Parcel.objects.filter(status='pending').exclude(id__in=rejected_parcel_ids).order_by('-created_at')
# Check Approval Status
if not profile.is_approved:
@ -337,7 +350,11 @@ def accept_parcel(request, parcel_id):
messages.error(request, _("Only car owners can accept shipments."))
return redirect('dashboard')
# Check Approval
# Check Approval and Ban
if profile.is_banned:
messages.error(request, _("Your account is banned."))
return redirect('dashboard')
if not profile.is_approved:
messages.error(request, _("Your account is pending approval. You cannot accept shipments yet."))
return redirect('dashboard')
@ -360,6 +377,45 @@ def accept_parcel(request, parcel_id):
messages.success(request, _("You have accepted the shipment!"))
return redirect('dashboard')
@login_required
@require_POST
def reject_parcel(request, parcel_id):
profile, created = Profile.objects.get_or_create(user=request.user)
if profile.role != 'car_owner':
messages.error(request, _("Only car owners can reject shipments."))
return redirect('dashboard')
parcel = get_object_or_404(Parcel, id=parcel_id, status='pending')
reason = request.POST.get('reason')
if not reason:
messages.error(request, _("Please provide a reason for rejection."))
return redirect('dashboard')
# Record Rejection
DriverRejection.objects.create(
driver=request.user,
parcel=parcel,
reason=reason
)
# Check Auto-Ban
platform_profile = PlatformProfile.objects.first()
if platform_profile and platform_profile.auto_ban_on_rejections:
rejection_count = DriverRejection.objects.filter(driver=request.user).count()
if rejection_count >= platform_profile.rejection_limit:
profile.is_banned = True
profile.is_approved = False # Also unapprove to be safe
profile.ban_reason = _("Automated ban: Exceeded rejection limit ({}).").format(platform_profile.rejection_limit)
profile.save()
logout(request)
messages.error(request, _("Your account has been banned due to excessive shipment rejections."))
return redirect('login')
messages.success(request, _("Shipment rejected successfully."))
return redirect('dashboard')
@login_required
def update_status(request, parcel_id):
parcel = get_object_or_404(Parcel, id=parcel_id, carrier=request.user)
@ -927,7 +983,10 @@ def update_parcel_status_ajax(request):
if payments_enabled and parcel.payment_status != 'paid':
return JsonResponse({'success': False, 'error': _('Payment pending')})
# Check Approval for Driver via AJAX
# Check Approval and Ban for Driver via AJAX
if request.user.profile.is_banned:
return JsonResponse({'success': False, 'error': _('Account is banned')})
if not request.user.profile.is_approved:
return JsonResponse({'success': False, 'error': _('Account pending approval')})
@ -1098,4 +1157,35 @@ def verify_2fa_otp(request):
except OTPVerification.DoesNotExist:
messages.error(request, _("No valid OTP found. Please request a new one."))
return render(request, 'core/verify_2fa_otp.html')
return render(request, 'core/verify_2fa_otp.html')
@login_required
def report_driver(request, parcel_id):
parcel = get_object_or_404(Parcel, id=parcel_id)
# Validation: Only shipper of the parcel can report the carrier
if parcel.shipper != request.user:
messages.error(request, _("You are not authorized to report for this shipment."))
return redirect('dashboard')
if not parcel.carrier:
messages.error(request, _("No driver was assigned to this shipment."))
return redirect('dashboard')
if request.method == 'POST':
form = DriverReportForm(request.POST)
if form.is_valid():
report = form.save(commit=False)
report.reporter = request.user
report.driver = parcel.carrier
report.parcel = parcel
report.save()
messages.success(request, _("Thank you. Your report has been submitted and will be investigated."))
return redirect('dashboard')
else:
form = DriverReportForm()
return render(request, 'core/report_driver.html', {
'form': form,
'parcel': parcel
})

View File

@ -12,7 +12,6 @@
body {
font-family: 'Inter', sans-serif;
background-color: var(--soft-cloud);
color: var(--primary-dark);
}
h1, h2, h3, h4, h5, h6 {
@ -260,9 +259,81 @@ h1, h2, h3, h4, h5, h6 {
padding: 12px 30px !important;
border-radius: 12px !important;
font-weight: 600 !important;
cursor: not-allowed;
opacity: 0.8;
}
.btn-masarx-stopped:hover {
background-color: #b02a2a !important;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(214, 57, 57, 0.2);
}
/* Live Ticker Styling - Aggressive Horizontal Enforcement */
.ticker-wrap {
width: 100%;
overflow: hidden !important;
background-color: white;
padding: 15px 0;
border-bottom: 1px solid rgba(0,0,0,0.05);
white-space: nowrap !important;
position: relative;
z-index: 10;
box-shadow: 0 2px 10px rgba(0,0,0,0.02);
display: block !important;
}
.ticker {
display: inline-flex !important; /* Flex ensures items stay in line */
align-items: center;
white-space: nowrap !important;
padding-right: 50px;
box-sizing: content-box;
animation: ticker 40s linear infinite;
will-change: transform;
}
.ticker:hover {
animation-play-state: paused;
}
.ticker-item {
display: inline-flex !important;
align-items: center;
padding: 0 3rem;
font-size: 0.95rem;
color: inherit;
font-weight: 500;
white-space: nowrap !important;
flex-shrink: 0; /* Prevent items from shrinking */
}
.ticker-item i {
color: var(--accent-orange);
margin-right: 12px;
}
.ticker-badge {
font-size: 0.7rem;
padding: 3px 10px;
border-radius: 20px;
background: rgba(0,0,0,0.05); border: 1px solid currentColor;
margin-left: 10px;
vertical-align: middle;
color: inherit; opacity: 0.8;
text-transform: uppercase;
font-weight: 600;
}
@keyframes ticker {
0% { transform: translate3d(0, 0, 0); }
100% { transform: translate3d(-50%, 0, 0); } /* Updated to 50% because we duplicate items */
}
[dir="rtl"] .ticker {
animation: ticker-rtl 40s linear infinite;
}
@keyframes ticker-rtl {
0% { transform: translate3d(0, 0, 0); }
100% { transform: translate3d(50%, 0, 0); }
}

View File

@ -12,7 +12,6 @@
body {
font-family: 'Inter', sans-serif;
background-color: var(--soft-cloud);
color: var(--primary-dark);
}
h1, h2, h3, h4, h5, h6 {
@ -260,9 +259,81 @@ h1, h2, h3, h4, h5, h6 {
padding: 12px 30px !important;
border-radius: 12px !important;
font-weight: 600 !important;
cursor: not-allowed;
opacity: 0.8;
}
.btn-masarx-stopped:hover {
background-color: #b02a2a !important;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(214, 57, 57, 0.2);
}
/* Live Ticker Styling - Aggressive Horizontal Enforcement */
.ticker-wrap {
width: 100%;
overflow: hidden !important;
background-color: white;
padding: 15px 0;
border-bottom: 1px solid rgba(0,0,0,0.05);
white-space: nowrap !important;
position: relative;
z-index: 10;
box-shadow: 0 2px 10px rgba(0,0,0,0.02);
display: block !important;
}
.ticker {
display: inline-flex !important; /* Flex ensures items stay in line */
align-items: center;
white-space: nowrap !important;
padding-right: 50px;
box-sizing: content-box;
animation: ticker 40s linear infinite;
will-change: transform;
}
.ticker:hover {
animation-play-state: paused;
}
.ticker-item {
display: inline-flex !important;
align-items: center;
padding: 0 3rem;
font-size: 0.95rem;
color: inherit;
font-weight: 500;
white-space: nowrap !important;
flex-shrink: 0; /* Prevent items from shrinking */
}
.ticker-item i {
color: var(--accent-orange);
margin-right: 12px;
}
.ticker-badge {
font-size: 0.7rem;
padding: 3px 10px;
border-radius: 20px;
background: rgba(0,0,0,0.05); border: 1px solid currentColor;
margin-left: 10px;
vertical-align: middle;
color: inherit; opacity: 0.8;
text-transform: uppercase;
font-weight: 600;
}
@keyframes ticker {
0% { transform: translate3d(0, 0, 0); }
100% { transform: translate3d(-50%, 0, 0); } /* Updated to 50% because we duplicate items */
}
[dir="rtl"] .ticker {
animation: ticker-rtl 40s linear infinite;
}
@keyframes ticker-rtl {
0% { transform: translate3d(0, 0, 0); }
100% { transform: translate3d(50%, 0, 0); }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB