Skip to content
Merged

Maps #26

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
15 changes: 15 additions & 0 deletions apps/properties/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
17 changes: 16 additions & 1 deletion apps/properties/serializers/photo_serializers.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
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

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)

Expand All @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion apps/properties/serializers/property_serializers.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)

Expand Down
118 changes: 118 additions & 0 deletions apps/properties/services.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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"),
)

Expand Down
13 changes: 13 additions & 0 deletions apps/properties/tasks.py
Original file line number Diff line number Diff line change
@@ -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}")
7 changes: 6 additions & 1 deletion apps/properties/views/property_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading