addin driver rating

This commit is contained in:
Flatlogic Bot 2026-01-26 05:04:37 +00:00
parent a3174399c8
commit 121c77dd5f
19 changed files with 866 additions and 211 deletions

View File

@ -2,7 +2,7 @@ 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
from .models import Profile, Parcel, Country, Governate, City, DriverRating
class ContactForm(forms.Form):
name = forms.CharField(max_length=100, label=_("Name"), widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Your Name')}))
@ -321,4 +321,17 @@ class ParcelForm(forms.ModelForm):
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
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'),
}

View File

@ -0,0 +1,32 @@
# Generated by Django 5.2.7 on 2026-01-26 04:58
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0016_country_phone_code'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='DriverRating',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rating', models.PositiveSmallIntegerField(choices=[(1, '1'), (2, '2'), (3, '3'), (4, '4'), (5, '5')], verbose_name='Rating')),
('comment', models.TextField(blank=True, verbose_name='Comment')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('driver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_ratings', to=settings.AUTH_USER_MODEL, verbose_name='Driver')),
('parcel', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='rating', to='core.parcel', verbose_name='Parcel')),
('shipper', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='given_ratings', to=settings.AUTH_USER_MODEL, verbose_name='Shipper')),
],
options={
'verbose_name': 'Driver Rating',
'verbose_name_plural': 'Driver Ratings',
},
),
]

View File

@ -79,6 +79,19 @@ class Profile(models.Model):
def __str__(self):
return f"{self.user.username} - {self.get_role_display()}"
def get_average_rating(self):
if self.role != 'car_owner':
return None
ratings = self.user.received_ratings.all()
if not ratings:
return 0
return sum(r.rating for r in ratings) / len(ratings)
def get_rating_count(self):
if self.role != 'car_owner':
return 0
return self.user.received_ratings.count()
class Meta:
verbose_name = _('Profile')
@ -241,4 +254,19 @@ class Testimonial(models.Model):
class Meta:
verbose_name = _('Testimonial')
verbose_name_plural = _('Testimonials')
ordering = ['-created_at']
ordering = ['-created_at']
class DriverRating(models.Model):
parcel = models.OneToOneField(Parcel, on_delete=models.CASCADE, related_name='rating', verbose_name=_('Parcel'))
driver = models.ForeignKey(User, on_delete=models.CASCADE, related_name='received_ratings', verbose_name=_('Driver'))
shipper = models.ForeignKey(User, on_delete=models.CASCADE, related_name='given_ratings', verbose_name=_('Shipper'))
rating = models.PositiveSmallIntegerField(_('Rating'), choices=[(i, str(i)) for i in range(1, 6)])
comment = models.TextField(_('Comment'), blank=True)
created_at = models.DateTimeField(_('Created At'), auto_now_add=True)
def __str__(self):
return f"Rating {self.rating} for {self.driver.username} by {self.shipper.username}"
class Meta:
verbose_name = _('Driver Rating')
verbose_name_plural = _('Driver Ratings')

View File

@ -0,0 +1,17 @@
{% load i18n %}
{% trans "Reset Your Password" %}
{% trans "Hello," %}
{% trans "You are receiving this email because you requested a password reset for your account at" %} {{ site_name }}.
{% trans "Please go to the following page and choose a new password:" %}
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
{% trans "Your username, in case you've forgotten:" %} {{ user.get_username }}
{% trans "Thanks," %}
{% trans "The" %} {{ site_name }} {% trans "Team" %}
{% trans "If you did not request this, please ignore this email." %}

View File

@ -1 +1 @@
{% trans "Password reset on" %} {{ site_name }}
{% load i18n %}{% trans "Password reset on" %} {{ site_name }}

View File

