diff --git a/apps/properties/models.py b/apps/properties/models.py index ea74438..d9ba03a 100644 --- a/apps/properties/models.py +++ b/apps/properties/models.py @@ -71,6 +71,8 @@ class Properties(models.Model): city = models.CharField(max_length=100, null=False) has_mobilia = models.BooleanField(default=False) status = models.BooleanField(default=True) + latitude = models.FloatField(null=True, blank=True) + longitude = models.FloatField(null=True, blank=True) description = models.TextField() embedding = models.TextField() created_at = models.DateTimeField(auto_now_add=True) @@ -108,3 +110,16 @@ class Meta: name="unique_review_per_user_per_property" ) ] + +class NearbyPlaces(models.Model): + CATEGORY_CHOICES = [("R", "Restaurant"), ("G", "Gym"), ("S", "School"), ("H", "Hospital"), ("SM", "Supermarket"), ("P", "Park")] + property = models.ForeignKey( + Properties, + on_delete=models.CASCADE, + related_name="nearby_places" + ) + name = models.TextField(null=False) + category = models.CharField(max_length=2, choices=CATEGORY_CHOICES) + distance_meters = models.FloatField() + rating = models.FloatField(null=True, blank=True) + fetched_at = models.DateTimeField(auto_now_add=True) diff --git a/apps/properties/serializers/photo_serializers.py b/apps/properties/serializers/photo_serializers.py index 08fb9cb..c89275b 100644 --- a/apps/properties/serializers/photo_serializers.py +++ b/apps/properties/serializers/photo_serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers from apps.properties.models import PropertiesPhotos +from apps.properties.services import CloudService from apps.properties.services import generate_url from apps.properties.use_cases import PhotoUseCase @@ -7,6 +8,20 @@ class PropertiesUploadPhotosSerializer(serializers.ModelSerializer): image = serializers.ImageField(write_only=True) def create(self, validated_data): + image = validated_data.pop('image') + + r2_key = CloudService.upload_to_cloud(image) + return PropertiesPhotos.objects.create(r2_key=r2_key, **validated_data) + + def update(self, instance, validated_data): + new_image = validated_data.pop('image', None) + + if new_image: + CloudService.delete_from_cloud(instance.r2_key) + instance.r2_key = CloudService.upload_to_cloud(new_image) + instance.save() + + return instance property_obj = validated_data["property"] return PhotoUseCase.create_photo(property_obj=property_obj, validated_data=validated_data) @@ -22,7 +37,7 @@ class PropertiesPhotosSerializer(serializers.ModelSerializer): url = serializers.SerializerMethodField() def get_url(self, obj): - return generate_url(obj.r2_key) + return CloudService.generate_url(obj.r2_key) class Meta: diff --git a/apps/properties/serializers/property_serializers.py b/apps/properties/serializers/property_serializers.py index f36880d..8a0523c 100644 --- a/apps/properties/serializers/property_serializers.py +++ b/apps/properties/serializers/property_serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from apps.properties.models import Condo, Properties, Rooms, RoomsExtras +from apps.properties.models import Condo, Properties, Rooms, RoomsExtras, NearbyPlaces from apps.properties.validators import validate_positive_number, validate_required_field from apps.properties.serializers.photo_serializers import PropertiesPhotosSerializer from apps.properties.use_cases import PropertyUseCase, ReviewUseCase @@ -38,12 +38,17 @@ class Meta: 'parking_spots': {'required': False}, } +class NearbyPlacesSerializer(serializers.ModelSerializer): + class Meta: + model = NearbyPlaces + fields = ["name", "category", "distance_meters", "rating"] class PropertiesReadSerializer(serializers.ModelSerializer): rooms = RoomsSerializer() condo = CondoSerializer() rooms_extras = RoomsExtrasSerializer() images = PropertiesPhotosSerializer(many=True, read_only=True, source="photos") + nearby_places = NearbyPlacesSerializer(many=True, read_only=True) average_rating = serializers.SerializerMethodField() owner_name = serializers.CharField(source="owner.name", read_only=True) diff --git a/apps/properties/services.py b/apps/properties/services.py index f6b3c04..6e5de1f 100644 --- a/apps/properties/services.py +++ b/apps/properties/services.py @@ -1,10 +1,37 @@ import uuid +import requests +import math from pathlib import Path import boto3 from botocore.config import Config from django.conf import settings +from .models import NearbyPlaces +class NomatimService: + BASE_URL = "https://nominatim.openstreetmap.org/search" + + @staticmethod + def geocode(property_obj): + params = { + "q": f"{property_obj.address}, {property_obj.city}", + "format": "json", + "limit": 1 + } + headers = { + "User-Agent": "HomeMatch/1.0" + } + response = requests.get(NominatimService.BASE_URL, params=params, headers=headers) + data = response.json() + + if data: + property_obj.latitude = float(data[0]["lat"]) + property_obj.longitude = float(data[0]["lon"]) + property_obj.save(update_fields=["latitude", "longitude"]) + + +class CloudService: + s3_client = boto3.client( def _use_local(): return getattr(settings, "USE_LOCAL_STORAGE", False) @@ -49,6 +76,97 @@ def _get_s3_client(): endpoint_url=f"https://{settings.R2_ACCOUNT_ID}.r2.cloudflarestorage.com", aws_access_key_id=settings.R2_ACCESS_KEY_ID, aws_secret_access_key=settings.R2_SECRET_ACCESS_KEY, + config=Config(signature_version="s3v4") + ) + + @staticmethod + def upload_to_cloud(image): + r2_key = f"properties/{uuid.uuid4()}/{image.name}" + + CloudService.s3_client.upload_fileobj( + image, + settings.R2_BUCKET_NAME, + r2_key, + ExtraArgs={"ContentType": image.content_type} + ) + + return r2_key + + @staticmethod + def delete_from_cloud(r2_key): + CloudService.s3_client.delete_object( + Bucket=settings.R2_BUCKET_NAME, + Key=r2_key + ) + @staticmethod + def generate_url(r2_key): + return CloudService.s3_client.generate_presigned_url( + "get_object", + Params={"Bucket": settings.R2_BUCKET_NAME, "Key": r2_key}, + ExpiresIn=3600 + + + + + ) + +class NearbyPlacesService: + CATEGORIES = [("R", "Restaurant"), ("G", "Gym"), ("S", "School"), ("H", "Hospital"), ("SM", "Supermarket"), ("P", "Park")] + RADIUS = 3000 + + @staticmethod + def search_categories(lat, long, type): + url = "https://places.googleapis.com/v1/places:searchNearby" + headers = { + "Content-Type": "application/json", + "X-Goog-Api-Key": settings.GOOGLE_PLACES_API_KEY, + "X-Goog-FieldMask": "places.displayName,places.rating,places.location" + } + body = { + "includedTypes": [type], + "maxResultCount": 5, + "locationRestriction": { + "circle": { + "center": {"latitude": lat, "longitude": long}, + "radius": NearbyPlacesService.RADIUS + } + } + } + response = requests.post(url, json=body, headers=headers) + data = response.json() + return data.get("places", []) + + @staticmethod + def calculate_distance(lat1, long1, lat2, long2): + R = 637100 + phi1 = math.radians(lat1) + phi2 = math.radians(lat2) + dphi = math.radians(lat2 - lat1) + dlambda = math.radians(long2 - long1) + + a = math.sin(dphi/2)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda/2)**2 + return round(2 * R * math.atan2(math.sqrt(a), math.sqrt(1 - a)), 2) + + @staticmethod + def search(property): + lat = property.latitude + long = property.longitude + + for category_code, category_type in NearbyPlacesService.CATEGORIES: + places = NearbyPlacesService.search_categories(lat, long, category_type) + for place in places: + place_lat = place["location"]["latitude"] + place_long = place["location"]["longitude"] + NearbyPlaces.objects.update_or_create( + property=property, + name=place["displayName"]["text"], + category=category_code, + defaults={ + "distance_meters": NearbyPlacesService.calculate_distance(lat, long, place_lat, place_long), + "rating": place.get("rating") + } + ) + config=Config(signature_version="s3v4"), ) diff --git a/apps/properties/tasks.py b/apps/properties/tasks.py new file mode 100644 index 0000000..28c6c63 --- /dev/null +++ b/apps/properties/tasks.py @@ -0,0 +1,13 @@ +import logging +from celery import shared_task + +logger = logging.getLogger(__name__) + +@shared_task +def search_nearby_places(property_id): + from .models import Properties + from .services import NearbyPlacesService + property_obj = Properties.objects.get(id=property_id) + + NearbyPlacesService.search(property_obj) + logger.info(f"Nearby places searched with success for property {property_id}") diff --git a/apps/properties/views/property_views.py b/apps/properties/views/property_views.py index bc85a30..88b06ac 100644 --- a/apps/properties/views/property_views.py +++ b/apps/properties/views/property_views.py @@ -8,6 +8,8 @@ from apps.properties.serializers.property_serializers import PropertiesWriteSerializer, PropertiesReadSerializer from apps.properties.serializers.reviews_serializers import ReviewsSerializer from apps.properties.permissions import IsAdvertiser, IsReviewOwner, IsPropertyOwner +from apps.properties.tasks import search_nearby_places +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 @@ -66,7 +68,10 @@ def get_permissions(self): return [AllowAny()] def perform_create(self, serializer): - serializer.save(owner_id=self.request.user.id) + property_obj = serializer.save(owner_id=self.request.user.id) + NomatimService.geocode(property_obj) + if property_obj.latitude and property_obj.longitude: + search_nearby_places.delay(property_obj.id) class RUDPropertyView(generics.RetrieveUpdateDestroyAPIView): queryset = Properties.objects.all() diff --git a/config/settings.py b/config/settings.py index 26149c6..b6ff379 100644 --- a/config/settings.py +++ b/config/settings.py @@ -127,6 +127,8 @@ # O redis vai enfileirar as tarefas e atribuir aos workers do celery. # O celery vai ter seus workers que vão ter suas funções já pré definidas nos arquivos tasks.py # Quando uma tarefa é terminada, o redis vai armazenar seu resultado + +GOOGLE_PLACES_API_KEY = config("GOOGLE_PLACES_API_KEY") # Cloudflare R2 # Default to None so the app starts without R2 in local dev. # AiVisionClient / boto3 will raise at the point of first use if unset.