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
Binary file added .coverage
Binary file not shown.
74 changes: 72 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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://<host>:8001/ws/notifications/?token=<access_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
Expand Down Expand Up @@ -363,6 +399,40 @@ Change admin password:
docker compose exec web python manage.py changepassword <admin-email-or-username>
```

---

## 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
Expand All @@ -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
27 changes: 27 additions & 0 deletions apps/ai_analysis/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/notifications/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Inicialização do aplicativo de notificações."""
14 changes: 14 additions & 0 deletions apps/notifications/admin.py
Original file line number Diff line number Diff line change
@@ -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")
9 changes: 9 additions & 0 deletions apps/notifications/apps.py
Original file line number Diff line number Diff line change
@@ -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"
68 changes: 68 additions & 0 deletions apps/notifications/consumers.py
Original file line number Diff line number Diff line change
@@ -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=<access_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
44 changes: 44 additions & 0 deletions apps/notifications/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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"],
},
),
]
1 change: 1 addition & 0 deletions apps/notifications/migrations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Pacote de migrações para notificações."""
36 changes: 36 additions & 0 deletions apps/notifications/models.py
Original file line number Diff line number Diff line change
@@ -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}"
12 changes: 12 additions & 0 deletions apps/notifications/serializers.py
Original file line number Diff line number Diff line change
@@ -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"]
11 changes: 11 additions & 0 deletions apps/notifications/urls.py
Original file line number Diff line number Diff line change
@@ -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("<int:pk>/", NotificationUpdateView.as_view(), name="notification-detail"),
]
38 changes: 38 additions & 0 deletions apps/notifications/views.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 0 additions & 1 deletion apps/properties/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ def create_property(*, rooms, rooms_extras, condo, validated_data):
rooms=rooms,
rooms_extras=rooms_extras,
condo=condo,
embedding="[]",
**validated_data,
)

Expand Down
Loading
Loading