addin driver rating
This commit is contained in:
parent
a3174399c8
commit
121c77dd5f
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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'),
|
||||
}
|
||||
|
||||
32
core/migrations/0017_driverrating.py
Normal file
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',
|
||||
},
|
||||
),
|
||||
]
|
||||
BIN
core/migrations/__pycache__/0017_driverrating.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0017_driverrating.cpython-311.pyc
Normal file
Binary file not shown.
@ -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')
|
||||
|
||||
17
core/templates/core/emails/password_reset_email.txt
Normal file
17
core/templates/core/emails/password_reset_email.txt
Normal 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." %}
|
||||
@ -1 +1 @@
|
||||
{% trans "Password reset on" %} {{ site_name }}
|
||||
{% load i18n %}{% trans "Password reset on" %} {{ site_name }}
|
||||
@ -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 %}
|
||||
|
||||
126
core/templates/core/rate_driver.html
Normal file
126
core/templates/core/rate_driver.html
Normal 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 %}
|
||||
@ -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 %}
|
||||
|
||||
Binary file not shown.
@ -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
|
||||
@ -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'),
|
||||
]
|
||||
]
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user