Skip to content

Commit 0cbffda

Browse files
authored
Merge pull request #72 from UNLV-CS472-672/#45-Booking_Expiry_Logic_and_Reviews_Model
#45 Booking Expiry Logic and Reviews Model
2 parents 9db90b8 + 1558fdc commit 0cbffda

File tree

9 files changed

+216
-15
lines changed

9 files changed

+216
-15
lines changed

backend/apps/bookings/admin.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
11
from django.contrib import admin
2-
from .models import Booking
3-
2+
from .models import Booking, Review
43

54
@admin.register(Booking)
6-
class BookingAdmin(admin.ModelAdmin):
5+
class BookingsAdmin(admin.ModelAdmin):
76
list_display = ('session_date', 'tutor', 'student', 'booking_status')
87
search_fields = ('tutor__username', 'student__username', 'session_date')
98
list_filter = ('booking_status',)
9+
10+
@admin.register(Review)
11+
class ReviewAdmin(admin.ModelAdmin):
12+
list_display = ['tutor', 'reviewer', 'rating', 'feedback', 'is_visible', 'is_moderated', 'created_at']
13+
list_filter = ['is_visible', 'is_moderated', 'rating']
14+
search_fields = ['reviewer__username', 'feedback']
15+
list_editable = ['is_visible', 'is_moderated'] # Allow inline editing
16+
17+
def make_visible(self, request, queryset):
18+
queryset.update(is_visible=True)
19+
20+
def make_invisible(self, request, queryset):
21+
queryset.update(is_visible=False)
22+
23+
actions = [make_visible, make_invisible]
24+
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Generated by Django 5.1.6 on 2025-03-19 03:15
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('bookings', '0001_initial'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='booking',
15+
name='session_end_time',
16+
field=models.DateTimeField(null=True),
17+
),
18+
migrations.AlterField(
19+
model_name='booking',
20+
name='booking_status',
21+
field=models.CharField(choices=[('Pending', 'Pending'), ('Approved', 'Approved'), ('Rejected', 'Rejected'), ('Expired', 'Expired'), ('Cancelled', 'Cancelled'), ('Fulfilled', 'Fulfilled')], default='Pending', max_length=20),
22+
),
23+
]
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Generated by Django 5.1.6 on 2025-03-19 05:26
2+
3+
import django.core.validators
4+
import django.db.models.deletion
5+
from django.conf import settings
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('bookings', '0002_booking_session_end_time_and_more'),
13+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14+
]
15+
16+
operations = [
17+
migrations.RenameModel(
18+
old_name='Booking',
19+
new_name='Bookings',
20+
),
21+
migrations.CreateModel(
22+
name='Reviews',
23+
fields=[
24+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
25+
('rating', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)])),
26+
('feedback', models.TextField(blank=True, null=True)),
27+
('created_at', models.DateTimeField(auto_now_add=True)),
28+
('is_visible', models.BooleanField(default=True)),
29+
('is_moderated', models.BooleanField(default=False)),
30+
('reviewer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviewer', to=settings.AUTH_USER_MODEL)),
31+
('tutor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to=settings.AUTH_USER_MODEL)),
32+
],
33+
options={
34+
'ordering': ['-created_at'],
35+
},
36+
),
37+
]
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Generated by Django 5.1.6 on 2025-03-19 05:38
2+
3+
from django.conf import settings
4+
from django.db import migrations
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('bookings', '0003_rename_booking_bookings_reviews'),
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
]
13+
14+
operations = [
15+
migrations.RenameModel(
16+
old_name='Bookings',
17+
new_name='Booking',
18+
),
19+
migrations.RenameModel(
20+
old_name='Reviews',
21+
new_name='Review',
22+
),
23+
]

backend/apps/bookings/models.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,35 @@
1+
from django.core.validators import MinValueValidator, MaxValueValidator
12
from django.db import models
23
from django.contrib.auth.models import User
34
import uuid
45
from datetime import timedelta
56
from django.utils.timezone import now
7+
from rest_framework.exceptions import ValidationError
68

79

