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.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')
|
||||
|
||||
@ -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'),
|
||||
}
|
||||
|
||||
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_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']
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
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 %}
|
||||
<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 %}
|
||||
|
||||
104
core/urls.py
104
core/urls.py
@ -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'),
|
||||
]
|
||||
|
||||
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.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
|
||||
})
|
||||
@ -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); }
|
||||
}
|
||||
@ -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); }
|
||||
}
|
||||
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