adding notifications

This commit is contained in:
Flatlogic Bot 2026-01-25 07:53:43 +00:00
parent 95e2847b94
commit f213aed6e7
18 changed files with 322 additions and 12 deletions

View File

@ -185,6 +185,23 @@ CONTACT_EMAIL_TO = [
# When both TLS and SSL flags are enabled, prefer SSL explicitly
if EMAIL_USE_SSL:
EMAIL_USE_TLS = False
# Thawani Payment Settings
THAWANI_API_KEY = os.getenv("THAWANI_API_KEY", "rRQ26GcsZ60u9YCD9As60reHscS3Jt") # Placeholder Test Key
THAWANI_PUBLISHABLE_KEY = os.getenv("THAWANI_PUBLISHABLE_KEY", "HGvTMLsnssOfssSshvSOfssOfsSshv") # Placeholder
THAWANI_MODE = os.getenv("THAWANI_MODE", "test") # 'test' or 'live'
if THAWANI_MODE == 'live':
THAWANI_API_URL = "https://checkout.thawani.om/api/v1"
else:
THAWANI_API_URL = "https://uatcheckout.thawani.om/api/v1"
# WhatsApp Notification Settings
WHATSAPP_API_KEY = os.getenv("WHATSAPP_API_KEY", "")
WHATSAPP_PHONE_ID = os.getenv("WHATSAPP_PHONE_ID", "")
WHATSAPP_BUSINESS_ACCOUNT_ID = os.getenv("WHATSAPP_BUSINESS_ACCOUNT_ID", "")
WHATSAPP_ENABLED = os.getenv("WHATSAPP_ENABLED", "false").lower() == "true"
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field

Binary file not shown.

Binary file not shown.

View File

@ -69,7 +69,7 @@ class ParcelForm(forms.ModelForm):
class Meta:
model = Parcel
fields = [
'description', 'weight',
'description', 'weight', 'price',
'pickup_country', 'pickup_governate', 'pickup_city', 'pickup_address',
'delivery_country', 'delivery_governate', 'delivery_city', 'delivery_address',
'receiver_name', 'receiver_phone'
@ -77,6 +77,7 @@ class ParcelForm(forms.ModelForm):
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'}),
'pickup_country': forms.Select(attrs={'class': 'form-control'}),
'pickup_governate': forms.Select(attrs={'class': 'form-control'}),
@ -94,6 +95,7 @@ class ParcelForm(forms.ModelForm):
labels = {
'description': _('Package Description'),
'weight': _('Weight (kg)'),
'price': _('Shipping Price (OMR)'),
'pickup_country': _('Pickup Country'),
'pickup_governate': _('Pickup Governate'),
'pickup_city': _('Pickup City'),
@ -142,4 +144,4 @@ class ParcelForm(forms.ModelForm):
gov_id = int(self.data.get('delivery_governate'))
self.fields['delivery_city'].queryset = City.objects.filter(governate_id=gov_id).order_by('name')
except (ValueError, TypeError):
pass
pass

View File

@ -0,0 +1,34 @@
# Generated by Django 5.2.7 on 2026-01-25 07:48
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_city_country_parcel_delivery_city_parcel_pickup_city_and_more'),
]
operations = [
migrations.AddField(
model_name='parcel',
name='payment_status',
field=models.CharField(choices=[('pending', 'Pending'), ('paid', 'Paid'), ('failed', 'Failed')], default='pending', max_length=20, verbose_name='Payment Status'),
),
migrations.AddField(
model_name='parcel',
name='price',
field=models.DecimalField(decimal_places=3, default=0.0, max_digits=10, verbose_name='Price (OMR)'),
),
migrations.AddField(
model_name='parcel',
name='thawani_session_id',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Thawani Session ID'),
),
migrations.AlterField(
model_name='parcel',
name='delivery_city',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='delivery_city_parcels', to='core.city', verbose_name='Delivery City'),
),
]

View File

@ -76,12 +76,19 @@ class Parcel(models.Model):
('cancelled', _('Cancelled')),
)
PAYMENT_STATUS_CHOICES = (
('pending', _('Pending')),
('paid', _('Paid')),
('failed', _('Failed')),
)
tracking_number = models.CharField(_('Tracking Number'), max_length=20, unique=True, blank=True)
shipper = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sent_parcels', verbose_name=_('Shipper'))
carrier = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='carried_parcels', verbose_name=_('Carrier'))
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)
# Pickup Location
pickup_country = models.ForeignKey(Country, on_delete=models.SET_NULL, null=True, blank=True, related_name='pickup_parcels', verbose_name=_('Pickup Country'))
@ -92,13 +99,16 @@ class Parcel(models.Model):
# 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_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)
receiver_name = models.CharField(_('Receiver Name'), max_length=100)
receiver_phone = models.CharField(_('Receiver Phone'), max_length=20)
status = models.CharField(_('Status'), max_length=20, choices=STATUS_CHOICES, default='pending')
payment_status = models.CharField(_('Payment Status'), max_length=20, choices=PAYMENT_STATUS_CHOICES, default='pending')
thawani_session_id = models.CharField(_('Thawani Session ID'), max_length=255, blank=True, null=True)
created_at = models.DateTimeField(_('Created At'), auto_now_add=True)
updated_at = models.DateTimeField(_('Updated At'), auto_now=True)
@ -112,4 +122,4 @@ class Parcel(models.Model):
class Meta:
verbose_name = _('Parcel')
verbose_name_plural = _('Parcels')
verbose_name_plural = _('Parcels')

