Skip to content

Commit 54f824e

Browse files
Merge branch 'main' into assignment_create
2 parents dc36ba3 + 99729c1 commit 54f824e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+3106
-513
lines changed

.github/workflows/django_ci_cd.yml

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: Django CI/CD
2-
2+
# Triggers when making a pull request to main and commits to main
33
on:
44
push:
55
branches:
@@ -31,12 +31,6 @@ jobs:
3131
sudo service redis-server start
3232
sudo service redis-server status
3333
34-
- name: Install Flake8 and Run Linter
35-
run: |
36-
pip install flake8
37-
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
38-
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=88 --statistics
39-
4034
- name: Install Dependencies
4135
run: |
4236
python -m pip install --upgrade pip
@@ -53,19 +47,16 @@ jobs:
5347
echo "CLOUDINARY_API_SECRET=${{ secrets.CLOUDINARY_API_SECRET }}" >> .env
5448
shell: bash
5549

50+
# Applying migrations
5651
- name: Run Migrations
5752
run: python manage.py migrate
5853

59-
- name: Run Tests with Coverage
60-
run: |
61-
coverage run manage.py test
62-
coverage report
63-
coverage html # Generates the HTML report in 'htmlcov' directory
54+
# Generate coverage XML report for Codecov
55+
- name: Run tests and generate coverage XML report
56+
run: pytest --cov --cov-branch --cov-report=xml
6457

65-
# Upload coverage HTML report as artifact
66-
- name: Upload coverage HTML report
67-
uses: actions/upload-artifact@v4
58+
# Upload to Codecov (external visualization tool)
59+
- name: Upload coverage reports to Codecov
60+
uses: codecov/codecov-action@v5
6861
with:
69-
name: coverage-report-html
70-
path: backend/htmlcov/ # Upload the entire 'htmlcov' directory
71-
62+
token: ${{ secrets.CODECOV_TOKEN }}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Django Linter
2+
# Triggers when making a pull request to main and commits to main
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
branches:
9+
- main
10+
11+
jobs:
12+
build:
13+
runs-on: ubuntu-latest
14+
defaults:
15+
run:
16+
working-directory: backend # Set the default working directory
17+
18+
steps:
19+
- name: Checkout Repository
20+
uses: actions/checkout@v3
21+
22+
- name: Set up Python
23+
uses: actions/setup-python@v4
24+
with:
25+
python-version: 3.10.12
26+
27+
- name: Install Flake8 and Run Linter
28+
run: |
29+
pip install flake8
30+
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
31+
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=88 --statistics

README.md

1.89 KB

LessonConnect

Pipeline Status Coverage
Frontend (React + Vite) Frontend CI Codecov Frontend
Backend (Django + DRF) Backend CI Codecov Backend

Project Description

LessonConnect streamlines communication, scheduling, and resource sharing for private tutors, students, and parents. Focused on one-on-one or small group tutoring, it features tailored messaging, parental oversight, and an integrated calendar—making it a modern, targeted alternative to general platforms like Google Classroom or Canvas.

backend/.coveragerc

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
[run]
2+
source =
3+
apps/users
4+
apps/lessons
5+
apps/submissions
6+
apps/uploads
7+
apps/chat
8+
apps/notifications
9+
apps/pomodoro
10+
apps/bookings
11+
apps/planner
12+
apps/search
13+
14+
omit =
15+
*/tests/*
16+
*/migrations/*
17+
*/__init__.py
18+
*/settings.py
19+
*/wsgi.py
20+
*/asgi.py
21+
*/manage.py
22+
23+
[report]
24+
show_missing = True
25+
skip_covered = True

backend/apps/bookings/admin.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,36 @@
11
from django.contrib import admin
2-
from .models import Booking, Review
2+
from .models import Booking, Review, Availability
33

44
@admin.register(Booking)
55
class BookingsAdmin(admin.ModelAdmin):
6-
list_display = ('session_date', 'tutor', 'student', 'booking_status')
7-
search_fields = ('tutor__username', 'student__username', 'session_date')
6+
list_display = ('student', 'get_tutor', 'booking_status')
7+
search_fields = ('availability__tutor__profile__user__username', 'student__username')
88
list_filter = ('booking_status',)
99

