diff --git a/apps/properties/repositories.py b/apps/properties/repositories.py index e1101b9..2ca6370 100644 --- a/apps/properties/repositories.py +++ b/apps/properties/repositories.py @@ -1,4 +1,4 @@ -from django.db.models import Avg +from django.db.models import Avg, Count from apps.properties.models import Condo, Properties, PropertiesPhotos, Reviews, Rooms, RoomsExtras from apps.properties.services import delete_from_cloud, upload_to_cloud @@ -40,7 +40,15 @@ def save_model(instance): @staticmethod def list_properties_with_order(): - return Properties.objects.all().order_by("created_at") + return ( + Properties.objects.select_related("rooms", "rooms_extras", "condo", "owner") + .prefetch_related("photos", "nearby_places") + .annotate( + average_rating=Avg("reviews__rating"), + favorite_count=Count("favorited_by", distinct=True), + ) + .order_by("created_at") + ) class PhotoRepository: diff --git a/apps/properties/serializers/property_serializers.py b/apps/properties/serializers/property_serializers.py index 8a0523c..f62b97d 100644 --- a/apps/properties/serializers/property_serializers.py +++ b/apps/properties/serializers/property_serializers.py @@ -50,6 +50,7 @@ class PropertiesReadSerializer(serializers.ModelSerializer): images = PropertiesPhotosSerializer(many=True, read_only=True, source="photos") nearby_places = NearbyPlacesSerializer(many=True, read_only=True) average_rating = serializers.SerializerMethodField() + match_score = serializers.SerializerMethodField() owner_name = serializers.CharField(source="owner.name", read_only=True) def get_average_rating(self, obj): @@ -57,6 +58,16 @@ def get_average_rating(self, obj): return obj.average_rating return ReviewUseCase.get_average_rating(obj) + def get_match_score(self, obj): + return getattr(obj, "match_score", None) + + # se match_score não for calculado, remove ele da resposta + def to_representation(self, instance): + data = super().to_representation(instance) + if data["match_score"] is None: + data.pop("match_score") + return data + class Meta: model = Properties exclude = ["embedding"] diff --git a/apps/properties/use_cases.py b/apps/properties/use_cases.py index 3e16d37..59cfbc1 100644 --- a/apps/properties/use_cases.py +++ b/apps/properties/use_cases.py @@ -1,6 +1,269 @@ +from collections import Counter +from decimal import Decimal + +from django.core.exceptions import ObjectDoesNotExist + from apps.properties.repositories import PhotoRepository, PropertyRepository, ReviewRepository +class MatchScoreUseCase: + FILTER_TO_SCORE_FIELDS = { + "type": {"property_type"}, + "city": {"city"}, + "neighborhood": {"neighborhood"}, + "min_price": {"min_price"}, + "max_price": {"max_price"}, + } + + @staticmethod + def apply_match_scores(queryset, user, query_params=None): + properties = list(queryset) + try: + preferences = user.preferences + except ObjectDoesNotExist: + preferences = None + + current_filters = MatchScoreUseCase._current_filters(query_params) + ignored_score_fields = MatchScoreUseCase._ignored_score_fields( + current_filters.keys() + ) + favorite_profile = MatchScoreUseCase._favorite_profile(user) + + for property_obj in properties: + property_obj.match_score = MatchScoreUseCase.calculate_match_score( + property_obj, + preferences=preferences, + ignored_score_fields=ignored_score_fields, + favorite_profile=favorite_profile, + ) + + return sorted(properties, key=lambda item: item.match_score, reverse=True) + + @staticmethod + def calculate_match_score( + property_obj, + *, + preferences=None, + ignored_score_fields=None, + favorite_profile=None, + ): + weighted_scores = [] + + if preferences: + preference_score = MatchScoreUseCase._preference_score( + property_obj, + preferences, + ignored_score_fields=ignored_score_fields or set(), + ) + if preference_score is not None: + weighted_scores.append((preference_score, 45)) + + if favorite_profile: + weighted_scores.append( + ( + MatchScoreUseCase._favorite_profile_score( + property_obj, + favorite_profile, + ), + 40, + ) + ) + + popularity_score = MatchScoreUseCase._popularity_score(property_obj) + if weighted_scores: + weighted_scores.append((popularity_score, 15)) + total_weight = sum(weight for _, weight in weighted_scores) + return round( + sum(score * weight for score, weight in weighted_scores) / total_weight + ) + + return popularity_score + + @staticmethod + def _preference_score(property_obj, preferences, *, ignored_score_fields): + total_weight = 0 + earned = 0 + + rules = [ + ( + "property_type", + preferences.property_type, + 25, + property_obj.type == preferences.property_type, + ), + ( + "city", + preferences.city, + 20, + MatchScoreUseCase._same_text(property_obj.city, preferences.city), + ), + ( + "neighborhood", + preferences.neighborhood, + 15, + MatchScoreUseCase._same_text( + property_obj.neighborhood, + preferences.neighborhood, + ), + ), + ] + + for field_name, expected, weight, matched in rules: + if field_name not in ignored_score_fields and expected: + total_weight += weight + if matched: + earned += weight + + if ( + "min_price" not in ignored_score_fields + and preferences.min_price is not None + ): + total_weight += 15 + if property_obj.price >= preferences.min_price: + earned += 15 + + if ( + "max_price" not in ignored_score_fields + and preferences.max_price is not None + ): + total_weight += 25 + if property_obj.price <= preferences.max_price: + earned += 25 + elif property_obj.price <= preferences.max_price * Decimal("1.1"): + earned += 10 + + if total_weight == 0: + return None + + return round((earned / total_weight) * 100) + + @staticmethod + def _favorite_profile(user): + favorites = list( + user.favorites.select_related("rooms").only( + "type", + "city", + "neighborhood", + "price", + "rooms__id", + ) + ) + if not favorites: + return None + + type_counter = Counter(item.type for item in favorites if item.type) + city_counter = Counter( + MatchScoreUseCase._normalize_text(item.city) + for item in favorites + if item.city + ) + neighborhood_counter = Counter( + MatchScoreUseCase._normalize_text(item.neighborhood) + for item in favorites + if item.neighborhood + ) + prices = [item.price for item in favorites if item.price is not None] + + return { + "favorite_type": MatchScoreUseCase._most_common(type_counter), + "favorite_city": MatchScoreUseCase._most_common(city_counter), + "favorite_neighborhoods": { + value for value, _ in neighborhood_counter.most_common(3) + }, + "average_price": sum(prices) / len(prices) if prices else None, + } + + @staticmethod + def _favorite_profile_score(property_obj, profile): + total_weight = 0 + earned = 0 + + rules = [ + ( + profile["favorite_type"], + 25, + property_obj.type == profile["favorite_type"], + ), + ( + profile["favorite_city"], + 20, + MatchScoreUseCase._normalize_text(property_obj.city) + == profile["favorite_city"], + ), + ( + profile["favorite_neighborhoods"], + 20, + MatchScoreUseCase._normalize_text(property_obj.neighborhood) + in profile["favorite_neighborhoods"], + ), + ] + + for expected, weight, matched in rules: + if expected: + total_weight += weight + if matched: + earned += weight + + average_price = profile["average_price"] + if average_price: + total_weight += 35 + price_distance = abs(property_obj.price - average_price) / average_price + if price_distance <= Decimal("0.10"): + earned += 35 + elif price_distance <= Decimal("0.20"): + earned += 25 + elif price_distance <= Decimal("0.35"): + earned += 10 + + if total_weight == 0: + return 0 + + return round((earned / total_weight) * 100) + + @staticmethod + def _popularity_score(property_obj): + favorite_count = getattr(property_obj, "favorite_count", 0) or 0 + average_rating = getattr(property_obj, "average_rating", None) or 0 + + rating_score = min(float(average_rating), 5.0) / 5 * 70 + favorite_score = min(favorite_count, 10) / 10 * 30 + + return round(rating_score + favorite_score) + + @staticmethod + def _same_text(value, expected): + if value is None or expected is None: + return False + return MatchScoreUseCase._normalize_text(value) == MatchScoreUseCase._normalize_text(expected) + + @staticmethod + def _normalize_text(value): + return str(value).strip().lower() + + @staticmethod + def _ignored_score_fields(query_params): + ignored = set() + for filter_name in query_params or []: + ignored.update(MatchScoreUseCase.FILTER_TO_SCORE_FIELDS.get(filter_name, set())) + return ignored + + @staticmethod + def _current_filters(query_params): + if not query_params: + return {} + return { + name: query_params.get(name) + for name in MatchScoreUseCase.FILTER_TO_SCORE_FIELDS + if query_params.get(name) not in (None, "") + } + + @staticmethod + def _most_common(counter): + if not counter: + return None + return counter.most_common(1)[0][0] + + class PropertyUseCase: @staticmethod def create_property(validated_data): diff --git a/apps/properties/views/property_views.py b/apps/properties/views/property_views.py index 88b06ac..9688760 100644 --- a/apps/properties/views/property_views.py +++ b/apps/properties/views/property_views.py @@ -12,7 +12,7 @@ from apps.properties.services import NomatimService from apps.properties.pagination import HomeMatchPagination from apps.properties.repositories import PropertyRepository -from apps.properties.use_cases import PropertyUseCase, ReviewUseCase +from apps.properties.use_cases import MatchScoreUseCase, PropertyUseCase, ReviewUseCase # C -> Create # R -> Read @@ -57,6 +57,25 @@ class CreateListPropertyView(generics.ListCreateAPIView): def get_queryset(self): return PropertyRepository.list_properties_with_order() + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + + should_match = request.query_params.get("match") == "true" + if should_match and request.user.is_authenticated: + queryset = MatchScoreUseCase.apply_match_scores( + queryset, + request.user, + query_params=request.query_params, + ) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + def get_serializer_class(self): if self.request.method == "POST": return PropertiesWriteSerializer @@ -95,4 +114,4 @@ def destroy(self, request, *args, **kwargs): }, status=status.HTTP_204_NO_CONTENT) class SearchPropertyAIView(APIView): - pass \ No newline at end of file + pass diff --git a/requirements.txt b/requirements.txt index a5d528b..37ae0bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,4 +13,4 @@ boto3 openai celery redis -google-generativeai \ No newline at end of file +google-generativeai