66
core/payment_utils.py Normal file
View File

@ -0,0 +1,66 @@
import requests
from django.conf import settings
import logging
logger = logging.getLogger(__name__)
class ThawaniPay:
def __init__(self):
self.api_key = settings.THAWANI_API_KEY
self.base_url = settings.THAWANI_API_URL
self.headers = {
"thawani-api-key": self.api_key,
"Content-Type": "application/json"
}
def create_checkout_session(self, parcel, success_url, cancel_url):
endpoint = f"{self.base_url}/checkout/session"
# Thawani expects price in baiza (1 OMR = 1000 baiza)
# We need to convert Decimal price to integer baiza
amount_baiza = int(parcel.price * 1000)
payload = {
"client_reference_id": str(parcel.tracking_number),
"mode": "payment",
"products": [
{
"name": f"Shipping for Parcel {parcel.tracking_number}",
"unit_amount": amount_baiza,
"quantity": 1
}
],
"success_url": success_url,
"cancel_url": cancel_url,
"metadata": {
"parcel_id": parcel.id,
"customer_name": parcel.shipper.get_full_name() or parcel.shipper.username,
"customer_phone": parcel.shipper.profile.phone_number
}
}
try:
response = requests.post(endpoint, json=payload, headers=self.headers)
response.raise_for_status()
data = response.json()
if data.get("success"):
return data["data"]["session_id"]
else:
logger.error(f"Thawani Error: {data.get('description')}")
return None
except Exception as e:
logger.error(f"Thawani Request Failed: {str(e)}")
return None
def get_checkout_session(self, session_id):
endpoint = f"{self.base_url}/checkout/session/{session_id}"
try:
response = requests.get(endpoint, headers=self.headers)
response.raise_for_status()
data = response.json()
if data.get("success"):
return data["data"]
return None
except Exception as e:
logger.error(f"Thawani Check Failed: {str(e)}")
return None

View File

@ -26,7 +26,10 @@
<h5 class="card-title">{{ parcel.description|truncatechars:30 }}</h5>
<p class="card-text mb-1 small"><strong>{% trans "Pickup" %}:</strong> {{ parcel.pickup_address }}</p>
<p class="card-text mb-3 small"><strong>{% trans "Delivery" %}:</strong> {{ parcel.delivery_address }}</p>
<p class="card-text mb-3 small text-muted"><strong>{% trans "Weight" %}:</strong> {{ parcel.weight }} kg</p>
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="text-muted small"><strong>{% trans "Weight" %}:</strong> {{ parcel.weight }} kg</span>
<span class="text-primary fw-bold">{{ parcel.price }} OMR</span>
</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>
@ -79,4 +82,4 @@
</div>
</div>
</div>
{% endblock %}
{% endblock %}

View File