10+
def get_tutor(self, obj):
11+
"""Displays tutor username in the admin panel."""
12+
return obj.availability.tutor.profile.user.username
13+
14+
get_tutor.short_description = 'Tutor' # Renames column in admin panel
15+
16+
@admin.register(Availability)
17+
class AvailabilityAdmin(admin.ModelAdmin):
18+
list_display = ('tutor', 'start_time', 'end_time', 'is_booked')
19+
search_fields = ('tutor__profile__user__username',) # Allows searching by tutor's username
20+
list_filter = ('is_booked',)
21+
1022
@admin.register(Review)
1123
class ReviewAdmin(admin.ModelAdmin):
12-
list_display = ['tutor', 'reviewer', 'rating', 'feedback', 'is_visible', 'is_moderated', 'created_at']
24+
list_display = ['get_tutor', 'reviewer', 'rating', 'feedback', 'is_visible', 'is_moderated', 'created_at']
1325
list_filter = ['is_visible', 'is_moderated', 'rating']
14-
search_fields = ['reviewer__username', 'feedback']
26+
search_fields = ['tutor__profile__user__username', 'reviewer__username', 'feedback']
1527
list_editable = ['is_visible', 'is_moderated'] # Allow inline editing
1628

29+
def get_tutor(self, obj):
30+
"""Displays tutor username in the admin panel."""
31+
return obj.tutor.profile.user.username
32+
get_tutor.short_description = 'Tutor' # Renames column in admin panel
33+
1734
def make_visible(self, request, queryset):
1835
queryset.update(is_visible=True)
1936

backend/apps/bookings/managers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from django.db import models
2+
from django.utils.timezone import now
3+
from datetime import timedelta
4+
5+
class BookingManager(models.Manager):
6+
def get_bookings_by_status(cls, status):
7+
return cls.objects.filter(booking_status=status)
8+
9+
def is_expired(self):
10+
return self.availability.start_time < now()
11+
12+
class ReviewManager(models.Manager):
13+
def average_rating(self, tutor):
14+
reviews = self.filter(tutor=tutor)
15+
total = sum([review.rating for review in reviews])
16+
return total / len(reviews) if reviews else 0
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Generated by Django 5.1.6 on 2025-04-07 04:03
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('bookings', '0005_alter_booking_student_alter_review_unique_together'),
11+
('users', '0009_alter_tutorprofile_rating'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='Availability',
17+
fields=[
18+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19+
('start_time', models.DateTimeField()),
20+
('end_time', models.DateTimeField()),
21+
('is_booked', models.BooleanField(default=False)),
22+
],
23+
options={
24+
'ordering': ['start_time', 'tutor'],
25+
},
26+
),
27+
migrations.AlterModelOptions(
28+
name='booking',
29+
options={},
30+
),
31+
migrations.RemoveConstraint(
32+
model_name='booking',
33+
name='unique_session_per_tutor',
34+
),
35+
migrations.RemoveField(
36+
model_name='booking',
37+
name='session_date',
38+
),
39+
migrations.RemoveField(
40+
model_name='booking',
41+
name='session_end_time',
42+
),
43+
migrations.RemoveField(
44+
model_name='booking',
45+
name='tutor',
46+
),
47+
migrations.AlterField(
48+
model_name='review',
49+
name='tutor',
50+
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='users.tutorprofile'),
51+
),
52+
migrations.AddField(
53+
model_name='availability',
54+
name='tutor',
55+
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='availabilities', to='users.tutorprofile'),
56+
),
57+
migrations.AddField(
58+
model_name='booking',
59+
name='availability',
60+
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='availability_booking', to='bookings.availability'),
61+
),
62+
migrations.AddConstraint(
63+
model_name='availability',
64+
constraint=models.UniqueConstraint(fields=('start_time', 'tutor'), name='unique_time_slot'),
65+
),
66+
]

backend/apps/bookings/models.py

Lines changed: 56 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,30 @@
55
from datetime import timedelta
66
from django.utils.timezone import now
77
from rest_framework.exceptions import ValidationError
8+
from apps.users.models import TutorProfile
9+
from apps.bookings.managers import ReviewManager, BookingManager
810

911

12+
class Availability(models.Model):
13+
"""Defines available time slots for tutors."""
14+
tutor = models.ForeignKey(TutorProfile, on_delete=models.CASCADE, related_name="availabilities")
15+
start_time = models.DateTimeField()
16+
end_time = models.DateTimeField()
17+
is_booked = models.BooleanField(default=False)
18+
19+
def __str__(self):
20+
return f"Availability: {self.tutor.profile.user.username} from {self.start_time} to {self.end_time}"
21+
22+
def clean(self):
23+
if self.end_time <= self.start_time:
24+
raise ValidationError("End time must be after start time.")
25+
26+
class Meta:
27+
constraints = [
28+
models.UniqueConstraint(fields=['start_time', 'tutor'], name='unique_time_slot')
29+
]
30+
ordering = ['start_time', 'tutor']
31+
1032
class Booking(models.Model):
1133
PENDING = 'Pending'
1234
APPROVED = 'Approved'
@@ -24,89 +46,83 @@ class Booking(models.Model):
2446
(FULFILLED, 'Fulfilled'),
2547
]
2648

