From 042dc07d4f68ecab822e8a20a6b024bb49c50f2c Mon Sep 17 00:00:00 2001 From: DevlTz Date: Mon, 18 May 2026 19:08:07 -0300 Subject: [PATCH 1/3] feat(api): implementa endpoints de alertas e fluxos de reset de senha/email --- apps/properties/signals.py | 50 +++++++++ apps/users/email_service.py | 57 ++++++++++ apps/users/serializers.py | 23 +++- apps/users/urls.py | 9 +- apps/users/views.py | 208 +++++++++++++++++++++++++++++++++++- 5 files changed, 340 insertions(+), 7 deletions(-) create mode 100644 apps/users/email_service.py diff --git a/apps/properties/signals.py b/apps/properties/signals.py index 2929db7..1b8be58 100644 --- a/apps/properties/signals.py +++ b/apps/properties/signals.py @@ -3,6 +3,10 @@ from django.dispatch import receiver from apps.ai_analysis.tasks import analyze_photo_task from .models import Properties, PropertiesPhotos +from django.utils import timezone +from apps.users.models import PropertyAlert +from apps.users.email_service import PropertyAlertEmailService +from apps.properties.filters import PropertiesFilters # É um decorator que fica "escutando" eventos que acontecem no banco @@ -41,3 +45,49 @@ def trigger_ai_analysis_on_photo_upload( return analyze_photo_task.delay(instance.pk, prompt) + +@receiver(post_save, sender=Properties) +def check_property_alerts(sender, instance, created, **kwargs): + """ + Signal disparado após salvar um imóvel. + Verifica alertas ativos e notifica usuários cujos critérios casam com o novo imóvel. + """ + # Só processa para imóveis recém-criados + if not created: + return + + # Buscar todos os alertas ativos + active_alerts = PropertyAlert.objects.filter(is_active=True).select_related('user') + for alert in active_alerts: + # Verificar se o imóvel casa com os filtros do alerta + if property_matches_alert(instance, alert.filters): + # Enviar email + success = PropertyAlertEmailService.send_property_alert_email( + user_email=alert.user.email, + user_name=alert.user.name, + property_obj=instance, + alert=alert + ) + # Atualizar last_notified_at se email foi enviado + if success: + alert.last_notified_at = timezone.now() + alert.save(update_fields=['last_notified_at']) + + +def property_matches_alert(property_obj, filters): + """ + Verifica se um imóvel corresponde aos filtros de um alerta. + + Args: + property_obj (Properties): Objeto de imóvel criado. + filters (dict): Dicionário com os filtros do alerta. + + Returns: + bool: True se o imóvel casa com os filtros, False caso contrário. + """ + # Criar um queryset contendo apenas este imóvel + queryset = Properties.objects.filter(id=property_obj.id) + # Aplicar os filtros usando o PropertiesFilters + 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() diff --git a/apps/users/email_service.py b/apps/users/email_service.py new file mode 100644 index 0000000..0b8c0b9 --- /dev/null +++ b/apps/users/email_service.py @@ -0,0 +1,57 @@ +from django.core.mail import send_mail +from django.conf import settings + + +class PropertyAlertEmailService: + """ + Serviço para envio de emails de alertas de imóveis. + """ + + @staticmethod + def send_property_alert_email(user_email, user_name, property_obj, alert): + """ + Envia email notificando sobre novo imóvel que casa com os critérios do alerta. + + Args: + user_email (str): Email do usuário + user_name (str): Nome do usuário + property_obj (Properties): Objeto Properties que foi criado + alert (PropertyAlert): Objeto PropertyAlert que foi ativado + + Returns: + bool: True se o email foi enviado com sucesso, False caso contrário. + """ + # Construir URL do imóvel (ajustar conforme o frontend) + property_url = f"{settings.FRONTEND_URL}/properties/{property_obj.id}" + + subject = f"🏠 Novo imóvel encontrado: {property_obj.get_type_display()} em {property_obj.neighborhood}" + + # Mensagem em texto plano (fallback) + message = ( + f"Olá {user_name},\n\n" + "Encontramos um novo imóvel que corresponde aos seus critérios de busca!\n\n" + "Detalhes do imóvel:\n" + f"- Tipo: {property_obj.get_type_display()}\n" + f"- Localização: {property_obj.neighborhood}, {property_obj.city}\n" + f"- Preço: R$ {property_obj.price}\n" + f"- Área: {property_obj.area}m²\n" + f"- Quartos: {property_obj.rooms.bedrooms}\n" + f"- Banheiros: {property_obj.rooms.bathrooms}\n\n" + f"Ver imóvel: {property_url}\n\n" + "---\n" + "Esta é uma notificação automática do sistema de alertas HomeMatch.\n" + f"Para gerenciar seus alertas, acesse: {settings.FRONTEND_URL}/alerts\n" + ) + + try: + send_mail( + subject=subject, + message=message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user_email], + fail_silently=False, + ) + return True + except Exception as e: + print(f"Erro ao enviar email de alerta: {e}") + return False \ No newline at end of file diff --git a/apps/users/serializers.py b/apps/users/serializers.py index 4345f0a..1895c6c 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -1,6 +1,6 @@ # apps/users/serializers.py from rest_framework import serializers -from .models import User, SearchPreference +from .models import PropertyAlert, User, SearchPreference from .services import UserService class SearchPreferenceSerializer(serializers.ModelSerializer): @@ -33,4 +33,23 @@ def validate_email(self, value): email = UserService.normalize_and_validate_email(value) if email is None: raise serializers.ValidationError("This email is currently in use.") - return email \ No newline at end of file + return email + + +class PropertyAlertSerializer(serializers.ModelSerializer): + """ + Serializer para alertas de imóveis. + """ + + class Meta: + model = PropertyAlert + fields = ['id', 'filters', 'is_active', 'created_at', 'last_notified_at'] + read_only_fields = ['created_at', 'last_notified_at'] + + def validate_filters(self, value): + """ + Valida que o campo filters é um dicionário válido. + """ + if not isinstance(value, dict): + raise serializers.ValidationError("Os filtros devem ser um objeto JSON válido.") + return value \ No newline at end of file diff --git a/apps/users/urls.py b/apps/users/urls.py index 18760a7..8e19a82 100644 --- a/apps/users/urls.py +++ b/apps/users/urls.py @@ -1,5 +1,5 @@ from rest_framework.routers import DefaultRouter -from .views import UserViewSet, RegisterUserView +from .views import (UserViewSet, RegisterUserView, PasswordResetRequestView, PasswordResetConfirmView, EmailChangeRequestView, EmailChangeConfirmView,) from django.urls import path from rest_framework_simplejwt.views import (TokenObtainPairView, TokenRefreshView, TokenBlacklistView) @@ -9,6 +9,11 @@ path('register/', RegisterUserView.as_view(), name='register'), path('login/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), - path('logout/', TokenBlacklistView.as_view(), name='logout') + path('logout/', TokenBlacklistView.as_view(), name='logout'), + path('password-reset/', PasswordResetRequestView.as_view(), name='password_reset'), + path('password-reset/confirm/', PasswordResetConfirmView.as_view(), name='password_reset_confirm'), + path('email-change/', EmailChangeRequestView.as_view(), name='email_change'), + path('email-change/confirm/', EmailChangeConfirmView.as_view(), name='email_change_confirm'), + path('alerts//', UserViewSet.as_view({'delete': 'delete_alert'}), name='alert-delete'), ] + router.urls diff --git a/apps/users/views.py b/apps/users/views.py index 5b3763d..837e548 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -2,8 +2,17 @@ from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated, AllowAny -from .models import User -from .serializers import UserSerializer, RegisterSerializer +from .models import User, PropertyAlert +from .serializers import UserSerializer, RegisterSerializer, PropertyAlertSerializer +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.utils.encoding import force_bytes, force_str +from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode +from rest_framework.views import APIView +from django.core.mail import send_mail +from django.conf import settings +from datetime import timedelta, datetime +import jwt +from rest_framework_simplejwt.token_blacklist.models import OutstandingToken, BlacklistedToken from .services import FavoriteService from apps.properties.serializers.property_serializers import PropertiesReadSerializer @@ -51,4 +60,197 @@ def favorites(self, request): elif request.method == 'DELETE': FavoriteService.remove_property_from_favorites(user, property_id) - return Response({"message": "Property removed from favorites"}, status=status.HTTP_200_OK) \ No newline at end of file + return Response({"message": "Property removed from favorites"}, status=status.HTTP_200_OK) + + # GET e POST em /api/users/alerts/ + @action(detail=False, methods=['get', 'post'], url_path='alerts') + def alerts(self, request): + """ + GET: Lista todos os alertas do usuário + POST: Cria um novo alerta com os filtros fornecidos + """ + user = request.user + if request.method == 'GET': + alerts_qs = PropertyAlert.objects.filter(user=user) + serializer = PropertyAlertSerializer(alerts_qs, many=True) + return Response(serializer.data) + + # POST: criar novo alerta + serializer = PropertyAlertSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save(user=user) + return Response( + { + "message": "Você será avisado por email quando um imóvel com esses critérios for cadastrado", + "alert": serializer.data, + }, + status=status.HTTP_201_CREATED, + ) + + # DELETE em /api/users/alerts/{id}/ + @action(detail=True, methods=['delete'], url_path='alerts') + def delete_alert(self, request, pk=None): + """ + DELETE: Remove um alerta específico pertencente ao usuário + """ + try: + alert = PropertyAlert.objects.get(pk=pk, user=request.user) + alert.delete() + return Response({"message": "Alerta removido com sucesso"}, status=status.HTTP_204_NO_CONTENT) + except PropertyAlert.DoesNotExist: + return Response({"error": "Alerta não encontrado"}, status=status.HTTP_404_NOT_FOUND) + +class PasswordResetRequestView(APIView): + """ + Endpoint para solicitar redefinição de senha. + Recebe {email}, gera um token e envia um link de redefinição por email. + """ + permission_classes = [AllowAny] + + def post(self, request): + email = request.data.get('email') + if not email: + return Response({"error": "Email é obrigatório"}, status=status.HTTP_400_BAD_REQUEST) + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + # Retornar resposta genérica para não expor se email existe + return Response({"message": "Se uma conta com esse email existir, um link de redefinição foi enviado."}, status=status.HTTP_200_OK) + + token_generator = PasswordResetTokenGenerator() + token = token_generator.make_token(user) + uid = urlsafe_base64_encode(force_bytes(user.pk)) + # Construir URL de redefinição + reset_url = f"{settings.FRONTEND_URL}/password-reset-confirm/?uid={uid}&token={token}" + # Conteúdo do email + subject = "Redefinição de senha" + message = ( + f"Olá {user.name},\n\n" + "Recebemos uma solicitação para redefinir sua senha.\n" + f"Use o link abaixo para definir uma nova senha (válido por 24h):\n\n{reset_url}\n\n" + "Se você não solicitou, ignore este email." + ) + send_mail( + subject=subject, + message=message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + fail_silently=False, + ) + return Response({"message": "Se uma conta com esse email existir, um link de redefinição foi enviado."}, status=status.HTTP_200_OK) + + +class PasswordResetConfirmView(APIView): + """ + Endpoint para confirmar redefinição de senha. + Recebe {uid, token, new_password}, valida e atualiza a senha. + """ + permission_classes = [AllowAny] + + def post(self, request): + uidb64 = request.data.get('uid') + token = request.data.get('token') + new_password = request.data.get('new_password') + if not uidb64 or not token or not new_password: + return Response({"error": "uid, token e new_password são obrigatórios"}, status=status.HTTP_400_BAD_REQUEST) + try: + uid = force_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(pk=uid) + except (TypeError, ValueError, OverflowError, User.DoesNotExist): + return Response({"error": "Token inválido"}, status=status.HTTP_400_BAD_REQUEST) + + token_generator = PasswordResetTokenGenerator() + if not token_generator.check_token(user, token): + return Response({"error": "Token inválido ou expirado"}, status=status.HTTP_400_BAD_REQUEST) + + # Atualizar senha + user.set_password(new_password) + user.save() + return Response({"message": "Senha atualizada com sucesso"}, status=status.HTTP_200_OK) + +class EmailChangeRequestView(APIView): + """ + Endpoint para solicitar troca de email. + Usuário autenticado envia {new_email}, e recebe email de confirmação no novo endereço. + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + user = request.user + new_email = request.data.get('new_email') + if not new_email: + return Response({"error": "new_email é obrigatório"}, status=status.HTTP_400_BAD_REQUEST) + # Verifica se email já está em uso + if User.objects.filter(email=new_email).exists(): + return Response({"error": "Este email já está em uso"}, status=status.HTTP_400_BAD_REQUEST) + # Gerar token JWT com TTL de 24h + payload = { + 'user_id': user.id, + 'new_email': new_email, + 'exp': datetime.utcnow() + timedelta(hours=24) + } + token = jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256") + # URL de confirmação (frontend irá fazer requisição para o backend) + confirm_url = f"{settings.FRONTEND_URL}/email-change-confirm/?token={token}" + subject = "Confirmação de troca de email" + message = ( + f"Olá {user.name},\n\n" + f"Você solicitou alterar seu email de acesso no HomeMatch para {new_email}.\n" + f"Para confirmar a alteração, acesse o link abaixo (válido por 24h):\n\n{confirm_url}\n\n" + "Se você não solicitou, ignore este email." + ) + send_mail( + subject=subject, + message=message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[new_email], + fail_silently=False, + ) + return Response({"message": "Um email de confirmação foi enviado para o novo endereço"}, status=status.HTTP_200_OK) + + +class EmailChangeConfirmView(APIView): + """ + Endpoint para confirmar troca de email. + Valida token e atualiza email do usuário. Invalida tokens JWT existentes. + """ + permission_classes = [AllowAny] + + def get(self, request): + token = request.query_params.get('token') + if not token: + return Response({"error": "Token é obrigatório"}, status=status.HTTP_400_BAD_REQUEST) + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) + except jwt.ExpiredSignatureError: + return Response({"error": "Token expirado"}, status=status.HTTP_400_BAD_REQUEST) + except jwt.InvalidTokenError: + return Response({"error": "Token inválido"}, status=status.HTTP_400_BAD_REQUEST) + + user_id = payload.get('user_id') + new_email = payload.get('new_email') + if not user_id or not new_email: + return Response({"error": "Token inválido"}, status=status.HTTP_400_BAD_REQUEST) + try: + user = User.objects.get(pk=user_id) + except User.DoesNotExist: + return Response({"error": "Usuário não encontrado"}, status=status.HTTP_404_NOT_FOUND) + + # Verifica se email já está em uso por outro usuário + if User.objects.filter(email=new_email).exclude(pk=user.pk).exists(): + return Response({"error": "Email já está em uso"}, status=status.HTTP_400_BAD_REQUEST) + + # Atualiza email + user.email = new_email + user.save(update_fields=['email']) + + # Invalidar todos os tokens JWT existentes para o usuário + try: + tokens = OutstandingToken.objects.filter(user=user) + for t in tokens: + BlacklistedToken.objects.get_or_create(token=t) + except Exception: + # Caso modelos de blacklist não estejam configurados, ignore + pass + + return Response({"message": "Email atualizado com sucesso"}, status=status.HTTP_200_OK) \ No newline at end of file From 948b661b7781c6cdf1ce767fa13aeac88a994f33 Mon Sep 17 00:00:00 2001 From: DevlTz Date: Mon, 18 May 2026 19:08:24 -0300 Subject: [PATCH 2/3] chore(config): atualiza settings de email e dependencias do projeto --- .env.example | 13 +++++++++++++ config/settings.py | 5 ++++- requirements.txt | 9 +-------- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 10bafb8..6918466 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,16 @@ USE_LOCAL_STORAGE= AI_API_BASE_URL= AI_API_KEY= AI_MODEL= + +# E‑mail (envia mensagens para o console) +EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend +EMAIL_HOST=smtp.gmail.com +EMAIL_PORT=587 +EMAIL_USE_TLS=True +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +DEFAULT_FROM_EMAIL=noreply@homematch.com + +# URLs +FRONTEND_URL=http://localhost:3000 +API_BASE_URL=http://localhost:8000 \ No newline at end of file diff --git a/config/settings.py b/config/settings.py index db782ed..64c8554 100644 --- a/config/settings.py +++ b/config/settings.py @@ -109,6 +109,9 @@ } +# Tempo de expiração do token de redefinição de senha (24 horas) +PASSWORD_RESET_TIMEOUT = 60 * 60 * 24 # 24 horas em segundos + SIMPLE_JWT = { "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), "REFRESH_TOKEN_LIFETIME": timedelta(days=1), @@ -160,4 +163,4 @@ # 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") \ No newline at end of file +GOOGLE_PLACES_API_KEY = config("GOOGLE_PLACES_API_KEY", default="") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 303d2d1..a5d528b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,19 +5,12 @@ django-filter==25.1 django-cors-headers psycopg2-binary==2.9.10 python-decouple==3.8 -<<<<<<< HEAD requests pandas -======= ->>>>>>> fbc3088e55a1fadc42e692bd916a3dbf732cbf28 Markdown==3.8 pillow==11.2.1 boto3 openai -<<<<<<< HEAD celery redis -google-generativeai -======= -google-generativeai ->>>>>>> fbc3088e55a1fadc42e692bd916a3dbf732cbf28 +google-generativeai \ No newline at end of file From 721bf80fc0c4896dceae463a2dddbc7dd3fb9281 Mon Sep 17 00:00:00 2001 From: DevlTz Date: Mon, 18 May 2026 19:09:13 -0300 Subject: [PATCH 3/3] feat(users): cria model e migration para alertas de imoveis (Issue 18) --- apps/users/migrations/0005_propertyalert.py | 33 +++++++++++++++++++++ apps/users/models.py | 28 ++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 apps/users/migrations/0005_propertyalert.py diff --git a/apps/users/migrations/0005_propertyalert.py b/apps/users/migrations/0005_propertyalert.py new file mode 100644 index 0000000..eb149a2 --- /dev/null +++ b/apps/users/migrations/0005_propertyalert.py @@ -0,0 +1,33 @@ +# Generated by ChatGPT to add PropertyAlert model +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0004_user_favorites'), + ] + + operations = [ + migrations.CreateModel( + name='PropertyAlert', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('filters', models.JSONField(help_text='Filtros de busca salvos (mesmo formato da query string da API de busca)')), + ('is_active', models.BooleanField(db_index=True, default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('last_notified_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='property_alerts', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'property_alerts', + 'ordering': ['-created_at'], + }, + ), + migrations.AddIndex( + model_name='propertyalert', + index=models.Index(fields=['user', 'is_active'], name='property_al_user_is_active_idx'), + ), + ] \ No newline at end of file diff --git a/apps/users/models.py b/apps/users/models.py index 9427b28..8b6d616 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -68,4 +68,30 @@ class SearchPreference(models.Model): neighborhood = models.CharField(max_length=100, null=True, blank=True) class Meta: - db_table = "search_preferences" \ No newline at end of file + db_table = "search_preferences" + +class PropertyAlert(models.Model): + """ + Alerta de imóveis - notifica usuário quando um imóvel com os critérios especificados for cadastrado. + """ + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='property_alerts' + ) + filters = models.JSONField( + help_text="Filtros de busca salvos (mesmo formato da query string da API de busca)" + ) + is_active = models.BooleanField(default=True, db_index=True) + created_at = models.DateTimeField(auto_now_add=True) + last_notified_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "property_alerts" + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['user', 'is_active']), + ] + + def __str__(self): + return f"Alerta de {self.user.email} - {self.filters}" \ No newline at end of file