Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions apps/properties/repositories.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions apps/properties/serializers/property_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,24 @@ 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):
if hasattr(obj, "average_rating"):
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"]
Expand Down
263 changes: 263 additions & 0 deletions apps/properties/use_cases.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
23 changes: 21 additions & 2 deletions apps/properties/views/property_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -95,4 +114,4 @@ def destroy(self, request, *args, **kwargs):
}, status=status.HTTP_204_NO_CONTENT)

class SearchPropertyAIView(APIView):
pass
pass
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ boto3
openai
celery
redis
google-generativeai
google-generativeai
Loading