@ -60,6 +60,49 @@
<p class="fw-semibold">{{ profile.address|default:"-" }}</p>
</div>
</div>
<!-- Rating Section (Drivers Only) -->
{% if profile.role == 'car_owner' %}
<hr class="my-5">
<div class="mb-4">
<h4 class="fw-bold">{% trans "Driver Rating" %}</h4>
<div class="d-flex align-items-center mb-3">
<div class="display-4 fw-bold me-3">{{ profile.get_average_rating|default:"0.0"|floatformat:1 }}</div>
<div>
<div class="text-warning fs-5">
<i class="fas fa-star"></i>
</div>
<div class="text-muted small">
{{ profile.get_rating_count }} {% trans "reviews" %}
</div>
</div>
</div>
{% if reviews %}
<div class="list-group list-group-flush">
{% for review in reviews %}
<div class="list-group-item px-0 py-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<span class="fw-bold">{{ review.shipper.first_name }}</span>
<small class="text-muted ms-2">{{ review.created_at|date:"M d, Y" }}</small>
</div>
<div class="text-warning">
{{ review.rating }} <i class="fas fa-star small"></i>
</div>
</div>
{% if review.comment %}
<p class="mb-0 text-muted">{{ review.comment }}</p>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted">{% trans "No reviews yet." %}</p>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
@ -75,4 +118,4 @@
border-radius: 8px;
}
</style>
{% endblock %}
{% endblock %}

View File

@ -0,0 +1,126 @@
{% extends 'base.html' %}
{% load i18n static core_tags %}
{% block content %}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card shadow-sm border-0 rounded-4">
<div class="card-body p-5">
<div class="text-center mb-4">
<h2 class="fw-bold mb-3">{% trans "Rate Your Experience" %}</h2>
<p class="text-muted">
{% trans "How was the service provided by" %}
<strong>{{ parcel.carrier.username }}</strong>?
</p>
</div>
<form method="post">
{% csrf_token %}
<!-- Star Rating -->
<div class="mb-4 text-center">
<label class="form-label d-block mb-2">{% trans "Rating" %}</label>
<div class="rating-stars">
{% for radio in form.rating %}
{{ radio.tag }}
<label for="{{ radio.id_for_label }}" class="star-label">
<i class="fas fa-star"></i>
</label>
{% endfor %}
</div>
{% if form.rating.errors %}
<div class="text-danger small mt-1">
{{ form.rating.errors }}
</div>
{% endif %}
</div>
<!-- Comment -->
<div class="mb-4">
<label for="{{ form.comment.id_for_label }}" class="form-label">{% trans "Comment" %}</label>
{{ form.comment }}
{% if form.comment.errors %}
<div class="text-danger small mt-1">
{{ form.comment.errors }}
</div>
{% endif %}
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg rounded-pill">
{% trans "Submit Review" %}
</button>
<a href="{% url 'dashboard' %}" class="btn btn-light rounded-pill">
{% trans "Cancel" %}
</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<style>
/* Star Rating Styles */
.rating-stars {
display: flex;
flex-direction: row-reverse;
justify-content: center;
gap: 0.5rem;
}
.rating-stars input[type="radio"] {
display: none;
}
.rating-stars label {
font-size: 2rem;
color: #ddd;
cursor: pointer;
transition: color 0.2s;
}
.rating-stars label:hover,
.rating-stars label:hover ~ label,
.rating-stars input[type="radio"]:checked ~ label {
color: #ffc107; /* Bootstrap warning color (yellow) */
}
/* Reverse order fix for logic but display is reversed,
so we might need to adjust logic or just use flex-direction: row-reverse
and ensure inputs are 5,4,3,2,1 order?
Django RadioSelect usually outputs in order 1,2,3,4,5.
If we reverse via flex, 1 is rightmost. That's wrong.
We need 1 on left.
Actually simpler pure CSS rating usually involves flex-reverse.
Let's check Django output.
Django outputs 1, 2, 3, 4, 5.
If we use flex-direction: row-reverse, it shows 5 4 3 2 1.
Hovering 5 highlights 5, 4, 3, 2, 1. Correct.
So visually:
[5] [4] [3] [2] [1]
If I click left-most (5), it checks 5.
Wait, users expect [1] [2] [3] [4] [5].
If I use row-reverse:
DOM: 1 2 3 4 5
Visual: 5 4 3 2 1
This is counter-intuitive for LTR.
Let's use a different technique.
Use :checked ~ label selector, but then we need the input BEFORE the label.
Django widgets renders input then label?
{{ radio.tag }} is input. <label> we write manually.
So DOM is: Input1 Label1 Input2 Label2 ...
We can style this.
*/
</style>
{% endblock %}

View File

