Autosave: 20260128-000421

This commit is contained in:
Flatlogic Bot 2026-01-28 00:04:21 +00:00
parent c728c4e116
commit 5f2219fc0f
16 changed files with 563 additions and 9 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -1,6 +1,25 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'core'
verbose_name = _('Masar Express Management')
default = True
def ready(self):
from django.contrib.auth.models import Permission
# Monkey-patch Permission.__str__ to show a VERY short name
# Standard was: "app_label | model | name" (e.g. core | country | Can add country)
# Previous fix: "Country | Can add country"
# New fix: "add Country", "change Country" (strips "Can " prefix)
def short_str(self):
name = str(self.name)
if name.startswith("Can "):
return name[4:]
return name
Permission.__str__ = short_str

View File

@ -96,14 +96,44 @@ class UserRegistrationForm(forms.ModelForm):
user.save()
# Profile is created by signal, so we update it
profile, created = Profile.objects.get_or_create(user=user)
profile.role = self.cleaned_data['role']
# 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']
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'}))
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'}))
@ -347,4 +377,4 @@ class DriverRatingForm(forms.ModelForm):
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)]
self.fields['rating'].choices = [(i, str(i)) for i in range(5, 0, -1)]

View File

@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2026-01-27 23:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0018_alter_otpverification_purpose'),
]
operations = [
migrations.AddField(
model_name='profile',
name='car_plate_number',
field=models.CharField(blank=True, max_length=20, verbose_name='Car Plate Number'),
),
migrations.AddField(
model_name='profile',
name='license_back_image',
field=models.ImageField(blank=True, null=True, upload_to='licenses/', verbose_name='License Back Image'),
),
migrations.AddField(
model_name='profile',
name='license_front_image',
field=models.ImageField(blank=True, null=True, upload_to='licenses/', verbose_name='License Front Image'),
),
]

View File

@ -73,6 +73,11 @@ class Profile(models.Model):
profile_picture = models.ImageField(_('Profile Picture'), upload_to='profile_pics/', blank=True, null=True)
address = models.CharField(_('Address'), max_length=255, blank=True)
# Driver specific fields
license_front_image = models.ImageField(_('License Front Image'), upload_to='licenses/', blank=True, null=True)
license_back_image = models.ImageField(_('License Back Image'), upload_to='licenses/', blank=True, null=True)
car_plate_number = models.CharField(_('Car Plate Number'), max_length=20, blank=True)
country = models.ForeignKey(Country, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('Country'))
governate = models.ForeignKey(Governate, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('Governate'))
city = models.ForeignKey(City, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('City'))
@ -270,4 +275,4 @@ class DriverRating(models.Model):
class Meta:
verbose_name = _('Driver Rating')
verbose_name_plural = _('Driver Ratings')
verbose_name_plural = _('Driver Ratings')

View File

