Compare commits

...

110 Commits

Author SHA1 Message Date
Flatlogic Bot
90ec729b40 fix chatbot 2026-02-02 06:17:54 +00:00
Flatlogic Bot
c99a83fc67 editing login screen 2026-02-02 04:08:48 +00:00
Flatlogic Bot
c2bd9dc53c more modifications 2026-02-02 03:53:32 +00:00
Flatlogic Bot
8ac308c73f Autosave: 20260202-033511 2026-02-02 03:35:13 +00:00
Flatlogic Bot
9f0927a406 add a list 2026-02-01 17:33:49 +00:00
Flatlogic Bot
4975582b68 Autosave: 20260201-171858 2026-02-01 17:18:59 +00:00
Flatlogic Bot
3bb4e04513 some features 2026-02-01 13:59:50 +00:00
Flatlogic Bot
e1e791abf6 adding some features 2026-02-01 13:17:06 +00:00
Flatlogic Bot
856be645ab Fix Date Range Filter: Use native Select element to fix broken dropdown list 2026-02-01 08:20:03 +00:00
Flatlogic Bot
2d3c1bc4df Fix(CSS): Remove overflow hidden from Date Filter to show dropdown 2026-02-01 08:13:10 +00:00
Flatlogic Bot
786df69fa7 Fix(UI): Fix Admin Date Filter Layout Overflow 2026-02-01 08:01:03 +00:00
Flatlogic Bot
608119b06c Fix(Admin): Force Date Filter to be horizontal using JS DOM restructuring 2026-02-01 07:55:13 +00:00
Flatlogic Bot
a3ff664ed9 Fix Admin Date Filter Alignment 2026-02-01 07:52:14 +00:00
Flatlogic Bot
b01c5f2dce Fix Date Filter layout to be compact/horizontal 2026-02-01 07:43:41 +00:00
Flatlogic Bot
e5357909b3 Fix duplicate date range dropdowns in Admin 2026-02-01 07:34:23 +00:00
Flatlogic Bot
f32a54acdf Fix typo in requirements.txt: separate requests and whitenoise 2026-02-01 07:21:56 +00:00
Flatlogic Bot
083a505bb7 Fix: Add whitenoise to requirements and use safer storage backend 2026-02-01 07:16:48 +00:00
Flatlogic Bot
ddf28fea79 feat: Add WhiteNoise for optimized static file serving 2026-02-01 07:12:17 +00:00
Flatlogic Bot
28595c8f0f Fix RTL sidebar icon alignment (move to right) 2026-02-01 06:55:26 +00:00
Flatlogic Bot
ceaa26d36c Fix: Aggressive RTL Sidebar overrides and cache busting 2026-02-01 06:50:32 +00:00
Flatlogic Bot
b972987f77 Fix: Force RTL sidebar layout in Admin Panel 2026-02-01 06:46:38 +00:00
Flatlogic Bot
ef50367f00 Fix: Admin search bar layout for RTL (Arabic) 2026-02-01 06:41:03 +00:00
Flatlogic Bot
5189171048 Fix Admin Search Layout: Add Reset button, force inline filters, fix date range dropdown style 2026-02-01 04:49:02 +00:00
Flatlogic Bot
e20e167414 Autosave: 20260201-044711 2026-02-01 04:47:12 +00:00
Flatlogic Bot
1fdd01e827 Fix Admin Search: Override search_form template for compact inline layout 2026-02-01 04:39:18 +00:00
Flatlogic Bot
12a9185f1e adding links for delivery 2026-02-01 04:22:40 +00:00
Flatlogic Bot
bf29a0b21b feat: Add Google Maps navigation links for drivers
- Added  and  properties to  model.
- Exposed these URLs in .
- Updated driver dashboard to show 'Navigate to Pickup' and 'Navigate to Delivery' buttons for active shipments.
2026-02-01 04:20:12 +00:00
Flatlogic Bot
f1de11cc52 Fix admin totals template syntax error and enhance search box styling 2026-02-01 04:09:57 +00:00
Flatlogic Bot
82b68976c5 Enhance Admin: Add Dropdown Date Range Filter for Parcels 2026-02-01 03:09:04 +00:00
Flatlogic Bot
7f5da18a24 Fix: Remove 'for' attribute from profile image labels to prevent accidental upload dialog 2026-02-01 02:54:19 +00:00
Flatlogic Bot
8b582d5924 Fix Admin UI: Disable label click on logos and move View Website to sidebar 2026-02-01 02:48:20 +00:00
Flatlogic Bot
73bbc63c0c Enhance Admin UI: Singleton PlatformProfile and Sidebar Icons 2026-02-01 02:41:14 +00:00
Flatlogic Bot
bd260781ff added lang switch in admin panel 2026-01-31 13:27:58 +00:00
Flatlogic Bot
0197784aa9 Fix Admin Panel language persistence and add language switcher 2026-01-31 13:24:54 +00:00
Flatlogic Bot
60332e7b5b Add bank account number to profile model, admin, and forms 2026-01-31 13:11:58 +00:00
Flatlogic Bot
582c5271c7 feat: Add favicon and admin panel logo to PlatformProfile 2026-01-31 13:07:20 +00:00
Flatlogic Bot
cf8af51ff6 config: Set default language to Arabic 2026-01-31 13:01:48 +00:00
Flatlogic Bot
e42f1985b5 feat(core): add auto_mark_paid setting to PlatformProfile for testing 2026-01-31 10:43:01 +00:00
Flatlogic Bot
ce26876865 Autosave: 20260131-103428 2026-01-31 10:34:29 +00:00
Flatlogic Bot
ac8178554a feat(admin): set default parcel payment status to paid for testing 2026-01-31 10:31:23 +00:00
Flatlogic Bot
effc25d32e feat(admin): Add driver_amount and platform_fee to Parcel list 2026-01-31 10:03:36 +00:00
Flatlogic Bot
d2c877a5e1 updating distance calculations 2026-01-31 10:00:18 +00:00
Flatlogic Bot
d43de11987 Fix distance calculation and display in Admin, Dashboards, and Notifications 2026-01-31 09:58:55 +00:00
Flatlogic Bot
bc518a1fbf Add auto-creation of admin user via entrypoint 2026-01-31 08:09:47 +00:00
Flatlogic Bot
4b1f09a2a5 fix: configure CSRF trusted origins and proxy SSL headers 2026-01-31 06:51:16 +00:00
Flatlogic Bot
ad76a12150 Update entrypoint to print Public IP for DB whitelisting 2026-01-31 04:47:28 +00:00
Flatlogic Bot
fae5727752 fix(docker): replace libgobject-2.0-0 with libglib2.0-0 for debian bookworm 2026-01-31 04:09:12 +00:00
Flatlogic Bot
90f98ed775 Fix Dockerfile: Correct package name for libgdk-pixbuf in Debian Bookworm 2026-01-31 04:04:28 +00:00
Flatlogic Bot
7c8d1751b7 fix(docker): remove libharfbuzz-subset0 incompatible with debian bookworm 2026-01-31 03:58:38 +00:00
Flatlogic Bot
72aff798e2 Build: Fix WeasyPrint dependencies in Dockerfile and Nixpacks 2026-01-31 03:53:48 +00:00
Flatlogic Bot
92f18ce7f0 Update shipment request map layout 2026-01-31 03:42:41 +00:00
Flatlogic Bot
e2d742d9ae Fix Google Maps loading: add auth failure handling and remove unnecessary libraries 2026-01-31 02:53:43 +00:00
Flatlogic Bot
0a784beb41 Autosave: 20260131-024701 2026-01-31 02:47:03 +00:00
Flatlogic Bot
d909e02470 deploy4 2026-01-30 17:33:28 +00:00
Flatlogic Bot
66fc23962f Fix WeasyPrint dependencies in Nixpacks config 2026-01-30 17:32:10 +00:00
Flatlogic Bot
827080c473 deploy3 2026-01-30 17:20:44 +00:00
Flatlogic Bot
ac3f000214 editing deploy 2026-01-30 16:40:00 +00:00
Flatlogic Bot
74aa3572c9 Autosave: 20260130-045645 2026-01-30 04:56:47 +00:00
Flatlogic Bot
7b86c4d661 Fix: Force PyMySQL usage to resolve Coolify deployment error (2059) 2026-01-29 18:09:02 +00:00
Flatlogic Bot
279c717fdd update dockerfile 2026-01-29 17:42:36 +00:00
Flatlogic Bot
c1eea39519 Autosave: 20260129-150543 2026-01-29 15:05:45 +00:00
Flatlogic Bot
212e4e71cc editing deployment 2026-01-29 02:38:23 +00:00
Flatlogic Bot
04fa45e5f8 changes on deploy 2026-01-28 16:28:11 +00:00
Flatlogic Bot
d8d794fe04 Fix: Add requests to requirements and update settings/urls for deployment 2026-01-28 16:18:03 +00:00
Flatlogic Bot
131b7284cc dd33 2026-01-28 10:10:43 +00:00
Flatlogic Bot
f3e3a1c221 connecting to github 2026-01-28 06:56:49 +00:00
Flatlogic Bot
0beefaf8a8 fffuin 2026-01-28 04:47:18 +00:00
Flatlogic Bot
e6c45971eb Autosave: 20260128-021353 2026-01-28 02:13:53 +00:00
Flatlogic Bot
a982102796 adding arabic plicy 2026-01-28 01:00:42 +00:00
Flatlogic Bot
920999ccb0 add send message from admin panel 2026-01-28 00:51:55 +00:00
Flatlogic Bot
8e628f5334 login changes 2026-01-28 00:43:39 +00:00
Flatlogic Bot
9d123496e2 editing registeration 2026-01-28 00:24:24 +00:00
Flatlogic Bot
5f2219fc0f Autosave: 20260128-000421 2026-01-28 00:04:21 +00:00
Flatlogic Bot
c728c4e116 adding api for phone app 2026-01-27 02:20:46 +00:00
Flatlogic Bot
63818c6fe3 Autosave: 20260126-175314 2026-01-26 17:53:15 +00:00
Flatlogic Bot
e27928f933 mobile app apis 2026-01-26 17:46:01 +00:00
Flatlogic Bot
59561573fc Autosave: 20260126-125014 2026-01-26 12:50:14 +00:00
Flatlogic Bot
6943d83b2c Autosave: 20260126-082949 2026-01-26 08:29:49 +00:00
Flatlogic Bot
a094518411 adding reports 2026-01-26 08:25:00 +00:00
Flatlogic Bot
595ca7c1fe adding invoice 2026-01-26 08:06:46 +00:00
Flatlogic Bot
4020492307 add printing labels 2026-01-26 07:53:04 +00:00
Flatlogic Bot
1e9f216ade Autosave: 20260126-073515 2026-01-26 07:35:15 +00:00
Flatlogic Bot
88187c1cc8 Autosave: 20260126-061311 2026-01-26 06:13:11 +00:00
Flatlogic Bot
e35fea8cf0 admin dashboard 2026-01-26 05:25:39 +00:00
Flatlogic Bot
121c77dd5f addin driver rating 2026-01-26 05:04:37 +00:00
Flatlogic Bot
a3174399c8 Autosave: 20260126-043309 2026-01-26 04:33:09 +00:00
Flatlogic Bot
7e8ed2b3cb adding reports 2026-01-25 17:15:46 +00:00
Flatlogic Bot
3cf59feab0 adding bids 2026-01-25 17:00:23 +00:00
Flatlogic Bot
08e8aa82d4 adding testimonial 2026-01-25 16:46:17 +00:00
Flatlogic Bot
d8387d341e editing email 2026-01-25 16:35:29 +00:00
Flatlogic Bot
a8a48697b3 Autosave: 20260125-162650 2026-01-25 16:26:50 +00:00
Flatlogic Bot
b77b8b1619 adding variviction 2026-01-25 16:21:07 +00:00
Flatlogic Bot
241aa3abd2 pages nav 2026-01-25 15:30:26 +00:00
Flatlogic Bot
9868906b5e editing navigation 2026-01-25 15:26:43 +00:00
Flatlogic Bot
4c9baf95e0 Autosave: 20260125-151503 2026-01-25 15:15:04 +00:00
Flatlogic Bot
ce3101780f adding disable payments 2026-01-25 13:39:17 +00:00
Flatlogic Bot
df3c7ad9f5 Edit config/settings.py via Editor 2026-01-25 13:18:13 +00:00
Flatlogic Bot
917a89a262 Autosave: 20260125-124556 2026-01-25 12:45:56 +00:00
Flatlogic Bot
1e836a1d9d adding whats config 2026-01-25 12:05:49 +00:00
Flatlogic Bot
0bccc28caf user profile 2026-01-25 11:58:26 +00:00
Flatlogic Bot
59204ba309 changes rtl 2026-01-25 11:35:22 +00:00
Flatlogic Bot
192b5d7408 Autosave: 20260125-113016 2026-01-25 11:30:16 +00:00
Flatlogic Bot
f2a747c61a updating profile 2026-01-25 11:03:54 +00:00
Flatlogic Bot
59f1cf28c3 adding profile 2026-01-25 10:56:32 +00:00
Flatlogic Bot
bae3510e83 changes on entering cities 2026-01-25 10:31:06 +00:00
Flatlogic Bot
f213aed6e7 adding notifications 2026-01-25 07:53:43 +00:00
Flatlogic Bot
95e2847b94 changes in cities 2026-01-25 07:46:55 +00:00
Flatlogic Bot
2d5dec02ad adding nav bar 2026-01-25 07:42:40 +00:00
Flatlogic Bot
0e850fd80b adding thwani gateway 2026-01-25 07:26:38 +00:00
Flatlogic Bot
f69cb03bdb 1st update 2026-01-25 07:17:07 +00:00
347 changed files with 32390 additions and 291 deletions