@ -1,5 +1,5 @@
{% extends 'base.html' %}
{% load i18n %}
{% load i18n core_tags %}
{% block content %}
<div class="container py-5">
@ -84,10 +84,12 @@
<th>{% trans "Carrier" %}</th>
<th>{% trans "Bid/Price" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Action" %}</th>
</tr>
</thead>
<tbody>
{% for parcel in history_parcels %}
{% get_rating parcel as rating %}
<tr>
<td class="ps-4">{{ parcel.created_at|date:"Y-m-d" }}</td>
<td><span class="badge bg-light text-dark">#{{ parcel.tracking_number }}</span></td>
@ -105,6 +107,19 @@
{{ parcel.get_status_display }}
</span>
</td>
<td>
{% if parcel.status == 'delivered' and parcel.carrier %}
{% if not rating %}
<a href="{% url 'rate_driver' parcel.id %}" class="btn btn-sm btn-outline-warning">
<i class="fas fa-star"></i> {% trans "Rate Driver" %}
</a>
{% else %}
<span class="text-warning" title="{{ rating.comment }}">
{{ rating.rating }} <i class="fas fa-star"></i>
</span>
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
@ -120,4 +135,4 @@
</div>
</div>
</div>
{% endblock %}
{% endblock %}

View File

@ -1,8 +1,15 @@
from django import template
from core.models import PlatformProfile
from core.models import PlatformProfile, DriverRating
register = template.Library()
@register.simple_tag
def get_platform_profile():
return PlatformProfile.objects.first()
@register.simple_tag
def get_rating(parcel):
try:
return parcel.rating
except:
return None

View File

@ -12,7 +12,8 @@ urlpatterns = [
# Password Reset URLs
path('password-reset/', auth_views.PasswordResetView.as_view(
template_name='core/password_reset_form.html',
email_template_name='core/emails/password_reset_email.html',
email_template_name='core/emails/password_reset_email.txt',
html_email_template_name='core/emails/password_reset_email.html',
subject_template_name='core/emails/password_reset_subject.txt',
success_url='/password-reset/done/'
), name='password_reset'),
@ -48,4 +49,4 @@ urlpatterns = [
path('profile/', views.profile_view, name='profile'),
path('profile/edit/', views.edit_profile, name='edit_profile'),
path('profile/verify-otp/', views.verify_otp_view, name='verify_otp'),
]
]

View File

@ -3,8 +3,8 @@ from django.contrib.auth import login, authenticate, logout
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
from .forms import UserRegistrationForm, ParcelForm, ContactForm, UserProfileForm
from .models import Parcel, Profile, Country, Governate, City, OTPVerification, PlatformProfile, Testimonial, DriverRating
from .forms import UserRegistrationForm, ParcelForm, ContactForm, UserProfileForm, DriverRatingForm
from django.utils.translation import gettext_lazy as _
from django.utils.translation import get_language
from django.contrib import messages
@ -319,7 +319,15 @@ def contact(request):
@login_required
def profile_view(request):
return render(request, 'core/profile.html', {'profile': request.user.profile})
profile = request.user.profile
reviews = []
if profile.role == 'car_owner':
reviews = request.user.received_ratings.all().order_by('-created_at')
return render(request, 'core/profile.html', {
'profile': profile,
'reviews': reviews
})
@login_required
def edit_profile(request):
@ -425,4 +433,43 @@ def verify_otp_view(request):
except OTPVerification.DoesNotExist:
messages.error(request, _("Invalid code."))
return render(request, 'core/verify_otp.html')
return render(request, 'core/verify_otp.html')
@login_required
def rate_driver(request, parcel_id):
parcel = get_object_or_404(Parcel, id=parcel_id)
# Validation
if parcel.shipper != request.user:
messages.error(request, _("You are not authorized to rate this shipment."))
return redirect('dashboard')
if parcel.status != 'delivered':
messages.error(request, _("You can only rate delivered shipments."))
return redirect('dashboard')
if not parcel.carrier:
messages.error(request, _("No driver was assigned to this shipment."))
return redirect('dashboard')
if hasattr(parcel, 'rating'):
messages.info(request, _("You have already rated this shipment."))
return redirect('dashboard')
if request.method == 'POST':
form = DriverRatingForm(request.POST)
if form.is_valid():
rating = form.save(commit=False)
rating.parcel = parcel
rating.driver = parcel.carrier
rating.shipper = request.user
rating.save()
messages.success(request, _("Thank you for your feedback!"))
return redirect('dashboard')
else:
form = DriverRatingForm()
return render(request, 'core/rate_driver.html', {
'form': form,
'parcel': parcel
})

Binary file not shown.

File diff suppressed because it is too large Load Diff