user profile

This commit is contained in:
Flatlogic Bot 2026-01-25 11:58:26 +00:00
parent 59204ba309
commit 0bccc28caf
16 changed files with 526 additions and 9 deletions

View File

@ -84,6 +84,75 @@ class UserRegistrationForm(forms.ModelForm):
profile.save() profile.save()
return user return user
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_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'}))
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']
widgets = {
'country': forms.Select(attrs={'class': 'form-control'}),
'governate': forms.Select(attrs={'class': 'form-control'}),
'city': forms.Select(attrs={'class': 'form-control'}),
}
labels = {
'country': _('Country'),
'governate': _('Governate'),
'city': _('City'),
}
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'
self.fields['country'].queryset = Country.objects.all().order_by(name_field)
# Default Country logic (Oman)
oman = Country.objects.filter(name_en='Oman').first()
# 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)
class ParcelForm(forms.ModelForm): class ParcelForm(forms.ModelForm):
class Meta: class Meta:
model = Parcel model = Parcel
@ -180,4 +249,4 @@ class ParcelForm(forms.ModelForm):
gov_id = int(self.data.get('delivery_governate')) gov_id = int(self.data.get('delivery_governate'))
self.fields['delivery_city'].queryset = City.objects.filter(governate_id=gov_id).order_by(name_field) self.fields['delivery_city'].queryset = City.objects.filter(governate_id=gov_id).order_by(name_field)
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass

View File

