Autosave: 20260131-024701

This commit is contained in:
Flatlogic Bot 2026-01-31 02:47:03 +00:00
parent d909e02470
commit 0a784beb41
17 changed files with 526 additions and 54 deletions

Binary file not shown.

View File

@ -1,7 +1,7 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import User
from .models import Profile, Parcel, Country, Governate, City, PlatformProfile, Testimonial, DriverRating, NotificationTemplate
from .models import Profile, Parcel, Country, Governate, City, PlatformProfile, Testimonial, DriverRating, NotificationTemplate, PricingRule
from django.utils.translation import gettext_lazy as _
from django.urls import path, reverse
from django.shortcuts import render
@ -94,7 +94,7 @@ class CustomUserAdmin(UserAdmin):
send_whatsapp_link.allow_tags = True
class ParcelAdmin(admin.ModelAdmin):
list_display = ('tracking_number', 'shipper', 'carrier', 'price', 'status', 'payment_status', 'created_at')
list_display = ('tracking_number', 'shipper', 'carrier', 'price', 'distance_km', 'status', 'payment_status', 'created_at')
list_filter = (
'status',
'payment_status',
@ -102,13 +102,32 @@ class ParcelAdmin(admin.ModelAdmin):
)
search_fields = ('tracking_number', 'shipper__username', 'receiver_name', 'carrier__username')
actions = ['export_as_csv', 'print_parcels', 'export_pdf']
fieldsets = (
(None, {
'fields': ('tracking_number', 'shipper', 'carrier', 'status', 'payment_status', 'thawani_session_id')
}),
(_('Description'), {
'fields': ('description', 'receiver_name', 'receiver_phone')
}),
(_('Trip & Pricing'), {
'fields': ('distance_km', 'weight', 'price', 'platform_fee_percentage', 'platform_fee', 'driver_amount'),
'description': _('Pricing is calculated based on Distance and Weight.')
}),
(_('Pickup Location'), {
'fields': ('pickup_country', 'pickup_governate', 'pickup_city', 'pickup_address', 'pickup_lat', 'pickup_lng')
}),
(_('Delivery Location'), {
'fields': ('delivery_country', 'delivery_governate', 'delivery_city', 'delivery_address', 'delivery_lat', 'delivery_lng')
}),
)
def export_as_csv(self, request, queryset):
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename="parcels_report.csv"'
writer = csv.writer(response)
writer.writerow(['Tracking Number', 'Shipper', 'Carrier', 'Price (OMR)', 'Status', 'Payment Status', 'Created At', 'Updated At'])
writer.writerow(['Tracking Number', 'Shipper', 'Carrier', 'Total Price (OMR)', 'Platform Fee (%)', 'Platform Charge (OMR)', 'Driver Amount (OMR)', 'Distance (km)', 'Weight (kg)', 'Status', 'Payment Status', 'Created At'])
for obj in queryset:
writer.writerow([
@ -116,10 +135,14 @@ class ParcelAdmin(admin.ModelAdmin):
obj.shipper.username if obj.shipper else '',
obj.carrier.username if obj.carrier else '',
obj.price,
obj.platform_fee_percentage,
obj.platform_fee,
obj.driver_amount,
obj.distance_km,
obj.weight,
obj.get_status_display(),
obj.get_payment_status_display(),
obj.created_at,
obj.updated_at
obj.created_at
])
return response
@ -145,12 +168,16 @@ class PlatformProfileAdmin(admin.ModelAdmin):
(_('General Info'), {
'fields': ('name', 'logo', 'slogan', 'address', 'phone_number', 'registration_number', 'vat_number')
}),
(_('Financial Configuration'), {
'fields': ('platform_fee_percentage', 'enable_payment')
}),
(_('Integrations'), {
'fields': ('google_maps_api_key',),
'description': _('API Keys for external services.')
}),
(_('Policies'), {
'fields': ('privacy_policy_en', 'privacy_policy_ar', 'terms_conditions_en', 'terms_conditions_ar')
}),
(_('Payment Configuration'), {
'fields': ('enable_payment',)
}),
(_('WhatsApp Configuration (Wablas Gateway)'), {
'fields': ('whatsapp_access_token', 'whatsapp_app_secret', 'whatsapp_business_phone_number_id'),
'description': _('Configure your Wablas API connection. Use "Test WhatsApp Configuration" to verify.')
@ -238,6 +265,20 @@ class PlatformProfileAdmin(admin.ModelAdmin):
fieldsets += ((_('Tools'), {'fields': ('test_connection_link',)}),)
return fieldsets
class PricingRuleAdmin(admin.ModelAdmin):
list_display = ('distance_range', 'weight_range', 'price')
list_filter = ('min_distance', 'min_weight')
search_fields = ('price',)
ordering = ('min_distance', 'min_weight')
def distance_range(self, obj):
return f"{obj.min_distance} - {obj.max_distance} km"
distance_range.short_description = _("Distance Range")
def weight_range(self, obj):
return f"{obj.min_weight} - {obj.max_weight} kg"
weight_range.short_description = _("Weight Range")
class CountryAdmin(admin.ModelAdmin):
list_display = ('name_en', 'name_ar', 'phone_code')
search_fields = ('name_en', 'name_ar', 'phone_code')
@ -257,6 +298,7 @@ admin.site.register(City)
admin.site.register(PlatformProfile, PlatformProfileAdmin)
admin.site.register(Testimonial, TestimonialAdmin)
admin.site.register(DriverRating)
admin.site.register(PricingRule, PricingRuleAdmin)
class NotificationTemplateAdmin(admin.ModelAdmin):
list_display = ('key', 'description')
readonly_fields = ('key', 'description', 'available_variables')

View File

@ -6,6 +6,8 @@ from rest_framework.views import APIView
from django.db.models import Q
from .models import Parcel, Profile
from .serializers import ParcelSerializer, ProfileSerializer, PublicParcelSerializer
from .pricing import calculate_haversine_distance, get_pricing_breakdown
from decimal import Decimal
class CustomAuthToken(ObtainAuthToken):
def post(self, request, *args, **kwargs):
@ -87,4 +89,44 @@ class PublicParcelTrackView(generics.RetrieveAPIView):
serializer_class = PublicParcelSerializer
permission_classes = [permissions.AllowAny]
queryset = Parcel.objects.all()
lookup_field = 'tracking_number'
lookup_field = 'tracking_number'
class PriceCalculatorView(APIView):
permission_classes = [permissions.AllowAny] # Allow frontend to query without strict auth if needed, or IsAuthenticated
def post(self, request):
try:
data = request.data
pickup_lat = data.get('pickup_lat')
pickup_lng = data.get('pickup_lng')
delivery_lat = data.get('delivery_lat')
delivery_lng = data.get('delivery_lng')
weight = data.get('weight')
if not all([pickup_lat, pickup_lng, delivery_lat, delivery_lng, weight]):
return Response({'error': 'Missing location or weight data.'}, status=status.HTTP_400_BAD_REQUEST)
weight = Decimal(str(weight))
# Calculate Distance
distance_km = calculate_haversine_distance(pickup_lat, pickup_lng, delivery_lat, delivery_lng)
# Get Breakdown
breakdown = get_pricing_breakdown(distance_km, weight)
if 'error' in breakdown:
return Response(breakdown, status=status.HTTP_400_BAD_REQUEST)
response_data = {
'distance_km': round(float(distance_km), 2),
'weight_kg': float(weight),
'price': float(breakdown['price']),
'platform_fee': float(breakdown['platform_fee']),
'driver_amount': float(breakdown['driver_amount']),
'platform_fee_percentage': float(breakdown['platform_fee_percentage']),
}
return Response(response_data)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@ -238,19 +238,31 @@ class ParcelForm(forms.ModelForm):
fields = [
'description', 'weight', 'price',
'pickup_country', 'pickup_governate', 'pickup_city', 'pickup_address',
'pickup_lat', 'pickup_lng',
'delivery_country', 'delivery_governate', 'delivery_city', 'delivery_address',
'delivery_lat', 'delivery_lng',
'distance_km', 'platform_fee', 'driver_amount', 'platform_fee_percentage',
'receiver_name', 'receiver_phone'
]
widgets = {
'description': forms.Textarea(attrs={'rows': 3, 'class': 'form-control', 'placeholder': _('What are you sending?')}),
'weight': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
'price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}),
'price': forms.TextInput(attrs={'class': 'form-control', 'readonly': 'readonly'}),
'pickup_country': forms.Select(attrs={'class': 'form-control'}),
'pickup_governate': forms.Select(attrs={'class': 'form-control'}),
'pickup_city': forms.Select(attrs={'class': 'form-control'}),
'pickup_address': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Street/Building')}),
'pickup_lat': forms.HiddenInput(),
'pickup_lng': forms.HiddenInput(),
'delivery_lat': forms.HiddenInput(),
'delivery_lng': forms.HiddenInput(),
'distance_km': forms.HiddenInput(),
'platform_fee': forms.HiddenInput(),
'driver_amount': forms.HiddenInput(),
'platform_fee_percentage': forms.HiddenInput(),
'delivery_country': forms.Select(attrs={'class': 'form-control'}),
'delivery_governate': forms.Select(attrs={'class': 'form-control'}),
'delivery_city': forms.Select(attrs={'class': 'form-control'}),
@ -262,7 +274,7 @@ class ParcelForm(forms.ModelForm):
labels = {
'description': _('Package Description'),
'weight': _('Weight (kg)'),
'price': _('Your Offer Price (Bid) (OMR)'),
'price': _('Calculated Price (OMR)'),
'pickup_country': _('Pickup Country'),
'pickup_governate': _('Pickup Governate'),
'pickup_city': _('Pickup City'),
@ -377,4 +389,4 @@ 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)]

View File

@ -0,0 +1,85 @@
# Generated by Django 5.2.7 on 2026-01-31 02:03
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0022_notificationtemplate'),
]
operations = [
migrations.CreateModel(
name='PricingRule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('min_distance', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Min Distance (km)')),
('max_distance', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Max Distance (km)')),
('min_weight', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Min Weight (kg)')),
('max_weight', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Max Weight (kg)')),
('price', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Price (OMR)')),
],
options={
'verbose_name': 'Pricing Rule',
'verbose_name_plural': 'Pricing Rules',
'ordering': ['min_distance', 'min_weight'],
},
),
migrations.AddField(
model_name='parcel',
name='delivery_lat',
field=models.DecimalField(blank=True, decimal_places=16, max_digits=20, null=True, verbose_name='Delivery Latitude'),
),
migrations.AddField(
model_name='parcel',
name='delivery_lng',
field=models.DecimalField(blank=True, decimal_places=16, max_digits=20, null=True, verbose_name='Delivery Longitude'),
),
migrations.AddField(
model_name='parcel',
name='distance_km',
field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='Distance (km)'),
),
migrations.AddField(
model_name='parcel',
name='driver_amount',
field=models.DecimalField(decimal_places=3, default=Decimal('0.000'), max_digits=10, verbose_name='Driver Amount (OMR)'),
),
migrations.AddField(
model_name='parcel',
name='pickup_lat',
field=models.DecimalField(blank=True, decimal_places=16, max_digits=20, null=True, verbose_name='Pickup Latitude'),
),
migrations.AddField(
model_name='parcel',
name='pickup_lng',
field=models.DecimalField(blank=True, decimal_places=16, max_digits=20, null=True, verbose_name='Pickup Longitude'),
),
migrations.AddField(
model_name='parcel',
name='platform_fee',
field=models.DecimalField(decimal_places=3, default=Decimal('0.000'), max_digits=10, verbose_name='Platform Fee (OMR)'),
),
migrations.AddField(
model_name='parcel',
name='platform_fee_percentage',
field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=5, verbose_name='Fee Percentage (%)'),
),
migrations.AddField(
model_name='platformprofile',
name='google_maps_api_key',
field=models.CharField(blank=True, help_text='API Key for Google Maps (Distance Matrix, Maps JS).', max_length=255, verbose_name='Google Maps API Key'),
),
migrations.AddField(
model_name='platformprofile',
name='platform_fee_percentage',
field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Percentage deducted from total trip price.', max_digits=5, verbose_name='Platform Fee (%)'),
),
migrations.AlterField(
model_name='parcel',
name='price',
field=models.DecimalField(decimal_places=3, default=Decimal('0.000'), max_digits=10, verbose_name='Total Price (OMR)'),
),
]