@ -0,0 +1,73 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Register" %} | masarX{% endblock %}
{% block content %}
<section class="py-5 bg-light" style="min-height: 80vh;">
<div class="container py-5">
<div class="row justify-content-center text-center mb-5">
<div class="col-lg-8">
<h2 class="fw-bold mb-3">{% trans "Welcome to masarX" %}</h2>
<p class="text-muted lead">{% trans "Choose how you want to join us today." %}</p>
</div>
</div>
<div class="row justify-content-center g-4">
<!-- Shipper Card -->
<div class="col-md-5 col-lg-4">
<div class="card h-100 shadow-sm border-0 transition-hover">
<div class="card-body p-5 text-center">
<div class="mb-4 text-masarx-orange">
<i class="bi bi-box-seam display-4"></i>
</div>
<h3 class="h4 fw-bold mb-3">{% trans "I am a Shipper" %}</h3>
<p class="text-muted mb-4">{% trans "I want to send parcels and track my shipments easily." %}</p>
<a href="{% url 'register_shipper' %}" class="btn btn-masarx-primary w-100 py-2">{% trans "Register as Shipper" %}</a>
</div>
</div>
</div>
<!-- Driver Card -->
<div class="col-md-5 col-lg-4">
<div class="card h-100 shadow-sm border-0 transition-hover">
<div class="card-body p-5 text-center">
<div class="mb-4 text-masarx-dark">
<i class="bi bi-truck display-4"></i>
</div>
<h3 class="h4 fw-bold mb-3">{% trans "I am a Car Owner" %}</h3>
<p class="text-muted mb-4">{% trans "I want to deliver parcels and earn money on my own schedule." %}</p>
<a href="{% url 'register_driver' %}" class="btn btn-outline-dark w-100 py-2">{% trans "Register as Car Owner" %}</a>
</div>
</div>
</div>
</div>
<div class="text-center mt-5">
<p class="text-muted">{% trans "Already have an account?" %} <a href="{% url 'login' %}" class="text-masarx-orange fw-semibold">{% trans "Login here" %}</a></p>
</div>
</div>
</section>
<style>
.transition-hover {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.transition-hover:hover {
transform: translateY(-5px);
box-shadow: 0 1rem 3rem rgba(0,0,0,.175)!important;
}
.btn-masarx-primary {
background-color: var(--accent-orange);
border-color: var(--accent-orange);
color: white;
font-weight: 600;
}
.text-masarx-orange {
color: var(--accent-orange);
}
.text-masarx-dark {
color: var(--accent-dark);
}
</style>
{% endblock %}

View File

@ -0,0 +1,222 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "Register as Car Owner" %} | masarX{% endblock %}
{% block content %}
<section class="py-5 bg-light" style="min-height: 80vh;">
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-10 col-lg-8">
<!-- Back -->
<div class="mb-4">
<a href="{% url 'register' %}" class="btn btn-link text-decoration-none text-muted ps-0">
<i class="bi {% if LANGUAGE_BIDI %}bi-arrow-right{% else %}bi-arrow-left{% endif %} me-2"></i>{% trans "Back" %}
</a>
</div>
<div class="card shadow-sm border-0">
<div class="card-body p-5">
<h2 class="fw-bold mb-4 text-center">{% trans "Car Owner Registration" %}</h2>
<form method="post" id="registrationForm" enctype="multipart/form-data">
{% csrf_token %}
<div class="row">
{% for field in form %}
{% if field.is_hidden %}
{{ field }}
{% else %}
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold" for="{{ field.id_for_label }}">
{{ field.label }}
</label>
{% if field.name == 'profile_picture' %}
<div class="input-group">
{{ field }}
<button type="button" class="btn btn-secondary" id="openWebcamBtn" title="{% trans 'Take photo with Webcam' %}">
<i class="bi bi-camera-video"></i>
</button>
</div>
{% else %}
{{ field }}
{% endif %}
{% if field.help_text %}
<div class="form-text small">{{ field.help_text }}</div>
{% endif %}
{% if field.errors %}
<div class="text-danger small">{{ field.errors }}</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
<div class="alert alert-info small">
<i class="bi bi-info-circle me-1"></i>
{% trans "Please provide clear photos of your license (front and back) and car plate number for verification." %}
</div>
<button type="submit" class="btn btn-dark w-100 py-2 mt-3">{% trans "Submit Application" %}</button>
</form>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Webcam Modal -->
<div class="modal fade" id="webcamModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans "Take Photo" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center bg-dark p-0">
<video id="webcamVideo" style="width: 100%; max-height: 400px;" autoplay playsinline></video>
<canvas id="webcamCanvas" style="display:none;"></canvas>
</div>
<div class="modal-footer justify-content-center">
<button type="button" class="btn btn-primary rounded-circle p-3" id="captureBtn">
<i class="bi bi-camera-fill fs-4"></i>
</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Location Logic
const countrySelect = document.getElementById('id_country');
const governateSelect = document.getElementById('id_governate');
const citySelect = document.getElementById('id_city');
if (countrySelect && governateSelect && citySelect) {
countrySelect.addEventListener('change', function() {
const countryId = this.value;
governateSelect.innerHTML = '<option value="">{% trans "Select Governate" %}</option>';
citySelect.innerHTML = '<option value="">{% trans "Select City" %}</option>';
if (countryId) {
fetch(`{% url 'get_governates' %}?country_id=${countryId}`)
.then(response => response.json())
.then(data => {
data.forEach(gov => {
const option = document.createElement('option');
option.value = gov.id;
option.textContent = gov.name;
governateSelect.appendChild(option);
});
});
}
});
governateSelect.addEventListener('change', function() {
const governateId = this.value;
citySelect.innerHTML = '<option value="">{% trans "Select City" %}</option>';
if (governateId) {
fetch(`{% url 'get_cities' %}?governate_id=${governateId}`)
.then(response => response.json())
.then(data => {
data.forEach(city => {
const option = document.createElement('option');
option.value = city.id;
option.textContent = city.name;
citySelect.appendChild(option);
});
});
}
});
}
// Webcam Logic
const openWebcamBtn = document.getElementById('openWebcamBtn');
const webcamModal = new bootstrap.Modal(document.getElementById('webcamModal'));
const video = document.getElementById('webcamVideo');
const canvas = document.getElementById('webcamCanvas');
const captureBtn = document.getElementById('captureBtn');
const profileInput = document.getElementById('id_profile_picture');
let stream = null;
if (openWebcamBtn) {
openWebcamBtn.addEventListener('click', function() {
webcamModal.show();
navigator.mediaDevices.getUserMedia({ video: true })
.then(function(s) {
stream = s;
video.srcObject = stream;
video.play();
})
.catch(function(err) {
console.error("An error occurred: " + err);
alert("{% trans 'Could not access webcam. Please check permissions.' %}");
});
});
document.getElementById('webcamModal').addEventListener('hidden.bs.modal', function () {
if (stream) {
stream.getTracks().forEach(track => track.stop());
}
});
captureBtn.addEventListener('click', function() {
const context = canvas.getContext('2d');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob(function(blob) {
// Create a file from the blob
const file = new File([blob], "webcam_profile.jpg", { type: "image/jpeg" });
// Use DataTransfer to set the file input value
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
profileInput.files = dataTransfer.files;
// Close modal
webcamModal.hide();
// Visual feedback (optional)
// alert("{% trans 'Photo captured!' %}");
}, 'image/jpeg');
});
}
});
</script>
<style>
input:not([type=radio]):not([type=checkbox]), select {
display: block;
width: 100%;
padding: 0.375rem 0.75rem;
font-size: 1rem;
border: 1px solid #ced4da;
border-radius: 8px;
}
#id_verification_method {
list-style: none;
padding-left: 0;
display: flex;
gap: 1rem;
}
#id_verification_method li {
display: flex;
align-items: center;
gap: 0.5rem;
}
.input-group > .form-control {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.input-group > .btn {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
</style>
{% endblock %}

View File

@ -0,0 +1,125 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Register as Shipper" %} | masarX{% endblock %}
{% block content %}
<section class="py-5 bg-light" style="min-height: 80vh;">
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-10 col-lg-8">
<!-- Back -->
<div class="mb-4">
<a href="{% url 'register' %}" class="btn btn-link text-decoration-none text-muted ps-0">
<i class="bi {% if LANGUAGE_BIDI %}bi-arrow-right{% else %}bi-arrow-left{% endif %} me-2"></i>{% trans "Back" %}
</a>
</div>
<div class="card shadow-sm border-0">
<div class="card-body p-5">
<h2 class="fw-bold mb-4 text-center">{% trans "Shipper Registration" %}</h2>
<form method="post" id="registrationForm">
{% csrf_token %}
<div class="row">
{% for field in form %}
{% if field.is_hidden %}
{{ field }}
{% else %}
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}
<div class="form-text small">{{ field.help_text }}</div>
{% endif %}
{% if field.errors %}
<div class="text-danger small">{{ field.errors }}</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
<button type="submit" class="btn btn-masarx-primary w-100 py-2 mt-3">{% trans "Create Account" %}</button>
</form>
</div>
</div>
</div>
</div>
</div>
</section>
<script>
document.addEventListener('DOMContentLoaded', function() {
const countrySelect = document.getElementById('id_country');
const governateSelect = document.getElementById('id_governate');
const citySelect = document.getElementById('id_city');
if (countrySelect && governateSelect && citySelect) {
countrySelect.addEventListener('change', function() {
const countryId = this.value;
governateSelect.innerHTML = '<option value="">{% trans "Select Governate" %}</option>';
citySelect.innerHTML = '<option value="">{% trans "Select City" %}</option>';
if (countryId) {
fetch(`{% url 'get_governates' %}?country_id=${countryId}`)
.then(response => response.json())
.then(data => {
data.forEach(gov => {
const option = document.createElement('option');
option.value = gov.id;
option.textContent = gov.name;
governateSelect.appendChild(option);
});
});
}
});
governateSelect.addEventListener('change', function() {
const governateId = this.value;
citySelect.innerHTML = '<option value="">{% trans "Select City" %}</option>';
if (governateId) {
fetch(`{% url 'get_cities' %}?governate_id=${governateId}`)
.then(response => response.json())
.then(data => {
data.forEach(city => {
const option = document.createElement('option');
option.value = city.id;
option.textContent = city.name;
citySelect.appendChild(option);
});
});
}
});
}
});
</script>
<style>
input:not([type=radio]):not([type=checkbox]), select {
display: block;
width: 100%;
padding: 0.375rem 0.75rem;
font-size: 1rem;
border: 1px solid #ced4da;
border-radius: 8px;
}
#id_verification_method {
list-style: none;
padding-left: 0;
display: flex;
gap: 1rem;
}
#id_verification_method li {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-masarx-primary {
background-color: var(--accent-orange);
border-color: var(--accent-orange);
color: white;
font-weight: 600;
border-radius: 8px;
}
</style>
{% endblock %}