@ -23,6 +23,20 @@
<h5 class="card-title">{{ parcel.description|truncatechars:30 }}</h5>
<p class="card-text mb-1 small text-muted"><i class="fas fa-map-marker-alt"></i> <strong>{% trans "From" %}:</strong> {{ parcel.pickup_address }}</p>
<p class="card-text mb-3 small text-muted"><i class="fas fa-flag-checkered"></i> <strong>{% trans "To" %}:</strong> {{ parcel.delivery_address }}</p>
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="text-primary fw-bold">{{ parcel.price }} OMR</span>
<span class="badge {% if parcel.payment_status == 'paid' %}bg-success{% else %}bg-secondary{% endif %}">
{{ parcel.get_payment_status_display }}
</span>
</div>
{% if parcel.payment_status == 'pending' %}
<a href="{% url 'initiate_payment' parcel.id %}" class="btn btn-sm btn-outline-primary w-100 mb-3">
<i class="fas fa-credit-card me-1"></i> {% trans "Pay Now" %}
</a>
{% endif %}
<hr>
<p class="card-text small mb-0"><strong>{% trans "Receiver" %}:</strong> {{ parcel.receiver_name }}</p>
<p class="card-text small"><strong>{% trans "Carrier" %}:</strong> {% if parcel.carrier %}{{ parcel.carrier.get_full_name|default:parcel.carrier.username }}{% else %}{% trans "Waiting for pickup" %}{% endif %}</p>
@ -38,4 +52,4 @@
</div>
{% endif %}
</div>
{% endblock %}
{% endblock %}

View File

@ -1,6 +1,6 @@
from django.urls import path
from django.contrib.auth import views as auth_views
from . import views
from django.contrib.auth import views as auth_views
urlpatterns = [
path('', views.index, name='index'),
@ -16,4 +16,9 @@ urlpatterns = [
# AJAX for locations
path('ajax/get-governates/', views.get_governates, name='get_governates'),
path('ajax/get-cities/', views.get_cities, name='get_cities'),
]
# Thawani Payment
path('payment/initiate/<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'),
]

View File