View File

@ -7,6 +7,7 @@ from django.dispatch import receiver
from django.utils import timezone
from django.core.exceptions import ValidationError
import uuid
from decimal import Decimal
class Country(models.Model):
name_en = models.CharField(_('Name (English)'), max_length=100)
@ -115,6 +116,21 @@ def save_user_profile(sender, instance, **kwargs):
if hasattr(instance, 'profile'):
instance.profile.save()
class PricingRule(models.Model):
min_distance = models.DecimalField(_('Min Distance (km)'), max_digits=10, decimal_places=2)
max_distance = models.DecimalField(_('Max Distance (km)'), max_digits=10, decimal_places=2)
min_weight = models.DecimalField(_('Min Weight (kg)'), max_digits=10, decimal_places=2)
max_weight = models.DecimalField(_('Max Weight (kg)'), max_digits=10, decimal_places=2)
price = models.DecimalField(_('Price (OMR)'), max_digits=10, decimal_places=3)
def __str__(self):
return f"{self.min_distance}-{self.max_distance}km | {self.min_weight}-{self.max_weight}kg = {self.price} OMR"
class Meta:
verbose_name = _('Pricing Rule')
verbose_name_plural = _('Pricing Rules')
ordering = ['min_distance', 'min_weight']
class Parcel(models.Model):
STATUS_CHOICES = (
('pending', _('Pending Pickup')),
@ -136,19 +152,31 @@ class Parcel(models.Model):
description = models.TextField(_('Description'))
weight = models.DecimalField(_('Weight (kg)'), max_digits=5, decimal_places=2, help_text=_("Weight in kg"))
price = models.DecimalField(_('Price (OMR)'), max_digits=10, decimal_places=3, default=0.000)
price = models.DecimalField(_('Total Price (OMR)'), max_digits=10, decimal_places=3, default=Decimal('0.000'))
# Financial Breakdown
platform_fee = models.DecimalField(_('Platform Fee (OMR)'), max_digits=10, decimal_places=3, default=Decimal('0.000'))
platform_fee_percentage = models.DecimalField(_('Fee Percentage (%)'), max_digits=5, decimal_places=2, default=Decimal('0.00'))
driver_amount = models.DecimalField(_('Driver Amount (OMR)'), max_digits=10, decimal_places=3, default=Decimal('0.000'))
# Trip Info
distance_km = models.DecimalField(_('Distance (km)'), max_digits=10, decimal_places=2, default=Decimal('0.00'))
# Pickup Location
pickup_country = models.ForeignKey(Country, on_delete=models.SET_NULL, null=True, blank=True, related_name='pickup_parcels', verbose_name=_('Pickup Country'))
pickup_governate = models.ForeignKey(Governate, on_delete=models.SET_NULL, null=True, blank=True, related_name='pickup_parcels', verbose_name=_('Pickup Governate'))
pickup_city = models.ForeignKey(City, on_delete=models.SET_NULL, null=True, blank=True, related_name='pickup_parcels', verbose_name=_('Pickup City'))
pickup_address = models.CharField(_('Pickup Address'), max_length=255)
pickup_lat = models.DecimalField(_('Pickup Latitude'), max_digits=20, decimal_places=16, null=True, blank=True)
pickup_lng = models.DecimalField(_('Pickup Longitude'), max_digits=20, decimal_places=16, null=True, blank=True)
# Delivery Location
delivery_country = models.ForeignKey(Country, on_delete=models.SET_NULL, null=True, blank=True, related_name='delivery_parcels', verbose_name=_('Delivery Country'))
delivery_governate = models.ForeignKey(Governate, on_delete=models.SET_NULL, null=True, blank=True, related_name='delivery_parcels', verbose_name=_('Delivery Governate'))
delivery_city = models.ForeignKey(City, on_delete=models.SET_NULL, null=True, blank=True, related_name='delivery_city_parcels', verbose_name=_('Delivery City'))
delivery_address = models.CharField(_('Delivery Address'), max_length=255)
delivery_lat = models.DecimalField(_('Delivery Latitude'), max_digits=20, decimal_places=16, null=True, blank=True)
delivery_lng = models.DecimalField(_('Delivery Longitude'), max_digits=20, decimal_places=16, null=True, blank=True)
receiver_name = models.CharField(_('Receiver Name'), max_length=100)
receiver_phone = models.CharField(_('Receiver Phone'), max_length=20)
@ -181,6 +209,12 @@ class PlatformProfile(models.Model):
registration_number = models.CharField(_('Registration Number'), max_length=100, blank=True)
vat_number = models.CharField(_('VAT Number'), max_length=100, blank=True)
# Financial Configuration
platform_fee_percentage = models.DecimalField(_('Platform Fee (%)'), max_digits=5, decimal_places=2, default=Decimal('0.00'), help_text=_("Percentage deducted from total trip price."))
# Integrations
google_maps_api_key = models.CharField(_('Google Maps API Key'), max_length=255, blank=True, help_text=_("API Key for Google Maps (Distance Matrix, Maps JS)."))
# Bilingual Policies
privacy_policy_en = models.TextField(_('Privacy Policy (English)'), blank=True)
privacy_policy_ar = models.TextField(_('Privacy Policy (Arabic)'), blank=True)

70
core/pricing.py Normal file
View File

@ -0,0 +1,70 @@
from decimal import Decimal
import math
from .models import PricingRule, PlatformProfile
def calculate_haversine_distance(lat1, lon1, lat2, lon2):
"""
Calculate the great circle distance in kilometers between two points
on the earth (specified in decimal degrees)
"""
if lat1 is None or lon1 is None or lat2 is None or lon2 is None:
return Decimal('0.00')
# Convert decimal degrees to radians
lat1, lon1, lat2, lon2 = map(float, [lat1, lon1, lat2, lon2])
# Haversine formula
dlon = lon2 - lon1
dlat = lat2 - lat1
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
c = 2 * math.asin(math.sqrt(a))
r = 6371 # Radius of earth in kilometers. Use 3956 for miles
return Decimal(c * r)
def get_pricing_breakdown(distance_km, weight_kg):
"""
Returns a dictionary with pricing breakdown:
{
'price': Decimal,
'platform_fee': Decimal,
'platform_fee_percentage': Decimal,
'driver_amount': Decimal,
'error': str (optional)
}
"""
# 1. Find matching rule
# We look for a rule that covers this distance and weight
rule = PricingRule.objects.filter(
min_distance__lte=distance_km,
max_distance__gte=distance_km,
min_weight__lte=weight_kg,
max_weight__gte=weight_kg
).first()
if not rule:
# Fallback or Error
# Try to find a rule just by distance if weight is slightly off? No, strict for now.
return {
'price': Decimal('0.000'),
'platform_fee': Decimal('0.000'),
'platform_fee_percentage': Decimal('0.00'),
'driver_amount': Decimal('0.000'),
'error': 'No pricing rule found for this distance/weight combination.'
}
total_price = rule.price
# 2. Calculate Fees
profile = PlatformProfile.objects.first()
fee_percentage = profile.platform_fee_percentage if profile else Decimal('0.00')
platform_fee = total_price * (fee_percentage / Decimal('100.00'))
driver_amount = total_price - platform_fee
return {
'price': total_price,
'platform_fee': platform_fee,
'platform_fee_percentage': fee_percentage,
'driver_amount': driver_amount
}

View File

@ -15,8 +15,26 @@
<div class="card border-0 shadow-sm p-4" style="border-radius: 20px;">
<h2 class="mb-4">{% trans "Request a Shipment" %}</h2>
<form method="POST">
{% if not google_maps_api_key %}
<div class="alert alert-warning">
{% trans "Map integration is disabled (API Key missing). Distance must be entered manually or functionality is limited." %}
</div>
{% endif %}
<form method="POST" id="shipmentForm">
{% csrf_token %}
<!-- Hidden Fields -->
{{ form.pickup_lat }}
{{ form.pickup_lng }}
{{ form.delivery_lat }}
{{ form.delivery_lng }}
{{ form.distance_km }}
{{ form.platform_fee }}
{{ form.driver_amount }}
{{ form.platform_fee_percentage }}
<div class="row g-3">
<!-- General Info -->
<div class="col-12">
@ -36,6 +54,7 @@
<div class="col-md-6">
<label class="form-label" for="{{ form.price.id_for_label }}">{{ form.price.label }}</label>
{{ form.price }}
<small class="text-muted">{% trans "Calculated automatically based on distance and weight." %}</small>
{% if form.price.errors %}
<div class="text-danger small">{{ form.price.errors }}</div>
{% endif %}
@ -43,68 +62,58 @@
<!-- Pickup Details -->
<div class="col-12 mt-4">
<h4 class="mb-3 text-secondary border-bottom pb-2">{% trans "Pickup Details" %}</h4>
<h4 class="mb-3 text-secondary border-bottom pb-2 d-flex justify-content-between align-items-center">
{% trans "Pickup Details" %}
{% if google_maps_api_key %}
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openMap('pickup')">
<i class="bi bi-geo-alt-fill"></i> {% trans "Select on Map" %}
</button>
{% endif %}
</h4>
</div>
<div class="col-md-6">
<label class="form-label" for="{{ form.pickup_country.id_for_label }}">{{ form.pickup_country.label }}</label>
{{ form.pickup_country }}
{% if form.pickup_country.errors %}
<div class="text-danger small">{{ form.pickup_country.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label class="form-label" for="{{ form.pickup_governate.id_for_label }}">{{ form.pickup_governate.label }}</label>
{{ form.pickup_governate }}
{% if form.pickup_governate.errors %}
<div class="text-danger small">{{ form.pickup_governate.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label class="form-label" for="{{ form.pickup_city.id_for_label }}">{{ form.pickup_city.label }}</label>
{{ form.pickup_city }}
{% if form.pickup_city.errors %}
<div class="text-danger small">{{ form.pickup_city.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label class="form-label" for="{{ form.pickup_address.id_for_label }}">{{ form.pickup_address.label }}</label>
{{ form.pickup_address }}
{% if form.pickup_address.errors %}
<div class="text-danger small">{{ form.pickup_address.errors }}</div>
{% endif %}
</div>
<!-- Delivery Details -->
<div class="col-12 mt-4">
<h4 class="mb-3 text-secondary border-bottom pb-2">{% trans "Delivery Details" %}</h4>
<h4 class="mb-3 text-secondary border-bottom pb-2 d-flex justify-content-between align-items-center">
{% trans "Delivery Details" %}
{% if google_maps_api_key %}
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openMap('delivery')">
<i class="bi bi-geo-alt-fill"></i> {% trans "Select on Map" %}
</button>
{% endif %}
</h4>
</div>
<div class="col-md-6">
<label class="form-label" for="{{ form.delivery_country.id_for_label }}">{{ form.delivery_country.label }}</label>
{{ form.delivery_country }}
{% if form.delivery_country.errors %}
<div class="text-danger small">{{ form.delivery_country.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label class="form-label" for="{{ form.delivery_governate.id_for_label }}">{{ form.delivery_governate.label }}</label>
{{ form.delivery_governate }}
{% if form.delivery_governate.errors %}
<div class="text-danger small">{{ form.delivery_governate.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label class="form-label" for="{{ form.delivery_city.id_for_label }}">{{ form.delivery_city.label }}</label>
{{ form.delivery_city }}
{% if form.delivery_city.errors %}
<div class="text-danger small">{{ form.delivery_city.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label class="form-label" for="{{ form.delivery_address.id_for_label }}">{{ form.delivery_address.label }}</label>
{{ form.delivery_address }}
{% if form.delivery_address.errors %}
<div class="text-danger small">{{ form.delivery_address.errors }}</div>
{% endif %}
</div>
<!-- Receiver Details -->
@ -114,9 +123,6 @@
<div class="col-md-6">
<label class="form-label" for="{{ form.receiver_name.id_for_label }}">{{ form.receiver_name.label }}</label>
{{ form.receiver_name }}
{% if form.receiver_name.errors %}
<div class="text-danger small">{{ form.receiver_name.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label class="form-label" for="{{ form.receiver_phone.id_for_label }}">{{ form.receiver_phone.label }}</label>
@ -128,12 +134,6 @@
{{ form.receiver_phone }}
</div>
</div>
{% if form.receiver_phone_code.errors %}
<div class="text-danger small">{{ form.receiver_phone_code.errors }}</div>
{% endif %}
{% if form.receiver_phone.errors %}
<div class="text-danger small">{{ form.receiver_phone.errors }}</div>
{% endif %}
</div>
<div class="col-12 mt-4 d-flex gap-2">
@ -147,6 +147,159 @@
</div>
</div>
<!-- Map Modal -->
<div class="modal fade" id="mapModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans "Choose Location" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<div id="map" style="height: 450px; width: 100%;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
<button type="button" class="btn btn-primary" onclick="confirmLocation()">{% trans "Confirm Location" %}</button>
</div>
</div>
</div>
</div>
{% if google_maps_api_key %}
<script src="https://maps.googleapis.com/maps/api/js?key={{ google_maps_api_key }}&libraries=places"></script>
<script>
let map;
let marker;
let currentMode = 'pickup'; // 'pickup' or 'delivery'
let mapModal;
function initMap() {
const defaultLocation = { lat: 23.5880, lng: 58.3829 }; // Muscat, Oman
map = new google.maps.Map(document.getElementById("map"), {
zoom: 12,
center: defaultLocation,
});
marker = new google.maps.Marker({
map: map,
draggable: true,
animation: google.maps.Animation.DROP,
});
map.addListener("click", (e) => {
placeMarkerAndPanTo(e.latLng, map);
});
}
function placeMarkerAndPanTo(latLng, map) {
marker.setPosition(latLng);
map.panTo(latLng);
}
function openMap(mode) {
currentMode = mode;
mapModal = new bootstrap.Modal(document.getElementById('mapModal'));
mapModal.show();
// Resize map when modal opens
document.getElementById('mapModal').addEventListener('shown.bs.modal', function () {
google.maps.event.trigger(map, "resize");
// Set marker if existing value
let latField = document.getElementById(`id_${mode}_lat`);
let lngField = document.getElementById(`id_${mode}_lng`);
if (latField.value && lngField.value) {
let loc = { lat: parseFloat(latField.value), lng: parseFloat(lngField.value) };
marker.setPosition(loc);
map.setCenter(loc);
} else {
// Default to Muscat if no value
map.setCenter({ lat: 23.5880, lng: 58.3829 });
}
});
}
function confirmLocation() {
if (!marker.getPosition()) return;
const lat = marker.getPosition().lat();
const lng = marker.getPosition().lng();
document.getElementById(`id_${currentMode}_lat`).value = lat;
document.getElementById(`id_${currentMode}_lng`).value = lng;
// Try reverse geocoding to fill address (optional but nice)
const geocoder = new google.maps.Geocoder();
geocoder.geocode({ location: { lat: lat, lng: lng } }, (results, status) => {
if (status === "OK" && results[0]) {
// Simple fill of address field if empty
const addrField = document.getElementById(`id_${currentMode}_address`);
if (!addrField.value) {
addrField.value = results[0].formatted_address;
}
}
});
// Close modal
const modalEl = document.getElementById('mapModal');
const modal = bootstrap.Modal.getInstance(modalEl);
modal.hide();
// Trigger Price Calculation
calculatePrice();
}
function calculatePrice() {
const pickupLat = document.getElementById('id_pickup_lat').value;
const pickupLng = document.getElementById('id_pickup_lng').value;
const deliveryLat = document.getElementById('id_delivery_lat').value;
const deliveryLng = document.getElementById('id_delivery_lng').value;
const weight = document.getElementById('id_weight').value;
if (pickupLat && pickupLng && deliveryLat && deliveryLng && weight) {
fetch('{% url "api_calculate_price" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
},
body: JSON.stringify({
pickup_lat: pickupLat,
pickup_lng: pickupLng,
delivery_lat: deliveryLat,
delivery_lng: deliveryLng,
weight: weight
})
})
.then(response => response.json())
.then(data => {
if (data.error) {
// console.error(data.error);
// Maybe show error to user?
// alert(data.error);
} else {
document.getElementById('id_price').value = data.price;
document.getElementById('id_distance_km').value = data.distance_km;
document.getElementById('id_platform_fee').value = data.platform_fee;
document.getElementById('id_driver_amount').value = data.driver_amount;
document.getElementById('id_platform_fee_percentage').value = data.platform_fee_percentage;
}
})
.catch(error => console.error('Error:', error));
}
}
// Attach listener to weight
document.getElementById('id_weight').addEventListener('change', calculatePrice);
document.getElementById('id_weight').addEventListener('keyup', calculatePrice);
window.initMap = initMap;
</script>
{% endif %}
<script>
document.addEventListener('DOMContentLoaded', function() {
function setupDependentDropdowns(countryId, governateId, cityId) {

View File

@ -78,6 +78,7 @@ urlpatterns = [
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)
path('api/shipments/', api_views.ParcelListCreateView.as_view(), name='api_shipment_list'),
@ -86,4 +87,4 @@ urlpatterns = [
# 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'),
]
]

View File

@ -35,6 +35,7 @@ import weasyprint
import qrcode
from io import BytesIO
import base64
from .pricing import get_pricing_breakdown # Import pricing logic
def index(request):
# If tracking_id is present, redirect to the new track view
@ -295,6 +296,17 @@ def shipment_request(request):
if form.is_valid():
parcel = form.save(commit=False)
parcel.shipper = request.user
# Recalculate price on backend to ensure integrity
# We trust the form's distance/weight if populated, but good to verify
# Ideally we recalculate from PricingRule here too
breakdown = get_pricing_breakdown(parcel.distance_km, parcel.weight)
if 'error' not in breakdown:
parcel.price = breakdown['price']
parcel.platform_fee = breakdown['platform_fee']
parcel.platform_fee_percentage = breakdown['platform_fee_percentage']
parcel.driver_amount = breakdown['driver_amount']
parcel.save()
# WhatsApp Notification
@ -304,7 +316,14 @@ def shipment_request(request):
return redirect('dashboard')
else:
form = ParcelForm()
return render(request, 'core/shipment_request.html', {'form': form})
platform_profile = PlatformProfile.objects.first()
google_maps_api_key = platform_profile.google_maps_api_key if platform_profile else None
return render(request, 'core/shipment_request.html', {
'form': form,
'google_maps_api_key': google_maps_api_key
})
@login_required
def accept_parcel(request, parcel_id):
@ -939,13 +958,27 @@ def edit_parcel(request, parcel_id):
if request.method == 'POST':
form = ParcelForm(request.POST, instance=parcel)
if form.is_valid():
form.save()
parcel_updated = form.save(commit=False)
# Recalculate if fields changed
breakdown = get_pricing_breakdown(parcel_updated.distance_km, parcel_updated.weight)
if 'error' not in breakdown:
parcel_updated.price = breakdown['price']
parcel_updated.platform_fee = breakdown['platform_fee']
parcel_updated.platform_fee_percentage = breakdown['platform_fee_percentage']
parcel_updated.driver_amount = breakdown['driver_amount']
parcel_updated.save()
messages.success(request, _("Shipment updated successfully."))
return redirect('dashboard')
else:
form = ParcelForm(instance=parcel)
platform_profile = PlatformProfile.objects.first()
google_maps_api_key = platform_profile.google_maps_api_key if platform_profile else None
return render(request, 'core/edit_parcel.html', {'form': form, 'parcel': parcel})
return render(request, 'core/edit_parcel.html', {'form': form, 'parcel': parcel, 'google_maps_api_key': google_maps_api_key})
@login_required
def cancel_parcel(request, parcel_id):
@ -1060,4 +1093,4 @@ 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')