810
class Booking(models.Model):
911
PENDING = 'Pending'
1012
APPROVED = 'Approved'
1113
REJECTED = 'Rejected'
14+
EXPIRED = 'Expired'
15+
CANCELLED = 'Cancelled'
16+
FULFILLED = 'Fulfilled'
1217

1318
BOOKING_STATUS_TYPES = [
1419
(PENDING, 'Pending'),
1520
(APPROVED, 'Approved'),
1621
(REJECTED, 'Rejected'),
22+
(EXPIRED, 'Expired'),
23+
(CANCELLED, 'Cancelled'),
24+
(FULFILLED, 'Fulfilled'),
1725
]
1826

1927
session_date = models.DateTimeField()
2028
session_updated_at = models.DateTimeField(auto_now=True)
29+
session_end_time = models.DateTimeField(null=True)
2130
booking_status = models.CharField(max_length=20, choices=BOOKING_STATUS_TYPES, default=PENDING)
2231
session_price = models.DecimalField(max_digits=10, decimal_places=2)
23-
student = models.ForeignKey(User, on_delete=models.CASCADE, related_name="student")
32+
student = models.ForeignKey(User, on_delete=models.CASCADE, related_name="student_bookings")
2433
tutor = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tutor")
2534
description = models.TextField(null=True, blank=True)
2635
payment_gateway_ref = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@@ -39,19 +48,65 @@ def is_paid(self):
3948
return self.payment_gateway_ref is not None
4049

4150
def reschedule_booking(self, new_date):
51+
if self.session_date < now():
52+
return False
4253
self.session_date = new_date
4354
self.save()
4455

4556
def cancel_booking(self):
4657
if self.session_date < now():
4758
return False
4859
self.booking_status = self.REJECTED
49-
self.save()
60+
self.save(update_fields=['booking_status'])
5061
return True
5162

52-
def booking_end_time(self, duration_in_minutes=60):
53-
return self.session_date + timedelta(minutes=duration_in_minutes)
63+
def booking_duration(self):
64+
if not self.session_end_time:
65+
return timedelta(0)
66+
return self.session_end_time - self.session_date
5467

5568
@classmethod
5669
def get_bookings_by_status(cls, status):
5770
return cls.objects.filter(booking_status=status)
71+
72+
def is_expired(self):
73+
return self.session_end_time < now()
74+
75+
def clean(self):
76+
if self.session_end_time <= self.session_date:
77+
raise ValidationError("Session end time must be after the session start time.")
78+
79+
def save(self, *args, **kwargs):
80+
"""Run full validation before saving."""
81+
self.full_clean() # Ensures validation runs before saving
82+
super().save(*args, **kwargs)
83+
84+
85+
class Review(models.Model):
86+
tutor = models.ForeignKey(User, on_delete=models.CASCADE, related_name="reviews")
87+
reviewer = models.ForeignKey(User, on_delete=models.CASCADE, related_name="reviewer")
88+
rating = models.PositiveSmallIntegerField(validators=[MinValueValidator(1), MaxValueValidator(5)])
89+
feedback = models.TextField(null=True, blank=True)
90+
created_at = models.DateTimeField(auto_now_add=True)
91+
92+
is_visible = models.BooleanField(default=True) # Whether the review is visible to others
93+
is_moderated = models.BooleanField(
94+
default=False) # Whether the review has been moderated (e.g., checked for inappropriate content)
95+
96+
def __str__(self):
97+
return f"Review for {self.tutor.username} by {self.reviewer.username}"
98+
99+
class Meta:
100+
ordering = ['-created_at'] # Sort reviews by most recent first
101+
unique_together = ('tutor', 'reviewer')
102+
103+
def is_valid_review(self):
104+
"""Checks if the review has a valid rating and comments."""
105+
return self.rating is not None and self.feedback.strip() != ""
106+
107+
@staticmethod
108+
def average_rating(tutor):
109+
"""Calculates the average rating for a tutor based on all reviews."""
110+
reviews = Review.objects.filter(tutor=tutor)
111+
total_rating = sum([review.rating for review in reviews])
112+
return total_rating / len(reviews) if reviews else 0

