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 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 from .models import Profile, Parcel, Country, Governate, City, PlatformProfile, Testimonial, DriverRating, NotificationTemplate, PricingRule
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 from django.shortcuts import render
@ -94,7 +94,7 @@ class CustomUserAdmin(UserAdmin):
send_whatsapp_link.allow_tags = True send_whatsapp_link.allow_tags = True
class ParcelAdmin(admin.ModelAdmin): 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 = ( list_filter = (
'status', 'status',
'payment_status', 'payment_status',
@ -103,12 +103,31 @@ class ParcelAdmin(admin.ModelAdmin):
search_fields = ('tracking_number', 'shipper__username', 'receiver_name', 'carrier__username') search_fields = ('tracking_number', 'shipper__username', 'receiver_name', 'carrier__username')
actions = ['export_as_csv', 'print_parcels', 'export_pdf'] 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): def export_as_csv(self, request, queryset):
response = HttpResponse(content_type='text/csv') response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename="parcels_report.csv"' response['Content-Disposition'] = 'attachment; filename="parcels_report.csv"'
writer = csv.writer(response) 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: for obj in queryset:
writer.writerow([ writer.writerow([
@ -116,10 +135,14 @@ class ParcelAdmin(admin.ModelAdmin):
obj.shipper.username if obj.shipper else '', obj.shipper.username if obj.shipper else '',
obj.carrier.username if obj.carrier else '', obj.carrier.username if obj.carrier else '',
obj.price, obj.price,
obj.platform_fee_percentage,
obj.platform_fee,
obj.driver_amount,
obj.distance_km,
obj.weight,
obj.get_status_display(), obj.get_status_display(),
obj.get_payment_status_display(), obj.get_payment_status_display(),
obj.created_at, obj.created_at
obj.updated_at
]) ])
return response return response
@ -145,12 +168,16 @@ class PlatformProfileAdmin(admin.ModelAdmin):
(_('General Info'), { (_('General Info'), {
'fields': ('name', 'logo', 'slogan', 'address', 'phone_number', 'registration_number', 'vat_number') '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'), { (_('Policies'), {
'fields': ('privacy_policy_en', 'privacy_policy_ar', 'terms_conditions_en', 'terms_conditions_ar') 'fields': ('privacy_policy_en', 'privacy_policy_ar', 'terms_conditions_en', 'terms_conditions_ar')
}), }),
(_('Payment Configuration'), {
'fields': ('enable_payment',)
}),
(_('WhatsApp Configuration (Wablas Gateway)'), { (_('WhatsApp Configuration (Wablas Gateway)'), {
'fields': ('whatsapp_access_token', 'whatsapp_app_secret', 'whatsapp_business_phone_number_id'), 'fields': ('whatsapp_access_token', 'whatsapp_app_secret', 'whatsapp_business_phone_number_id'),
'description': _('Configure your Wablas API connection. Use "Test WhatsApp Configuration" to verify.') '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',)}),) fieldsets += ((_('Tools'), {'fields': ('test_connection_link',)}),)
return fieldsets 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): class CountryAdmin(admin.ModelAdmin):
list_display = ('name_en', 'name_ar', 'phone_code') list_display = ('name_en', 'name_ar', 'phone_code')
search_fields = ('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(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)
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')

View File

@ -6,6 +6,8 @@ from rest_framework.views import APIView
from django.db.models import Q from django.db.models import Q
from .models import Parcel, Profile from .models import Parcel, Profile
from .serializers import ParcelSerializer, ProfileSerializer, PublicParcelSerializer from .serializers import ParcelSerializer, ProfileSerializer, PublicParcelSerializer
from .pricing import calculate_haversine_distance, get_pricing_breakdown
from decimal import Decimal
class CustomAuthToken(ObtainAuthToken): class CustomAuthToken(ObtainAuthToken):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -88,3 +90,43 @@ class PublicParcelTrackView(generics.RetrieveAPIView):
permission_classes = [permissions.AllowAny] permission_classes = [permissions.AllowAny]
queryset = Parcel.objects.all() 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 = [ fields = [
'description', 'weight', 'price', 'description', 'weight', 'price',
'pickup_country', 'pickup_governate', 'pickup_city', 'pickup_address', 'pickup_country', 'pickup_governate', 'pickup_city', 'pickup_address',
'pickup_lat', 'pickup_lng',
'delivery_country', 'delivery_governate', 'delivery_city', 'delivery_address', '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' 'receiver_name', 'receiver_phone'
] ]
widgets = { widgets = {
'description': forms.Textarea(attrs={'rows': 3, 'class': 'form-control', 'placeholder': _('What are you sending?')}), 'description': forms.Textarea(attrs={'rows': 3, 'class': 'form-control', 'placeholder': _('What are you sending?')}),
'weight': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}), '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_country': forms.Select(attrs={'class': 'form-control'}),
'pickup_governate': forms.Select(attrs={'class': 'form-control'}), 'pickup_governate': forms.Select(attrs={'class': 'form-control'}),
'pickup_city': 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_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_country': forms.Select(attrs={'class': 'form-control'}),
'delivery_governate': forms.Select(attrs={'class': 'form-control'}), 'delivery_governate': forms.Select(attrs={'class': 'form-control'}),
'delivery_city': forms.Select(attrs={'class': 'form-control'}), 'delivery_city': forms.Select(attrs={'class': 'form-control'}),
@ -262,7 +274,7 @@ class ParcelForm(forms.ModelForm):
labels = { labels = {
'description': _('Package Description'), 'description': _('Package Description'),
'weight': _('Weight (kg)'), 'weight': _('Weight (kg)'),
'price': _('Your Offer Price (Bid) (OMR)'), 'price': _('Calculated Price (OMR)'),
'pickup_country': _('Pickup Country'), 'pickup_country': _('Pickup Country'),
'pickup_governate': _('Pickup Governate'), 'pickup_governate': _('Pickup Governate'),
'pickup_city': _('Pickup City'), 'pickup_city': _('Pickup City'),

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.utils import timezone
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
import uuid import uuid
from decimal import Decimal
class Country(models.Model): class Country(models.Model):
name_en = models.CharField(_('Name (English)'), max_length=100) name_en = models.CharField(_('Name (English)'), max_length=100)
@ -115,6 +116,21 @@ def save_user_profile(sender, instance, **kwargs):
if hasattr(instance, 'profile'): if hasattr(instance, 'profile'):
instance.profile.save() 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): class Parcel(models.Model):
STATUS_CHOICES = ( STATUS_CHOICES = (
('pending', _('Pending Pickup')), ('pending', _('Pending Pickup')),
@ -136,19 +152,31 @@ class Parcel(models.Model):
description = models.TextField(_('Description')) description = models.TextField(_('Description'))
weight = models.DecimalField(_('Weight (kg)'), max_digits=5, decimal_places=2, help_text=_("Weight in kg")) 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 Location
pickup_country = models.ForeignKey(Country, on_delete=models.SET_NULL, null=True, blank=True, related_name='pickup_parcels', verbose_name=_('Pickup Country')) 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_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_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_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 Location
delivery_country = models.ForeignKey(Country, on_delete=models.SET_NULL, null=True, blank=True, related_name='delivery_parcels', verbose_name=_('Delivery Country')) 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_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_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_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_name = models.CharField(_('Receiver Name'), max_length=100)
receiver_phone = models.CharField(_('Receiver Phone'), max_length=20) 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) registration_number = models.CharField(_('Registration Number'), max_length=100, blank=True)
vat_number = models.CharField(_('VAT 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 # Bilingual Policies
privacy_policy_en = models.TextField(_('Privacy Policy (English)'), blank=True) privacy_policy_en = models.TextField(_('Privacy Policy (English)'), blank=True)
privacy_policy_ar = models.TextField(_('Privacy Policy (Arabic)'), 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;"> <div class="card border-0 shadow-sm p-4" style="border-radius: 20px;">
<h2 class="mb-4">{% trans "Request a Shipment" %}</h2> <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 %} {% 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"> <div class="row g-3">
<!-- General Info --> <!-- General Info -->
<div class="col-12"> <div class="col-12">
@ -36,6 +54,7 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label" for="{{ form.price.id_for_label }}">{{ form.price.label }}</label> <label class="form-label" for="{{ form.price.id_for_label }}">{{ form.price.label }}</label>
{{ form.price }} {{ form.price }}
<small class="text-muted">{% trans "Calculated automatically based on distance and weight." %}</small>
{% if form.price.errors %} {% if form.price.errors %}
<div class="text-danger small">{{ form.price.errors }}</div> <div class="text-danger small">{{ form.price.errors }}</div>
{% endif %} {% endif %}
@ -43,68 +62,58 @@
<!-- Pickup Details --> <!-- Pickup Details -->
<div class="col-12 mt-4"> <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>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label" for="{{ form.pickup_country.id_for_label }}">{{ form.pickup_country.label }}</label> <label class="form-label" for="{{ form.pickup_country.id_for_label }}">{{ form.pickup_country.label }}</label>
{{ form.pickup_country }} {{ form.pickup_country }}
{% if form.pickup_country.errors %}
<div class="text-danger small">{{ form.pickup_country.errors }}</div>
{% endif %}
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label" for="{{ form.pickup_governate.id_for_label }}">{{ form.pickup_governate.label }}</label> <label class="form-label" for="{{ form.pickup_governate.id_for_label }}">{{ form.pickup_governate.label }}</label>
{{ form.pickup_governate }} {{ form.pickup_governate }}
{% if form.pickup_governate.errors %}
<div class="text-danger small">{{ form.pickup_governate.errors }}</div>
{% endif %}
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label" for="{{ form.pickup_city.id_for_label }}">{{ form.pickup_city.label }}</label> <label class="form-label" for="{{ form.pickup_city.id_for_label }}">{{ form.pickup_city.label }}</label>
{{ form.pickup_city }} {{ form.pickup_city }}
{% if form.pickup_city.errors %}
<div class="text-danger small">{{ form.pickup_city.errors }}</div>
{% endif %}
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label" for="{{ form.pickup_address.id_for_label }}">{{ form.pickup_address.label }}</label> <label class="form-label" for="{{ form.pickup_address.id_for_label }}">{{ form.pickup_address.label }}</label>
{{ form.pickup_address }} {{ form.pickup_address }}
{% if form.pickup_address.errors %}
<div class="text-danger small">{{ form.pickup_address.errors }}</div>
{% endif %}
</div> </div>
<!-- Delivery Details --> <!-- Delivery Details -->
<div class="col-12 mt-4"> <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>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label" for="{{ form.delivery_country.id_for_label }}">{{ form.delivery_country.label }}</label> <label class="form-label" for="{{ form.delivery_country.id_for_label }}">{{ form.delivery_country.label }}</label>
{{ form.delivery_country }} {{ form.delivery_country }}
{% if form.delivery_country.errors %}
<div class="text-danger small">{{ form.delivery_country.errors }}</div>
{% endif %}
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label" for="{{ form.delivery_governate.id_for_label }}">{{ form.delivery_governate.label }}</label> <label class="form-label" for="{{ form.delivery_governate.id_for_label }}">{{ form.delivery_governate.label }}</label>
{{ form.delivery_governate }} {{ form.delivery_governate }}
{% if form.delivery_governate.errors %}
<div class="text-danger small">{{ form.delivery_governate.errors }}</div>
{% endif %}
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label" for="{{ form.delivery_city.id_for_label }}">{{ form.delivery_city.label }}</label> <label class="form-label" for="{{ form.delivery_city.id_for_label }}">{{ form.delivery_city.label }}</label>
{{ form.delivery_city }} {{ form.delivery_city }}
{% if form.delivery_city.errors %}
<div class="text-danger small">{{ form.delivery_city.errors }}</div>
{% endif %}
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label" for="{{ form.delivery_address.id_for_label }}">{{ form.delivery_address.label }}</label> <label class="form-label" for="{{ form.delivery_address.id_for_label }}">{{ form.delivery_address.label }}</label>
{{ form.delivery_address }} {{ form.delivery_address }}
{% if form.delivery_address.errors %}
<div class="text-danger small">{{ form.delivery_address.errors }}</div>
{% endif %}
</div> </div>
<!-- Receiver Details --> <!-- Receiver Details -->
@ -114,9 +123,6 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label" for="{{ form.receiver_name.id_for_label }}">{{ form.receiver_name.label }}</label> <label class="form-label" for="{{ form.receiver_name.id_for_label }}">{{ form.receiver_name.label }}</label>
{{ form.receiver_name }} {{ form.receiver_name }}
{% if form.receiver_name.errors %}
<div class="text-danger small">{{ form.receiver_name.errors }}</div>
{% endif %}
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label" for="{{ form.receiver_phone.id_for_label }}">{{ form.receiver_phone.label }}</label> <label class="form-label" for="{{ form.receiver_phone.id_for_label }}">{{ form.receiver_phone.label }}</label>
@ -128,12 +134,6 @@
{{ form.receiver_phone }} {{ form.receiver_phone }}
</div> </div>
</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>
<div class="col-12 mt-4 d-flex gap-2"> <div class="col-12 mt-4 d-flex gap-2">
@ -147,6 +147,159 @@
</div> </div>
</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> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
function setupDependentDropdowns(countryId, governateId, cityId) { 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/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/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/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) # Aliases for mobile app compatibility (API v1)
path('api/shipments/', api_views.ParcelListCreateView.as_view(), name='api_shipment_list'), path('api/shipments/', api_views.ParcelListCreateView.as_view(), name='api_shipment_list'),

View File

@ -35,6 +35,7 @@ import weasyprint
import qrcode import qrcode
from io import BytesIO from io import BytesIO
import base64 import base64
from .pricing import get_pricing_breakdown # Import pricing logic
def index(request): def index(request):
# If tracking_id is present, redirect to the new track view # If tracking_id is present, redirect to the new track view
@ -295,6 +296,17 @@ def shipment_request(request):
if form.is_valid(): if form.is_valid():
parcel = form.save(commit=False) parcel = form.save(commit=False)
parcel.shipper = request.user 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() parcel.save()
# WhatsApp Notification # WhatsApp Notification
@ -304,7 +316,14 @@ def shipment_request(request):
return redirect('dashboard') return redirect('dashboard')
else: else:
form = ParcelForm() 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 @login_required
def accept_parcel(request, parcel_id): def accept_parcel(request, parcel_id):
@ -939,13 +958,27 @@ def edit_parcel(request, parcel_id):
if request.method == 'POST': if request.method == 'POST':
form = ParcelForm(request.POST, instance=parcel) form = ParcelForm(request.POST, instance=parcel)
if form.is_valid(): 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.")) messages.success(request, _("Shipment updated successfully."))
return redirect('dashboard') return redirect('dashboard')
else: else:
form = ParcelForm(instance=parcel) form = ParcelForm(instance=parcel)
return render(request, 'core/edit_parcel.html', {'form': form, 'parcel': 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, 'google_maps_api_key': google_maps_api_key})
@login_required @login_required
def cancel_parcel(request, parcel_id): def cancel_parcel(request, parcel_id):