@ -0,0 +1,37 @@
# Generated by Django 5.2.7 on 2026-01-25 11:40
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_platformprofile_privacy_policy_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='profile',
name='address',
field=models.CharField(blank=True, max_length=255, verbose_name='Address'),
),
migrations.AddField(
model_name='profile',
name='profile_picture',
field=models.ImageField(blank=True, null=True, upload_to='profile_pics/', verbose_name='Profile Picture'),
),
migrations.CreateModel(
name='OTPVerification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=6)),
('purpose', models.CharField(choices=[('profile_update', 'Profile Update'), ('password_reset', 'Password Reset')], default='profile_update', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('is_verified', models.BooleanField(default=False)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
from django.utils.translation import get_language from django.utils.translation import get_language
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import timezone
import uuid import uuid
class Country(models.Model): class Country(models.Model):
@ -67,6 +68,8 @@ class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, verbose_name=_('User')) user = models.OneToOneField(User, on_delete=models.CASCADE, verbose_name=_('User'))
role = models.CharField(_('Role'), max_length=20, choices=ROLE_CHOICES, default='shipper') role = models.CharField(_('Role'), max_length=20, choices=ROLE_CHOICES, default='shipper')
phone_number = models.CharField(_('Phone Number'), max_length=20, blank=True) phone_number = models.CharField(_('Phone Number'), max_length=20, blank=True)
profile_picture = models.ImageField(_('Profile Picture'), upload_to='profile_pics/', blank=True, null=True)
address = models.CharField(_('Address'), max_length=255, blank=True)
country = models.ForeignKey(Country, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('Country')) 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')) governate = models.ForeignKey(Governate, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('Governate'))
@ -162,4 +165,19 @@ class PlatformProfile(models.Model):
class Meta: class Meta:
verbose_name = _('Platform Profile') verbose_name = _('Platform Profile')
verbose_name_plural = _('Platform Profile') verbose_name_plural = _('Platform Profile')
class OTPVerification(models.Model):
PURPOSE_CHOICES = (
('profile_update', _('Profile Update')),
('password_reset', _('Password Reset')),
)
user = models.ForeignKey(User, on_delete=models.CASCADE)
code = models.CharField(max_length=6)
purpose = models.CharField(max_length=20, choices=PURPOSE_CHOICES, default='profile_update')
created_at = models.DateTimeField(auto_now_add=True)
is_verified = models.BooleanField(default=False)
def is_valid(self):
# OTP valid for 10 minutes
return self.created_at >= timezone.now() - timezone.timedelta(minutes=10)

View File

@ -87,7 +87,7 @@
</li> </li>
{% endif %} {% endif %}
<li class="nav-item"> <li class="nav-item">
<span class="nav-link text-white-50">{% trans "Hello" %}, {{ user.username }}</span> <a href="{% url 'profile' %}" class="nav-link text-white-50">{% trans "Hello" %}, {{ user.username }}</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<form action="{% url 'logout' %}" method="post" class="d-inline"> <form action="{% url 'logout' %}" method="post" class="d-inline">

View File

@ -0,0 +1,94 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Edit Profile" %} | 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-8">
<div class="card shadow-sm border-0">
<div class="card-body p-5">
<h2 class="fw-bold mb-4 text-center">{% trans "Edit Profile" %}</h2>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% for field in form %}
<div class="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>
{% endfor %}
<div class="d-flex gap-2 mt-4">
<a href="{% url 'profile' %}" class="btn btn-outline-secondary w-50 py-2">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-masarx-primary w-50 py-2">{% trans "Save & Verify" %}</button>
</div>
</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');
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>
.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

@ -0,0 +1,71 @@
{% extends 'base.html' %}
{% load i18n static %}
{% block title %}{% trans "My Profile" %} | 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-8">
<div class="card shadow-sm border-0">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fw-bold mb-0">{% trans "My Profile" %}</h2>
<a href="{% url 'edit_profile' %}" class="btn btn-masarx-primary">
{% trans "Edit Profile" %}
</a>
</div>
<div class="text-center mb-5">
{% if profile.profile_picture %}
<img src="{{ profile.profile_picture.url }}" alt="Profile" class="rounded-circle img-thumbnail" style="width: 150px; height: 150px; object-fit: cover;">
{% else %}
<div class="rounded-circle bg-secondary d-flex align-items-center justify-content-center mx-auto" style="width: 150px; height: 150px;">
<span class="text-white fs-1">{{ profile.user.first_name|first|upper }}</span>
</div>
{% endif %}
<h3 class="mt-3">{{ profile.user.get_full_name }}</h3>
<p class="text-muted">{{ profile.get_role_display }}</p>
</div>
<div class="row g-4">
<div class="col-md-6">
<label class="text-muted small text-uppercase">{% trans "Email" %}</label>
<p class="fw-semibold">{{ profile.user.email }}</p>
</div>
<div class="col-md-6">
<label class="text-muted small text-uppercase">{% trans "Phone" %}</label>
<p class="fw-semibold">{{ profile.phone_number|default:"-" }}</p>
</div>
<div class="col-12">
<label class="text-muted small text-uppercase">{% trans "Location" %}</label>
<p class="fw-semibold">
{{ profile.city.name|default:"" }}
{% if profile.city and profile.governate %}, {% endif %}
{{ profile.governate.name|default:"" }}
{% if profile.governate and profile.country %}, {% endif %}
{{ profile.country.name|default:"" }}
</p>
</div>
<div class="col-12">
<label class="text-muted small text-uppercase">{% trans "Address" %}</label>
<p class="fw-semibold">{{ profile.address|default:"-" }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<style>
.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

@ -0,0 +1,44 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Verify Profile Update" %} | 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-6">
<div class="card shadow-sm border-0">
<div class="card-body p-5">
<h2 class="fw-bold mb-4 text-center">{% trans "Verification Required" %}</h2>
<p class="text-center text-muted mb-4">
{% trans "We have sent a verification code to your selected contact method. Please enter it below to save your changes." %}
</p>
<form method="post">
{% csrf_token %}
<div class="mb-4">
<label class="form-label fw-semibold" for="code">{% trans "Verification Code" %}</label>
<input type="text" name="code" id="code" class="form-control text-center fs-3 tracking-widest" maxlength="6" required placeholder="000000">
</div>
<button type="submit" class="btn btn-masarx-primary w-100 py-2">{% trans "Verify & Save" %}</button>
</form>
</div>
</div>
</div>
</div>
</div>
</section>
<style>
.btn-masarx-primary {
background-color: var(--accent-orange);
border-color: var(--accent-orange);
color: white;
font-weight: 600;
border-radius: 8px;
}
.tracking-widest {
letter-spacing: 0.5em;
}
</style>
{% endblock %}

View File

@ -21,4 +21,8 @@ urlpatterns = [
path('privacy-policy/', views.privacy_policy, name='privacy_policy'), path('privacy-policy/', views.privacy_policy, name='privacy_policy'),
path('terms-conditions/', views.terms_conditions, name='terms_conditions'), path('terms-conditions/', views.terms_conditions, name='terms_conditions'),
path('contact/', views.contact_view, name='contact'), path('contact/', views.contact_view, name='contact'),
]
path('profile/', views.profile_view, name='profile'),
path('profile/edit/', views.edit_profile_view, name='edit_profile'),
path('profile/verify-otp/', views.verify_otp_view, name='verify_otp'),
]

View File

@ -2,8 +2,8 @@ from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth import login, authenticate, logout from django.contrib.auth import login, authenticate, logout
from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from .models import Parcel, Profile, Country, Governate, City from .models import Parcel, Profile, Country, Governate, City, OTPVerification
from .forms import UserRegistrationForm, ParcelForm, ContactForm from .forms import UserRegistrationForm, ParcelForm, ContactForm, UserProfileForm
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils.translation import get_language from django.utils.translation import get_language
from django.contrib import messages from django.contrib import messages
@ -11,11 +11,15 @@ from django.http import JsonResponse
from django.urls import reverse from django.urls import reverse
from .payment_utils import ThawaniPay from .payment_utils import ThawaniPay
from django.conf import settings from django.conf import settings
from django.core.mail import send_mail
import random
import string
from .whatsapp_utils import ( from .whatsapp_utils import (
notify_shipment_created, notify_shipment_created,
notify_payment_received, notify_payment_received,
notify_driver_assigned, notify_driver_assigned,
notify_status_change notify_status_change,
send_whatsapp_message
) )
from .mail import send_contact_message from .mail import send_contact_message
@ -207,4 +211,112 @@ def contact_view(request):
return redirect('contact') return redirect('contact')
else: else:
form = ContactForm() form = ContactForm()
return render(request, 'core/contact.html', {'form': form}) return render(request, 'core/contact.html', {'form': form})
@login_required
def profile_view(request):
return render(request, 'core/profile.html', {'profile': request.user.profile})
@login_required
def edit_profile_view(request):
if request.method == 'POST':
form = UserProfileForm(request.POST, request.FILES, instance=request.user.profile)
if form.is_valid():
# 1. Handle Image immediately (easier than session storage)
if 'profile_picture' in request.FILES:
request.user.profile.profile_picture = request.FILES['profile_picture']
request.user.profile.save()
# 2. Store other data in session for verification
data = form.cleaned_data
# Remove objects that can't be serialized or we've already handled
safe_data = {
'first_name': data['first_name'],
'last_name': data['last_name'],
'email': data['email'],
'phone_number': data['phone_number'],
'address': data['address'],
'country_id': data['country'].id if data['country'] else None,
'governate_id': data['governate'].id if data['governate'] else None,
'city_id': data['city'].id if data['city'] else None,
}
request.session['pending_profile_update'] = safe_data
# 3. Generate OTP
code = ''.join(random.choices(string.digits, k=6))
OTPVerification.objects.create(user=request.user, code=code, purpose='profile_update')
# 4. Send OTP
method = data.get('otp_method', 'email')
if method == 'whatsapp':
# Use current phone if available, else new phone
phone = request.user.profile.phone_number or data['phone_number']
send_whatsapp_message(phone, f"Your verification code is: {code}")
messages.info(request, _("Verification code sent to WhatsApp."))
else:
# Default to email
send_mail(
_('Verification Code'),
f'Your verification code is: {code}',
settings.DEFAULT_FROM_EMAIL,
[request.user.email],
fail_silently=False,
)
messages.info(request, _("Verification code sent to email."))
return redirect('verify_otp')
else:
form = UserProfileForm(instance=request.user.profile)
return render(request, 'core/edit_profile.html', {'form': form})
@login_required
def verify_otp_view(request):
if request.method == 'POST':
code = request.POST.get('code')
try:
otp = OTPVerification.objects.filter(
user=request.user,
code=code,
purpose='profile_update',
is_verified=False
).latest('created_at')
if otp.is_valid():
# Apply changes
data = request.session.get('pending_profile_update')
if data:
# Update User
request.user.first_name = data['first_name']
request.user.last_name = data['last_name']
request.user.email = data['email']
request.user.save()
# Update Profile
profile = request.user.profile
profile.phone_number = data['phone_number']
profile.address = data['address']
if data.get('country_id'):
profile.country_id = data['country_id']
if data.get('governate_id'):
profile.governate_id = data['governate_id']
if data.get('city_id'):
profile.city_id = data['city_id']
profile.save()
# Cleanup
otp.is_verified = True
otp.save()
del request.session['pending_profile_update']
messages.success(request, _("Profile updated successfully!"))
return redirect('profile')
else:
messages.error(request, _("Session expired. Please try again."))
return redirect('edit_profile')
else:
messages.error(request, _("Invalid or expired code."))
except OTPVerification.DoesNotExist:
messages.error(request, _("Invalid code."))
return render(request, 'core/verify_otp.html')

Binary file not shown.

View File

@ -688,4 +688,72 @@ msgstr "تم إرسال رسالتك بنجاح!"
#: core/views.py:206 #: core/views.py:206
msgid "There was an error sending your message. Please try again later." msgid "There was an error sending your message. Please try again later."
msgstr "حدث خطأ أثناء إرسال رسالتك. يرجى المحاولة مرة أخرى لاحقاً." msgstr "حدث خطأ أثناء إرسال رسالتك. يرجى المحاولة مرة أخرى لاحقاً."
#: core/forms.py:new
msgid "Profile Picture"
msgstr "الصورة الشخصية"
#: core/forms.py:new
msgid "Verify changes via"
msgstr "التحقق من التغييرات عبر"
#: core/templates/core/profile.html:new
msgid "My Profile"
msgstr "ملفي الشخصي"
#: core/templates/core/profile.html:new
msgid "Edit Profile"
msgstr "تعديل الملف الشخصي"
#: core/templates/core/edit_profile.html:new
msgid "Save & Verify"
msgstr "حفظ وتحقق"
#: core/templates/core/verify_otp.html:new
msgid "Verification Required"
msgstr "التحقق مطلوب"
#: core/templates/core/verify_otp.html:new
msgid "We have sent a verification code to your selected contact method. Please enter it below to save your changes."
msgstr "لقد أرسلنا رمز التحقق إلى وسيلة الاتصال المحددة. يرجى إدخاله أدناه لحفظ تغييراتك."
#: core/templates/core/verify_otp.html:new
msgid "Verification Code"
msgstr "رمز التحقق"
#: core/templates/core/verify_otp.html:new
msgid "Verify & Save"
msgstr "تحقق وحفظ"
#: core/views.py:new
msgid "Profile updated successfully!"
msgstr "تم تحديث الملف الشخصي بنجاح!"
#: core/views.py:new
msgid "Invalid or expired code."
msgstr "الرمز غير صالح أو منتهي الصلاحية."
#: core/views.py:new
msgid "Verification code sent to WhatsApp."
msgstr "تم إرسال رمز التحقق إلى واتساب."
#: core/views.py:new
msgid "Verification code sent to email."
msgstr "تم إرسال رمز التحقق إلى البريد الإلكتروني."
#: core/views.py:new
msgid "Session expired. Please try again."
msgstr "انتهت صلاحية الجلسة. يرجى المحاولة مرة أخرى."
#: core/views.py:new
msgid "Invalid code."
msgstr "رمز غير صالح."
#: core/models.py:new
msgid "Profile Update"
msgstr "تحديث الملف الشخصي"
#: core/models.py:new
msgid "Password Reset"
msgstr "إعادة تعيين كلمة المرور"