backend/apps/bookings/serializers.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from rest_framework import serializers
2-
from .models import Booking
2+
from .models import Booking, Review
33

44

55
class BookingSerializer(serializers.ModelSerializer):
@@ -18,3 +18,8 @@ class Meta(BookingSerializer.Meta):
1818
'booking': {'read_only': True}
1919
}
2020

21+
class ReviewSerializer(serializers.ModelSerializer):
22+
class Meta:
23+
model = Review
24+
fields = ['tutor', 'reviewer', 'rating', 'feedback', 'created_at', 'is_visible', 'is_moderated']
25+
read_only_fields = ['created_at', 'is_visible', 'is_moderated']

backend/apps/bookings/tasks.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from datetime import timedelta
2+
from celery import shared_task
3+
from django.utils import timezone
4+
from .models import Booking
5+
6+
@shared_task
7+
def mark_expired_bookings():
8+
threshold_time = timezone.now() - timedelta(weeks=1)
9+
expired_bookings = Booking.objects.filter(session_date__lt=threshold_time, booking_status="Approved")
10+
expired_count = expired_bookings.update(booking_status="Expired")
11+
return f"Marked {expired_count} bookings as expired"
12+
13+
@shared_task
14+
def delete_rejected_bookings():
15+
threshold_time = timezone.now() - timedelta(hours=24)
16+
deleted_count, _ = Booking.objects.filter(booking_status="Rejected", session_end_time__lt=threshold_time).delete()
17+
return f"Deleted {deleted_count} rejected bookings older than 24 hours."

backend/apps/bookings/urls.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from django.urls import path, include
22
from rest_framework.routers import DefaultRouter
3-
from .views import BookingViewSet
3+
from . import views
44

55
router = DefaultRouter()
6-
router.register(r'bookings', BookingViewSet)
6+
router.register(r'bookings', views.BookingViewSet)
77

88
urlpatterns = [
9-
path('api/', include(router.urls), name='bookings-api'),
9+
path('', include(router.urls), name='bookings_api'),
10+
path('reviews/', views.get_reviews, name='get_reviews'),
11+
path('reviews/submit/', views.submit_review, name='submit_reviews'),
1012
]

backend/apps/bookings/views.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
from rest_framework import viewsets
2-
from .models import Booking
3-
from .serializers import BookingSerializer
1+
from rest_framework import viewsets, status
2+
from rest_framework.decorators import api_view
3+
from rest_framework.response import Response
4+
from .models import Booking, Review
5+
from .serializers import BookingSerializer, ReviewSerializer
46
from rest_framework.permissions import IsAuthenticated
57

68

@@ -17,4 +19,26 @@ def get_queryset(self):
1719
user = self.request.user
1820
if user.is_staff:
1921
return Booking.objects.all()
20-
return Booking.objects.filter(student=user) | Booking.objects.filter(tutor=user)
22+
return Booking.objects.filter(student=user) | Booking.objects.filter(tutor=user)
23+
24+
# GET /api/reviews/ - Get all reviews
25+
@api_view(['GET'])
26+
def get_reviews(request):
27+
reviews = Review.objects.filter(is_visible=True) # Only show visible reviews
28+
serializer = ReviewSerializer(reviews, many=True)
29+
return Response(serializer.data)
30+
31+
32+
# POST /api/reviews/ - Submit a new review
33+
@api_view(['POST'])
34+
def submit_review(request):
35+
# Check if the required data exists in the request
36+
if 'tutor' not in request.data or 'rating' not in request.data or 'feedback' not in request.data:
37+
return Response({"error": "Missing required fields."}, status=status.HTTP_400_BAD_REQUEST)
38+
39+
serializer = ReviewSerializer(data=request.data)
40+
41+
if serializer.is_valid():
42+
serializer.save(reviewer=request.user) # Associate the logged-in user as the student
43+
return Response(serializer.data, status=status.HTTP_201_CREATED)
44+
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

0 commit comments

Comments
 (0)