Compare commits
No commits in common. "90ec729b4096c0450ae7867c153fc139f9db3443" and "254e46f4b2dfd604eded001194e0d7347504ee05" have entirely different histories.
90ec729b40
...
254e46f4b2
@ -1,15 +0,0 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.venv
|
||||
venv/
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.DS_Store
|
||||
db.sqlite3
|
||||
staticfiles/
|
||||
media/
|
||||
.idea
|
||||
.vscode
|
||||
45
Dockerfile
@ -1,45 +0,0 @@
|
||||
FROM python:3.11-slim-bookworm
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Set work directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
# WeasyPrint/Pango dependencies for Debian 12 (Bookworm)
|
||||
# Note: package names must match Bookworm repositories
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
pkg-config \
|
||||
libglib2.0-0 \
|
||||
libcairo2 \
|
||||
libpango-1.0-0 \
|
||||
libpangoft2-1.0-0 \
|
||||
libjpeg-dev \
|
||||
libopenjp2-7-dev \
|
||||
libxcb1 \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
shared-mime-info \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install python dependencies
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy project
|
||||
COPY . /app/
|
||||
|
||||
# Copy entrypoint script and make it executable
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Entrypoint
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
@ -1,22 +0,0 @@
|
||||
Plan:
|
||||
|
||||
1. **Add Configuration:** I have added a new field `enable_payment` (checkbox) to the **Platform Profile** model.
|
||||
2. **Enforce Logic:** I updated the payment initiation view (`initiate_payment`) to check this flag. If disabled, users cannot start a payment.
|
||||
3. **Update UI:** I updated the **Shipper Dashboard** to hide the "Pay Now" button if payments are disabled.
|
||||
4. **Deploy:** Created migrations and restarted the server.
|
||||
|
||||
Changed (if any):
|
||||
|
||||
* `core/models.py`: Added `enable_payment` boolean field to `PlatformProfile`.
|
||||
* `core/views.py`: Added a check in `initiate_payment` to block payments if disabled.
|
||||
* `core/templates/core/shipper_dashboard.html`: Wrapped the "Pay Now" button in a conditional check.
|
||||
|
||||
Notes:
|
||||
|
||||
* **Action Required:** Go to **Django Admin > Platform Profiles**. You will see a new checkbox **Enable Payment**.
|
||||
* **Checked:** Users can pay.
|
||||
* **Unchecked:** The "Pay Now" button disappears, and direct URL access is blocked.
|
||||
|
||||
Next: You can test this by toggling the checkbox in the admin and refreshing your dashboard. What's next?
|
||||
|
||||
Reminder: click Save in the editor to sync changes.
|
||||
@ -145,7 +145,6 @@ def request(path: Optional[str], payload: Dict[str, Any], options: Optional[Dict
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
cfg["project_header"]: project_uuid,
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
}
|
||||
extra_headers = options.get("headers")
|
||||
if isinstance(extra_headers, Iterable):
|
||||
@ -181,7 +180,6 @@ def fetch_status(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -
|
||||
headers: Dict[str, str] = {
|
||||
"Accept": "application/json",
|
||||
cfg["project_header"]: project_uuid,
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
}
|
||||
extra_headers = options.get("headers")
|
||||
if isinstance(extra_headers, Iterable):
|
||||
|
||||
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 95 KiB |
@ -1,3 +0,0 @@
|
||||
import pymysql
|
||||
|
||||
pymysql.install_as_MySQLdb()
|
||||
@ -20,32 +20,22 @@ load_dotenv(BASE_DIR.parent / ".env")
|
||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me")
|
||||
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
|
||||
|
||||
# Allow all hosts to avoid 404/400 errors during initial deployment
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
|
||||
# CSRF & Proxy Settings
|
||||
# ------------------------------------------------------------------------------
|
||||
# Trust the 'X-Forwarded-Proto' header from the proxy (Traefik/Nginx)
|
||||
# This is required for Django to know it's running over HTTPS.
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
|
||||
# Parse comma-separated trusted origins from env
|
||||
_csrf_env_list = (
|
||||
os.getenv("HOST_FQDN", "") + "," + os.getenv("CSRF_TRUSTED_ORIGINS", "")
|
||||
).split(",")
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = []
|
||||
for origin in _csrf_env_list:
|
||||
origin = origin.strip()
|
||||
if origin:
|
||||
if not origin.startswith(("http://", "https://")):
|
||||
CSRF_TRUSTED_ORIGINS.append(f"https://{origin}")
|
||||
else:
|
||||
CSRF_TRUSTED_ORIGINS.append(origin)
|
||||
|
||||
# Remove duplicates
|
||||
CSRF_TRUSTED_ORIGINS = list(set(CSRF_TRUSTED_ORIGINS))
|
||||
ALLOWED_HOSTS = [
|
||||
"127.0.0.1",
|
||||
"localhost",
|
||||
os.getenv("HOST_FQDN", ""),
|
||||
]
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
origin for origin in [
|
||||
os.getenv("HOST_FQDN", ""),
|
||||
os.getenv("CSRF_TRUSTED_ORIGIN", "")
|
||||
] if origin
|
||||
]
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
f"https://{host}" if not host.startswith(("http://", "https://")) else host
|
||||
for host in CSRF_TRUSTED_ORIGINS
|
||||
]
|
||||
|
||||
# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy.
|
||||
SESSION_COOKIE_SECURE = True
|
||||
@ -59,26 +49,18 @@ CSRF_COOKIE_SAMESITE = "None"
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'jazzmin',
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.humanize',
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
'drf_yasg',
|
||||
'rangefilter',
|
||||
'core',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware", # Add WhiteNoise Middleware
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
@ -94,14 +76,13 @@ ROOT_URLCONF = 'config.urls'
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR / 'core/templates'],
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'django.template.context_processors.i18n',
|
||||
# IMPORTANT: do not remove – injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp
|
||||
'core.context_processors.project_context',
|
||||
],
|
||||
@ -152,15 +133,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'ar'
|
||||
LANGUAGES = [
|
||||
('en', 'English'),
|
||||
('ar', 'Arabic'),
|
||||
]
|
||||
|
||||
LOCALE_PATHS = [
|
||||
BASE_DIR / 'locale',
|
||||
]
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
@ -182,26 +155,18 @@ STATICFILES_DIRS = [
|
||||
BASE_DIR / 'node_modules',
|
||||
]
|
||||
|
||||
# Enable WhiteNoise's Gzip compression of static assets.
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedStaticFilesStorage"
|
||||
|
||||
|
||||
# Media files
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
# Email
|
||||
EMAIL_BACKEND = os.getenv(
|
||||
"EMAIL_BACKEND",
|
||||
"django.core.mail.backends.smtp.EmailBackend"
|
||||
)
|
||||
EMAIL_HOST = os.getenv("EMAIL_HOST", "smtp.gmail.com")
|
||||
EMAIL_HOST = os.getenv("EMAIL_HOST", "127.0.0.1")
|
||||
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
|
||||
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "aalabry@gmail.com")
|
||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "accd uacy kzdq aejp")
|
||||
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "")
|
||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "")
|
||||
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true"
|
||||
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true"
|
||||
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", EMAIL_HOST_USER)
|
||||
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "no-reply@example.com")
|
||||
CONTACT_EMAIL_TO = [
|
||||
item.strip()
|
||||
for item in os.getenv("CONTACT_EMAIL_TO", DEFAULT_FROM_EMAIL).split(",")
|
||||
@ -211,125 +176,7 @@ 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", "true").lower() == "true"
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
LOGIN_URL = 'login'
|
||||
LOGIN_REDIRECT_URL = 'dashboard'
|
||||
LOGOUT_REDIRECT_URL = 'index'
|
||||
|
||||
# Site URL for Emails
|
||||
HOST_FQDN = os.getenv("HOST_FQDN", "")
|
||||
if HOST_FQDN:
|
||||
if not HOST_FQDN.startswith(("http://", "https://")):
|
||||
SITE_URL = f"https://{HOST_FQDN}"
|
||||
else:
|
||||
SITE_URL = HOST_FQDN
|
||||
else:
|
||||
SITE_URL = "http://127.0.0.1:8000"
|
||||
|
||||
# Jazzmin Settings
|
||||
JAZZMIN_SETTINGS = {
|
||||
"site_title": "Masar Express Admin",
|
||||
"site_header": "Masar Express",
|
||||
"site_brand": "Masar Express",
|
||||
"site_logo": "img/logo.jpg",
|
||||
"login_logo": "img/logo.jpg",
|
||||
"welcome_sign": "Welcome to Masar Express Admin",
|
||||
"copyright": "Masar Express",
|
||||
"search_model": ["core.Parcel", "auth.User"],
|
||||
"user_avatar": None,
|
||||
"topmenu_links": [],
|
||||
"usermenu_links": [
|
||||
{"model": "auth.User"}
|
||||
],
|
||||
"custom_links": {
|
||||
"core": [{
|
||||
"name": "View Website",
|
||||
"url": "index",
|
||||
"icon": "fas fa-external-link-alt",
|
||||
"new_window": True,
|
||||
}]
|
||||
},
|
||||
"show_sidebar": True,
|
||||
"navigation_expanded": True,
|
||||
"hide_apps": [],
|
||||
"hide_models": [],
|
||||
"order_with_respect_to": ["core", "auth"],
|
||||
"icons": {
|
||||
"auth": "fas fa-users-cog",
|
||||
"auth.user": "fas fa-user",
|
||||
"auth.Group": "fas fa-users",
|
||||
"core.Parcel": "fas fa-box-open",
|
||||
"core.Profile": "fas fa-id-card",
|
||||
"core.PlatformProfile": "fas fa-cogs",
|
||||
"core.Country": "fas fa-globe",
|
||||
"core.City": "fas fa-city",
|
||||
"core.Governate": "fas fa-map-marked-alt",
|
||||
"core.DriverRating": "fas fa-star",
|
||||
"core.Testimonial": "fas fa-comment-dots",
|
||||
"core.NotificationTemplate": "fas fa-envelope-open-text",
|
||||
"core.PricingRule": "fas fa-tags",
|
||||
},
|
||||
"default_icon_parents": "fas fa-chevron-circle-right",
|
||||
"default_icon_children": "fas fa-circle",
|
||||
"related_modal_active": False,
|
||||
"custom_css": "css/custom_v2.css",
|
||||
"use_google_fonts_cdn": True,
|
||||
"show_ui_builder": False,
|
||||
"language_chooser": True,
|
||||
"changeform_format": "horizontal_tabs",
|
||||
"changeform_format_overrides": {"core.platformprofile": "vertical_tabs"}
|
||||
}
|
||||
|
||||
JAZZMIN_UI_TWEAKS = {
|
||||
"navbar_small_text": False,
|
||||
"footer_small_text": False,
|
||||
"body_small_text": False,
|
||||
"brand_small_text": False,
|
||||
"brand_colour": False,
|
||||
"accent": "accent-primary",
|
||||
"navbar": "navbar-white navbar-light",
|
||||
"no_navbar_border": False,
|
||||
"navbar_fixed": False,
|
||||
"layout_boxed": False,
|
||||
"footer_fixed": False,
|
||||
"sidebar_fixed": True,
|
||||
"sidebar": "sidebar-dark-primary",
|
||||
"sidebar_nav_small_text": False,
|
||||
"theme": "flatly",
|
||||
"dark_mode_theme": None,
|
||||
"button_classes": {
|
||||
"primary": "btn-primary",
|
||||
"secondary": "btn-secondary",
|
||||
"info": "btn-info",
|
||||
"warning": "btn-warning",
|
||||
"danger": "btn-danger",
|
||||
"success": "btn-success"
|
||||
}
|
||||
}
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework.authentication.TokenAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
],
|
||||
}
|
||||
|
||||
@ -1,43 +1,29 @@
|
||||
"""
|
||||
URL configuration for config project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.urls import include, path
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.conf.urls.i18n import i18n_patterns
|
||||
from django.http import HttpResponse
|
||||
|
||||
from rest_framework import permissions
|
||||
from drf_yasg.views import get_schema_view
|
||||
from drf_yasg import openapi
|
||||
|
||||
schema_view = get_schema_view(
|
||||
openapi.Info(
|
||||
title="Masar Express API",
|
||||
default_version='v1',
|
||||
description="API documentation for Masar Express Mobile App & Drivers",
|
||||
terms_of_service="https://www.google.com/policies/terms/",
|
||||
contact=openapi.Contact(email="support@masarexpress.com"),
|
||||
license=openapi.License(name="BSD License"),
|
||||
),
|
||||
public=True,
|
||||
permission_classes=(permissions.AllowAny,),
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('health/', lambda request: HttpResponse("OK")), # Simple health check
|
||||
path('i18n/', include('django.conf.urls.i18n')),
|
||||
# Swagger / Redoc
|
||||
path('swagger<format>/', schema_view.without_ui(cache_timeout=0), name='schema-json'),
|
||||
path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
|
||||
path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
|
||||
path("admin/", admin.site.urls),
|
||||
path("", include("core.urls")),
|
||||
]
|
||||
|
||||
urlpatterns += i18n_patterns(
|
||||
path('admin/', admin.site.urls),
|
||||
path('', include('core.urls')),
|
||||
prefix_default_language=False
|
||||
)
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
|
||||
@ -8,9 +8,6 @@ https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
import pymysql
|
||||
|
||||
pymysql.install_as_MySQLdb()
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
|
||||
423
core/admin.py
@ -1,424 +1,3 @@
|
||||
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, DriverReport, DriverRejection, ParcelType, DriverWarning
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.urls import path, reverse
|
||||
from django.shortcuts import render, redirect
|
||||
from django.utils.html import format_html
|
||||
from django.contrib import messages
|
||||
from .whatsapp_utils import send_whatsapp_message_detailed
|
||||
from django.conf import settings
|
||||
from .mail import send_html_email
|
||||
import logging
|
||||
import csv
|
||||
from django.http import HttpResponse, HttpResponseRedirect
|
||||
from rangefilter.filters import DateRangeFilter
|
||||
from django.template.loader import render_to_string
|
||||
import weasyprint
|
||||
from django.db.models import Sum
|
||||
|
||||
class DriverWarningInline(admin.TabularInline):
|
||||
model = DriverWarning
|
||||
extra = 1
|
||||
|
||||
class ProfileInline(admin.StackedInline):
|
||||
model = Profile
|
||||
can_delete = False
|
||||
verbose_name_plural = _('Profiles')
|
||||
fieldsets = (
|
||||
(None, {'fields': ('role', 'is_approved', 'is_banned', 'ban_reason', 'phone_number', 'profile_picture', 'address', 'language')}),
|
||||
(_('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',)}),
|
||||
)
|
||||
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
inlines = (ProfileInline, DriverWarningInline)
|
||||
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()
|
||||
get_role.short_description = _('Role')
|
||||
|
||||
def get_driver_grade(self, obj):
|
||||
if obj.profile.role == 'car_owner':
|
||||
return obj.profile.get_driver_grade_display()
|
||||
return "-"
|
||||
get_driver_grade.short_description = _('Grade')
|
||||
|
||||
def get_approval_status(self, obj):
|
||||
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:
|
||||
return list()
|
||||
return super(CustomUserAdmin, self).get_inline_instances(request, obj)
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path('<int:user_id>/send-whatsapp/', self.admin_site.admin_view(self.send_whatsapp_view), name='user-send-whatsapp'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def send_whatsapp_view(self, request, user_id):
|
||||
user = self.get_object(request, user_id)
|
||||
if not user:
|
||||
messages.error(request, _("User not found."))
|
||||
return HttpResponseRedirect(reverse('admin:auth_user_changelist'))
|
||||
|
||||
if not hasattr(user, 'profile') or not user.profile.phone_number:
|
||||
messages.warning(request, _("This user does not have a phone number in their profile."))
|
||||
return HttpResponseRedirect(reverse('admin:auth_user_changelist'))
|
||||
|
||||
if request.method == 'POST':
|
||||
message = request.POST.get('message')
|
||||
if message:
|
||||
success, msg = send_whatsapp_message_detailed(user.profile.phone_number, message)
|
||||
if success:
|
||||
messages.success(request, _("WhatsApp message sent successfully."))
|
||||
return HttpResponseRedirect(reverse('admin:auth_user_changelist'))
|
||||
else:
|
||||
messages.error(request, _(f"Failed to send message: {msg}"))
|
||||
else:
|
||||
messages.warning(request, _("Message cannot be empty."))
|
||||
|
||||
context = dict(
|
||||
self.admin_site.each_context(request),
|
||||
user_obj=user,
|
||||
phone_number=user.profile.phone_number,
|
||||
)
|
||||
return render(request, "admin/core/user/send_whatsapp_message.html", context)
|
||||
|
||||
def send_whatsapp_link(self, obj):
|
||||
if hasattr(obj, 'profile') and obj.profile.phone_number:
|
||||
return format_html(
|
||||
'<a class="button" href="{}">{}</a>',
|
||||
reverse('admin:user-send-whatsapp', args=[obj.pk]),
|
||||
_('Send WhatsApp')
|
||||
)
|
||||
return "-"
|
||||
send_whatsapp_link.short_description = _("WhatsApp")
|
||||
send_whatsapp_link.allow_tags = True
|
||||
|
||||
class ParcelAdmin(admin.ModelAdmin):
|
||||
change_list_template = 'admin/core/parcel/change_list.html'
|
||||
|
||||
list_display = ('tracking_number', 'shipper', 'carrier', 'parcel_type', 'price', 'driver_amount', 'platform_fee', 'distance_km', 'status', 'payment_status', 'created_at')
|
||||
list_filter = (
|
||||
'status',
|
||||
'payment_status',
|
||||
('created_at', DateRangeFilter),
|
||||
)
|
||||
search_fields = ('tracking_number', 'shipper__username', 'receiver_name', 'carrier__username')
|
||||
actions = ['export_as_csv', 'print_parcels', 'export_pdf']
|
||||
|
||||
class Media:
|
||||
js = ('js/admin_date_range_dropdown.js',)
|
||||
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('tracking_number', 'shipper', 'carrier', 'parcel_type', '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 changelist_view(self, request, extra_context=None):
|
||||
response = super().changelist_view(request, extra_context)
|
||||
|
||||
# Calculate totals for the filtered queryset
|
||||
if hasattr(response, 'context_data') and 'cl' in response.context_data:
|
||||
qs = response.context_data['cl'].queryset
|
||||
metrics = qs.aggregate(
|
||||
total_price=Sum('price'),
|
||||
total_driver_amount=Sum('driver_amount'),
|
||||
total_platform_fee=Sum('platform_fee')
|
||||
)
|
||||
response.context_data['summary_metrics'] = metrics
|
||||
|
||||
return response
|
||||
|
||||
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', '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([
|
||||
obj.tracking_number,
|
||||
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
|
||||
])
|
||||
|
||||
return response
|
||||
export_as_csv.short_description = _("Export Selected to CSV")
|
||||
|
||||
def print_parcels(self, request, queryset):
|
||||
return render(request, 'admin/core/parcel/parcel_list_print.html', {'parcels': queryset, 'is_pdf': False})
|
||||
print_parcels.short_description = _("Print Selected Parcels")
|
||||
|
||||
def export_pdf(self, request, queryset):
|
||||
html_string = render_to_string('admin/core/parcel/parcel_list_print.html', {'parcels': queryset, 'is_pdf': True})
|
||||
html = weasyprint.HTML(string=html_string, base_url=request.build_absolute_uri())
|
||||
result = html.write_pdf()
|
||||
|
||||
response = HttpResponse(content_type='application/pdf')
|
||||
response['Content-Disposition'] = 'attachment; filename="parcels_list.pdf"'
|
||||
response.write(result)
|
||||
return response
|
||||
export_pdf.short_description = _("Download Selected as PDF")
|
||||
|
||||
class PlatformProfileAdmin(admin.ModelAdmin):
|
||||
class Media:
|
||||
js = ("js/admin_platform_profile.js",)
|
||||
css = {
|
||||
"all": ("css/admin_platform_profile.css",)
|
||||
}
|
||||
fieldsets = (
|
||||
(_('General Info'), {
|
||||
'fields': ('name', 'logo', 'favicon', 'admin_panel_logo', 'slogan', 'address', 'phone_number', 'registration_number', 'vat_number')
|
||||
}),
|
||||
(_('Financial Configuration'), {
|
||||
'fields': ('platform_fee_percentage', 'enable_payment')
|
||||
}),
|
||||
(_('Maintenance / Availability'), {
|
||||
'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 Warning & Rejection / Auto-Ban'), {
|
||||
'fields': (
|
||||
'enable_auto_ban_on_warnings', 'max_warnings_before_ban',
|
||||
'auto_ban_on_rejections', 'rejection_limit'
|
||||
),
|
||||
'description': _('Configure automatic banning for drivers who exceed warning or rejection limits.')
|
||||
}),
|
||||
(_('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).')
|
||||
}),
|
||||
(_('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')
|
||||
}),
|
||||
(_('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.')
|
||||
}),
|
||||
)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
# Allow only one instance
|
||||
if self.model.objects.exists():
|
||||
return False
|
||||
return super().has_add_permission(request)
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
# Redirect directly to the change page if a profile exists
|
||||
profile = self.model.objects.first()
|
||||
if profile:
|
||||
return redirect('admin:core_platformprofile_change', profile.pk)
|
||||
return super().changelist_view(request, extra_context)
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path('test-whatsapp/', self.admin_site.admin_view(self.test_whatsapp_view), name='test-whatsapp'),
|
||||
path('test-email/', self.admin_site.admin_view(self.test_email_view), name='test-email'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def test_whatsapp_view(self, request):
|
||||
phone_number = ''
|
||||
if request.method == 'POST':
|
||||
phone_number = request.POST.get('phone_number')
|
||||
if phone_number:
|
||||
success, msg = send_whatsapp_message_detailed(phone_number, "This is a test message from your Platform.")
|
||||
if success:
|
||||
messages.success(request, f"Success: {msg}")
|
||||
else:
|
||||
messages.error(request, f"Error: {msg}")
|
||||
else:
|
||||
messages.warning(request, "Please enter a phone number.")
|
||||
|
||||
context = dict(
|
||||
self.admin_site.each_context(request),
|
||||
phone_number=phone_number,
|
||||
)
|
||||
return render(request, "admin/core/platformprofile/test_whatsapp.html", context)
|
||||
|
||||
def test_email_view(self, request):
|
||||
email = ''
|
||||
if request.method == 'POST':
|
||||
email = request.POST.get('email')
|
||||
if email:
|
||||
try:
|
||||
send_html_email(
|
||||
subject="Test Email from Platform",
|
||||
message="This is a test email to verify your platform's email configuration. If you see the logo and nice formatting, it works!",
|
||||
recipient_list=[email],
|
||||
title="Test Email",
|
||||
request=request
|
||||
)
|
||||
messages.success(request, f"Success: Test email sent to {email}.")
|
||||
except Exception as e:
|
||||
messages.error(request, f"Error sending email: {str(e)}")
|
||||
else:
|
||||
messages.warning(request, "Please enter an email address.")
|
||||
|
||||
context = dict(
|
||||
self.admin_site.each_context(request),
|
||||
email=email,
|
||||
)
|
||||
return render(request, "admin/core/platformprofile/test_email.html", context)
|
||||
|
||||
def test_connection_link(self, obj):
|
||||
return format_html(
|
||||
'<a class="button" href="{}" style="margin-right: 10px;">{}</a>'
|
||||
'<a class="button" href="{}">{}</a>',
|
||||
reverse('admin:test-whatsapp'),
|
||||
_('Test WhatsApp'),
|
||||
reverse('admin:test-email'),
|
||||
_('Test Email')
|
||||
)
|
||||
test_connection_link.short_description = _("Actions")
|
||||
test_connection_link.allow_tags = True
|
||||
|
||||
readonly_fields = ('test_connection_link',)
|
||||
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
fieldsets = super().get_fieldsets(request, obj)
|
||||
# Add the test link to the first fieldset or a new one
|
||||
if obj:
|
||||
# Check if 'Tools' fieldset already exists to avoid duplication if called multiple times (though get_fieldsets is usually fresh)
|
||||
# Easier: just append it.
|
||||
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')
|
||||
|
||||
class TestimonialAdmin(admin.ModelAdmin):
|
||||
list_display = ('name_en', 'role_en', 'is_active', 'created_at')
|
||||
list_filter = ('is_active', 'created_at')
|
||||
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)
|
||||
admin.site.register(Country, CountryAdmin)
|
||||
admin.site.register(Governate)
|
||||
admin.site.register(City)
|
||||
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')
|
||||
search_fields = ('key', 'description')
|
||||
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('key', 'description', 'available_variables')
|
||||
}),
|
||||
(_('Email Content'), {
|
||||
'fields': ('subject_en', 'subject_ar', 'email_body_en', 'email_body_ar'),
|
||||
'description': _('For emails, the body is wrapped in a base template. Use HTML if needed.')
|
||||
}),
|
||||
(_('WhatsApp Content'), {
|
||||
'fields': ('whatsapp_body_en', 'whatsapp_body_ar'),
|
||||
'description': _('For WhatsApp, use plain text with newlines.')
|
||||
}),
|
||||
)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False # Prevent adding new keys manually
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
admin.site.register(NotificationTemplate, NotificationTemplateAdmin)
|
||||
admin.site.register(ParcelType)
|
||||
|
||||
class DriverWarningAdmin(admin.ModelAdmin):
|
||||
list_display = ('driver', 'reason', 'created_at')
|
||||
list_filter = ('created_at',)
|
||||
search_fields = ('driver__username', 'reason')
|
||||
|
||||
admin.site.register(DriverWarning, DriverWarningAdmin)
|
||||
# Register your models here.
|
||||
|
||||
@ -1,136 +0,0 @@
|
||||
from rest_framework import generics, permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.authtoken.views import ObtainAuthToken
|
||||
from rest_framework.authtoken.models import Token
|
||||
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):
|
||||
serializer = self.serializer_class(data=request.data,
|
||||
context={'request': request})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user = serializer.validated_data['user']
|
||||
token, created = Token.objects.get_or_create(user=user)
|
||||
|
||||
# Ensure profile exists
|
||||
profile, created = Profile.objects.get_or_create(user=user)
|
||||
|
||||
return Response({
|
||||
'token': token.key,
|
||||
'user_id': user.pk,
|
||||
'email': user.email,
|
||||
'role': profile.role,
|
||||
'username': user.username
|
||||
})
|
||||
|
||||
class ParcelListCreateView(generics.ListCreateAPIView):
|
||||
serializer_class = ParcelSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
profile = user.profile
|
||||
|
||||
if profile.role == 'shipper':
|
||||
return Parcel.objects.filter(shipper=user).order_by('-created_at')
|
||||
elif profile.role == 'car_owner':
|
||||
# Drivers see available parcels (pending) or their own assignments
|
||||
return Parcel.objects.filter(
|
||||
Q(status='pending') | Q(carrier=user)
|
||||
).order_by('-created_at')
|
||||
else:
|
||||
return Parcel.objects.none()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
from .models import PlatformProfile
|
||||
platform_profile = PlatformProfile.objects.first()
|
||||
if platform_profile and not platform_profile.accepting_shipments:
|
||||
raise permissions.PermissionDenied(platform_profile.maintenance_message or "The platform is currently not accepting new shipments.")
|
||||
# Only shippers can create
|
||||
if self.request.user.profile.role != 'shipper':
|
||||
raise permissions.PermissionDenied("Only shippers can create parcels.")
|
||||
serializer.save(shipper=self.request.user)
|
||||
|
||||
class ParcelDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||
serializer_class = ParcelSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
queryset = Parcel.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
# Restrict access
|
||||
user = self.request.user
|
||||
if user.profile.role == 'shipper':
|
||||
return Parcel.objects.filter(shipper=user)
|
||||
elif user.profile.role == 'car_owner':
|
||||
# Drivers can see parcels they can accept (pending) or are assigned to
|
||||
return Parcel.objects.filter(
|
||||
Q(status='pending') | Q(carrier=user)
|
||||
)
|
||||
return Parcel.objects.none()
|
||||
|
||||
def perform_update(self, serializer):
|
||||
# Add logic: Drivers can only update status, Shippers can edit details if pending
|
||||
# For simplicity in this v1, we allow updates but validation should be improved for production
|
||||
serializer.save()
|
||||
|
||||
class UserProfileView(generics.RetrieveUpdateAPIView):
|
||||
serializer_class = ProfileSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_object(self):
|
||||
return self.request.user.profile
|
||||
|
||||
class PublicParcelTrackView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Public endpoint to track a parcel by its tracking number.
|
||||
No authentication required.
|
||||
"""
|
||||
serializer_class = PublicParcelSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
queryset = Parcel.objects.all()
|
||||
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)
|
||||
19
core/apps.py
@ -1,25 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'core'
|
||||
verbose_name = _('Masar Express Management')
|
||||
default = True
|
||||
|
||||
def ready(self):
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
# Monkey-patch Permission.__str__ to show a VERY short name
|
||||
# Standard was: "app_label | model | name" (e.g. core | country | Can add country)
|
||||
# Previous fix: "Country | Can add country"
|
||||
# New fix: "add Country", "change Country" (strips "Can " prefix)
|
||||
|
||||
def short_str(self):
|
||||
name = str(self.name)
|
||||
if name.startswith("Can "):
|
||||
return name[4:]
|
||||
return name
|
||||
|
||||
Permission.__str__ = short_str
|
||||
@ -1,25 +1,13 @@
|
||||
import os
|
||||
import time
|
||||
from .models import Profile, PlatformProfile
|
||||
|
||||
def project_context(request):
|
||||
"""
|
||||
Adds project-specific environment variables to the template context globally.
|
||||
"""
|
||||
profile = None
|
||||
if request.user.is_authenticated:
|
||||
try:
|
||||
profile = request.user.profile
|
||||
except:
|
||||
profile, created = Profile.objects.get_or_create(user=request.user)
|
||||
|
||||
platform_profile = PlatformProfile.objects.first()
|
||||
|
||||
return {
|
||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
||||
# Used for cache-busting static assets
|
||||
"deployment_timestamp": int(time.time()),
|
||||
"user_profile": profile,
|
||||
"platform_profile": platform_profile,
|
||||
}
|
||||
441
core/forms.py
@ -1,441 +0,0 @@
|
||||
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, DriverReport, ParcelType
|
||||
from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm
|
||||
|
||||
class LoginForm(AuthenticationForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
|
||||
class CustomPasswordResetForm(PasswordResetForm):
|
||||
def get_users(self, email):
|
||||
"""
|
||||
Custom version that returns only the first matching user to avoid
|
||||
sending multiple emails if multiple accounts share the same email.
|
||||
Returns the most recently active account.
|
||||
"""
|
||||
users = list(super().get_users(email))
|
||||
if users:
|
||||
# Sort by last login (descending) to get the most active account
|
||||
users.sort(key=lambda u: (u.last_login is not None, u.last_login, u.date_joined), reverse=True)
|
||||
return [users[0]]
|
||||
return []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({'class': 'form-control', 'placeholder': _('Your Email Address')})
|
||||
|
||||
class ContactForm(forms.Form):
|
||||
name = forms.CharField(max_length=100, label=_("Name"), widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Your Name')}))
|
||||
email = forms.EmailField(label=_("Email"), widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': _('Your Email')}))
|
||||
subject = forms.CharField(max_length=200, label=_("Subject"), widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Subject')}))
|
||||
message = forms.CharField(label=_("Message"), widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 5, 'placeholder': _('Your Message')}))
|
||||
|
||||
class UserRegistrationForm(forms.ModelForm):
|
||||
password = forms.CharField(widget=forms.PasswordInput, label=_("Password"))
|
||||
password_confirm = forms.CharField(widget=forms.PasswordInput, label=_("Confirm Password"))
|
||||
role = forms.ChoiceField(choices=Profile.ROLE_CHOICES, label=_("Register as"))
|
||||
|
||||
phone_code = forms.ModelChoiceField(queryset=Country.objects.none(), label=_("Code"), required=False, widget=forms.Select(attrs={'class': 'form-control'}))
|
||||
phone_number = forms.CharField(max_length=20, label=_("Phone Number"))
|
||||
|
||||
verification_method = forms.ChoiceField(choices=[('email', _('Email')), ('whatsapp', _('WhatsApp'))], label=_("Verify via"), widget=forms.RadioSelect, initial='email')
|
||||
|
||||
country = forms.ModelChoiceField(queryset=Country.objects.all(), required=False, label=_("Country"))
|
||||
governate = forms.ModelChoiceField(queryset=Governate.objects.none(), required=False, label=_("Governate"))
|
||||
city = forms.ModelChoiceField(queryset=City.objects.none(), required=False, label=_("City"))
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['username', 'email', 'first_name', 'last_name']
|
||||
labels = {
|
||||
'username': _('Username'),
|
||||
'email': _('Email'),
|
||||
'first_name': _('First Name'),
|
||||
'last_name': _('Last Name'),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
lang = get_language()
|
||||
name_field = 'name_ar' if lang == 'ar' else 'name_en'
|
||||
|
||||
# Phone Code setup
|
||||
self.fields['phone_code'].queryset = Country.objects.exclude(phone_code='').order_by(name_field)
|
||||
self.fields['phone_code'].label_from_instance = lambda obj: f"{obj.phone_code} ({obj.name})"
|
||||
|
||||
self.fields['country'].queryset = Country.objects.all().order_by(name_field)
|
||||
|
||||
# Default Country logic
|
||||
oman = Country.objects.filter(name_en='Oman').first()
|
||||
if oman:
|
||||
self.fields['country'].initial = oman
|
||||
self.fields['phone_code'].initial = oman
|
||||
|
||||
if 'country' in self.data:
|
||||
try:
|
||||
country_id = int(self.data.get('country'))
|
||||
self.fields['governate'].queryset = Governate.objects.filter(country_id=country_id).order_by(name_field)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif self.instance.pk and hasattr(self.instance, 'profile') and self.instance.profile.country:
|
||||
self.fields['governate'].queryset = self.instance.profile.country.governate_set.order_by(name_field)
|
||||
elif oman:
|
||||
self.fields['governate'].queryset = Governate.objects.filter(country=oman).order_by(name_field)
|
||||
|
||||
if 'governate' in self.data:
|
||||
try:
|
||||
governate_id = int(self.data.get('governate'))
|
||||
self.fields['city'].queryset = City.objects.filter(governate_id=governate_id).order_by(name_field)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif self.instance.pk and hasattr(self.instance, 'profile') and self.instance.profile.governate:
|
||||
self.fields['city'].queryset = self.instance.profile.governate.city_set.order_by(name_field)
|
||||
|
||||
def clean_password_confirm(self):
|
||||
password = self.cleaned_data.get('password')
|
||||
password_confirm = self.cleaned_data.get('password_confirm')
|
||||
if password and password_confirm and password != password_confirm:
|
||||
raise forms.ValidationError(_("Passwords don't match"))
|
||||
return password_confirm
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
phone_code = cleaned_data.get('phone_code')
|
||||
phone_number = cleaned_data.get('phone_number')
|
||||
|
||||
if phone_code and phone_number:
|
||||
# If user didn't type the code in the phone number input, prepend it
|
||||
if not phone_number.startswith(phone_code.phone_code):
|
||||
cleaned_data['phone_number'] = f"{phone_code.phone_code}{phone_number}"
|
||||
return cleaned_data
|
||||
|
||||
def save(self, commit=True):
|
||||
user = super().save(commit=False)
|
||||
user.set_password(self.cleaned_data['password'])
|
||||
if commit:
|
||||
user.save()
|
||||
# Profile is created by signal, so we update it
|
||||
profile, created = Profile.objects.get_or_create(user=user)
|
||||
# Handle role if it exists in cleaned_data (it might be excluded in subclasses)
|
||||
if 'role' in self.cleaned_data:
|
||||
profile.role = self.cleaned_data['role']
|
||||
profile.phone_number = self.cleaned_data['phone_number']
|
||||
profile.country = self.cleaned_data['country']
|
||||
profile.governate = self.cleaned_data['governate']
|
||||
profile.city = self.cleaned_data['city']
|
||||
|
||||
# Save extra driver fields if they exist
|
||||
if 'profile_picture' in self.cleaned_data and self.cleaned_data['profile_picture']:
|
||||
profile.profile_picture = self.cleaned_data['profile_picture']
|
||||
if 'license_front_image' in self.cleaned_data and self.cleaned_data['license_front_image']:
|
||||
profile.license_front_image = self.cleaned_data['license_front_image']
|
||||
if 'license_back_image' in self.cleaned_data and self.cleaned_data['license_back_image']:
|
||||
profile.license_back_image = self.cleaned_data['license_back_image']
|
||||
if 'car_plate_number' in self.cleaned_data:
|
||||
profile.car_plate_number = self.cleaned_data['car_plate_number']
|
||||
if 'bank_account_number' in self.cleaned_data:
|
||||
profile.bank_account_number = self.cleaned_data['bank_account_number']
|
||||
profile.language = get_language()
|
||||
|
||||
profile.save()
|
||||
return user
|
||||
|
||||
class ShipperRegistrationForm(UserRegistrationForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['role'].widget = forms.HiddenInput()
|
||||
self.fields['role'].initial = 'shipper'
|
||||
|
||||
class DriverRegistrationForm(UserRegistrationForm):
|
||||
profile_picture = forms.ImageField(label=_("Profile Picture (Webcam/Upload)"), required=True, widget=forms.FileInput(attrs={'class': 'form-control', 'capture': 'user', 'accept': 'image/*'}))
|
||||
license_front_image = forms.ImageField(label=_("License Front Image"), required=True, widget=forms.FileInput(attrs={'class': 'form-control', 'accept': 'image/*'}))
|
||||
license_back_image = forms.ImageField(label=_("License Back Image"), required=True, widget=forms.FileInput(attrs={'class': 'form-control', 'accept': 'image/*'}))
|
||||
car_plate_number = forms.CharField(label=_("Car Plate Number"), max_length=20, required=True, widget=forms.TextInput(attrs={'class': 'form-control'}))
|
||||
bank_account_number = forms.CharField(label=_("Bank Account Number"), max_length=50, required=True, widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Bank Name - Account Number')}))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['role'].widget = forms.HiddenInput()
|
||||
self.fields['role'].initial = 'car_owner'
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
first_name = forms.CharField(label=_("First Name"), max_length=150, widget=forms.TextInput(attrs={'class': 'form-control'}))
|
||||
last_name = forms.CharField(label=_("Last Name"), max_length=150, widget=forms.TextInput(attrs={'class': 'form-control'}))
|
||||
email = forms.EmailField(label=_("Email"), widget=forms.EmailInput(attrs={'class': 'form-control'}))
|
||||
|
||||
phone_code = forms.ModelChoiceField(queryset=Country.objects.none(), label=_("Code"), required=False, widget=forms.Select(attrs={'class': 'form-control'}))
|
||||
phone_number = forms.CharField(label=_("Phone Number"), max_length=20, widget=forms.TextInput(attrs={'class': 'form-control'}))
|
||||
|
||||
address = forms.CharField(label=_("Address"), required=False, widget=forms.TextInput(attrs={'class': 'form-control'}))
|
||||
profile_picture = forms.ImageField(label=_("Profile Picture"), required=False, widget=forms.FileInput(attrs={'class': 'form-control'}))
|
||||
bank_account_number = forms.CharField(label=_("Bank Account Number"), required=False, max_length=50, widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Bank Name - Account Number')}))
|
||||
|
||||
otp_method = forms.ChoiceField(
|
||||
choices=[('email', _('Email')), ('whatsapp', _('WhatsApp'))],
|
||||
label=_('Verify changes via'),
|
||||
widget=forms.RadioSelect,
|
||||
initial='email'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Profile
|
||||
fields = ['profile_picture', 'phone_number', 'address', 'country', 'governate', 'city', 'bank_account_number', 'language']
|
||||
widgets = {
|
||||
'country': forms.Select(attrs={'class': 'form-control'}),
|
||||
'governate': forms.Select(attrs={'class': 'form-control'}),
|
||||
'city': forms.Select(attrs={'class': 'form-control'}),
|
||||
'language': forms.Select(attrs={'class': 'form-control'}),
|
||||
}
|
||||
labels = {
|
||||
'country': _('Country'),
|
||||
'governate': _('Governate'),
|
||||
'city': _('City'),
|
||||
'language': _('Language'),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.instance.user:
|
||||
self.fields['first_name'].initial = self.instance.user.first_name
|
||||
self.fields['last_name'].initial = self.instance.user.last_name
|
||||
self.fields['email'].initial = self.instance.user.email
|
||||
|
||||
lang = get_language()
|
||||
name_field = 'name_ar' if lang == 'ar' else 'name_en'
|
||||
|
||||
# Phone Code setup
|
||||
self.fields['phone_code'].queryset = Country.objects.exclude(phone_code='').order_by(name_field)
|
||||
self.fields['phone_code'].label_from_instance = lambda obj: f"{obj.phone_code} ({obj.name})"
|
||||
|
||||
# Default Country logic (Oman)
|
||||
oman = Country.objects.filter(name_en='Oman').first()
|
||||
if oman:
|
||||
self.fields['phone_code'].initial = oman
|
||||
|
||||
# Initial splitting of phone number
|
||||
if self.instance.pk and self.instance.phone_number:
|
||||
for country in Country.objects.exclude(phone_code=''):
|
||||
if self.instance.phone_number.startswith(country.phone_code):
|
||||
self.fields['phone_code'].initial = country
|
||||
# Strip code from display
|
||||
self.fields['phone_number'].initial = self.instance.phone_number[len(country.phone_code):]
|
||||
break
|
||||
|
||||
self.fields['country'].queryset = Country.objects.all().order_by(name_field)
|
||||
|
||||
# Initial QS setup
|
||||
self.fields['governate'].queryset = Governate.objects.none()
|
||||
self.fields['city'].queryset = City.objects.none()
|
||||
|
||||
if 'country' in self.data:
|
||||
try:
|
||||
country_id = int(self.data.get('country'))
|
||||
self.fields['governate'].queryset = Governate.objects.filter(country_id=country_id).order_by(name_field)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif self.instance.pk and self.instance.country:
|
||||
self.fields['governate'].queryset = self.instance.country.governate_set.order_by(name_field)
|
||||
elif oman:
|
||||
self.fields['governate'].queryset = Governate.objects.filter(country=oman).order_by(name_field)
|
||||
|
||||
if 'governate' in self.data:
|
||||
try:
|
||||
gov_id = int(self.data.get('governate'))
|
||||
self.fields['city'].queryset = City.objects.filter(governate_id=gov_id).order_by(name_field)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif self.instance.pk and self.instance.governate:
|
||||
self.fields['city'].queryset = self.instance.governate.city_set.order_by(name_field)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
phone_code = cleaned_data.get('phone_code')
|
||||
phone_number = cleaned_data.get('phone_number')
|
||||
|
||||
if phone_code and phone_number:
|
||||
if not phone_number.startswith(phone_code.phone_code):
|
||||
cleaned_data['phone_number'] = f"{phone_code.phone_code}{phone_number}"
|
||||
return cleaned_data
|
||||
|
||||
class ParcelForm(forms.ModelForm):
|
||||
receiver_phone_code = forms.ModelChoiceField(queryset=Country.objects.none(), label=_("Receiver Code"), required=False, widget=forms.Select(attrs={'class': 'form-control'}))
|
||||
|
||||
class Meta:
|
||||
model = Parcel
|
||||
fields = [
|
||||
'parcel_type',
|
||||
'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 = {
|
||||
'parcel_type': forms.Select(attrs={'class': 'form-control'}),
|
||||
'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.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'}),
|
||||
'delivery_address': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Street/Building')}),
|
||||
|
||||
'receiver_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'receiver_phone': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
}
|
||||
labels = {
|
||||
'parcel_type': _('Parcel Type'),
|
||||
'description': _('Package Description'),
|
||||
'weight': _('Weight (kg)'),
|
||||
'price': _('Calculated Price (OMR)'),
|
||||
'pickup_country': _('Pickup Country'),
|
||||
'pickup_governate': _('Pickup Governate'),
|
||||
'pickup_city': _('Pickup City'),
|
||||
'pickup_address': _('Pickup Address (Street/Building)'),
|
||||
'delivery_country': _('Delivery Country'),
|
||||
'delivery_governate': _('Delivery Governate'),
|
||||
'delivery_city': _('Delivery City'),
|
||||
'delivery_address': _('Delivery Address (Street/Building)'),
|
||||
'receiver_name': _('Receiver Name'),
|
||||
'receiver_phone': _('Receiver Phone'),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
lang = get_language()
|
||||
name_field = 'name_ar' if lang == 'ar' else 'name_en'
|
||||
|
||||
# Phone Code setup
|
||||
self.fields['receiver_phone_code'].queryset = Country.objects.exclude(phone_code='').order_by(name_field)
|
||||
self.fields['receiver_phone_code'].label_from_instance = lambda obj: f"{obj.phone_code} ({obj.name})"
|
||||
|
||||
# Default Country logic (Oman) - Only if not editing
|
||||
oman = Country.objects.filter(name_en='Oman').first()
|
||||
if not self.instance.pk and oman:
|
||||
self.fields['receiver_phone_code'].initial = oman
|
||||
self.fields['pickup_country'].initial = oman
|
||||
self.fields['delivery_country'].initial = oman
|
||||
|
||||
# Initial splitting of phone number (if editing)
|
||||
if self.instance.pk and self.instance.receiver_phone:
|
||||
for country in Country.objects.exclude(phone_code=''):
|
||||
if self.instance.receiver_phone.startswith(country.phone_code):
|
||||
self.fields['receiver_phone_code'].initial = country
|
||||
self.fields['receiver_phone'].initial = self.instance.receiver_phone[len(country.phone_code):]
|
||||
break
|
||||
|
||||
# Set querysets for countries
|
||||
self.fields['pickup_country'].queryset = Country.objects.all().order_by(name_field)
|
||||
self.fields['delivery_country'].queryset = Country.objects.all().order_by(name_field)
|
||||
|
||||
# Pickup
|
||||
self.fields['pickup_governate'].queryset = Governate.objects.none()
|
||||
self.fields['pickup_city'].queryset = City.objects.none()
|
||||
|
||||
if 'pickup_country' in self.data:
|
||||
try:
|
||||
country_id = int(self.data.get('pickup_country'))
|
||||
self.fields['pickup_governate'].queryset = Governate.objects.filter(country_id=country_id).order_by(name_field)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif self.instance.pk and self.instance.pickup_country:
|
||||
self.fields['pickup_governate'].queryset = Governate.objects.filter(country=self.instance.pickup_country).order_by(name_field)
|
||||
elif oman:
|
||||
self.fields['pickup_governate'].queryset = Governate.objects.filter(country=oman).order_by(name_field)
|
||||
|
||||
if 'pickup_governate' in self.data:
|
||||
try:
|
||||
gov_id = int(self.data.get('pickup_governate'))
|
||||
self.fields['pickup_city'].queryset = City.objects.filter(governate_id=gov_id).order_by(name_field)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif self.instance.pk and self.instance.pickup_governate:
|
||||
self.fields['pickup_city'].queryset = City.objects.filter(governate_id=self.instance.pickup_governate.id).order_by(name_field)
|
||||
|
||||
# Delivery
|
||||
self.fields['delivery_governate'].queryset = Governate.objects.none()
|
||||
self.fields['delivery_city'].queryset = City.objects.none()
|
||||
|
||||
if 'delivery_country' in self.data:
|
||||
try:
|
||||
country_id = int(self.data.get('delivery_country'))
|
||||
self.fields['delivery_governate'].queryset = Governate.objects.filter(country_id=country_id).order_by(name_field)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif self.instance.pk and self.instance.delivery_country:
|
||||
self.fields['delivery_governate'].queryset = Governate.objects.filter(country=self.instance.delivery_country).order_by(name_field)
|
||||
elif oman:
|
||||
self.fields['delivery_governate'].queryset = Governate.objects.filter(country=oman).order_by(name_field)
|
||||
|
||||
if 'delivery_governate' in self.data:
|
||||
try:
|
||||
gov_id = int(self.data.get('delivery_governate'))
|
||||
self.fields['delivery_city'].queryset = City.objects.filter(governate_id=gov_id).order_by(name_field)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif self.instance.pk and self.instance.delivery_governate:
|
||||
self.fields['delivery_city'].queryset = City.objects.filter(governate_id=self.instance.delivery_governate.id).order_by(name_field)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
phone_code = cleaned_data.get('receiver_phone_code')
|
||||
phone_number = cleaned_data.get('receiver_phone')
|
||||
|
||||
if phone_code and phone_number:
|
||||
if not phone_number.startswith(phone_code.phone_code):
|
||||
cleaned_data['receiver_phone'] = f"{phone_code.phone_code}{phone_number}"
|
||||
return cleaned_data
|
||||
|
||||
class DriverRatingForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = DriverRating
|
||||
fields = ['rating', 'comment']
|
||||
widgets = {
|
||||
'rating': forms.RadioSelect(attrs={'class': 'rating-stars'}),
|
||||
'comment': forms.Textarea(attrs={'class': 'form-control', 'rows': 4, 'placeholder': _('Write your review here...')}),
|
||||
}
|
||||
labels = {
|
||||
'rating': _('Rating'),
|
||||
'comment': _('Comment'),
|
||||
}
|
||||
|
||||
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)]
|
||||
|
||||
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'),
|
||||
}
|
||||
90
core/mail.py
@ -1,90 +0,0 @@
|
||||
from django.core.mail import send_mail, EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def send_html_email(subject, message, recipient_list, title=None, action_url=None, action_text=None, request=None):
|
||||
"""
|
||||
Sends a styled HTML email using the platform template.
|
||||
"""
|
||||
try:
|
||||
from .models import PlatformProfile
|
||||
platform = PlatformProfile.objects.first()
|
||||
if not platform:
|
||||
# Create a dummy platform object if none exists, to avoid errors
|
||||
class DummyPlatform:
|
||||
name = "Platform"
|
||||
logo = None
|
||||
address = ""
|
||||
platform = DummyPlatform()
|
||||
|
||||
# Determine site URL
|
||||
site_url = settings.SITE_URL if hasattr(settings, 'SITE_URL') else 'http://127.0.0.1:8000'
|
||||
if request:
|
||||
site_url = f"{request.scheme}://{request.get_host()}"
|
||||
|
||||
context = {
|
||||
'platform': platform,
|
||||
'title': title or subject,
|
||||
'message': message,
|
||||
'action_url': action_url,
|
||||
'action_text': action_text,
|
||||
'site_url': site_url,
|
||||
}
|
||||
|
||||
html_content = render_to_string('emails/base_email.html', context)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
msg = EmailMultiAlternatives(subject, text_content, settings.DEFAULT_FROM_EMAIL, recipient_list)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send HTML email: {e}")
|
||||
# Fallback to plain text
|
||||
try:
|
||||
send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, recipient_list)
|
||||
return True
|
||||
except Exception as e2:
|
||||
logger.error(f"Failed to send fallback email: {e2}")
|
||||
return False
|
||||
|
||||
def send_contact_message(name, email, message):
|
||||
"""
|
||||
Sends a contact form message to the platform admins.
|
||||
"""
|
||||
try:
|
||||
from .notifications import get_notification_content
|
||||
|
||||
context = {'name': name, 'email': email, 'message': message}
|
||||
# Admin alerts default to EN
|
||||
subj, email_msg, wa_msg = get_notification_content('contact_form_admin', context, language='en')
|
||||
|
||||
recipient_list = settings.CONTACT_EMAIL_TO or [settings.DEFAULT_FROM_EMAIL]
|
||||
|
||||
# Email
|
||||
email_sent = send_html_email(
|
||||
subject=subj,
|
||||
message=email_msg,
|
||||
recipient_list=recipient_list,
|
||||
title="New Contact Message"
|
||||
)
|
||||
|
||||
# WhatsApp (New feature: Notify admin on WhatsApp too)
|
||||
try:
|
||||
from .models import PlatformProfile
|
||||
from .whatsapp_utils import send_whatsapp_message
|
||||
|
||||
profile = PlatformProfile.objects.first()
|
||||
if profile and profile.phone_number:
|
||||
send_whatsapp_message(profile.phone_number, wa_msg)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send admin WhatsApp for contact form: {e}")
|
||||
|
||||
return email_sent
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send contact message: {e}")
|
||||
return False
|
||||
@ -1,19 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
import os
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Ensures an admin user exists (idempotent)'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
User = get_user_model()
|
||||
username = os.environ.get('ADMIN_USER', 'admin')
|
||||
email = os.environ.get('ADMIN_EMAIL', 'admin@example.com')
|
||||
password = os.environ.get('ADMIN_PASS', 'admin')
|
||||
|
||||
if not User.objects.filter(username=username).exists():
|
||||
self.stdout.write(f"Creating superuser '{username}'...")
|
||||
User.objects.create_superuser(username, email, password)
|
||||
self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' created successfully."))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' already exists. Skipping creation."))
|
||||
@ -1,30 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from core.models import NotificationTemplate
|
||||
from core.notifications import DEFAULT_TEMPLATES, get_notification_content
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Initialize default notification templates'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
count = 0
|
||||
for key, default in DEFAULT_TEMPLATES.items():
|
||||
obj, created = NotificationTemplate.objects.get_or_create(
|
||||
key=key,
|
||||
defaults={
|
||||
'description': default.get('description', ''),
|
||||
'available_variables': default.get('variables', ''),
|
||||
'subject_en': default.get('subject_en', ''),
|
||||
'subject_ar': default.get('subject_ar', ''),
|
||||
'email_body_en': default.get('email_body_en', ''),
|
||||
'email_body_ar': default.get('email_body_ar', ''),
|
||||
'whatsapp_body_en': default.get('whatsapp_body_en', ''),
|
||||
'whatsapp_body_ar': default.get('whatsapp_body_ar', ''),
|
||||
}
|
||||
)
|
||||
if created:
|
||||
self.stdout.write(self.style.SUCCESS(f'Created template: {key}'))
|
||||
count += 1
|
||||
else:
|
||||
self.stdout.write(f'Template exists: {key}')
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'Initialized {count} new templates.'))
|
||||
@ -1,90 +0,0 @@
|
||||
import socket
|
||||
import sys
|
||||
import os
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.db import connections
|
||||
from django.db.utils import OperationalError
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Tests database connectivity step-by-step (DNS, TCP, Auth)'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write(self.style.SUCCESS("=== Starting Database Connection Diagnostics ==="))
|
||||
|
||||
# 1. Inspect Configuration
|
||||
db_conf = settings.DATABASES['default']
|
||||
host = db_conf.get('HOST')
|
||||
port = db_conf.get('PORT')
|
||||
user = db_conf.get('USER')
|
||||
name = db_conf.get('NAME')
|
||||
|
||||
# Mask password
|
||||
password = db_conf.get('PASSWORD')
|
||||
masked_password = "*****" if password else "None"
|
||||
|
||||
self.stdout.write(f"Configuration:")
|
||||
self.stdout.write(f" HOST: {host}")
|
||||
self.stdout.write(f" PORT: {port}")
|
||||
self.stdout.write(f" USER: {user}")
|
||||
self.stdout.write(f" NAME: {name}")
|
||||
self.stdout.write(f" PASS: {masked_password}")
|
||||
|
||||
if not host:
|
||||
self.stdout.write(self.style.ERROR("ERROR: DB_HOST is not set or empty."))
|
||||
return
|
||||
|
||||
# 2. DNS Resolution
|
||||
self.stdout.write("\n--- Step 1: DNS Resolution ---")
|
||||
ip_address = None
|
||||
try:
|
||||
ip_address = socket.gethostbyname(host)
|
||||
self.stdout.write(self.style.SUCCESS(f"✔ Success: '{host}' resolved to {ip_address}"))
|
||||
except socket.gaierror as e:
|
||||
self.stdout.write(self.style.ERROR(f"✘ Failed: Could not resolve hostname '{host}'. Error: {e}"))
|
||||
self.stdout.write(self.style.WARNING("Tip: Check for typos in DB_HOST. If this is a Docker container, ensure it is in the same network."))
|
||||
return
|
||||
|
||||
# 3. TCP Connection Test
|
||||
self.stdout.write("\n--- Step 2: TCP Connection Check ---")
|
||||
try:
|
||||
port_int = int(port)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5) # 5 second timeout
|
||||
result = sock.connect_ex((ip_address, port_int))
|
||||
if result == 0:
|
||||
self.stdout.write(self.style.SUCCESS(f"✔ Success: Connected to {ip_address}:{port_int} via TCP."))
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR(f"✘ Failed: Could not connect to {ip_address}:{port_int}."))
|
||||
self.stdout.write(self.style.ERROR(f" Error Code: {result} (Check OS specific socket error codes)"))
|
||||
self.stdout.write(self.style.WARNING("Possible causes:"))
|
||||
self.stdout.write(" 1. Firewall blocking the port (Check 'Remote MySQL' in Hostinger/cPanel).")
|
||||
self.stdout.write(" 2. Database server is down.")
|
||||
self.stdout.write(" 3. Database is listening on localhost only (Bind Address issue).")
|
||||
sock.close()
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"✘ Error during TCP check: {e}"))
|
||||
|
||||
# 4. Django/Driver Connection Test
|
||||
self.stdout.write("\n--- Step 3: Database Authentication ---")
|
||||
try:
|
||||
conn = connections['default']
|
||||
conn.cursor() # Forces connection
|
||||
self.stdout.write(self.style.SUCCESS(f"✔ Success: Authenticated and connected to database '{name}'."))
|
||||
except OperationalError as e:
|
||||
self.stdout.write(self.style.ERROR("✘ Failed: Database Driver Error."))
|
||||
self.stdout.write(f" Error: {e}")
|
||||
|
||||
error_str = str(e)
|
||||
if "2003" in error_str:
|
||||
self.stdout.write(self.style.WARNING("\nAnalysis for Error 2003 (Can't connect to MySQL server):"))
|
||||
self.stdout.write(" - If TCP check (Step 2) failed: The issue is Network/Firewall.")
|
||||
self.stdout.write(" - If TCP check passed: The issue might be SSL/TLS requirements or packet filtering.")
|
||||
elif "1045" in error_str:
|
||||
self.stdout.write(self.style.WARNING("\nAnalysis for Error 1045 (Access Denied):"))
|
||||
self.stdout.write(" - User/Password is incorrect.")
|
||||
self.stdout.write(" - User is not allowed to connect from this specific IP (Hostinger 'Remote MySQL' whitelist).")
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"✘ Unexpected Error: {e}"))
|
||||
|
||||
self.stdout.write("\n=== Diagnostics Complete ===")
|
||||
@ -1,60 +0,0 @@
|
||||
import time
|
||||
import socket
|
||||
from django.db import connections
|
||||
from django.db.utils import OperationalError
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Django command to pause execution until database is available"""
|
||||
requires_system_checks = []
|
||||
|
||||
def handle(self, *args, **options):
|
||||
db_conf = settings.DATABASES['default']
|
||||
host = db_conf.get('HOST')
|
||||
port = db_conf.get('PORT')
|
||||
|
||||
self.stdout.write(f"Debug Info - Host: {host}, Port: {port}, User: {db_conf.get('USER')}")
|
||||
|
||||
# Try to resolve host immediately to catch DNS issues
|
||||
try:
|
||||
ip = socket.gethostbyname(host)
|
||||
self.stdout.write(f"DEBUG: Host '{host}' resolves to IP: {ip}")
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"DEBUG ERROR: Could not resolve hostname '{host}': {e}"))
|
||||
|
||||
self.stdout.write('Waiting for database...')
|
||||
for i in range(30):
|
||||
try:
|
||||
connections['default'].cursor()
|
||||
self.stdout.write(self.style.SUCCESS('Database available!'))
|
||||
return
|
||||
except OperationalError as e:
|
||||
error_str = str(e)
|
||||
if "2003" in error_str:
|
||||
self.stdout.write(self.style.WARNING(f"Connection Failed (Attempt {i+1}/30): Error 2003 - Can't connect to MySQL server."))
|
||||
# Perform a quick TCP check
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(2)
|
||||
result = sock.connect_ex((host, int(port)))
|
||||
if result == 0:
|
||||
self.stdout.write(f" > TCP Check: SUCCESS. Server is reachable at {host}:{port}. Issue is likely Auth/SSL or strict User Host limits.")
|
||||
else:
|
||||
self.stdout.write(f" > TCP Check: FAILED (Code {result}). Server is NOT reachable at {host}:{port}. Check Firewall/IP.")
|
||||
sock.close()
|
||||
except Exception as tcp_e:
|
||||
self.stdout.write(f" > TCP Check Error: {tcp_e}")
|
||||
|
||||
elif "1049" in error_str: # Unknown database
|
||||
self.stdout.write(self.style.ERROR(f"CRITICAL: Database '{db_conf.get('NAME')}' does not exist."))
|
||||
return
|
||||
else:
|
||||
self.stdout.write(f'Database unavailable (Error: {e}), waiting 1 second...')
|
||||
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.WARNING(f'Database error: {e}, waiting 1 second...'))
|
||||
time.sleep(1)
|
||||
|
||||
self.stdout.write(self.style.ERROR('Database unavailable after 30 seconds.'))
|
||||
@ -1,44 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 07:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Parcel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('tracking_number', models.CharField(blank=True, max_length=20, unique=True)),
|
||||
('description', models.TextField()),
|
||||
('weight', models.DecimalField(decimal_places=2, help_text='Weight in kg', max_digits=5)),
|
||||
('pickup_address', models.CharField(max_length=255)),
|
||||
('delivery_address', models.CharField(max_length=255)),
|
||||
('receiver_name', models.CharField(max_length=100)),
|
||||
('receiver_phone', models.CharField(max_length=20)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending Pickup'), ('picked_up', 'Picked Up'), ('in_transit', 'In Transit'), ('delivered', 'Delivered'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('carrier', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='carried_parcels', to=settings.AUTH_USER_MODEL)),
|
||||
('shipper', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_parcels', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Profile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(choices=[('shipper', 'Shipper'), ('car_owner', 'Car Owner')], default='shipper', max_length=20)),
|
||||
('phone_number', models.CharField(blank=True, max_length=20)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -1,99 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 07:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='parcel',
|
||||
options={'verbose_name': 'Parcel', 'verbose_name_plural': 'Parcels'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='profile',
|
||||
options={'verbose_name': 'Profile', 'verbose_name_plural': 'Profiles'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parcel',
|
||||
name='carrier',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='carried_parcels', to=settings.AUTH_USER_MODEL, verbose_name='Carrier'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parcel',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='Created At'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parcel',
|
||||
name='delivery_address',
|
||||
field=models.CharField(max_length=255, verbose_name='Delivery Address'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parcel',
|
||||
name='description',
|
||||
field=models.TextField(verbose_name='Description'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parcel',
|
||||
name='pickup_address',
|
||||
field=models.CharField(max_length=255, verbose_name='Pickup Address'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parcel',
|
||||
name='receiver_name',
|
||||
field=models.CharField(max_length=100, verbose_name='Receiver Name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parcel',
|
||||
name='receiver_phone',
|
||||
field=models.CharField(max_length=20, verbose_name='Receiver Phone'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parcel',
|
||||
name='shipper',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_parcels', to=settings.AUTH_USER_MODEL, verbose_name='Shipper'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parcel',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('pending', 'Pending Pickup'), ('picked_up', 'Picked Up'), ('in_transit', 'In Transit'), ('delivered', 'Delivered'), ('cancelled', 'Cancelled')], default='pending', max_length=20, verbose_name='Status'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parcel',
|
||||
name='tracking_number',
|
||||
field=models.CharField(blank=True, max_length=20, unique=True, verbose_name='Tracking Number'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parcel',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='Updated At'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parcel',
|
||||
name='weight',
|
||||
field=models.DecimalField(decimal_places=2, help_text='Weight in kg', max_digits=5, verbose_name='Weight (kg)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='profile',
|
||||
name='phone_number',
|
||||
field=models.CharField(blank=True, max_length=20, verbose_name='Phone Number'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='profile',
|
||||
name='role',
|
||||
field=models.CharField(choices=[('shipper', 'Shipper'), ('car_owner', 'Car Owner')], default='shipper', max_length=20, verbose_name='Role'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='profile',
|
||||
name='user',
|
||||
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'),
|
||||
),
|
||||
]
|
||||
@ -1,98 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 07:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_alter_parcel_options_alter_profile_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='City',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='Name')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'City',
|
||||
'verbose_name_plural': 'Cities',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Country',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='Name')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Country',
|
||||
'verbose_name_plural': 'Countries',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='parcel',
|
||||
name='delivery_city',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='delivery_parcels', to='core.city', verbose_name='Delivery City'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='parcel',
|
||||
name='pickup_city',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pickup_parcels', to='core.city', verbose_name='Pickup City'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='city',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.city', verbose_name='City'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='parcel',
|
||||
name='delivery_country',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='delivery_parcels', to='core.country', verbose_name='Delivery Country'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='parcel',
|
||||
name='pickup_country',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pickup_parcels', to='core.country', verbose_name='Pickup Country'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='country',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.country', verbose_name='Country'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Governate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='Name')),
|
||||
('country', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.country', verbose_name='Country')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Governate',
|
||||
'verbose_name_plural': 'Governates',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='city',
|
||||
name='governate',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.governate', verbose_name='Governate'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='parcel',
|
||||
name='delivery_governate',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='delivery_parcels', to='core.governate', verbose_name='Delivery Governate'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='parcel',
|
||||
name='pickup_governate',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pickup_parcels', to='core.governate', verbose_name='Pickup Governate'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='governate',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.governate', verbose_name='Governate'),
|
||||
),
|
||||
]
|
||||
@ -1,34 +0,0 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@ -1,55 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 10:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_parcel_payment_status_parcel_price_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='city',
|
||||
name='name',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='country',
|
||||
name='name',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='governate',
|
||||
name='name',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='city',
|
||||
name='name_ar',
|
||||
field=models.CharField(default='', max_length=100, verbose_name='Name (Arabic)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='city',
|
||||
name='name_en',
|
||||
field=models.CharField(default='', max_length=100, verbose_name='Name (English)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='country',
|
||||
name='name_ar',
|
||||
field=models.CharField(default='', max_length=100, verbose_name='Name (Arabic)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='country',
|
||||
name='name_en',
|
||||
field=models.CharField(default='', max_length=100, verbose_name='Name (English)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='governate',
|
||||
name='name_ar',
|
||||
field=models.CharField(default='', max_length=100, verbose_name='Name (Arabic)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='governate',
|
||||
name='name_en',
|
||||
field=models.CharField(default='', max_length=100, verbose_name='Name (English)'),
|
||||
),
|
||||
]
|
||||
@ -1,43 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 10:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_remove_city_name_remove_country_name_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='city',
|
||||
name='name_ar',
|
||||
field=models.CharField(max_length=100, verbose_name='Name (Arabic)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='city',
|
||||
name='name_en',
|
||||
field=models.CharField(max_length=100, verbose_name='Name (English)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='country',
|
||||
name='name_ar',
|
||||
field=models.CharField(max_length=100, verbose_name='Name (Arabic)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='country',
|
||||
name='name_en',
|
||||
field=models.CharField(max_length=100, verbose_name='Name (English)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='governate',
|
||||
name='name_ar',
|
||||
field=models.CharField(max_length=100, verbose_name='Name (Arabic)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='governate',
|
||||
name='name_en',
|
||||
field=models.CharField(max_length=100, verbose_name='Name (English)'),
|
||||
),
|
||||
]
|
||||
@ -1,30 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 10:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_alter_city_name_ar_alter_city_name_en_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PlatformProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='Platform Name')),
|
||||
('logo', models.ImageField(blank=True, null=True, upload_to='platform_logos/', verbose_name='Logo')),
|
||||
('slogan', models.CharField(blank=True, max_length=255, verbose_name='Slogan')),
|
||||
('address', models.TextField(blank=True, verbose_name='Address')),
|
||||
('phone_number', models.CharField(blank=True, max_length=50, verbose_name='Phone Number')),
|
||||
('registration_number', models.CharField(blank=True, max_length=100, verbose_name='Registration Number')),
|
||||
('vat_number', models.CharField(blank=True, max_length=100, verbose_name='VAT Number')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Platform Profile',
|
||||
'verbose_name_plural': 'Platform Profile',
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 11:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_platformprofile'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='platformprofile',
|
||||
name='privacy_policy',
|
||||
field=models.TextField(blank=True, verbose_name='Privacy Policy'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='platformprofile',
|
||||
name='terms_conditions',
|
||||
field=models.TextField(blank=True, verbose_name='Terms and Conditions'),
|
||||
),
|
||||
]
|
||||
@ -1,37 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 11:40
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_platformprofile_privacy_policy_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='address',
|
||||
field=models.CharField(blank=True, max_length=255, verbose_name='Address'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='profile_picture',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='profile_pics/', verbose_name='Profile Picture'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OTPVerification',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(max_length=6)),
|
||||
('purpose', models.CharField(choices=[('profile_update', 'Profile Update'), ('password_reset', 'Password Reset')], default='profile_update', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('is_verified', models.BooleanField(default=False)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 12:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0009_profile_address_profile_profile_picture_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='platformprofile',
|
||||
name='whatsapp_access_token',
|
||||
field=models.TextField(blank=True, help_text='Permanent or temporary access token from Meta Business.', verbose_name='WhatsApp Access Token'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='platformprofile',
|
||||
name='whatsapp_business_phone_number_id',
|
||||
field=models.CharField(blank=True, help_text='The Phone Number ID from WhatsApp API setup.', max_length=100, verbose_name='WhatsApp Phone Number ID'),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 12:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0010_platformprofile_whatsapp_access_token_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='platformprofile',
|
||||
name='whatsapp_app_secret',
|
||||
field=models.CharField(blank=True, help_text='App Secret or Verify Token for Webhooks.', max_length=255, verbose_name='WhatsApp App Secret (Security Key)'),
|
||||
),
|
||||
]
|
||||
@ -1,28 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 12:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0011_platformprofile_whatsapp_app_secret'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='platformprofile',
|
||||
name='whatsapp_access_token',
|
||||
field=models.TextField(blank=True, help_text='Your Wablas API Token.', verbose_name='Wablas API Token'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='platformprofile',
|
||||
name='whatsapp_app_secret',
|
||||
field=models.CharField(blank=True, help_text='Your Wablas API Secret Key (if required).', max_length=255, verbose_name='Wablas Secret Key'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='platformprofile',
|
||||
name='whatsapp_business_phone_number_id',
|
||||
field=models.CharField(blank=True, default='https://deu.wablas.com', help_text='The Wablas API domain (e.g., https://deu.wablas.com).', max_length=100, verbose_name='Wablas Domain'),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 13:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0012_alter_platformprofile_whatsapp_access_token_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='platformprofile',
|
||||
name='enable_payment',
|
||||
field=models.BooleanField(default=True, help_text='Toggle to enable or disable payments on the platform.', verbose_name='Enable Payment'),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 15:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0013_platformprofile_enable_payment'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='otpverification',
|
||||
name='purpose',
|
||||
field=models.CharField(choices=[('profile_update', 'Profile Update'), ('password_reset', 'Password Reset'), ('registration', 'Registration')], default='profile_update', max_length=20),
|
||||
),
|
||||
]
|
||||
@ -1,34 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 16:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0014_alter_otpverification_purpose'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Testimonial',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name_en', models.CharField(max_length=100, verbose_name='Name (English)')),
|
||||
('name_ar', models.CharField(max_length=100, verbose_name='Name (Arabic)')),
|
||||
('role_en', models.CharField(max_length=100, verbose_name='Role (English)')),
|
||||
('role_ar', models.CharField(max_length=100, verbose_name='Role (Arabic)')),
|
||||
('content_en', models.TextField(verbose_name='Testimony (English)')),
|
||||
('content_ar', models.TextField(verbose_name='Testimony (Arabic)')),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to='testimonials/', verbose_name='Image')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Active')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Testimonial',
|
||||
'verbose_name_plural': 'Testimonials',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-25 17:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0015_testimonial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='country',
|
||||
name='phone_code',
|
||||
field=models.CharField(blank=True, help_text='e.g. +968', max_length=10, verbose_name='Phone Code'),
|
||||
),
|
||||
]
|
||||
@ -1,32 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-26 04:58
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0016_country_phone_code'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DriverRating',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('rating', models.PositiveSmallIntegerField(choices=[(1, '1'), (2, '2'), (3, '3'), (4, '4'), (5, '5')], verbose_name='Rating')),
|
||||
('comment', models.TextField(blank=True, verbose_name='Comment')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('driver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_ratings', to=settings.AUTH_USER_MODEL, verbose_name='Driver')),
|
||||
('parcel', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='rating', to='core.parcel', verbose_name='Parcel')),
|
||||
('shipper', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='given_ratings', to=settings.AUTH_USER_MODEL, verbose_name='Shipper')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Driver Rating',
|
||||
'verbose_name_plural': 'Driver Ratings',
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-26 06:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0017_driverrating'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='otpverification',
|
||||
name='purpose',
|
||||
field=models.CharField(choices=[('profile_update', 'Profile Update'), ('password_reset', 'Password Reset'), ('registration', 'Registration'), ('login', 'Login')], default='profile_update', max_length=20),
|
||||
),
|
||||
]
|
||||
@ -1,28 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-27 23:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0018_alter_otpverification_purpose'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='car_plate_number',
|
||||
field=models.CharField(blank=True, max_length=20, verbose_name='Car Plate Number'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='license_back_image',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='licenses/', verbose_name='License Back Image'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='license_front_image',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='licenses/', verbose_name='License Front Image'),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-28 00:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0019_profile_car_plate_number_profile_license_back_image_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='is_approved',
|
||||
field=models.BooleanField(default=False, help_text='Designates whether this user is approved to use the platform (mainly for drivers).', verbose_name='Approved'),
|
||||
),
|
||||
]
|
||||
@ -1,41 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-28 00:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0020_profile_is_approved'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='platformprofile',
|
||||
name='privacy_policy',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='platformprofile',
|
||||
name='terms_conditions',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='platformprofile',
|
||||
name='privacy_policy_ar',
|
||||
field=models.TextField(blank=True, verbose_name='Privacy Policy (Arabic)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='platformprofile',
|
||||
name='privacy_policy_en',
|
||||
field=models.TextField(blank=True, verbose_name='Privacy Policy (English)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='platformprofile',
|
||||
name='terms_conditions_ar',
|
||||
field=models.TextField(blank=True, verbose_name='Terms and Conditions (Arabic)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='platformprofile',
|
||||
name='terms_conditions_en',
|
||||
field=models.TextField(blank=True, verbose_name='Terms and Conditions (English)'),
|
||||
),
|
||||
]
|
||||
@ -1,32 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-28 01:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0021_remove_platformprofile_privacy_policy_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NotificationTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(choices=[('otp_registration', 'OTP Registration'), ('otp_login', 'OTP Login'), ('otp_profile_update', 'OTP Profile Update'), ('shipment_created_shipper', 'Shipment Created (Shipper)'), ('payment_success_shipper', 'Payment Success (Shipper)'), ('shipment_visible_receiver', 'Shipment Visible (Receiver)'), ('driver_pickup_shipper', 'Driver Pickup (Shipper)'), ('driver_pickup_receiver', 'Driver Pickup (Receiver)'), ('driver_pickup_driver', 'Driver Pickup (Driver/Carrier)'), ('shipment_status_update', 'Shipment Status Update'), ('admin_alert_driver_accept', 'Admin Alert: Driver Accepted'), ('contact_form_admin', 'Contact Form (Admin)')], max_length=50, unique=True)),
|
||||
('description', models.CharField(help_text='Description of where this notification is used.', max_length=255)),
|
||||
('available_variables', models.TextField(blank=True, help_text='Comma-separated list of variables available in this template (e.g. {{ code }}, {{ name }}).')),
|
||||
('subject_en', models.CharField(blank=True, max_length=255, verbose_name='Email Subject (EN)')),
|
||||
('subject_ar', models.CharField(blank=True, max_length=255, verbose_name='Email Subject (AR)')),
|
||||
('email_body_en', models.TextField(blank=True, help_text='HTML allowed.', verbose_name='Email Body (EN)')),
|
||||
('email_body_ar', models.TextField(blank=True, help_text='HTML allowed.', verbose_name='Email Body (AR)')),
|
||||
('whatsapp_body_en', models.TextField(blank=True, verbose_name='WhatsApp Message (EN)')),
|
||||
('whatsapp_body_ar', models.TextField(blank=True, verbose_name='WhatsApp Message (AR)')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Notification Template',
|
||||
'verbose_name_plural': 'Notification Templates',
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,85 +0,0 @@
|
||||
# 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)'),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-31 10:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0023_pricingrule_parcel_delivery_lat_parcel_delivery_lng_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='platformprofile',
|
||||
name='auto_mark_paid',
|
||||
field=models.BooleanField(default=False, help_text="If enabled, newly created parcels will automatically be marked as 'Paid' for testing.", verbose_name='Test Mode: Auto-Paid'),
|
||||
),
|
||||
]
|
||||
@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-31 13:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0024_platformprofile_auto_mark_paid'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='platformprofile',
|
||||
name='admin_panel_logo',
|
||||
field=models.ImageField(blank=True, help_text='Logo for the Admin Panel (top left).', null=True, upload_to='platform_logos/', verbose_name='Admin Panel Logo'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='platformprofile',
|
||||
name='favicon',
|
||||
field=models.ImageField(blank=True, help_text='Upload a favicon (e.g., .ico or .png)', null=True, upload_to='platform_logos/', verbose_name='Favicon'),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-31 13:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0025_platformprofile_admin_panel_logo_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='bank_account_number',
|
||||
field=models.CharField(blank=True, max_length=50, verbose_name='Bank Account Number'),
|
||||
),
|
||||
]
|
||||
@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-01 12:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0026_profile_bank_account_number'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='driver_grade',
|
||||
field=models.CharField(choices=[('none', 'No Grade'), ('bronze_3', 'Bronze III'), ('bronze_2', 'Bronze II'), ('bronze_1', 'Bronze I'), ('silver', 'Silver'), ('gold', 'Gold')], default='none', max_length=20, verbose_name='Driver Grade'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='is_recommended',
|
||||
field=models.BooleanField(default=False, verbose_name='Recommended by Shippers'),
|
||||
),
|
||||
]
|
||||
@ -1,28 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-01 13:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0027_profile_driver_grade_profile_is_recommended'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='platformprofile',
|
||||
name='accepting_shipments',
|
||||
field=models.BooleanField(default=True, help_text='Toggle to allow or stop receiving new parcel shipments.', verbose_name='Accepting Shipments'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='platformprofile',
|
||||
name='maintenance_message_ar',
|
||||
field=models.TextField(blank=True, help_text='Message to show when shipments are stopped.', verbose_name='Maintenance Message (Arabic)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='platformprofile',
|
||||
name='maintenance_message_en',
|
||||
field=models.TextField(blank=True, help_text='Message to show when shipments are stopped.', verbose_name='Maintenance Message (English)'),
|
||||
),
|
||||
]
|
||||
@ -1,36 +0,0 @@
|
||||
# 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,51 +0,0 @@
|
||||
# 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,28 +0,0 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@ -1,31 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-01 17:10
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0031_platformprofile_ticker_bg_color_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ParcelType',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name_en', models.CharField(max_length=100, verbose_name='Name (English)')),
|
||||
('name_ar', models.CharField(max_length=100, verbose_name='Name (Arabic)')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Parcel Type',
|
||||
'verbose_name_plural': 'Parcel Types',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='parcel',
|
||||
name='parcel_type',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.parceltype', verbose_name='Parcel Type'),
|
||||
),
|
||||
]
|
||||
@ -1,40 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-02 02:52
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0032_parceltype_parcel_parcel_type'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='platformprofile',
|
||||
name='enable_auto_ban_on_warnings',
|
||||
field=models.BooleanField(default=False, help_text='Automatically ban drivers who exceed a certain number of warnings.', verbose_name='Enable Auto-Ban on Warnings'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='platformprofile',
|
||||
name='max_warnings_before_ban',
|
||||
field=models.PositiveIntegerField(default=3, help_text='Number of warnings allowed before auto-ban.', verbose_name='Max Warnings Before Ban'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DriverWarning',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('reason', models.TextField(verbose_name='Reason for Warning')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('driver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='warnings', to=settings.AUTH_USER_MODEL, verbose_name='Driver')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Driver Warning',
|
||||
'verbose_name_plural': 'Driver Warnings',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-02 03:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0033_platformprofile_enable_auto_ban_on_warnings_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='language',
|
||||
field=models.CharField(choices=[('en', 'English'), ('ar', 'Arabic')], default='ar', max_length=10, verbose_name='Language'),
|
||||
),
|
||||
]
|
||||