27-
session_date = models.DateTimeField()
2849
session_updated_at = models.DateTimeField(auto_now=True)
29-
session_end_time = models.DateTimeField(null=True)
3050
booking_status = models.CharField(max_length=20, choices=BOOKING_STATUS_TYPES, default=PENDING)
3151
session_price = models.DecimalField(max_digits=10, decimal_places=2)
3252
student = models.ForeignKey(User, on_delete=models.CASCADE, related_name="student_bookings")
33-
tutor = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tutor")
3453
description = models.TextField(null=True, blank=True)
3554
payment_gateway_ref = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
55+
availability = models.OneToOneField(Availability, on_delete=models.CASCADE, related_name="availability_booking", null=True, blank=True)
3656

37-
class Meta:
38-
constraints = [
39-
models.UniqueConstraint(fields=['session_date', 'tutor'], name='unique_session_per_tutor')
40-
]
41-
ordering = ['session_date', 'tutor']
57+
objects = BookingManager()
4258

4359
def __str__(self):
44-
return (f'Booking on {self.session_date.strftime("%Y-%m-%d %H:%M")} '
45-
f'with {self.tutor.username} - {self.booking_status}')
60+
return (f'Booking for {self.student.username} with {self.availability.tutor.profile.user.username} '
61+
f'on {self.availability.start_time} - {self.booking_status}')
4662

4763
def is_paid(self):
4864
return self.payment_gateway_ref is not None
4965

50-
def reschedule_booking(self, new_date):
51-
if self.session_date < now():
66+
def reschedule_booking(self, new_availability):
67+
if self.availability.start_time < now():
5268
return False
53-
self.session_date = new_date
54-
self.save()
69+
self.availability = new_availability
70+
self.save(update_fields=['availability'])
71+
return True
5572

5673
def cancel_booking(self):
57-
if self.session_date < now():
74+
if self.availability.start_time < now():
5875
return False
59-
self.booking_status = self.REJECTED
76+
self.booking_status = self.CANCELLED
77+
self.availability.is_booked = False # Mark slot as available again
78+
self.availability.save(update_fields=['is_booked'])
6079
self.save(update_fields=['booking_status'])
6180
return True
6281

6382
def booking_duration(self):
64-
if not self.session_end_time:
83+
if not self.availability.end_time:
6584
return timedelta(0)
66-
return self.session_end_time - self.session_date
67-
68-
@classmethod
69-
def get_bookings_by_status(cls, status):
70-
return cls.objects.filter(booking_status=status)
71-
72-
def is_expired(self):
73-
return self.session_end_time < now()
85+
return self.availability.end_time - self.availability.start_time
7486

7587
def clean(self):
76-
if self.session_end_time <= self.session_date:
88+
if self.availability.end_time <= self.availability.start_time:
7789
raise ValidationError("Session end time must be after the session start time.")
90+
elif self.availability.is_booked:
91+
raise ValidationError("This time slot is already booked.")
7892

7993
def save(self, *args, **kwargs):
8094
"""Run full validation before saving."""
8195
self.full_clean() # Ensures validation runs before saving
82-
super().save(*args, **kwargs)
96+
self.availability.is_booked = True
97+
98+
if not self.availability:
99+
self.availability = Availability.objects.create(
100+
tutor=self.student.Tu, # Assuming student books a tutor
101+
start_time=self.session_date,
102+
end_time=self.session_end_time or (self.session_date + timedelta(hours=1)),
103+
is_booked=True
104+
)
83105

106+
super().save(*args, **kwargs)
84107

85108
class Review(models.Model):
86-
tutor = models.ForeignKey(User, on_delete=models.CASCADE, related_name="reviews")
109+
tutor = models.ForeignKey(TutorProfile, on_delete=models.CASCADE, related_name="reviews")
87110
reviewer = models.ForeignKey(User, on_delete=models.CASCADE, related_name="reviewer")
88111
rating = models.PositiveSmallIntegerField(validators=[MinValueValidator(1), MaxValueValidator(5)])
89112
feedback = models.TextField(null=True, blank=True)
90113
created_at = models.DateTimeField(auto_now_add=True)
91114

92115
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)
116+
is_moderated = models.BooleanField(default=False)
117+
118+
objects = ReviewManager()
95119

96120
def __str__(self):
97-
return f"Review for {self.tutor.username} by {self.reviewer.username}"
121+
return f"Review for {self.tutor.profile.user.username} by {self.reviewer.username}"
98122

99123
class Meta:
100-
ordering = ['-created_at'] # Sort reviews by most recent first
124+
ordering = ['-created_at']
101125
unique_together = ('tutor', 'reviewer')
102126

103127
def is_valid_review(self):
104-
"""Checks if the review has a valid rating and comments."""
105128
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

0 commit comments

Comments
 (0)