Skip to content

Commit ed8606b

Browse files
Merge pull request #86 from UNLV-CS472-672/filters-for-search
PR 3: Filters for search and new ProfilePicture model
2 parents fc25ce5 + 59906a3 commit ed8606b

32 files changed

+434
-93
lines changed

backend/apps/search/models.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
from django.db import models
2+
from django.conf import settings
23

34
# Create your models here.
5+
6+
# A model that represents the possible categories that a subject can belong to
7+
# (i.e. Category: Math can have Subjects: Calculus, Linear Algebra)
48
class Category(models.Model):
59
title = models.CharField(max_length=100)
610

@@ -11,12 +15,10 @@ def get_default_category():
1115
# Assuming the default tutor is the first tutor in the database
1216
return Category.objects.first()
1317

18+
# A model that represents the possible subjects a tutor teaches
1419
class Subject(models.Model):
1520
title = models.CharField(max_length=100, unique=True)
1621
category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name="subjects", default = get_default_category)
1722

1823
def __str__(self):
1924
return self.title
20-
21-
22-

backend/apps/search/serializers.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
from rest_framework import serializers
22
from apps.users.models import TutorProfile
3+
from apps.uploads.models import UploadRecord
34

45
class TutorSearchResultSerializer(serializers.ModelSerializer):
56
# Define custom fields to fetch first and last names from the related user profile
67
first_name = serializers.CharField(source="profile.user.first_name")
78
last_name = serializers.CharField(source="profile.user.last_name")
9+
image_url = serializers.SerializerMethodField() # Custom field to get the file URL
10+
811
# Specify the model and fields to be included in the serialized output
912
class Meta:
1013
model = TutorProfile
11-
fields = ["first_name", "last_name", "bio", "hourly_rate", "state", "city"]
14+
fields = ["first_name", "last_name", "bio", "hourly_rate", "state", "city", "rating", "image_url"]
15+
16+
def get_image_url(self, result_data):
17+
# Ensure that the profile and profile_picture exist before accessing them
18+
if hasattr(result_data, "profile") and hasattr(result_data.profile, "upload_record"):
19+
return UploadRecord.objects.build_url(result_data.profile.upload_record)
20+
return None # Return None if there's no profile picture

backend/apps/search/tests.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def setUp(self):
2424
self.subject_geometry= Subject.objects.create(title="Geometry", category = self.category_math)
2525

2626
# Create test tutor profiles
27-
self.tutor = TutorProfile.objects.create_tutor_profile(profile=self.test_profile, city="New York", state="NY", hourly_rate=25.0)
27+
self.tutor = TutorProfile.objects.create(profile=self.test_profile, city="New York", state="NY", hourly_rate=25.0)
2828

2929
# Link tutors to subjects
3030
self.tutor.subjects.add(self.subject_geometry)

backend/apps/search/views.py

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import re
77
from .models import Subject, Category
88
from apps.users.models import TutorProfile
9-
from apps.users.managers import TutorProfileManager
9+
from apps.uploads.models import UploadRecord
1010
from .serializers import TutorSearchResultSerializer
1111