@ -7,6 +7,15 @@ from .forms import UserRegistrationForm, ParcelForm
from django.utils.translation import gettext_lazy as _
from django.contrib import messages
from django.http import JsonResponse
from django.urls import reverse
from .payment_utils import ThawaniPay
from django.conf import settings
from .whatsapp_utils import (
notify_shipment_created,
notify_payment_received,
notify_driver_assigned,
notify_status_change
)
def index(request):
tracking_id = request.GET.get('tracking_id')
@ -45,7 +54,7 @@ def dashboard(request):
return render(request, 'core/shipper_dashboard.html', {'parcels': parcels})
else:
# Car Owner view
available_parcels = Parcel.objects.filter(status='pending').order_by('-created_at')
available_parcels = Parcel.objects.filter(status='pending', payment_status='paid').order_by('-created_at')
my_parcels = Parcel.objects.filter(carrier=request.user).exclude(status='delivered').order_by('-created_at')
return render(request, 'core/driver_dashboard.html', {
'available_parcels': available_parcels,
@ -65,6 +74,10 @@ def shipment_request(request):
parcel = form.save(commit=False)
parcel.shipper = request.user
parcel.save()
# WhatsApp Notification
notify_shipment_created(parcel)
messages.success(request, _("Shipment requested successfully! Tracking ID: ") + parcel.tracking_number)
return redirect('dashboard')
else:
@ -78,10 +91,14 @@ def accept_parcel(request, parcel_id):
messages.error(request, _("Only car owners can accept shipments."))
return redirect('dashboard')
parcel = get_object_or_404(Parcel, id=parcel_id, status='pending')
parcel = get_object_or_404(Parcel, id=parcel_id, status='pending', payment_status='paid')
parcel.carrier = request.user
parcel.status = 'picked_up'
parcel.save()
# WhatsApp Notification
notify_driver_assigned(parcel)
messages.success(request, _("You have accepted the shipment!"))
return redirect('dashboard')
@ -93,9 +110,59 @@ def update_status(request, parcel_id):
if new_status in dict(Parcel.STATUS_CHOICES):
parcel.status = new_status
parcel.save()
# WhatsApp Notification
notify_status_change(parcel)
messages.success(request, _("Status updated successfully!"))
return redirect('dashboard')
@login_required
def initiate_payment(request, parcel_id):
parcel = get_object_or_404(Parcel, id=parcel_id, shipper=request.user, payment_status='pending')
thawani = ThawaniPay()
success_url = request.build_absolute_uri(reverse('payment_success')) + f"?session_id={{CHECKOUT_SESSION_ID}}&parcel_id={parcel.id}"
cancel_url = request.build_absolute_uri(reverse('payment_cancel')) + f"?parcel_id={parcel.id}"
session_id = thawani.create_checkout_session(parcel, success_url, cancel_url)
if session_id:
parcel.thawani_session_id = session_id
parcel.save()
checkout_url = f"{settings.THAWANI_API_URL.replace('/api/v1', '')}/pay/{session_id}?key={settings.THAWANI_PUBLISHABLE_KEY}"
return redirect(checkout_url)
else:
messages.error(request, _("Could not initiate payment. Please try again later."))
return redirect('dashboard')
@login_required
def payment_success(request):
session_id = request.GET.get('session_id')
parcel_id = request.GET.get('parcel_id')
parcel = get_object_or_404(Parcel, id=parcel_id, shipper=request.user)
thawani = ThawaniPay()
session_data = thawani.get_checkout_session(session_id)
if session_data and session_data.get('payment_status') == 'paid':
parcel.payment_status = 'paid'
parcel.save()
# WhatsApp Notification
notify_payment_received(parcel)
messages.success(request, _("Payment successful! Your shipment is now active."))
else:
messages.warning(request, _("Payment status is pending or failed. Please check your dashboard."))
return redirect('dashboard')
@login_required
def payment_cancel(request):
messages.info(request, _("Payment was cancelled."))
return redirect('dashboard')
def article_detail(request):
return render(request, 'core/article_detail.html')
@ -107,4 +174,4 @@ def get_governates(request):
def get_cities(request):
governate_id = request.GET.get('governate_id')
cities = City.objects.filter(governate_id=governate_id).values('id', 'name')
return JsonResponse(list(cities), safe=False)
return JsonResponse(list(cities), safe=False)

92
core/whatsapp_utils.py Normal file
View File

@ -0,0 +1,92 @@
import requests
import logging
from django.conf import settings
logger = logging.getLogger(__name__)
def send_whatsapp_message(phone_number, message):
"""
Sends a WhatsApp message using the configured gateway.
This implementation assumes Meta WhatsApp Business API (Graph API).
"""
if not settings.WHATSAPP_ENABLED:
logger.info("WhatsApp notifications are disabled.")
return False
if not settings.WHATSAPP_API_KEY or not settings.WHATSAPP_PHONE_ID:
logger.warning("WhatsApp API configuration is missing.")
return False
# Normalize phone number (ensure it has country code and no +)
clean_phone = "".join(filter(str.isdigit, str(phone_number)))
url = f"https://graph.facebook.com/v17.0/{settings.WHATSAPP_PHONE_ID}/messages"
headers = {
"Authorization": f"Bearer {settings.WHATSAPP_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"messaging_product": "whatsapp",
"to": clean_phone,
"type": "text",
"text": {"body": message}
}
try:
response = requests.post(url, headers=headers, json=payload, timeout=10)
response_data = response.json()
if response.status_code == 200:
logger.info(f"WhatsApp message sent to {clean_phone}")
return True
else:
logger.error(f"WhatsApp API error: {response.status_code} - {response_data}")
return False
except Exception as e:
logger.error(f"Failed to send WhatsApp message: {str(e)}")
return False
def notify_shipment_created(parcel):
"""Notifies the shipper that the shipment request was received."""
shipper_name = parcel.shipper.get_full_name() or parcel.shipper.username
message = f"""Hello {shipper_name},
Your shipment request for '{parcel.description}' has been received.
Tracking Number: {parcel.tracking_number}
Status: {parcel.get_status_display()}
Please proceed to payment to make it visible to drivers."""
return send_whatsapp_message(parcel.shipper.profile.phone_number, message)
def notify_payment_received(parcel):
"""Notifies the shipper and receiver about successful payment."""
# Notify Shipper
shipper_name = parcel.shipper.get_full_name() or parcel.shipper.username
shipper_msg = f"""Payment successful for shipment {parcel.tracking_number}.
Your shipment is now visible to available drivers."""
send_whatsapp_message(parcel.shipper.profile.phone_number, shipper_msg)
# Notify Receiver
receiver_msg = f"""Hello {parcel.receiver_name},
A shipment is coming your way from {shipper_name}.
Tracking Number: {parcel.tracking_number}
Status: {parcel.get_status_display()}"""
send_whatsapp_message(parcel.receiver_phone, receiver_msg)
def notify_driver_assigned(parcel):
"""Notifies the shipper and receiver that a driver has picked up the parcel."""
driver_name = parcel.carrier.get_full_name() or parcel.carrier.username
msg = f"""Shipment {parcel.tracking_number} has been picked up by {driver_name}.
Status: {parcel.get_status_display()}"""
send_whatsapp_message(parcel.shipper.profile.phone_number, msg)
send_whatsapp_message(parcel.receiver_phone, msg)
def notify_status_change(parcel):
"""Notifies parties about general status updates (In Transit, Delivered)."""
msg = f"""Update for shipment {parcel.tracking_number}:
New Status: {parcel.get_status_display()}"""
send_whatsapp_message(parcel.shipper.profile.phone_number, msg)
send_whatsapp_message(parcel.receiver_phone, msg)