From c8e02e2a567fa7ca6f6def3127c589daa162fc19 Mon Sep 17 00:00:00 2001 From: DevlTz Date: Mon, 18 May 2026 23:39:17 -0300 Subject: [PATCH 1/7] =?UTF-8?q?feat(ai=5Fanalysis):=20notificar=20usu?= =?UTF-8?q?=C3=A1rio=20quando=20a=20an=C3=A1lise=20de=20IA=20termina?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ai_analysis/tasks.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/ai_analysis/tasks.py b/apps/ai_analysis/tasks.py index 0bf0727..691cfc5 100644 --- a/apps/ai_analysis/tasks.py +++ b/apps/ai_analysis/tasks.py @@ -36,6 +36,33 @@ def analyze_photo_task(self, photo_id, prompt=None): photo.property_id, len(result), ) + # Notificar o proprietário que a análise foi concluída + try: + from apps.notifications.models import Notification + from apps.notifications.serializers import NotificationSerializer + from asgiref.sync import async_to_sync + from channels.layers import get_channel_layer + + property_obj = photo.property + owner = getattr(property_obj, "owner", None) + if owner: + message = f"A análise de IA do imóvel '{property_obj.address}' foi concluída." + notification = Notification.objects.create( + user=owner, + type=Notification.NotificationType.AI_ANALYSIS_COMPLETE, + message=message, + ) + channel_layer = get_channel_layer() + payload = NotificationSerializer(notification).data + async_to_sync(channel_layer.group_send)( + f"notifications_{owner.id}", + { + "type": "send.notification", + "notification": payload, + }, + ) + except Exception as exc: + logger.warning("Falha ao enviar notificação de IA: %s", exc) return { "photo_id": photo.id, "property_id": photo.property_id, From 3f668405f71582a9d533ccb362f916138eec5821 Mon Sep 17 00:00:00 2001 From: DevlTz Date: Mon, 18 May 2026 23:39:20 -0300 Subject: [PATCH 2/7] =?UTF-8?q?feat(properties):=20enviar=20notifica=C3=A7?= =?UTF-8?q?=C3=A3o=20ao=20alterar=20pre=C3=A7o=20ou=20criar=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/properties/signals.py | 69 +++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/apps/properties/signals.py b/apps/properties/signals.py index 1b8be58..a3e49f7 100644 --- a/apps/properties/signals.py +++ b/apps/properties/signals.py @@ -2,12 +2,18 @@ from django.db.models.signals import post_delete, post_save from django.dispatch import receiver from apps.ai_analysis.tasks import analyze_photo_task -from .models import Properties, PropertiesPhotos +from .models import Properties, PropertiesPhotos, Reviews from django.utils import timezone from apps.users.models import PropertyAlert from apps.users.email_service import PropertyAlertEmailService from apps.properties.filters import PropertiesFilters +# Imports para notificações em tempo real +from apps.notifications.models import Notification +from apps.notifications.serializers import NotificationSerializer +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + # É um decorator que fica "escutando" eventos que acontecem no banco # evento = post_delete @@ -91,3 +97,64 @@ def property_matches_alert(property_obj, filters): filterset = PropertiesFilters(data=filters, queryset=queryset) # Se o queryset filtrado contém o imóvel, então ele casa com os critérios return queryset.filter(id__in=filterset.qs.values_list('id', flat=True)).exists() + +# ========================================================================= +# Sinais para notificações em tempo real +# +# Os sinais abaixo notificam usuários quando um imóvel favoritado muda de preço +# ou quando uma review é adicionada ao imóvel do proprietário. Eles criam +# registros de Notification e enviam a mensagem para o WebSocket do usuário. + + +def _send_realtime_notification(user, notification: Notification) -> None: + """Helper: envia uma notificação via WebSocket para o usuário fornecido.""" + channel_layer = get_channel_layer() + payload = NotificationSerializer(notification).data + async_to_sync(channel_layer.group_send)( + f"notifications_{user.id}", + { + "type": "send.notification", + "notification": payload, + }, + ) + + +@receiver(post_save, sender=Properties) +def notify_favorite_price_update(sender, instance, created, **kwargs): # noqa: ANN001, D401 + """Se o preço de um imóvel favoritado foi alterado, notifique os usuários.""" + if created: + return + # Obter estado anterior para comparar preço + try: + previous = sender.objects.get(pk=instance.pk) + except sender.DoesNotExist: + return + # Se o preço mudou, criar notificação para todos que favoritaram + if previous.price != instance.price: + message = ( + f"O imóvel '{instance.address}' teve seu preço atualizado para R${instance.price}." + ) + for user in instance.favorited_by.all(): + notification = Notification.objects.create( + user=user, + type=Notification.NotificationType.PRICE_UPDATE, + message=message, + ) + _send_realtime_notification(user, notification) + + +@receiver(post_save, sender=Reviews) +def notify_new_review(sender, instance, created, **kwargs): # noqa: ANN001, D401 + """Quando uma review é criada, notifique o proprietário do imóvel.""" + if not created: + return + property_obj = instance.property + owner = getattr(property_obj, "owner", None) + if owner: + message = f"O imóvel '{property_obj.address}' recebeu uma nova avaliação." + notification = Notification.objects.create( + user=owner, + type=Notification.NotificationType.NEW_REVIEW, + message=message, + ) + _send_realtime_notification(owner, notification) From 1cec267bef77e695e9723300627c62ffe478003d Mon Sep 17 00:00:00 2001 From: DevlTz Date: Mon, 18 May 2026 23:39:24 -0300 Subject: [PATCH 3/7] chore: configurar Django Channels e infra de WebSocket --- config/asgi.py | 28 +++++++++++++++++++++++----- config/routing.py | 17 +++++++++++++++++ config/settings.py | 27 ++++++++++++++++++++++++++- config/urls.py | 1 + docker-compose.yaml | 14 ++++++++++++++ requirements.txt | 3 +++ 6 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 config/routing.py diff --git a/config/asgi.py b/config/asgi.py index ea8e191..9992379 100644 --- a/config/asgi.py +++ b/config/asgi.py @@ -1,16 +1,34 @@ """ ASGI config for HomeMatch project. -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ +Esta configuração expõe a aplicação ASGI e usa o `ProtocolTypeRouter` para +direcionar requisições HTTP e WebSocket para os manipuladores apropriados. +As rotas de WebSocket são definidas em ``config.routing`` e incluem +autenticação via JWT dentro do consumidor de notificações. """ import os from django.core.asgi import get_asgi_application +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.auth import AuthMiddlewareStack + +try: + # Importa os padrões de URL WebSocket. Se não existirem, define uma lista vazia. + from config.routing import websocket_urlpatterns +except Exception: + websocket_urlpatterns = [] +# Define o módulo de configurações padrão para o Django os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") -application = get_asgi_application() +# Aplicação ASGI padrão do Django para lidar com requisições HTTP +django_asgi_app = get_asgi_application() + +# Compor a aplicação ASGI com suporte a HTTP e WebSocket +application = ProtocolTypeRouter( + { + "http": django_asgi_app, + "websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns)), + } +) \ No newline at end of file diff --git a/config/routing.py b/config/routing.py new file mode 100644 index 0000000..d731786 --- /dev/null +++ b/config/routing.py @@ -0,0 +1,17 @@ +""" +Define rotas para WebSocket da aplicação HomeMatch. + +Atualmente, existe apenas uma rota que aceita conexões em ``/ws/notifications/`` +e delega ao ``NotificationConsumer``, que lida com autenticação via JWT e +entrega de notificações em tempo real para cada usuário. +""" + +from django.urls import re_path + +from apps.notifications.consumers import NotificationConsumer + +# Lista de padrões de URL WebSocket. O cliente deve incluir o token JWT na +# query string, por exemplo: ``/ws/notifications/?token=...``. +websocket_urlpatterns = [ + re_path(r"^ws/notifications/$", NotificationConsumer.as_asgi()), +] \ No newline at end of file diff --git a/config/settings.py b/config/settings.py index 0ebfeef..7988c0a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -24,6 +24,8 @@ "rest_framework_simplejwt.token_blacklist", "corsheaders", "django_filters", + # Django Channels para suporte a WebSocket + "channels", ] LOCAL_APPS = [ @@ -31,6 +33,8 @@ "apps.properties", "apps.search", "apps.ai_analysis", + # Novo aplicativo para notificações em tempo real + "apps.notifications", ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS @@ -65,7 +69,8 @@ }, ] -WSGI_APPLICATION = "config.wsgi.application" +# Utilizar ASGI ao invés de WSGI para suportar WebSockets via Django Channels +ASGI_APPLICATION = "config.asgi.application" DATABASES = { "default": { @@ -75,6 +80,11 @@ "PASSWORD": config("DB_PASSWORD"), "HOST": config("DB_HOST"), "PORT": config("DB_PORT"), + # Configuração do banco de dados de testes. Durante a execução dos testes, + # o Django criará automaticamente um banco com este nome. + "TEST": { + "NAME": "test_homematch", + }, } } @@ -167,4 +177,19 @@ GOOGLE_PLACES_API_KEY = config("GOOGLE_PLACES_API_KEY", default="") +# Configuração do canal layer usando Redis. O serviço redis já está configurado no docker-compose +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [ + ( + config("CHANNEL_REDIS_HOST", default="redis"), + int(config("CHANNEL_REDIS_PORT", default=6379)), + ) + ], + }, + }, +} + STATICFILES_DIRS = [BASE_DIR / "frontend"] diff --git a/config/urls.py b/config/urls.py index 7f67f08..2e716c8 100644 --- a/config/urls.py +++ b/config/urls.py @@ -11,5 +11,6 @@ path("api/properties/", include("apps.properties.urls")), path("api/search/", include("apps.search.urls")), path("api/ai-analysis/", include("apps.ai_analysis.urls")), + path("api/notifications/", include("apps.notifications.urls")), path("", TemplateView.as_view(template_name="index.html")), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/docker-compose.yaml b/docker-compose.yaml index 639b418..472fcfd 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -51,5 +51,19 @@ services: redis: condition: service_healthy + # Serviço adicional para lidar com conexões WebSocket via Django Channels. + channels_worker: + build: . + command: daphne -b 0.0.0.0 -p 8001 config.asgi:application + volumes: + - .:/app + env_file: + - .env + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + volumes: postgres_data: \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 37ae0bd..f6c1ea4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,6 @@ openai celery redis google-generativeai +channels +channels-redis +daphne From 3b3e4761a838bab0bb34ef58befd30bb8251352c Mon Sep 17 00:00:00 2001 From: DevlTz Date: Mon, 18 May 2026 23:39:27 -0300 Subject: [PATCH 4/7] =?UTF-8?q?feat(notifications):=20adicionar=20app=20de?= =?UTF-8?q?=20notifica=C3=A7=C3=B5es=20com=20modelo,=20consumer=20e=20endp?= =?UTF-8?q?oints=20REST?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/notifications/__init__.py | 1 + apps/notifications/admin.py | 14 ++++ apps/notifications/apps.py | 9 +++ apps/notifications/consumers.py | 68 +++++++++++++++++++ apps/notifications/migrations/0001_initial.py | 44 ++++++++++++ apps/notifications/migrations/__init__.py | 1 + apps/notifications/models.py | 36 ++++++++++ apps/notifications/serializers.py | 12 ++++ apps/notifications/urls.py | 11 +++ apps/notifications/views.py | 38 +++++++++++ 10 files changed, 234 insertions(+) create mode 100644 apps/notifications/__init__.py create mode 100644 apps/notifications/admin.py create mode 100644 apps/notifications/apps.py create mode 100644 apps/notifications/consumers.py create mode 100644 apps/notifications/migrations/0001_initial.py create mode 100644 apps/notifications/migrations/__init__.py create mode 100644 apps/notifications/models.py create mode 100644 apps/notifications/serializers.py create mode 100644 apps/notifications/urls.py create mode 100644 apps/notifications/views.py diff --git a/apps/notifications/__init__.py b/apps/notifications/__init__.py new file mode 100644 index 0000000..6f1ea3d --- /dev/null +++ b/apps/notifications/__init__.py @@ -0,0 +1 @@ +"""Inicialização do aplicativo de notificações.""" diff --git a/apps/notifications/admin.py b/apps/notifications/admin.py new file mode 100644 index 0000000..75067b3 --- /dev/null +++ b/apps/notifications/admin.py @@ -0,0 +1,14 @@ +"""Admin para o modelo Notification.""" + +from django.contrib import admin + +from .models import Notification + + +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): # noqa: D401 + """Configuração do admin para o modelo Notification.""" + + list_display = ("user", "type", "read", "created_at") + list_filter = ("type", "read") + search_fields = ("message", "user__email") \ No newline at end of file diff --git a/apps/notifications/apps.py b/apps/notifications/apps.py new file mode 100644 index 0000000..a546a10 --- /dev/null +++ b/apps/notifications/apps.py @@ -0,0 +1,9 @@ +"""Configuração do aplicativo de notificações.""" + +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.notifications" + verbose_name = "Notifications" diff --git a/apps/notifications/consumers.py b/apps/notifications/consumers.py new file mode 100644 index 0000000..ad147d5 --- /dev/null +++ b/apps/notifications/consumers.py @@ -0,0 +1,68 @@ +""" +Consumidor de WebSocket para entrega de notificações em tempo real. + +Os clientes devem conectar‑se em ``/ws/notifications/`` e fornecer um token +JWT válido na query string (`?token=`). O consumidor autentica o +usuário usando o mesmo mecanismo de JWT da API REST e adiciona a conexão a um +grupo específico por usuário. Quando uma notificação é enviada para esse grupo, +ela é serializada e encaminhada ao cliente. +""" + +import json +from urllib.parse import parse_qs + +from channels.generic.websocket import AsyncWebsocketConsumer +from channels.db import database_sync_to_async +from rest_framework_simplejwt.authentication import JWTAuthentication + +from .serializers import NotificationSerializer + + +class NotificationConsumer(AsyncWebsocketConsumer): + async def connect(self): # noqa: D401 + """Autentica o usuário via token JWT passado na query string.""" + # Extrai o token JWT da query string + query_string = self.scope.get("query_string", b"").decode() + params = parse_qs(query_string) + token = params.get("token", [None])[0] + if not token: + await self.close() + return + + # Autentica o usuário a partir do token + user = await self._get_user_from_token(token) + if user is None or not user.is_authenticated: + await self.close() + return + + self.user = user + self.group_name = f"notifications_{self.user.id}" + + # Adiciona a conexão ao grupo do usuário + await self.channel_layer.group_add(self.group_name, self.channel_name) + await self.accept() + + async def disconnect(self, code): # noqa: D401, ARG002 + """Remove a conexão do grupo ao desconectar.""" + if hasattr(self, "group_name"): + await self.channel_layer.group_discard(self.group_name, self.channel_name) + + async def receive(self, text_data=None, bytes_data=None): # noqa: D401, ARG002 + """Ignora mensagens enviadas pelo cliente.""" + return + + async def send_notification(self, event): # noqa: D401 + """Envia a notificação serializada de volta ao cliente.""" + notification = event.get("notification") + if notification: + await self.send(text_data=json.dumps(notification)) + + @database_sync_to_async + def _get_user_from_token(self, token): + # Valida o token e retorna o usuário correspondente + jwt_auth = JWTAuthentication() + try: + validated = jwt_auth.get_validated_token(token) + return jwt_auth.get_user(validated) + except Exception: + return None \ No newline at end of file diff --git a/apps/notifications/migrations/0001_initial.py b/apps/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..a5bb6ac --- /dev/null +++ b/apps/notifications/migrations/0001_initial.py @@ -0,0 +1,44 @@ +from django.db import migrations, models +import django.db.models.deletion +from django.conf import settings + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Notification", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("type", models.CharField(max_length=50)), + ("message", models.TextField()), + ("read", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "notifications", + "ordering": ["-created_at"], + }, + ), + ] diff --git a/apps/notifications/migrations/__init__.py b/apps/notifications/migrations/__init__.py new file mode 100644 index 0000000..f20e043 --- /dev/null +++ b/apps/notifications/migrations/__init__.py @@ -0,0 +1 @@ +"""Pacote de migrações para notificações.""" diff --git a/apps/notifications/models.py b/apps/notifications/models.py new file mode 100644 index 0000000..fc7ef78 --- /dev/null +++ b/apps/notifications/models.py @@ -0,0 +1,36 @@ +""" +Modelos para o sistema de notificações em tempo real. + +Cada notificação pertence a um usuário e registra o tipo do evento, uma +mensagem para exibição, se já foi lida e o instante de criação. +""" + +from django.conf import settings +from django.db import models + + +class Notification(models.Model): + class NotificationType(models.TextChoices): + PRICE_UPDATE = "price_update", "Price Update" + NEW_REVIEW = "new_review", "New Review" + AI_ANALYSIS_COMPLETE = "ai_analysis_complete", "AI Analysis Complete" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="notifications", + ) + type = models.CharField( + max_length=50, + choices=NotificationType.choices, + ) + message = models.TextField() + read = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "notifications" + ordering = ["-created_at"] + + def __str__(self) -> str: # pragma: no cover + return f"Notification to {self.user.email}: {self.type}" diff --git a/apps/notifications/serializers.py b/apps/notifications/serializers.py new file mode 100644 index 0000000..80acb27 --- /dev/null +++ b/apps/notifications/serializers.py @@ -0,0 +1,12 @@ +"""Serializers para o modelo Notification.""" + +from rest_framework import serializers + +from .models import Notification + + +class NotificationSerializer(serializers.ModelSerializer): + class Meta: + model = Notification + fields = ["id", "type", "message", "read", "created_at"] + read_only_fields = ["id", "type", "message", "created_at"] \ No newline at end of file diff --git a/apps/notifications/urls.py b/apps/notifications/urls.py new file mode 100644 index 0000000..6a8d441 --- /dev/null +++ b/apps/notifications/urls.py @@ -0,0 +1,11 @@ +"""URLs para as notificações via API REST.""" + +from django.urls import path + +from .views import NotificationListView, NotificationUpdateView + + +urlpatterns = [ + path("", NotificationListView.as_view(), name="notification-list"), + path("/", NotificationUpdateView.as_view(), name="notification-detail"), +] \ No newline at end of file diff --git a/apps/notifications/views.py b/apps/notifications/views.py new file mode 100644 index 0000000..b8c8bf2 --- /dev/null +++ b/apps/notifications/views.py @@ -0,0 +1,38 @@ +""" +Views para listar e atualizar notificações via API REST. + +Permite que um usuário autenticado veja suas notificações e marque uma +notificação como lida. +""" + +from rest_framework import generics, permissions + +from .models import Notification +from .serializers import NotificationSerializer + + +class NotificationListView(generics.ListAPIView): + """ + Lista todas as notificações do usuário autenticado, ordenadas da mais + recente para a mais antiga. + """ + + serializer_class = NotificationSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return Notification.objects.filter(user=self.request.user).order_by("-created_at") + + +class NotificationUpdateView(generics.UpdateAPIView): + """ + Marca uma notificação como lida. Apenas o campo ``read`` deve ser + atualizado via PATCH. + """ + + serializer_class = NotificationSerializer + permission_classes = [permissions.IsAuthenticated] + http_method_names = ["patch"] + + def get_queryset(self): + return Notification.objects.filter(user=self.request.user) From 4587ca1fccd6f612869209935d42e0fb3865e98f Mon Sep 17 00:00:00 2001 From: DevlTz Date: Mon, 18 May 2026 23:39:31 -0300 Subject: [PATCH 5/7] =?UTF-8?q?test:=20adicionar=20configura=C3=A7=C3=A3o?= =?UTF-8?q?=20do=20PyTest=20e=20testes=20de=20autentica=C3=A7=C3=A3o,=20im?= =?UTF-8?q?=C3=B3veis,=20reviews=20e=20favoritos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/properties/tests/__init__.py | 1 + apps/properties/tests/test_properties.py | 129 +++++++++++++++++++++++ apps/properties/tests/test_reviews.py | 92 ++++++++++++++++ apps/users/tests/__init__.py | 1 + apps/users/tests/test_auth.py | 86 +++++++++++++++ apps/users/tests/test_favorites.py | 54 ++++++++++ conftest.py | 80 ++++++++++++++ pytest.ini | 5 + requirements-dev.txt | 4 + 9 files changed, 452 insertions(+) create mode 100644 apps/properties/tests/__init__.py create mode 100644 apps/properties/tests/test_properties.py create mode 100644 apps/properties/tests/test_reviews.py create mode 100644 apps/users/tests/__init__.py create mode 100644 apps/users/tests/test_auth.py create mode 100644 apps/users/tests/test_favorites.py create mode 100644 conftest.py create mode 100644 pytest.ini create mode 100644 requirements-dev.txt diff --git a/apps/properties/tests/__init__.py b/apps/properties/tests/__init__.py new file mode 100644 index 0000000..f3c5beb --- /dev/null +++ b/apps/properties/tests/__init__.py @@ -0,0 +1 @@ +"""Pacote de testes para o app properties.""" \ No newline at end of file diff --git a/apps/properties/tests/test_properties.py b/apps/properties/tests/test_properties.py new file mode 100644 index 0000000..703baed --- /dev/null +++ b/apps/properties/tests/test_properties.py @@ -0,0 +1,129 @@ +""" +Testes para operações relacionadas a imóveis. + +Inclui listagem pública, filtros, criação, atualização e exclusão, com +verificações de permissões conforme o tipo de usuário (advertiser ou seeker). +""" + +import pytest +from rest_framework import status + +from apps.properties.models import Rooms + + +@pytest.mark.django_db +def test_property_list_public(api_client, property_factory): + # Cria ao menos uma propriedade + property_factory() + response = api_client.get("/api/properties/") + assert response.status_code == status.HTTP_200_OK + # A listagem usa paginação, então a chave results deve existir + assert "results" in response.data + + +@pytest.mark.django_db +def test_property_filters(api_client, property_factory): + # Uma propriedade barata e outra cara + property_factory(price=500) + property_factory(price=1500) + response = api_client.get("/api/properties/?min_price=1000") + assert response.status_code == status.HTTP_200_OK + # Esperamos apenas a propriedade acima de 1000 + assert len(response.data["results"]) == 1 +@pytest.mark.django_db +def test_property_create_by_advertiser(api_client, advertiser_user, auth_tokens): + tokens = auth_tokens(advertiser_user) + api_client.credentials(HTTP_AUTHORIZATION="Bearer " + tokens["access"]) + + data = { + "property_purpose": "S", + "type": "H", + "area": 120, + "floors": 2, + "floor_number": 0, + "price": 2500, + "address": "Rua das Casas", + "neighborhood": "Centro", + "city": "Natal", + "has_mobilia": False, + "status": True, + "description": "Casa ampla", + "embedding": "[]", + "rooms": { + "bedrooms": 2, + "bathrooms": 2, + "parking_spots": 1 + }, + "rooms_extras": {} + } + response = api_client.post("/api/properties/", data, format="json") + assert response.status_code == status.HTTP_201_CREATED + + +@pytest.mark.django_db +def test_property_create_by_seeker_forbidden(api_client, seeker_user, auth_tokens): + tokens = auth_tokens(seeker_user) + api_client.credentials(HTTP_AUTHORIZATION="Bearer " + tokens["access"]) + + data = { + "property_purpose": "R", + "type": "A", + "area": 60, + "floors": 1, + "floor_number": 1, + "price": 800, + "address": "Rua das Avenidas", + "neighborhood": "Centro", + "city": "Natal", + "has_mobilia": False, + "status": True, + "description": "Apartamento", + "embedding": "[]", + "rooms": { + "bedrooms": 1, + "bathrooms": 1, + "parking_spots": 1 + }, + "rooms_extras": {} + } + response = api_client.post("/api/properties/", data, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_property_update_by_owner(api_client, advertiser_user, property_factory, auth_tokens): + # Cria uma propriedade pertencente ao advertiser + prop = property_factory() + tokens = auth_tokens(advertiser_user) + api_client.credentials(HTTP_AUTHORIZATION="Bearer " + tokens["access"]) + # Atualiza preço via PATCH + response = api_client.patch(f"/api/properties/{prop.id}/", {"price": 1100}) + assert response.status_code == status.HTTP_200_OK + assert response.data.get("price") == "1100.00" + + +@pytest.mark.django_db +def test_property_update_by_other_user_forbidden(api_client, seeker_user, property_factory, auth_tokens): + prop = property_factory() + tokens = auth_tokens(seeker_user) + api_client.credentials(HTTP_AUTHORIZATION="Bearer " + tokens["access"]) + response = api_client.patch(f"/api/properties/{prop.id}/", {"price": 1300}) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_property_delete_by_owner(api_client, advertiser_user, property_factory, auth_tokens): + prop = property_factory() + tokens = auth_tokens(advertiser_user) + api_client.credentials(HTTP_AUTHORIZATION="Bearer " + tokens["access"]) + response = api_client.delete(f"/api/properties/{prop.id}/") + assert response.status_code == status.HTTP_204_NO_CONTENT + + +@pytest.mark.django_db +def test_property_delete_by_other_user_forbidden(api_client, seeker_user, property_factory, auth_tokens): + prop = property_factory() + tokens = auth_tokens(seeker_user) + api_client.credentials(HTTP_AUTHORIZATION="Bearer " + tokens["access"]) + response = api_client.delete(f"/api/properties/{prop.id}/") + assert response.status_code == status.HTTP_403_FORBIDDEN \ No newline at end of file diff --git a/apps/properties/tests/test_reviews.py b/apps/properties/tests/test_reviews.py new file mode 100644 index 0000000..8a75085 --- /dev/null +++ b/apps/properties/tests/test_reviews.py @@ -0,0 +1,92 @@ +""" +Testes para o gerenciamento de reviews nos imóveis. + +Inclui listagem pública, criação por usuário autenticado, edição e deleção +restritas ao autor. +""" + +import pytest +from rest_framework import status + + +@pytest.mark.django_db +def test_review_list_public(api_client, property_factory): + prop = property_factory() + response = api_client.get(f"/api/properties/{prop.id}/reviews/") + assert response.status_code == status.HTTP_200_OK + assert "results" in response.data + + +@pytest.mark.django_db +def test_review_create_authenticated(api_client, property_factory, seeker_user, auth_tokens): + prop = property_factory() + tokens = auth_tokens(seeker_user) + api_client.credentials(HTTP_AUTHORIZATION="Bearer " + tokens["access"]) + data = {"rating": 4, "comment": "Muito bom"} + response = api_client.post(f"/api/properties/{prop.id}/reviews/", data) + assert response.status_code == status.HTTP_201_CREATED + + +@pytest.mark.django_db +def test_review_update_by_author(api_client, property_factory, seeker_user, auth_tokens): + prop = property_factory() + tokens = auth_tokens(seeker_user) + api_client.credentials(HTTP_AUTHORIZATION="Bearer " + tokens["access"]) + # Cria review + create_resp = api_client.post(f"/api/properties/{prop.id}/reviews/", {"rating": 4, "comment": "Ok"}) + review_id = create_resp.data["id"] + # Atualiza + response = api_client.put( + f"/api/properties/{prop.id}/reviews/{review_id}/", + {"rating": 5, "comment": "Excelente"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.data.get("rating") == 5 + + +@pytest.mark.django_db +def test_review_update_by_other_user_forbidden( + api_client, property_factory, seeker_user, advertiser_user, auth_tokens +): + prop = property_factory() + # Cria review por seeker + seeker_tokens = auth_tokens(seeker_user) + api_client.credentials(HTTP_AUTHORIZATION="Bearer " + seeker_tokens["access"]) + create_resp = api_client.post(f"/api/properties/{prop.id}/reviews/", {"rating": 4, "comment": "Bom"}) + review_id = create_resp.data["id"] + # Tenta editar como advertiser + adv_tokens = auth_tokens(advertiser_user) + api_client.credentials(HTTP_AUTHORIZATION="Bearer " + adv_tokens["access"]) + response = api_client.put( + f"/api/properties/{prop.id}/reviews/{review_id}/", + {"rating": 3, "comment": "Razoável"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_review_delete_by_author(api_client, property_factory, seeker_user, auth_tokens): + prop = property_factory() + tokens = auth_tokens(seeker_user) + api_client.credentials(HTTP_AUTHORIZATION="Bearer " + tokens["access"]) + create_resp = api_client.post(f"/api/properties/{prop.id}/reviews/", {"rating": 4, "comment": "Ok"}) + review_id = create_resp.data["id"] + response = api_client.delete(f"/api/properties/{prop.id}/reviews/{review_id}/") + assert response.status_code == status.HTTP_204_NO_CONTENT + + +@pytest.mark.django_db +def test_review_delete_by_other_user_forbidden( + api_client, property_factory, seeker_user, advertiser_user, auth_tokens +): + prop = property_factory() + # Cria review como seeker + seeker_tokens = auth_tokens(seeker_user) + api_client.credentials(HTTP_AUTHORIZATION="Bearer " + seeker_tokens["access"]) + create_resp = api_client.post(f"/api/properties/{prop.id}/reviews/", {"rating": 4, "comment": "Ok"}) + review_id = create_resp.data["id"] + # Tenta deletar como advertiser (não autor) + adv_tokens = auth_tokens(advertiser_user) + api_client.credentials(HTTP_AUTHORIZATION="Bearer " + adv_tokens["access"]) + response = api_client.delete(f"/api/properties/{prop.id}/reviews/{review_id}/") + assert response.status_code == status.HTTP_403_FORBIDDEN \ No newline at end of file diff --git a/apps/users/tests/__init__.py b/apps/users/tests/__init__.py new file mode 100644 index 0000000..485858c --- /dev/null +++ b/apps/users/tests/__init__.py @@ -0,0 +1 @@ +"""Pacote de testes para o app users.""" \ No newline at end of file diff --git a/apps/users/tests/test_auth.py b/apps/users/tests/test_auth.py new file mode 100644 index 0000000..c25ffd9 --- /dev/null +++ b/apps/users/tests/test_auth.py @@ -0,0 +1,86 @@ +""" +Testes de autenticação para o aplicativo de usuários. + +Cobre registro, login, refresh e logout, incluindo casos de erro como +credenciais inválidas ou emails duplicados. +""" + +import pytest +from rest_framework import status + + +@pytest.mark.django_db +def test_register_user_success(api_client): + data = { + "name": "Novo Usuário", + "email": "novo@example.com", + "password": "password123", + "user_type": "S", + } + response = api_client.post("/api/users/register/", data) + assert response.status_code == status.HTTP_201_CREATED + + +@pytest.mark.django_db +def test_register_duplicate_email(api_client, create_user): + # Cria usuário com email duplicado + create_user(name="Usuário Existente", email="dup@example.com", user_type="S", password="password123") + data = { + "name": "Outro Usuário", + "email": "dup@example.com", + "password": "password123", + "user_type": "S", + } + response = api_client.post("/api/users/register/", data) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_login_success(api_client, create_user): + user = create_user(name="Usuário Teste", email="login@example.com", user_type="S", password="password123") + response = api_client.post( + "/api/users/login/", + {"email": user.email, "password": "password123"}, + ) + assert response.status_code == status.HTTP_200_OK + assert "access" in response.data and "refresh" in response.data + + +@pytest.mark.django_db +def test_login_invalid_credentials(api_client, create_user): + user = create_user(name="Usuário Teste", email="invalid@example.com", user_type="S", password="password123") + response = api_client.post( + "/api/users/login/", + {"email": user.email, "password": "senhaerrada"}, + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +@pytest.mark.django_db +def test_token_refresh(api_client, create_user): + user = create_user(name="Usuário Refresh", email="refresh@example.com", user_type="S", password="password123") + login_resp = api_client.post( + "/api/users/login/", + {"email": user.email, "password": "password123"}, + ) + refresh_token = login_resp.data["refresh"] + response = api_client.post("/api/users/token/refresh/", {"refresh": refresh_token}) + assert response.status_code == status.HTTP_200_OK + assert "access" in response.data + + +@pytest.mark.django_db +def test_logout_blacklists_refresh(api_client, create_user): + user = create_user(name="Usuário Logout", email="logout@example.com", user_type="S", password="password123") + login_resp = api_client.post( + "/api/users/login/", + {"email": user.email, "password": "password123"}, + ) + refresh_token = login_resp.data["refresh"] + # Efetua logout + response = api_client.post("/api/users/logout/", {"refresh": refresh_token}) + # TokenBlacklistView pode retornar 200 ou 205 depending on version + assert response.status_code in [status.HTTP_205_RESET_CONTENT, status.HTTP_200_OK] + # Tenta usar o mesmo refresh token novamente + refresh_resp = api_client.post("/api/users/token/refresh/", {"refresh": refresh_token}) + assert refresh_resp.status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_400_BAD_REQUEST] \ No newline at end of file diff --git a/apps/users/tests/test_favorites.py b/apps/users/tests/test_favorites.py new file mode 100644 index 0000000..04cfa3c --- /dev/null +++ b/apps/users/tests/test_favorites.py @@ -0,0 +1,54 @@ +""" +Testes para a funcionalidade de favoritos de imóveis. + +Inclui operações de listar, adicionar e remover favoritos, garantindo que +usuários não autenticados recebam o status apropriado. +""" + +import pytest +from rest_framework import status + + +@pytest.mark.django_db +def test_get_favorites_authenticated(api_client, seeker_user, property_factory, auth_tokens): + # Cria duas propriedades + prop1 = property_factory() + prop2 = property_factory() + # Adiciona uma propriedade aos favoritos do usuário + seeker_user.favorites.add(prop1) + tokens = auth_tokens(seeker_user) + api_client.credentials(HTTP_AUTHORIZATION="Bearer " + tokens["access"]) + response = api_client.get("/api/users/favorites/") + assert response.status_code == status.HTTP_200_OK + # Deve retornar apenas os favoritos do usuário (1 item) + assert isinstance(response.data, list) + assert len(response.data) == 1 + + +@pytest.mark.django_db +def test_add_favorite(api_client, seeker_user, property_factory, auth_tokens): + prop = property_factory() + tokens = auth_tokens(seeker_user) + api_client.credentials(HTTP_AUTHORIZATION="Bearer " + tokens["access"]) + response = api_client.post("/api/users/favorites/", {"property_id": prop.id}) + assert response.status_code == status.HTTP_200_OK + # Verifica se a propriedade está nos favoritos + assert seeker_user.favorites.filter(id=prop.id).exists() + + +@pytest.mark.django_db +def test_remove_favorite(api_client, seeker_user, property_factory, auth_tokens): + prop = property_factory() + seeker_user.favorites.add(prop) + tokens = auth_tokens(seeker_user) + api_client.credentials(HTTP_AUTHORIZATION="Bearer " + tokens["access"]) + # A API espera property_id no corpo da requisição + response = api_client.delete("/api/users/favorites/", {"property_id": prop.id}) + assert response.status_code == status.HTTP_200_OK + assert not seeker_user.favorites.filter(id=prop.id).exists() + + +@pytest.mark.django_db +def test_favorites_unauthenticated(api_client): + response = api_client.get("/api/users/favorites/") + assert response.status_code == status.HTTP_401_UNAUTHORIZED \ No newline at end of file diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..a6721f3 --- /dev/null +++ b/conftest.py @@ -0,0 +1,80 @@ +""" +Fixtures reutilizáveis para a suíte de testes do HomeMatch. +""" + +import pytest +from rest_framework.test import APIClient +from django.contrib.auth import get_user_model +from rest_framework_simplejwt.tokens import RefreshToken + +from apps.properties.models import Properties, Rooms, RoomsExtras + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def create_user(db): + def make_user(**kwargs): + User = get_user_model() + password = kwargs.pop("password", "password123") + user = User.objects.create(**kwargs) + user.set_password(password) + user.save() + return user + return make_user + + +@pytest.fixture +def advertiser_user(create_user): + return create_user(name="Advertiser User", email="advertiser@example.com", user_type="A", password="password123") + + +@pytest.fixture +def seeker_user(create_user): + return create_user(name="Seeker User", email="seeker@example.com", user_type="S", password="password123") + + +@pytest.fixture +def property_factory(db, advertiser_user): + def make_property(**kwargs): + # Usa get_or_create para evitar erro de quarto repetido (UniqueConstraint) + rooms, _ = Rooms.objects.get_or_create( + bedrooms=kwargs.pop("bedrooms", 1), + bathrooms=kwargs.pop("bathrooms", 1), + parking_spots=kwargs.pop("parking_spots", 1) + ) + extras, _ = RoomsExtras.objects.get_or_create() + + defaults = { + "owner": advertiser_user, + "rooms": rooms, + "rooms_extras": extras, + "property_purpose": "R", + "type": "A", + "area": 50, + "floors": 1, + "floor_number": 1, + "price": 1000, + "address": "Rua Teste", + "neighborhood": "Centro", + "city": "Natal", + "has_mobilia": False, + "status": True, + "description": "Propriedade de teste", + "embedding": "[]", + } + defaults.update(kwargs) + return Properties.objects.create(**defaults) + + return make_property + + +@pytest.fixture +def auth_tokens(): + def _tokens(user): + refresh = RefreshToken.for_user(user) + return {"access": str(refresh.access_token), "refresh": str(refresh)} + return _tokens \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..481e542 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +DJANGO_SETTINGS_MODULE = config.settings +python_files = test_*.py +# Adiciona opções de cobertura para gerar relatório durante a execução de CI +addopts = --reuse-db --cov=apps --cov-report=term-missing \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..d40779b --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +pytest==8.1.1 +pytest-django==4.8.0 +pytest-cov==4.1.0 +coverage==7.4.1 \ No newline at end of file From fab9eb7cf958fe534802c52c632ff87e4011afb8 Mon Sep 17 00:00:00 2001 From: DevlTz Date: Mon, 18 May 2026 23:47:13 -0300 Subject: [PATCH 6/7] fix(tests) --- .coverage | Bin 0 -> 69632 bytes apps/properties/tests/test_properties.py | 7 +-- apps/properties/tests/test_reviews.py | 65 +++++++++++------------ 3 files changed, 34 insertions(+), 38 deletions(-) create mode 100644 .coverage diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..08ce4833bd7e7d339fca77cf92ff2d6bcd0f0b49 GIT binary patch literal 69632 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 Date: Mon, 18 May 2026 23:53:27 -0300 Subject: [PATCH 7/7] Fix(end test 100%) --- .coverage | Bin 69632 -> 69632 bytes README.md | 74 +++++++++++++++++++++++++++++++- apps/properties/repositories.py | 1 - apps/properties/use_cases.py | 14 ++++-- conftest.py | 1 - 5 files changed, 82 insertions(+), 8 deletions(-) diff --git a/.coverage b/.coverage index 08ce4833bd7e7d339fca77cf92ff2d6bcd0f0b49..9bcc133816c0cb85938690bb7ffe700e8aee1605 100644 GIT binary patch delta 770 zcmX|+OH30{7==5`?abWHbf}ewBt?UrKIj|U@-zZQLS>^H72~6=Bt&COgDwaNX}b|M zghwt5OhgH~QCVolrIw8@G%+Oxq=aB#0ZnS8U`eE)t+ykvy61fV`Tv^}8V8|q@R|w{ z-G~l(id*?Jd;{<1&AgUZ@j#hYCY3A71=JqW>BhJR+zrmhwQwgm7iZzhxDrmq5$rPi zmYrZ9v4iYAwp)A?nqE0=<7g~SP>@%CmVPu z(6h`iT}j_z?94}WdAg3xz?9-VybmLaVR2%5gd&fVy{Iu+gwD?Ks3lei9DWc+9c#rB zco(aY+b@E!dq1)v2Q-1 z0)mUYg_aUW&`3fJ3~n+YnNNv>gb^(!YUR3XAlxjmpWF37ZzKJZ!3PPg`0~dyQ1VwX zq&NqE!S}EaUVvKW12e?f=o$L9*uEa6^6DIDeDly=AIfJ3vTze3{uJ&tqFQYjx%bh= zTm|K`?x&p#w=;Jh9SQ=9K~$zMTKb4k1Zt=l4J9sExX_47E82y`pew?|v~0v6 zLClW{0Z{@gjSG{;t=$SPAcoi)!$TG{DN!(hwxF%IBfIaMlXLI+!&4|ch2FAZx)->x z@9-tLS#FTsvQ1Xx5;;#sx;@>NZV7^6qw$3_DBY0)(nZNDxh1<)E*+KfC5^Zyz7uD} zadAW(5PKu}`i}ziJYGW&JqZ{1hpO~{rR8WX@jT1nKshK)*M+0u@!{m3L0B-j)hb?GaH7(vhUe+;k?ALI&dS^~ zZWjBof=BT$t_H3xc*F@|j^7n-3oiaqWO`wgMK@5t7aBK$BkOEq&BR+GnJHC=#AZl+Te zuhWNUBP=EBwdR|sze)8x8(yS1n1CwCm$V`BdSeEa%nIYeEy2m}@B{p1yo&+1;d1T+ zHyR0U&9m%v7ox~w`cDz8q%I&QLv^U4B_{tL{`;!NV~44oBcSe1RcrhXK_BmHUf@{{TW+|IGjZ diff --git a/README.md b/README.md index 6b2e0ab..77b4eb9 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,12 @@ The project currently includes a working backend API, asynchronous image analysi - Static frontend integrated with the API. - Frontend prepared for backend-owned natural-language search. +### New in Sprint 4 + +- **Real‑time notifications** for key events: when a favourited property's price changes, when someone reviews one of your properties, or when the AI finishes analysing your property photos. Notifications are delivered live over WebSockets using Django Channels. +- **Notification API** to list your notifications (`GET /api/notifications/`) and mark them as read (`PATCH /api/notifications/{id}/`). +- **Test suite** with pytest and pytest‑django covering authentication, property CRUD, reviews and favourites. Run `pip install -r requirements-dev.txt` and `pytest` to execute all tests with a coverage report. + --- ## Architecture @@ -170,6 +176,36 @@ The configured model must support `generateContent` and image input. --- +## Real‑Time Notifications + +HomeMatch agora suporta notificações em tempo real através de **WebSockets**. Quando um usuário favorita um imóvel, ele será avisado imediatamente caso: + +1. O preço do imóvel seja atualizado. +2. Uma nova review seja feita para esse imóvel. +3. A análise de IA de uma foto do imóvel seja concluída. + +### Como funciona + +- O backend utiliza **Django Channels** com Redis como *channel layer* para gerenciar conexões WebSocket. +- Um serviço adicional `channels_worker` foi adicionado ao `docker-compose.yaml` para processar mensagens WebSocket. +- A URL do WebSocket é: `ws://:8001/ws/notifications/?token=`. +- O token de acesso JWT deve ser passado como parâmetro de consulta (`token=`) para autenticar a conexão. + +### Endpoints REST associados + +Além das mensagens em tempo real, as notificações são persistidas no banco de dados e podem ser acessadas via API: + +| Método | Endpoint | Descrição | +|---|---|---| +| `GET` | `/api/notifications/` | Lista todas as notificações do usuário autenticado | +| `PATCH` | `/api/notifications/{id}/` | Marca uma notificação específica como lida | + +As notificações armazenam o tipo de evento, a mensagem e um campo `read` indicando se já foram visualizadas. + +--- + +--- + ## API Endpoints ### Authentication @@ -363,6 +399,40 @@ Change admin password: docker compose exec web python manage.py changepassword ``` +--- + +## Running Tests + +This project includes an automated test suite using **pytest** and **pytest-django**. The tests cover authentication, property creation and management, reviews, and favorites. To run the tests with a coverage report: + +1. Install dependencys: + + ```bash + pip install -r requirements-dev.txt + ``` + +2. Run the migrations in a test database (this is done automatically by pytest-django): + + ```bash + docker compose exec web python manage.py migrate + ``` + +3. Run the tests: + + ```bash + pytest + ``` + +4. To check code coverage, use: + + ```bash + pytest --cov=apps + ``` + +The `pytest.ini` file is already configured to point to the Django settings (`DJANGO_SETTINGS_MODULE=config.settings`) and to use the coverage option automatically when needed. + +Reusable fixtures (advertiser user, regular user, tokens, property factory) are defined in `conftest.py`. The test database is isolated and uses the `TEST` configuration in `settings.py` to avoid conflicts with the development database. + Run reports: ```bash @@ -375,11 +445,11 @@ pip install -r tools/requirements-dev.txt ## Team | Name | GitHub | -|---|---| +| --- | --- | | Kauã do Vale Ferreira | [@DevlTz](https://github.com/DevlTz) | | Luisa Ferreira de Souza Santos | [@luisaferreirass](https://github.com/luisaferreirass) | | Lucas Graziano dos Santos Anselmo | [@lucasanselmocc](https://github.com/lucasanselmocc) | --- -*Software Engineering course project @UFRN* +Software Engineering course project @UFRN diff --git a/apps/properties/repositories.py b/apps/properties/repositories.py index 77ada9e..2a799b8 100644 --- a/apps/properties/repositories.py +++ b/apps/properties/repositories.py @@ -27,7 +27,6 @@ def create_property(*, rooms, rooms_extras, condo, validated_data): rooms=rooms, rooms_extras=rooms_extras, condo=condo, - embedding="[]", **validated_data, ) diff --git a/apps/properties/use_cases.py b/apps/properties/use_cases.py index 397477b..4107f42 100644 --- a/apps/properties/use_cases.py +++ b/apps/properties/use_cases.py @@ -311,14 +311,20 @@ def update_property(instance, validated_data): @staticmethod def delete_property(instance): + property_id = instance.id rooms = instance.rooms rooms_extras = instance.rooms_extras + instance.delete() - if rooms and not rooms.properties.exists(): - rooms.delete() - if rooms_extras and not rooms_extras.properties.exists(): - rooms_extras.delete() + # Proteção contra erros de deleção em cascata/relacionamentos órfãos + if rooms and hasattr(rooms, 'id') and rooms.id is not None: + if not rooms.properties.exclude(id=property_id).exists(): + rooms.delete() + + if rooms_extras and hasattr(rooms_extras, 'id') and rooms_extras.id is not None: + if not rooms_extras.properties.exclude(id=property_id).exists(): + rooms_extras.delete() class PhotoUseCase: diff --git a/conftest.py b/conftest.py index a6721f3..da02653 100644 --- a/conftest.py +++ b/conftest.py @@ -64,7 +64,6 @@ def make_property(**kwargs): "has_mobilia": False, "status": True, "description": "Propriedade de teste", - "embedding": "[]", } defaults.update(kwargs) return Properties.objects.create(**defaults)