View File

@ -7,7 +7,11 @@ urlpatterns = [
path('', views.index, name='index'),
path('login/', auth_views.LoginView.as_view(template_name='core/login.html'), name='login'),
path('logout/', auth_views.LogoutView.as_view(next_page='/'), name='logout'),
# Registration Flow
path('register/', views.register, name='register'),
path('register/shipper/', views.register_shipper, name='register_shipper'),
path('register/driver/', views.register_driver, name='register_driver'),
path('register/verify/', views.verify_registration, name='verify_registration'),
# Password Reset URLs
@ -66,10 +70,18 @@ urlpatterns = [
path('login/request-otp/', views.request_login_otp, name='request_login_otp'),
path('login/verify-otp/', views.verify_login_otp, name='verify_login_otp'),
# API Endpoints
# API Endpoints (Standard)
path('api/auth/token/', api_views.CustomAuthToken.as_view(), name='api_token_auth'),
path('api/parcels/', api_views.ParcelListCreateView.as_view(), name='api_parcel_list'),
path('api/parcels/<int:pk>/', api_views.ParcelDetailView.as_view(), name='api_parcel_detail'),
path('api/track/<str:tracking_number>/', api_views.PublicParcelTrackView.as_view(), name='api_track_parcel'),
path('api/profile/', api_views.UserProfileView.as_view(), name='api_user_profile'),
# Aliases for mobile app compatibility (API v1)
path('api/shipments/', api_views.ParcelListCreateView.as_view(), name='api_shipment_list'),
path('api/shipments/<int:pk>/', api_views.ParcelDetailView.as_view(), name='api_shipment_detail'),
# Root-level Aliases (for apps hardcoded to /shipments/)
path('shipments/', api_views.ParcelListCreateView.as_view(), name='root_shipment_list'),
path('shipments/<int:pk>/', api_views.ParcelDetailView.as_view(), name='root_shipment_detail'),
]

View File

@ -4,7 +4,7 @@ from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from .models import Parcel, Profile, Country, Governate, City, OTPVerification, PlatformProfile, Testimonial, DriverRating
from .forms import UserRegistrationForm, ParcelForm, ContactForm, UserProfileForm, DriverRatingForm
from .forms import UserRegistrationForm, ParcelForm, ContactForm, UserProfileForm, DriverRatingForm, ShipperRegistrationForm, DriverRegistrationForm
from django.utils.translation import gettext_lazy as _
from django.utils.translation import get_language
from django.contrib import messages
@ -77,8 +77,11 @@ def track_parcel(request):
})
def register(request):
return render(request, 'core/register_choice.html')
def register_shipper(request):
if request.method == 'POST':
form = UserRegistrationForm(request.POST)
form = ShipperRegistrationForm(request.POST)
if form.is_valid():
# Save user but inactive
user = form.save(commit=True)
@ -110,8 +113,45 @@ def register(request):
request.session['registration_user_id'] = user.id
return redirect('verify_registration')
else:
form = UserRegistrationForm()
return render(request, 'core/register.html', {'form': form})
form = ShipperRegistrationForm()
return render(request, 'core/register_shipper.html', {'form': form})
def register_driver(request):
if request.method == 'POST':
form = DriverRegistrationForm(request.POST, request.FILES)
if form.is_valid():
# Save user but inactive
user = form.save(commit=True)
user.is_active = False
user.save()
# Generate OTP
code = ''.join(random.choices(string.digits, k=6))
OTPVerification.objects.create(user=user, code=code, purpose='registration')
# Send OTP
method = form.cleaned_data.get('verification_method', 'email')
otp_msg = _("Your Masar Verification Code is %(code)s") % {'code': code}
if method == 'whatsapp':
phone = user.profile.phone_number
send_whatsapp_message(phone, otp_msg)
messages.info(request, _("Verification code sent to WhatsApp."))
else:
send_html_email(
subject=_('Verification Code'),
message=otp_msg,
recipient_list=[user.email],
title=_('Welcome to Masar!'),
request=request
)
messages.info(request, _("Verification code sent to email."))
request.session['registration_user_id'] = user.id
return redirect('verify_registration')
else:
form = DriverRegistrationForm()
return render(request, 'core/register_driver.html', {'form': form})
def verify_registration(request):
if 'registration_user_id' not in request.session:
@ -888,4 +928,4 @@ def cancel_parcel(request, parcel_id):
parcel.save()
messages.success(request, _("Shipment cancelled successfully."))
return redirect('dashboard')
return redirect('dashboard')