Compare commits
110 Commits
254e46f4b2
...
90ec729b40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90ec729b40 | ||
|
|
c99a83fc67 | ||
|
|
c2bd9dc53c | ||
|
|
8ac308c73f | ||
|
|
9f0927a406 | ||
|
|
4975582b68 | ||
|
|
3bb4e04513 | ||
|
|
e1e791abf6 | ||
|
|
856be645ab | ||
|
|
2d3c1bc4df | ||
|
|
786df69fa7 | ||
|
|
608119b06c | ||
|
|
a3ff664ed9 | ||
|
|
b01c5f2dce | ||
|
|
e5357909b3 | ||
|
|
f32a54acdf | ||
|
|
083a505bb7 | ||
|
|
ddf28fea79 | ||
|
|
28595c8f0f | ||
|
|
ceaa26d36c | ||
|
|
b972987f77 | ||
|
|
ef50367f00 | ||
|
|
5189171048 | ||
|
|
e20e167414 | ||
|
|
1fdd01e827 | ||
|
|
12a9185f1e | ||
|
|
bf29a0b21b | ||
|
|
f1de11cc52 | ||
|
|
82b68976c5 | ||
|
|
7f5da18a24 | ||
|
|
8b582d5924 | ||
|
|
73bbc63c0c | ||
|
|
bd260781ff | ||
|
|
0197784aa9 | ||
|
|
60332e7b5b | ||
|
|
582c5271c7 | ||
|
|
cf8af51ff6 | ||
|
|
e42f1985b5 | ||
|
|
ce26876865 | ||
|
|
ac8178554a | ||
|
|
effc25d32e | ||
|
|
d2c877a5e1 | ||
|
|
d43de11987 | ||
|
|
bc518a1fbf | ||
|
|
4b1f09a2a5 | ||
|
|
ad76a12150 | ||
|
|
fae5727752 | ||
|
|
90f98ed775 | ||
|
|
7c8d1751b7 | ||
|
|
72aff798e2 | ||
|
|
92f18ce7f0 | ||
|
|
e2d742d9ae | ||
|
|
0a784beb41 | ||
|
|
d909e02470 | ||
|
|
66fc23962f | ||
|
|
827080c473 | ||
|
|
ac3f000214 | ||
|
|
74aa3572c9 | ||
|
|
7b86c4d661 | ||
|
|
279c717fdd | ||
|
|
c1eea39519 | ||
|
|
212e4e71cc | ||
|
|
04fa45e5f8 | ||
|
|
d8d794fe04 | ||
|
|
131b7284cc | ||
|
|
f3e3a1c221 | ||
|
|
0beefaf8a8 | ||
|
|
e6c45971eb | ||
|
|
a982102796 | ||
|
|
920999ccb0 | ||
|
|
8e628f5334 | ||
|
|
9d123496e2 | ||
|
|
5f2219fc0f | ||
|
|
c728c4e116 | ||
|
|
63818c6fe3 | ||
|
|
e27928f933 | ||
|
|
59561573fc | ||
|
|
6943d83b2c | ||
|
|
a094518411 | ||
|
|
595ca7c1fe | ||
|
|
4020492307 | ||
|
|
1e9f216ade | ||
|
|
88187c1cc8 | ||
|
|
e35fea8cf0 | ||
|
|
121c77dd5f | ||
|
|
a3174399c8 | ||
|
|
7e8ed2b3cb | ||
|
|
3cf59feab0 | ||
|
|
08e8aa82d4 | ||
|
|
d8387d341e | ||
|
|
a8a48697b3 | ||
|
|
b77b8b1619 | ||
|
|
241aa3abd2 | ||
|
|
9868906b5e | ||
|
|
4c9baf95e0 | ||
|
|
ce3101780f | ||
|
|
df3c7ad9f5 | ||
|
|
917a89a262 | ||
|
|
1e836a1d9d | ||
|
|
0bccc28caf | ||
|
|
59204ba309 | ||
|
|
192b5d7408 | ||
|
|
f2a747c61a | ||
|
|
59f1cf28c3 | ||
|
|
bae3510e83 | ||
|
|
f213aed6e7 | ||
|
|
95e2847b94 | ||
|
|
2d5dec02ad | ||
|
|
0e850fd80b | ||
|
|
f69cb03bdb |
15
.dockerignore
Normal 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
@ -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
@ -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.
|
||||||
BIN
ai/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
ai/__pycache__/local_ai_api.cpython-311.pyc
Normal 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):
|
||||||
|
|||||||
BIN
assets/pasted-20260128-062729-234b1f9b.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
assets/pasted-20260129-143332-d634d894.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
assets/pasted-20260130-173006-3da5a9e8.png
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
assets/pasted-20260201-034855-b2075578.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/pasted-20260201-075032-c186b500.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/pasted-20260201-081838-f3ed2987.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/pasted-20260201-135726-63c413b4.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
assets/pasted-20260201-154837-60ba680b.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
assets/vm-shot-2026-01-27T10-44-32-354Z.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
assets/vm-shot-2026-02-01T03-47-53-669Z.jpg
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
assets/vm-shot-2026-02-01T03-48-00-010Z.jpg
Normal file
|
After Width: | Height: | Size: 95 KiB |
@ -0,0 +1,3 @@
|
|||||||
|
import pymysql
|
||||||
|
|
||||||
|
pymysql.install_as_MySQLdb()
|
||||||
@ -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',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
@ -8,9 +8,12 @@ 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
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
|
||||||
application = get_wsgi_application()
|
application = get_wsgi_application()
|
||||||
BIN
core/__pycache__/api_views.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/mail.cpython-311.pyc
Normal file
BIN
core/__pycache__/notifications.cpython-311.pyc
Normal file
BIN
core/__pycache__/payment_utils.cpython-311.pyc
Normal file
BIN
core/__pycache__/pricing.cpython-311.pyc
Normal file
BIN
core/__pycache__/serializers.cpython-311.pyc
Normal file
BIN
core/__pycache__/whatsapp_utils.cpython-311.pyc
Normal file
423
core/admin.py
@ -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
@ -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)
|
||||||
19
core/apps.py
@ -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
|
||||||
@ -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
@ -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
@ -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
|
||||||
BIN
core/management/commands/__pycache__/wait_for_db.cpython-311.pyc
Normal file
19
core/management/commands/ensure_admin.py
Normal 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."))
|
||||||
30
core/management/commands/init_notifications.py
Normal 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.'))
|
||||||
90
core/management/commands/test_db_connection.py
Normal 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 ===")
|
||||||
60
core/management/commands/wait_for_db.py
Normal 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.'))
|
||||||
44
core/migrations/0001_initial.py
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
30
core/migrations/0007_platformprofile.py
Normal 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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
core/migrations/0011_platformprofile_whatsapp_app_secret.py
Normal 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)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
core/migrations/0013_platformprofile_enable_payment.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
core/migrations/0014_alter_otpverification_purpose.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
34
core/migrations/0015_testimonial.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
core/migrations/0016_country_phone_code.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
32
core/migrations/0017_driverrating.py
Normal 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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
core/migrations/0018_alter_otpverification_purpose.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
core/migrations/0020_profile_is_approved.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
32
core/migrations/0022_notificationtemplate.py
Normal 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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
core/migrations/0024_platformprofile_auto_mark_paid.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
core/migrations/0026_profile_bank_account_number.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
36
core/migrations/0029_driverreport.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
31
core/migrations/0032_parceltype_parcel_parcel_type.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
core/migrations/0034_profile_language.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||