some features
This commit is contained in:
parent
e1e791abf6
commit
3bb4e04513
BIN
assets/pasted-20260201-135726-63c413b4.png
Normal file
BIN
assets/pasted-20260201-135726-63c413b4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,7 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
from django.contrib.auth.models import User
|
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.utils.translation import gettext_lazy as _
|
||||||
from django.urls import path, reverse
|
from django.urls import path, reverse
|
||||||
from django.shortcuts import render, redirect
|
from django.shortcuts import render, redirect
|
||||||
@ -23,7 +23,7 @@ class ProfileInline(admin.StackedInline):
|
|||||||
can_delete = False
|
can_delete = False
|
||||||
verbose_name_plural = _('Profiles')
|
verbose_name_plural = _('Profiles')
|
||||||
fieldsets = (
|
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 Assessment'), {'fields': ('driver_grade', 'is_recommended')}),
|
||||||
(_('Driver Info'), {'fields': ('license_front_image', 'license_back_image', 'car_plate_number', 'bank_account_number'), 'classes': ('collapse',)}),
|
(_('Driver Info'), {'fields': ('license_front_image', 'license_back_image', 'car_plate_number', 'bank_account_number'), 'classes': ('collapse',)}),
|
||||||
(_('Location'), {'fields': ('country', 'governate', 'city'), 'classes': ('collapse',)}),
|
(_('Location'), {'fields': ('country', 'governate', 'city'), 'classes': ('collapse',)}),
|
||||||
@ -31,8 +31,8 @@ class ProfileInline(admin.StackedInline):
|
|||||||
|
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(UserAdmin):
|
||||||
inlines = (ProfileInline,)
|
inlines = (ProfileInline,)
|
||||||
list_display = ('username', 'email', 'get_role', 'get_driver_grade', 'get_approval_status', 'is_active', 'is_staff', 'send_whatsapp_link')
|
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__driver_grade')
|
list_filter = ('is_active', 'is_staff', 'profile__role', 'profile__is_approved', 'profile__is_banned', 'profile__driver_grade')
|
||||||
|
|
||||||
def get_role(self, obj):
|
def get_role(self, obj):
|
||||||
return obj.profile.get_role_display()
|
return obj.profile.get_role_display()
|
||||||
@ -48,6 +48,11 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
return obj.profile.is_approved
|
return obj.profile.is_approved
|
||||||
get_approval_status.short_description = _('Approved')
|
get_approval_status.short_description = _('Approved')
|
||||||
get_approval_status.boolean = True
|
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):
|
def get_inline_instances(self, request, obj=None):
|
||||||
if not obj:
|
if not obj:
|
||||||
@ -203,6 +208,10 @@ class PlatformProfileAdmin(admin.ModelAdmin):
|
|||||||
'fields': ('accepting_shipments', 'maintenance_message_en', 'maintenance_message_ar'),
|
'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.')
|
'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'), {
|
(_('Testing / Development'), {
|
||||||
'fields': ('auto_mark_paid',),
|
'fields': ('auto_mark_paid',),
|
||||||
'description': _('Enable this to automatically mark NEW parcels as "Paid" (useful for testing so drivers can see them immediately).')
|
'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')
|
search_fields = ('name_en', 'name_ar', 'content_en', 'content_ar')
|
||||||
list_editable = ('is_active',)
|
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.unregister(User)
|
||||||
admin.site.register(User, CustomUserAdmin)
|
admin.site.register(User, CustomUserAdmin)
|
||||||
admin.site.register(Parcel, ParcelAdmin)
|
admin.site.register(Parcel, ParcelAdmin)
|
||||||
@ -342,6 +373,9 @@ admin.site.register(PlatformProfile, PlatformProfileAdmin)
|
|||||||
admin.site.register(Testimonial, TestimonialAdmin)
|
admin.site.register(Testimonial, TestimonialAdmin)
|
||||||
admin.site.register(DriverRating)
|
admin.site.register(DriverRating)
|
||||||
admin.site.register(PricingRule, PricingRuleAdmin)
|
admin.site.register(PricingRule, PricingRuleAdmin)
|
||||||
|
admin.site.register(DriverReport, DriverReportAdmin)
|
||||||
|
admin.site.register(DriverRejection, DriverRejectionAdmin)
|
||||||
|
|
||||||
class NotificationTemplateAdmin(admin.ModelAdmin):
|
class NotificationTemplateAdmin(admin.ModelAdmin):
|
||||||
list_display = ('key', 'description')
|
list_display = ('key', 'description')
|
||||||
readonly_fields = ('key', 'description', 'available_variables')
|
readonly_fields = ('key', 'description', 'available_variables')
|
||||||
|
|||||||
@ -2,7 +2,7 @@ from django import forms
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.utils.translation import get_language
|
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):
|
class ContactForm(forms.Form):
|
||||||
name = forms.CharField(max_length=100, label=_("Name"), widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Your Name')}))
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
# Reverse choices for CSS star rating logic (5 to 1) to ensure left-to-right filling
|
# 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'),
|
||||||
|
}
|
||||||
|
|||||||
36
core/migrations/0029_driverreport.py
Normal file
36
core/migrations/0029_driverreport.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
BIN
core/migrations/__pycache__/0029_driverreport.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0029_driverreport.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -98,6 +98,10 @@ class Profile(models.Model):
|
|||||||
is_recommended = models.BooleanField(_("Recommended by Shippers"), default=False)
|
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)."))
|
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):
|
def __str__(self):
|
||||||
return f"{self.user.username} - {self.get_role_display()}"
|
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_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."))
|
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
|
@property
|
||||||
def maintenance_message(self):
|
def maintenance_message(self):
|
||||||
if get_language() == "ar":
|
if get_language() == "ar":
|
||||||
@ -442,3 +454,55 @@ class NotificationTemplate(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _('Notification Template')
|
verbose_name = _('Notification Template')
|
||||||
verbose_name_plural = _('Notification Templates')
|
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']
|
||||||
|
|||||||
@ -35,6 +35,7 @@
|
|||||||
|
|
||||||
<!-- Custom Styles -->
|
<!-- Custom Styles -->
|
||||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
<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 %}
|
{% if LANGUAGE_BIDI %}
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@ -76,10 +76,15 @@
|
|||||||
<div class="text-primary fw-bold fs-4">{{ parcel.price }} <span class="fs-6">OMR</span></div>
|
<div class="text-primary fw-bold fs-4">{{ parcel.price }} <span class="fs-6">OMR</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form action="{% url 'accept_parcel' parcel.id %}" method="POST">
|
<div class="d-grid gap-2">
|
||||||
{% csrf_token %}
|
<form action="{% url 'accept_parcel' parcel.id %}" method="POST">
|
||||||
<button type="submit" class="btn btn-masarx-primary w-100">{% trans "Accept Shipment" %}</button>
|
{% csrf_token %}
|
||||||
</form>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -114,11 +119,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 text-md-end">
|
<div class="col-md-4 text-md-end">
|
||||||
<div class="d-flex flex-column align-items-md-end gap-2">
|
<div class="d-flex flex-column align-items-md-end gap-2">
|
||||||
<div class="text-primary fw-bold fs-5">{{ parcel.price }} OMR</div>
|
<div class="text-primary fw-bold fs-5 mb-1">{{ parcel.price }} OMR</div>
|
||||||
<form action="{% url 'accept_parcel' parcel.id %}" method="POST" class="w-100 w-md-auto">
|
<div class="d-flex gap-2 w-100 w-md-auto">
|
||||||
{% csrf_token %}
|
<form action="{% url 'accept_parcel' parcel.id %}" method="POST" class="flex-grow-1">
|
||||||
<button type="submit" class="btn btn-masarx-primary btn-sm w-100">{% trans "Accept" %}</button>
|
{% csrf_token %}
|
||||||
</form>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -128,6 +138,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Pagination -->
|
||||||
{% if available_parcels.has_other_pages %}
|
{% if available_parcels.has_other_pages %}
|
||||||
<nav aria-label="Page navigation" class="mt-5">
|
<nav aria-label="Page navigation" class="mt-5">
|
||||||
|
|||||||
@ -43,6 +43,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Features Section -->
|
||||||
<section id="how-it-works" class="py-5 bg-white">
|
<section id="how-it-works" class="py-5 bg-white">
|
||||||
<div class="container py-5">
|
<div class="container py-5">
|
||||||
|
|||||||
78
core/templates/core/report_driver.html
Normal file
78
core/templates/core/report_driver.html
Normal 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 %}
|
||||||
@ -128,6 +128,7 @@
|
|||||||
{% if parcel.carrier.profile.is_recommended %}
|
{% 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>
|
<span class="badge bg-success small ms-1" title="{% trans "Recommended by Shippers" %}"><i class="bi bi-hand-thumbs-up"></i></span>
|
||||||
{% endif %}
|
{% 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 %}
|
{% else %}
|
||||||
{% trans "Waiting for pickup" %}
|
{% trans "Waiting for pickup" %}
|
||||||
{% endif %}</p>
|
{% endif %}</p>
|
||||||
@ -177,6 +178,7 @@
|
|||||||
{% if parcel.carrier.profile.is_recommended %}
|
{% 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>
|
<span class="badge bg-success small ms-1" title="{% trans "Recommended by Shippers" %}"><i class="bi bi-hand-thumbs-up"></i></span>
|
||||||
{% endif %}
|
{% 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 %}
|
{% else %}
|
||||||
{% trans "Waiting" %}
|
{% trans "Waiting" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -306,6 +308,7 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if parcel.carrier %}
|
{% if parcel.carrier %}
|
||||||
{{ parcel.carrier.get_full_name|default:parcel.carrier.username }}
|
{{ 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 %}
|
{% else %}
|
||||||
-
|
-
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -380,6 +383,7 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if parcel.carrier %}
|
{% if parcel.carrier %}
|
||||||
{{ parcel.carrier.get_full_name|default:parcel.carrier.username }}
|
{{ 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 %}
|
{% else %}
|
||||||
-
|
-
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -454,4 +458,4 @@
|
|||||||
if (listViewBtn) listViewBtn.addEventListener('click', () => setView('list'));
|
if (listViewBtn) listViewBtn.addEventListener('click', () => setView('list'));
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
104
core/urls.py
104
core/urls.py
@ -1,64 +1,28 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.contrib.auth import views as auth_views
|
from . import views, api_views
|
||||||
from . import views
|
|
||||||
from . import api_views
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.index, name='index'),
|
path('', views.index, name='index'),
|
||||||
path('login/', views.CustomLoginView.as_view(), name='login'),
|
path('track/', views.track_parcel, name='track'),
|
||||||
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('register/', views.register, name='register'),
|
path('register/', views.register, name='register'),
|
||||||
path('register/shipper/', views.register_shipper, name='register_shipper'),
|
path('register/shipper/', views.register_shipper, name='register_shipper'),
|
||||||
path('register/driver/', views.register_driver, name='register_driver'),
|
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('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('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('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('initiate-payment/<int:parcel_id>/', views.initiate_payment, name='initiate_payment'),
|
||||||
path('payment-success/', views.payment_success, name='payment_success'),
|
path('payment-success/', views.payment_success, name='payment_success'),
|
||||||
path('payment-cancel/', views.payment_cancel, name='payment_cancel'),
|
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-governates/', views.get_governates, name='get_governates'),
|
||||||
path('ajax/get-cities/', views.get_cities, name='get_cities'),
|
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('privacy-policy/', views.privacy_policy, name='privacy_policy'),
|
||||||
path('terms-conditions/', views.terms_conditions, name='terms_conditions'),
|
path('terms-conditions/', views.terms_conditions, name='terms_conditions'),
|
||||||
@ -66,25 +30,37 @@ urlpatterns = [
|
|||||||
|
|
||||||
path('profile/', views.profile_view, name='profile'),
|
path('profile/', views.profile_view, name='profile'),
|
||||||
path('profile/edit/', views.edit_profile, name='edit_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('rate-driver/<int:parcel_id>/', views.rate_driver, name='rate_driver'),
|
||||||
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'),
|
|
||||||
|
|
||||||
# Aliases for mobile app compatibility (API v1)
|
# OTP Login / Passwordless
|
||||||
path('api/shipments/', api_views.ParcelListCreateView.as_view(), name='api_shipment_list'),
|
path('ajax/request-login-otp/', views.request_login_otp, name='request_login_otp'),
|
||||||
path('api/shipments/<int:pk>/', api_views.ParcelDetailView.as_view(), name='api_shipment_detail'),
|
path('ajax/verify-login-otp/', views.verify_login_otp, name='verify_login_otp'),
|
||||||
|
|
||||||
# Root-level Aliases (for apps hardcoded to /shipments/)
|
# Chatbot
|
||||||
path('shipments/', api_views.ParcelListCreateView.as_view(), name='root_shipment_list'),
|
path('ajax/chatbot/', views.chatbot, name='chatbot'),
|
||||||
path('shipments/<int:pk>/', api_views.ParcelDetailView.as_view(), name='root_shipment_detail'),
|
|
||||||
]
|
# 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'),
|
||||||
|
]
|
||||||
|
|||||||
106
core/views.py
106
core/views.py
@ -4,8 +4,8 @@ from django.contrib.auth import login, authenticate, logout
|
|||||||
from django.contrib.auth.forms import AuthenticationForm
|
from django.contrib.auth.forms import AuthenticationForm
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from .models import Parcel, Profile, Country, Governate, City, OTPVerification, PlatformProfile, Testimonial, DriverRating
|
from .models import Parcel, Profile, Country, Governate, City, OTPVerification, PlatformProfile, Testimonial, DriverRating, DriverRejection
|
||||||
from .forms import UserRegistrationForm, ParcelForm, ContactForm, UserProfileForm, DriverRatingForm, ShipperRegistrationForm, DriverRegistrationForm
|
from .forms import UserRegistrationForm, ParcelForm, ContactForm, UserProfileForm, DriverRatingForm, ShipperRegistrationForm, DriverRegistrationForm, DriverReportForm
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.utils.translation import get_language
|
from django.utils.translation import get_language
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
@ -44,6 +44,9 @@ def index(request):
|
|||||||
return redirect(f"{reverse('track')}?tracking_number={tracking_id}")
|
return redirect(f"{reverse('track')}?tracking_number={tracking_id}")
|
||||||
|
|
||||||
testimonials = Testimonial.objects.filter(is_active=True)
|
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 5 Drivers (by Average Rating)
|
||||||
top_drivers = Profile.objects.filter(role='car_owner').annotate(
|
top_drivers = Profile.objects.filter(role='car_owner').annotate(
|
||||||
@ -59,7 +62,8 @@ def index(request):
|
|||||||
return render(request, 'core/index.html', {
|
return render(request, 'core/index.html', {
|
||||||
'testimonials': testimonials,
|
'testimonials': testimonials,
|
||||||
'top_drivers': top_drivers,
|
'top_drivers': top_drivers,
|
||||||
'top_shippers': top_shippers
|
'top_shippers': top_shippers,
|
||||||
|
'recent_shipments': recent_shipments
|
||||||
})
|
})
|
||||||
|
|
||||||
def track_parcel(request):
|
def track_parcel(request):
|
||||||
@ -210,6 +214,12 @@ def verify_registration(request):
|
|||||||
def dashboard(request):
|
def dashboard(request):
|
||||||
# Ensure profile exists
|
# Ensure profile exists
|
||||||
profile, created = Profile.objects.get_or_create(user=request.user)
|
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':
|
if profile.role == 'shipper':
|
||||||
all_parcels = Parcel.objects.filter(shipper=request.user).order_by('-created_at')
|
all_parcels = Parcel.objects.filter(shipper=request.user).order_by('-created_at')
|
||||||
@ -245,10 +255,13 @@ def dashboard(request):
|
|||||||
platform_profile = PlatformProfile.objects.first()
|
platform_profile = PlatformProfile.objects.first()
|
||||||
payments_enabled = platform_profile.enable_payment if platform_profile else True
|
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:
|
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:
|
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
|
# Check Approval Status
|
||||||
if not profile.is_approved:
|
if not profile.is_approved:
|
||||||
@ -337,7 +350,11 @@ def accept_parcel(request, parcel_id):
|
|||||||
messages.error(request, _("Only car owners can accept shipments."))
|
messages.error(request, _("Only car owners can accept shipments."))
|
||||||
return redirect('dashboard')
|
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:
|
if not profile.is_approved:
|
||||||
messages.error(request, _("Your account is pending approval. You cannot accept shipments yet."))
|
messages.error(request, _("Your account is pending approval. You cannot accept shipments yet."))
|
||||||
return redirect('dashboard')
|
return redirect('dashboard')
|
||||||
@ -360,6 +377,45 @@ def accept_parcel(request, parcel_id):
|
|||||||
messages.success(request, _("You have accepted the shipment!"))
|
messages.success(request, _("You have accepted the shipment!"))
|
||||||
return redirect('dashboard')
|
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
|
@login_required
|
||||||
def update_status(request, parcel_id):
|
def update_status(request, parcel_id):
|
||||||
parcel = get_object_or_404(Parcel, id=parcel_id, carrier=request.user)
|
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':
|
if payments_enabled and parcel.payment_status != 'paid':
|
||||||
return JsonResponse({'success': False, 'error': _('Payment pending')})
|
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:
|
if not request.user.profile.is_approved:
|
||||||
return JsonResponse({'success': False, 'error': _('Account pending approval')})
|
return JsonResponse({'success': False, 'error': _('Account pending approval')})
|
||||||
|
|
||||||
@ -1098,4 +1157,35 @@ def verify_2fa_otp(request):
|
|||||||
except OTPVerification.DoesNotExist:
|
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')
|
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
|
||||||
|
})
|
||||||
@ -12,7 +12,6 @@
|
|||||||
body {
|
body {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
background-color: var(--soft-cloud);
|
background-color: var(--soft-cloud);
|
||||||
color: var(--primary-dark);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
@ -260,9 +259,81 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
padding: 12px 30px !important;
|
padding: 12px 30px !important;
|
||||||
border-radius: 12px !important;
|
border-radius: 12px !important;
|
||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
.btn-masarx-stopped:hover {
|
.btn-masarx-stopped:hover {
|
||||||
background-color: #b02a2a !important;
|
background-color: #b02a2a !important;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(214, 57, 57, 0.2);
|
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); }
|
||||||
|
}
|
||||||
@ -12,7 +12,6 @@
|
|||||||
body {
|
body {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
background-color: var(--soft-cloud);
|
background-color: var(--soft-cloud);
|
||||||
color: var(--primary-dark);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
@ -260,9 +259,81 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
padding: 12px 30px !important;
|
padding: 12px 30px !important;
|
||||||
border-radius: 12px !important;
|
border-radius: 12px !important;
|
||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
.btn-masarx-stopped:hover {
|
.btn-masarx-stopped:hover {
|
||||||
background-color: #b02a2a !important;
|
background-color: #b02a2a !important;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(214, 57, 57, 0.2);
|
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); }
|
||||||
|
}
|
||||||
BIN
staticfiles/pasted-20260201-135726-63c413b4.png
Normal file
BIN
staticfiles/pasted-20260201-135726-63c413b4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Loading…
x
Reference in New Issue
Block a user