1212
# Create your views here.
@@ -29,29 +29,83 @@ def get(self, request, format=None):
2929
except Exception as e:
3030
return Response({"message": f"Error parsing location: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST)
3131

32-
# Filter tutors by location
33-
filtered_tutors = TutorProfile.objects.filter_tutors_by_location(what, city, state)
34-
# Perform search with 'what' term
35-
search_results = TutorProfile.objects.search(filtered_tutors, what)
32+
# Apply all filters before searching with django-watson
33+
34+
# Filter tutors by location (Required)
35+
filtered_tutors = TutorProfile.objects.filter_tutors_by_location(city, state)
36+
37+
# Filter tutors by pay (Optional)
38+
min_price = request.query_params.get('min-price', "").strip()
39+
max_price = request.query_params.get('max-price', "").strip()
40+
41+
try:
42+
# Convert from string to float
43+
min_price = float(min_price) if min_price else None
44+
max_price = float(max_price) if max_price else None
45+
46+
# Check if max price is not less than min price
47+
if min_price is not None and max_price is not None and max_price < min_price:
48+
return Response({"message": "Max price cannot be less than min price."}, status=status.HTTP_400_BAD_REQUEST)
49+
50+
# Filter based of price range
51+
filtered_tutors = TutorProfile.objects.filter_by_price_range(filtered_tutors, min_price, max_price)
52+
53+
except ValueError:
54+
return Response({"message": "Price filters must be valid numbers."}, status=status.HTTP_400_BAD_REQUEST)
55+
56+
57+
# Filter tutors by rating (Optional)
58+
rating = request.query_params.get('rating', "").strip()
59+
if rating:
60+
try:
61+
# Convert from string to int
62+
rating = int(rating)
63+
# Filter based of rating (i.e. 4 stars and up, 3 stars and up, etc)
64+
filtered_tutors = TutorProfile.objects.filter_tutors_by_rating(filtered_tutors, rating)
65+
66+
except ValueError:
67+
return Response({"message": "Invalid value for rating. It must be a number."}, status=status.HTTP_400_BAD_REQUEST)
68+
69+
is_subjects_filtered = False
70+
query = Q()
71+
72+
# Get subjects from request
73+
subjects = request.query_params.get('subjects', "").strip()
74+
# Filter tutors by subject (Optional)
75+
if subjects:
76+
subject_list = subjects.split(",") # Assuming subjects are comma-separated
77+
# Execute the query for subjects
78+
query = Subject.objects.filter(Q(title__in=subject_list))
79+
filtered_tutors = TutorProfile.objects.filter_tutors_by_subject(filtered_tutors, query, is_subjects_filtered)
80+
is_subjects_filtered = True
3681

3782
# Split the 'what' search term into individual words, handling non-word characters and spaces
38-
# This helps match partial words that django-watson might overlook
83+
# This helps match partial words regarding subject and is used to pull up other
84+
# tutor results whose subjects fits this search term
3985
search_terms = re.split(r'[\W\s]+', what)
40-
query = Q()
86+
lookup_subjects_query = Q()
87+
# This helps match partial names regarding tutors
88+
lookup_tutors_query = Q()
4189

4290
# Loop through each search term and create a query that checks if the term is
4391
# in the category title or the subject title
4492
for term in search_terms:
45-
query |= Q(category__title__icontains=term) | Q(title__icontains=term)
93+
lookup_subjects_query |= Q(category__title__icontains=term) | Q(title__icontains=term)
94+
lookup_tutors_query |= Q(profile__user__first_name__icontains=term) | Q(profile__user__last_name__icontains=term)
95+
96+
# Perform search with 'what' term
97+
search_results = TutorProfile.objects.search(filtered_tutors, what)
98+
# Combine with partial_tutor_matches
99+
partial_tutor_matches = TutorProfile.objects.filter(lookup_tutors_query)
100+
search_results = (search_results | partial_tutor_matches)
46101

47102
try:
48-
# Attempt to filter subjects based on the search query
49-
subject_query = Subject.objects.filter(query)
50-
# If matching subjects are found, filter tutors by these subjects
51-
if subject_query.exists():
52-
filtered_tutors = TutorProfile.objects.filter_tutors_by_subject(filtered_tutors, subject_query)
53-
# Combine the filtered tutors with the existing search results
54-
search_results = (filtered_tutors | search_results).distinct()
103+
# Execute the query for subjects
104+
subject_query = Subject.objects.filter(lookup_subjects_query)
105+
filtered_tutors = TutorProfile.objects.filter_tutors_by_subject(filtered_tutors, subject_query, is_subjects_filtered)
106+
# Combine the filtered tutors with the existing search results
107+
search_results = (search_results | filtered_tutors).distinct()
108+
55109
except Exception as e:
56110
return Response({"message": f"Error searching subjects: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
57111

backend/apps/submissions/tests.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def setUp(self):
2929
"version": 1,
3030
"asset_id": "sample_asset_id"
3131
}
32-
self.upload_record = UploadRecord.objects.create(upload_data=self.upload_data, user=self.user)
32+
self.upload_record = UploadRecord.objects.create(upload_data=self.upload_data, profile=self.profile)
3333

3434
# Create submission
3535
self.submission = Submissions.objects.create(

backend/apps/uploads/admin.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
# uploads/admin.py
22
from django.contrib import admin
3-
from apps.uploads.models import UploadRecord
3+
from apps.uploads.models import UploadRecord, ProfilePicture
44
# Debug Only: Shows public_id and id in localhost/admin
55

66

77
class UploadRecordAdmin(admin.ModelAdmin):
8-
list_display=['public_id', 'id']
8+
list_display=['cloudinary_public_id', 'id']
99
# Register your models here.
1010
admin.site.register(UploadRecord, UploadRecordAdmin)
11+
admin.site.register(ProfilePicture)

backend/apps/uploads/managers.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@
44
import cloudinary.uploader
55
from cloudinary import CloudinaryImage
66

7+
class ProfilePictureManager(models.Manager):
8+
def create(self, upload):
9+
# Create a new UploadRecord instance with the provided data
10+
profile_picture = self.model(
11+
upload=upload
12+
)
13+
# Save the instance to the database
14+
profile_picture.save()
15+
return profile_picture
16+
717
class UploadRecordManager(models.Manager):
818

919
def upload(self, file):
@@ -16,7 +26,7 @@ def delete_upload(self, cloudinary_public_id):
1626
result = cloudinary.uploader.destroy(cloudinary_public_id, invalidate = True)
1727
return result
1828

19-
def create(self, upload_data, user):
29+
def create(self, upload_data, profile):
2030
# Replace 'Z' with '+00:00' for compatibility with fromisoformat
2131
compatible_created_at = upload_data['created_at'].replace('Z', '+00:00')
2232
# Convert to datetime object
@@ -30,9 +40,9 @@ def create(self, upload_data, user):
3040
file_format=upload_data["format"],
3141
created_at=created_at_datetime,
3242
cloudinary_public_id=upload_data["public_id"],
33-
user=user,
3443
version=upload_data["version"],
35-
asset_id=upload_data["asset_id"]
44+
asset_id=upload_data["asset_id"],
45+
profile=profile
3646
)
3747
# Save the instance to the database
3848
upload_record.save()
@@ -45,9 +55,9 @@ def build_url(self, upload):
4555
dynamic_asset_url, _ = cloudinary.utils.cloudinary_url(cloudinary_public_id, resource_type = resource_type)
4656
return dynamic_asset_url
4757

48-
def get_upload(self,public_id):
58+
def get_upload(self, cloudinary_public_id):
4959
# Retrieve the upload record by its public ID
50-
upload_record = self.get_queryset().filter(public_id=public_id).first()
60+
upload_record = self.get_queryset().filter(cloudinary_public_id=cloudinary_public_id).first()
5161
return upload_record
5262

5363
def get_all_uploads(self):
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Generated by Django 5.1.6 on 2025-03-25 22:24
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+
('uploads', '0004_remove_uploadrecord_profile_uploadrecord_user'),
11+
('users', '0008_tutorprofile_rating'),
12+
]
13+
14+
operations = [
15+
migrations.RemoveField(
16+
model_name='uploadrecord',
17+
name='public_id',
18+
),
19+
migrations.RemoveField(
20+
model_name='uploadrecord',
21+
name='user',
22+
),
23+
migrations.CreateModel(
24+
name='ProfilePicture',
25+
fields=[
26+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
27+
('profile', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile_picture', to='users.profile')),
28+
('upload', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='uploads.uploadrecord')),
29+
],
30+
),
31+
]
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Generated by Django 5.1.6 on 2025-03-26 04:21
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+
('uploads', '0005_remove_uploadrecord_public_id_and_more'),
11+
('users', '0009_alter_tutorprofile_rating'),
12+
]
13+
14+
operations = [
15+
migrations.RemoveField(
16+
model_name='profilepicture',
17+
name='profile',
18+
),
19+
migrations.AddField(
20+
model_name='uploadrecord',
21+
name='profile',
22+
field=models.OneToOneField(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='profile', to='users.profile'),
23+
preserve_default=False,
24+
),
25+
]
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 5.1.6 on 2025-03-26 04:49
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+
('uploads', '0006_remove_profilepicture_profile_uploadrecord_profile'),
11+
('users', '0009_alter_tutorprofile_rating'),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name='uploadrecord',
17+
name='profile',
18+
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='upload_record', to='users.profile'),
19+
),
20+
]

0 commit comments

Comments
 (0)