15
.dockerignore Normal file
View File

@ -0,0 +1,15 @@
.git
.gitignore
.env
.venv
venv/
__pycache__
*.pyc
*.pyo
*.pyd
.DS_Store
db.sqlite3
staticfiles/
media/
.idea
.vscode

45
Dockerfile Normal file
View File

@ -0,0 +1,45 @@
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"]

22
GEMINI_RESPONSE.md Normal file
View File

@ -0,0 +1,22 @@
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.

Binary file not shown.

Binary file not shown.

View File

@ -145,6 +145,7 @@ def request(path: Optional[str], payload: Dict[str, Any], options: Optional[Dict
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "application/json", "Accept": "application/json",
cfg["project_header"]: project_uuid, 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") extra_headers = options.get("headers")
if isinstance(extra_headers, Iterable): if isinstance(extra_headers, Iterable):
@ -180,6 +181,7 @@ def fetch_status(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -
headers: Dict[str, str] = { headers: Dict[str, str] = {
"Accept": "application/json", "Accept": "application/json",
cfg["project_header"]: project_uuid, 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") extra_headers = options.get("headers")
if isinstance(extra_headers, Iterable): if isinstance(extra_headers, Iterable):

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

View File

@ -0,0 +1,3 @@
import pymysql
pymysql.install_as_MySQLdb()

View File

@ -20,22 +20,32 @@ load_dotenv(BASE_DIR.parent / ".env")
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me") SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me")
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true" DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
ALLOWED_HOSTS = [ # Allow all hosts to avoid 404/400 errors during initial deployment
"127.0.0.1", ALLOWED_HOSTS = ["*"]
"localhost",
os.getenv("HOST_FQDN", ""), # 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))
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. # Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy.
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
@ -49,18 +59,26 @@ CSRF_COOKIE_SAMESITE = "None"
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'jazzmin',
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.contrib.humanize',
'rest_framework',
'rest_framework.authtoken',
'drf_yasg',
'rangefilter',
'core', 'core',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
"whitenoise.middleware.WhiteNoiseMiddleware", # Add WhiteNoise Middleware
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
@ -76,13 +94,14 @@ ROOT_URLCONF = 'config.urls'
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [], 'DIRS': [BASE_DIR / 'core/templates'],
'APP_DIRS': True, 'APP_DIRS': True,
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', '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 # IMPORTANT: do not remove injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp
'core.context_processors.project_context', 'core.context_processors.project_context',
], ],
@ -133,7 +152,15 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/ # https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = 'ar'
LANGUAGES = [
('en', 'English'),
('ar', 'Arabic'),
]
LOCALE_PATHS = [
BASE_DIR / 'locale',
]
TIME_ZONE = 'UTC' TIME_ZONE = 'UTC'
@ -155,18 +182,26 @@ STATICFILES_DIRS = [
BASE_DIR / 'node_modules', 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
EMAIL_BACKEND = os.getenv( EMAIL_BACKEND = os.getenv(
"EMAIL_BACKEND", "EMAIL_BACKEND",
"django.core.mail.backends.smtp.EmailBackend" "django.core.mail.backends.smtp.EmailBackend"
) )
EMAIL_HOST = os.getenv("EMAIL_HOST", "127.0.0.1") EMAIL_HOST = os.getenv("EMAIL_HOST", "smtp.gmail.com")
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587")) EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "aalabry@gmail.com")
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "accd uacy kzdq aejp")
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true" EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true"
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true" EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true"
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "no-reply@example.com") DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", EMAIL_HOST_USER)
CONTACT_EMAIL_TO = [ CONTACT_EMAIL_TO = [
item.strip() item.strip()
for item in os.getenv("CONTACT_EMAIL_TO", DEFAULT_FROM_EMAIL).split(",") for item in os.getenv("CONTACT_EMAIL_TO", DEFAULT_FROM_EMAIL).split(",")
@ -176,7 +211,125 @@ CONTACT_EMAIL_TO = [
# When both TLS and SSL flags are enabled, prefer SSL explicitly # When both TLS and SSL flags are enabled, prefer SSL explicitly
if EMAIL_USE_SSL: if EMAIL_USE_SSL:
EMAIL_USE_TLS = False 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 # Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 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',
],
}

View File

@ -1,29 +1,43 @@
"""
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.contrib import admin
from django.urls import include, path from django.urls import path, include
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static 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 = [ urlpatterns = [
path("admin/", admin.site.urls), path('health/', lambda request: HttpResponse("OK")), # Simple health check
path("", include("core.urls")), 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'),
] ]
urlpatterns += i18n_patterns(
path('admin/', admin.site.urls),
path('', include('core.urls')),
prefix_default_language=False
)
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 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")

View File

@ -8,6 +8,9 @@ https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
""" """
import os import os
import pymysql
pymysql.install_as_MySQLdb()
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,3 +1,424 @@
from django.contrib import admin 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
# Register your models here. 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)

136
core/api_views.py Normal file
View File

@ -0,0 +1,136 @@
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)

View File

@ -1,6 +1,25 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class CoreConfig(AppConfig): class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'core' 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

View File

@ -1,13 +1,25 @@
import os import os
import time import time
from .models import Profile, PlatformProfile
def project_context(request): def project_context(request):
""" """
Adds project-specific environment variables to the template context globally. 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 { return {
"project_description": os.getenv("PROJECT_DESCRIPTION", ""), "project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
# Used for cache-busting static assets # Used for cache-busting static assets
"deployment_timestamp": int(time.time()), "deployment_timestamp": int(time.time()),
"user_profile": profile,
"platform_profile": platform_profile,
} }

441
core/forms.py Normal file
View File

@ -0,0 +1,441 @@
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 Normal file
View File

@ -0,0 +1,90 @@
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

View File

@ -0,0 +1,19 @@
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."))

View File

@ -0,0 +1,30 @@
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.'))

View File

@ -0,0 +1,90 @@
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 ===")

View File

@ -0,0 +1,60 @@
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.'))

View File

@ -0,0 +1,44 @@
# 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)),
],
),
]

View File

@ -0,0 +1,99 @@
# 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'),
),
]

View File

@ -0,0 +1,98 @@
# 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'),
),
]

View File

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

View File

@ -0,0 +1,55 @@
# 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)'),
),
]

View File

@ -0,0 +1,43 @@
# 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)'),
),
]

View File

@ -0,0 +1,30 @@
# 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',
},
),
]

View File

@ -0,0 +1,23 @@
# 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'),
),
]

View File

@ -0,0 +1,37 @@
# 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)),
],
),
]

View File

@ -0,0 +1,23 @@
# 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'),
),
]

View File

@ -0,0 +1,18 @@
# 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)'),
),
]

View File

@ -0,0 +1,28 @@
# 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'),
),
]

View File

@ -0,0 +1,18 @@
# 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'),
),
]

View File

@ -0,0 +1,18 @@
# 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),
),
]

View File

@ -0,0 +1,34 @@
# 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'],
},
),
]

View File

@ -0,0 +1,18 @@
# 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'),
),
]

View File

@ -0,0 +1,32 @@
# 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',
},
),
]

View File

@ -0,0 +1,18 @@
# 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),
),
]

View File

@ -0,0 +1,28 @@
# 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'),
),
]

View File

@ -0,0 +1,18 @@
# 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'),
),
]

View File

@ -0,0 +1,41 @@
# 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)'),
),
]

View File

@ -0,0 +1,32 @@
# 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',
},
),
]

View File

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

View File

@ -0,0 +1,18 @@
# 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'),
),
]

View File

@ -0,0 +1,23 @@
# 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'),
),
]

View File

@ -0,0 +1,18 @@
# 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'),
),
]

View File

@ -0,0 +1,23 @@
# 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'),
),
]

View File

@ -0,0 +1,28 @@
# 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)'),
),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 5.2.7 on 2026-02-01 13:24
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0028_platformprofile_accepting_shipments_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='DriverReport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reason', models.CharField(choices=[('unprofessional', 'Unprofessional Behavior'), ('reckless_driving', 'Reckless Driving'), ('delayed_delivery', 'Significant Delay'), ('item_damaged', 'Item Damaged'), ('item_missing', 'Item Missing'), ('other', 'Other')], max_length=50, verbose_name='Reason')),
('description', models.TextField(verbose_name='Detailed Description')),
('status', models.CharField(choices=[('pending', 'Pending Investigation'), ('investigating', 'Investigating'), ('resolved', 'Resolved'), ('dismissed', 'Dismissed')], default='pending', max_length=20, verbose_name='Status')),
('admin_note', models.TextField(blank=True, verbose_name='Admin Internal Note')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
('driver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports_received', to=settings.AUTH_USER_MODEL, verbose_name='Driver')),
('parcel', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reports', to='core.parcel', verbose_name='Related Parcel')),
('reporter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='filed_reports', to=settings.AUTH_USER_MODEL, verbose_name='Reporter')),
],
options={
'verbose_name': 'Driver Report',
'verbose_name_plural': 'Driver Reports',
'ordering': ['-created_at'],
},
),
]

View File

@ -0,0 +1,51 @@
# Generated by Django 5.2.7 on 2026-02-01 13:38
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0029_driverreport'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='platformprofile',
name='auto_ban_on_rejections',
field=models.BooleanField(default=False, help_text='Automatically ban drivers who exceed a certain number of rejections.', verbose_name='Enable Auto-Ban on Rejections'),
),
migrations.AddField(
model_name='platformprofile',
name='rejection_limit',
field=models.PositiveIntegerField(default=5, help_text='Number of rejections allowed before auto-ban.', verbose_name='Rejection Limit'),
),
migrations.AddField(
model_name='profile',
name='ban_reason',
field=models.TextField(blank=True, verbose_name='Ban Reason'),
),
migrations.AddField(
model_name='profile',
name='is_banned',
field=models.BooleanField(default=False, verbose_name='Banned'),
),
migrations.CreateModel(
name='DriverRejection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reason', models.TextField(verbose_name='Reason for Rejection')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('driver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rejections', to=settings.AUTH_USER_MODEL, verbose_name='Driver')),
('parcel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rejections', to='core.parcel', verbose_name='Parcel')),
],
options={
'verbose_name': 'Driver Rejection',
'verbose_name_plural': 'Driver Rejections',
'ordering': ['-created_at'],
},
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2026-02-01 13:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0030_platformprofile_auto_ban_on_rejections_and_more'),
]
operations = [
migrations.AddField(
model_name='platformprofile',
name='ticker_bg_color',
field=models.CharField(default='#FFFFFF', help_text='Background color for the live activity ticker (e.g. #FFFFFF or white).', max_length=20, verbose_name='Ticker Background Color'),
),
migrations.AddField(
model_name='platformprofile',
name='ticker_limit',
field=models.PositiveIntegerField(default=10, help_text='Number of recent shipments to show in the live activity ticker.', verbose_name='Ticker Shipment Limit'),
),
migrations.AddField(
model_name='platformprofile',
name='ticker_text_color',
field=models.CharField(default='#1A1A1D', help_text='Text color for the live activity ticker.', max_length=20, verbose_name='Ticker Text Color'),
),
]

View File

@ -0,0 +1,31 @@
# 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'),
),
]

View File

@ -0,0 +1,40 @@
# 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'],
},
),
]

View File

@ -0,0 +1,18 @@
# 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'),
),
]

Some files were not shown because too many files have changed in this diff Show More