From a8c44fc8b5711aeb232630cdd6776d03a50b1bdc Mon Sep 17 00:00:00 2001 From: Luiz Eduardo Date: Wed, 14 Jan 2026 16:33:33 -0300 Subject: [PATCH 1/2] Init app user_notifications --- backend/apps/schema.py | 14 +- backend/apps/user_notifications/__init__.py | 0 backend/apps/user_notifications/admin.py | 53 +++++ backend/apps/user_notifications/apps.py | 7 + backend/apps/user_notifications/graphql.py | 182 ++++++++++++++++++ .../management/commands/__init__.py | 0 .../management/commands/send_notification.py | 75 ++++++++ .../migrations/0001_initial.py | 46 +++++ .../user_notifications/migrations/__init__.py | 0 backend/apps/user_notifications/models.py | 33 ++++ backend/apps/user_notifications/tasks.py | 9 + .../update_table_notification.html | 90 +++++++++ backend/apps/user_notifications/tests.py | 3 + backend/apps/user_notifications/urls.py | 3 + backend/apps/user_notifications/views.py | 2 + backend/settings/base.py | 1 + 16 files changed, 514 insertions(+), 4 deletions(-) create mode 100644 backend/apps/user_notifications/__init__.py create mode 100644 backend/apps/user_notifications/admin.py create mode 100644 backend/apps/user_notifications/apps.py create mode 100644 backend/apps/user_notifications/graphql.py create mode 100644 backend/apps/user_notifications/management/commands/__init__.py create mode 100644 backend/apps/user_notifications/management/commands/send_notification.py create mode 100644 backend/apps/user_notifications/migrations/0001_initial.py create mode 100644 backend/apps/user_notifications/migrations/__init__.py create mode 100644 backend/apps/user_notifications/models.py create mode 100644 backend/apps/user_notifications/tasks.py create mode 100644 backend/apps/user_notifications/templates/notification/update_table_notification.html create mode 100644 backend/apps/user_notifications/tests.py create mode 100644 backend/apps/user_notifications/urls.py create mode 100644 backend/apps/user_notifications/views.py diff --git a/backend/apps/schema.py b/backend/apps/schema.py index 971c4c30..1e833d65 100644 --- a/backend/apps/schema.py +++ b/backend/apps/schema.py @@ -3,16 +3,22 @@ from backend.apps.account_payment.graphql import Mutation as PaymentMutation from backend.apps.account_payment.graphql import Query as PaymentQuery from backend.apps.api.v1.graphql import Query as APIQuery +from backend.apps.user_notifications.graphql import ( + DeactivateAllTableUpdateNotification, + DeactivateTableUpdateNotification, + TableUpdateNotification, +) +from backend.apps.user_notifications.graphql import Query as UserNotificationQuery from backend.custom.graphql_auto import build_schema schema = build_schema( applications=["account", "v1"], - extra_queries=[ - APIQuery, - PaymentQuery, - ], + extra_queries=[APIQuery, PaymentQuery, UserNotificationQuery], extra_mutations=[ AccountMutation, PaymentMutation, + TableUpdateNotification, + DeactivateTableUpdateNotification, + DeactivateAllTableUpdateNotification, ], ) diff --git a/backend/apps/user_notifications/__init__.py b/backend/apps/user_notifications/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/user_notifications/admin.py b/backend/apps/user_notifications/admin.py new file mode 100644 index 00000000..c03b34ba --- /dev/null +++ b/backend/apps/user_notifications/admin.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# Register your models here. +from django.contrib import admin + +from .models import TableUpdateSubscription + + +class TableUpdateSubscriptionAdmin(admin.ModelAdmin): + # Campos que serão exibidos na lista de objetos + list_display = ( + "id", + "table", + "user", + "created_at", + "deactivate_at", + "last_notification", + "updated_at", + "status", + ) + + # Filtros laterais para facilitar a busca + list_filter = ("status", "table", "user") + + # Campos que podem ser pesquisados diretamente + search_fields = ("table__name", "user__username") + + # Campos que serão editáveis diretamente na lista (inline) + list_editable = ("status",) + + # Exibir um campo de data mais legível na interface + date_hierarchy = "created_at" + + # Definindo o formulário de exibição de detalhes + fieldsets = ( + (None, {"fields": ("table", "user", "status")}), + ( + "Datas", + { + "fields": ("created_at", "deactivate_at", "last_notification", "updated_at"), + "classes": ("collapse",), + }, + ), + ) + + # Campos que não serão mostrados no formulário de edição + readonly_fields = ("created_at", "deactivate_at") + + # Exibir os campos na ordem desejada no formulário de edição + ordering = ("-created_at",) + + +# Registrar o modelo e a classe de admin personalizada +admin.site.register(TableUpdateSubscription, TableUpdateSubscriptionAdmin) diff --git a/backend/apps/user_notifications/apps.py b/backend/apps/user_notifications/apps.py new file mode 100644 index 00000000..a968cdf4 --- /dev/null +++ b/backend/apps/user_notifications/apps.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +from django.apps import AppConfig + + +class UserNotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "backend.apps.user_notifications" diff --git a/backend/apps/user_notifications/graphql.py b/backend/apps/user_notifications/graphql.py new file mode 100644 index 00000000..c4f0a160 --- /dev/null +++ b/backend/apps/user_notifications/graphql.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +# ruff: noqa + +from uuid import UUID + +import graphene +from django.utils import timezone +from graphene import Int, Mutation, String +from graphene_django import DjangoObjectType +from graphql_jwt.decorators import login_required + +from backend.apps.account.models import Account +from backend.apps.api.v1.models import Table + +from .models import TableUpdateSubscription + + +class TableUpdateSubscriptionType(DjangoObjectType): + class Meta: + model = TableUpdateSubscription + fields = "__all__" + + +### Mutations + + +class CreateTableUpdateSubscription(Mutation): + class Arguments: + table_id = String() + user_id = Int() + + table_update_subscription = graphene.Field(TableUpdateSubscriptionType) + + @login_required + def mutate(self, info, table_id: str, user_id: int): + # Verifica se já existe uma assinatura ativa para o usuário e a tabela + try: + table = Table.objects.get(id=UUID(table_id)) + user = Account.objects.get(id=user_id) + + existing_subscription = TableUpdateSubscription.objects.filter( + user_id=user, + table_id=table, + status=True, # Aqui você verifica apenas as assinaturas ativas + ).first() + + if existing_subscription: + # Se já existir, você pode retornar a assinatura existente ou lançar um erro + raise Exception("Já existe uma assinatura ativa para esse usuário e tabela.") + + # Caso contrário, cria uma nova assinatura + + subscription = TableUpdateSubscription.objects.create( + table=table, + user=user, + updated_at=table.last_updated_at, + status=True, # A assinatura será criada como ativa + ) + + return CreateTableUpdateSubscription(table_update_subscription=subscription) + + except Table.DoesNotExist: + return Exception("Tabela não encontrada.") + except Account.DoesNotExist: + return Exception("Usuário não encontrado.") + + +class TableUpdateNotification: + _table_upadate_notification = CreateTableUpdateSubscription.Field() + + +class DeactivateTableUpdateSubscription(Mutation): + class Arguments: + table_id = String() + user_id = Int() + + table_update_subscription = graphene.Field(TableUpdateSubscriptionType) + + @login_required + def mutate(self, info, table_id, user_id): + # Verifica se já existe uma assinatura ativa para o usuário e a tabela + try: + table = Table.objects.get(id=UUID(table_id)) + user = Account.objects.get(id=user_id) + + subscription = TableUpdateSubscription.objects.filter( + table=table, + user=user, + status=True, # Apenas as assinaturas ativas + ).first() + + if not subscription: + # Se já existir, você pode retornar a assinatura existente ou lançar um erro + raise Exception( + f"Não existe uma assinatura ativa para a tabela {table.name} e o usuário {user.username}." + ) + + # Atualizando o status para False e registrando a data de desativação + subscription.status = False + subscription.deactivate_at = timezone.now() # Atualizando com a data e hora atual + subscription.save() + + return CreateTableUpdateSubscription(table_update_subscription=subscription) + + except Table.DoesNotExist: + return Exception("Tabela não encontrada.") + except Account.DoesNotExist: + return Exception("Usuário não encontrado.") + + +class DeactivateTableUpdateNotification: + _deactivate_table_upadate_notification = DeactivateTableUpdateSubscription.Field() + + +class DeactivateAllTableUpdateSubscription(Mutation): + class Arguments: + user_id = Int() + + success = graphene.Boolean() + deactivated_count = graphene.Int() + + @login_required + def mutate(self, info, user_id): + # Verifica se já existe uma assinatura ativa para o usuário e a tabela + try: + user = Account.objects.get(id=user_id) + + subscriptions_qs = TableUpdateSubscription.objects.filter( + user=user, + status=True, # Apenas as assinaturas ativas + ).all() + + if not subscriptions_qs: + # Se já existir, você pode retornar a assinatura existente ou lançar um erro + raise Exception(f"Não existe uma assinatura ativa para o usuário {user.username}.") + + deactivated_count = subscriptions_qs.update(status=False, deactivate_at=timezone.now()) + + return DeactivateAllTableUpdateSubscription( + success=True, deactivated_count=deactivated_count + ) + + except Account.DoesNotExist: + return Exception("Usuário não encontrado.") + + +class DeactivateAllTableUpdateNotification: + _deactivate_all_table_upadate_notification = DeactivateAllTableUpdateSubscription.Field() + + +### Querys + + +class StatusTableUpadateNotificationQueryType(DjangoObjectType): + class Meta: + model = TableUpdateSubscription + fields = ("status",) + + +class StatusTableUpadateNotificationQuery(graphene.ObjectType): + # Definindo a query para obter o status de uma inscrição + status_table_update_notification = graphene.Field( + StatusTableUpadateNotificationQueryType, table_id=String(), user_id=Int() + ) + + @login_required + def resolve_status_table_update_notification( + self, info, table_id: str, user_id: int + ) -> TableUpdateSubscription | None: + try: + subscription = TableUpdateSubscription.objects.filter( + table=table_id, + user=user_id, + status=True, + ).exists() + return TableUpdateSubscription(status=subscription) + except TableUpdateSubscription.DoesNotExist: + return None + + +class Query(StatusTableUpadateNotificationQuery, graphene.ObjectType): + pass diff --git a/backend/apps/user_notifications/management/commands/__init__.py b/backend/apps/user_notifications/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/user_notifications/management/commands/send_notification.py b/backend/apps/user_notifications/management/commands/send_notification.py new file mode 100644 index 00000000..8beba3a4 --- /dev/null +++ b/backend/apps/user_notifications/management/commands/send_notification.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- + +from collections import defaultdict + +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.core.management.base import BaseCommand +from django.template.loader import render_to_string +from django.utils import timezone as dj_timezone + +from backend.apps.user_notifications.models import Account, TableUpdateSubscription +from backend.custom.environment import get_frontend_url + + +def check_for_updates(subscription: TableUpdateSubscription) -> TableUpdateSubscription | bool: + table = subscription.table + + if subscription.updated_at < table.last_updated_at: + return subscription + return subscription + + +def send_update_notification_email(user: Account, subscriptions: list, date_today: dj_timezone): + from_email = settings.EMAIL_HOST_USER + recipient_list = [user.email] + + subject = "Atualização disponível para sua tabela de interesse" + message = ( + f"Olá {user.username}, \n\nHá atualizações disponíveis para uma das tabelas que você segue." + ) + + content = render_to_string( + "notification/update_table_notification.html", + {"domain": get_frontend_url(), "subscriptions": subscriptions}, + ) + + msg = EmailMultiAlternatives(subject, message, from_email, recipient_list) + msg.attach_alternative(content, "text/html") + msg.send() + + for subscription in subscriptions: + subscription.last_notification = date_today + subscription.updated_at = subscription.table.last_updated_at + subscription.save() + + +class Command(BaseCommand): + help = "Botão para testar o envio de emails" + + def handle(self, *args, **options): + # Pegar + self.check_for_updates_and_send_emails() + + self.stdout.write(self.style.SUCCESS('Successfully "check_for_updates_and_send_emails"')) + + def check_for_updates_and_send_emails(self): + # Pega todas as inscrições ativas + subscriptions = TableUpdateSubscription.objects.filter(status=True) + # Pega a data atual + date_today = dj_timezone.now() + + users_to_notify = defaultdict(list) + # Lista de usuários que precisam receber o email e agrupada eles + + for subscription in subscriptions: + if check_for_updates(subscription): + users_to_notify[subscription.user].append(subscription) + + self.stdout.write( + self.style.SUCCESS(f"Serão enviados um total de {len(users_to_notify.keys())} emails") + ) + + # Envia e-mail para cada usuário que precisa ser notificado + for user, subscriptions_for_user in users_to_notify.items(): + send_update_notification_email(user, subscriptions_for_user, date_today) diff --git a/backend/apps/user_notifications/migrations/0001_initial.py b/backend/apps/user_notifications/migrations/0001_initial.py new file mode 100644 index 00000000..60b2ed1f --- /dev/null +++ b/backend/apps/user_notifications/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# ruff: noqa +# Generated by Django 4.2.10 on 2026-01-14 18:13 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("v1", "0055_alter_type_fields_many_tables"), + ] + + operations = [ + migrations.CreateModel( + name="TableUpdateSubscription", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("deactivate_at", models.DateTimeField(blank=True, null=True)), + ("last_notification", models.DateTimeField(blank=True, null=True)), + ("updated_at", models.DateTimeField(blank=True, null=True)), + ("status", models.BooleanField(default=True)), + ( + "table", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="v1.table"), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "verbose_name": "Table Update Subscription", + "verbose_name_plural": "Table Update Subscriptions", + }, + ), + ] diff --git a/backend/apps/user_notifications/migrations/__init__.py b/backend/apps/user_notifications/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/user_notifications/models.py b/backend/apps/user_notifications/models.py new file mode 100644 index 00000000..cb45cd6f --- /dev/null +++ b/backend/apps/user_notifications/models.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Create your models here. +# -*- coding: utf-8 -*- +import logging +from uuid import uuid4 + +from django.db import models + +from backend.apps.account.models import Account +from backend.apps.api.v1.models import Table +from backend.custom.model import BaseModel + +logger = logging.getLogger("django.request") + + +class TableUpdateSubscription(BaseModel): + """ "Table Update Subscription""" + + id = models.UUIDField(primary_key=True, default=uuid4) + table = models.ForeignKey(Table, on_delete=models.CASCADE) + user = models.ForeignKey(Account, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + deactivate_at = models.DateTimeField(null=True, blank=True) + last_notification = models.DateTimeField(null=True, blank=True) + updated_at = models.DateTimeField(null=True, blank=True) + status = models.BooleanField(default=True) + + def __str__(self): + return f"Subscription {self.id} - Table: {self.table.name}, User: {self.user.username}" + + class Meta: + verbose_name = "Table Update Subscription" + verbose_name_plural = "Table Update Subscriptions" diff --git a/backend/apps/user_notifications/tasks.py b/backend/apps/user_notifications/tasks.py new file mode 100644 index 00000000..d4ac8570 --- /dev/null +++ b/backend/apps/user_notifications/tasks.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +from django.core.management import call_command +from huey import crontab +from huey.contrib.djhuey import db_periodic_task + + +@db_periodic_task(crontab(day_of_week="1-5", hour="7", minute="0")) +def check_for_updates_and_send_emails(): + call_command("send_notification") diff --git a/backend/apps/user_notifications/templates/notification/update_table_notification.html b/backend/apps/user_notifications/templates/notification/update_table_notification.html new file mode 100644 index 00000000..78db26ba --- /dev/null +++ b/backend/apps/user_notifications/templates/notification/update_table_notification.html @@ -0,0 +1,90 @@ + + + + + + + + + + + + logo + + +

Olá,

+ +
+

Você pode conferir a atualização diretamente na plataforma das seguintes tabelas:

+
+ + {% for subscription in subscriptions %} +
  • + + Tabela: + + {{ subscription.table.name }} + + + + Dataset: + + {{ subscription.table.dataset.name }} + + +
  • + {% empty %} +
  • Não há atualizações para suas tabelas no momento.
  • + {% endfor %} + + + + +
    +

    + Se não quiser mais receber estes avisos, você pode desativar as notificações diretamente nas páginas de tabelas ou entrar em contato pelo e-mail +

    +

    + contato@basedosdados.org +

    +
    + + + +
    +

    Esta mensagem foi enviada pela

    +

    Base dos Dados

    +
    + + + + diff --git a/backend/apps/user_notifications/tests.py b/backend/apps/user_notifications/tests.py new file mode 100644 index 00000000..23e1480d --- /dev/null +++ b/backend/apps/user_notifications/tests.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +# Create your tests here. diff --git a/backend/apps/user_notifications/urls.py b/backend/apps/user_notifications/urls.py new file mode 100644 index 00000000..7baab896 --- /dev/null +++ b/backend/apps/user_notifications/urls.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +urlpatterns = [] diff --git a/backend/apps/user_notifications/views.py b/backend/apps/user_notifications/views.py new file mode 100644 index 00000000..1876a440 --- /dev/null +++ b/backend/apps/user_notifications/views.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +# Create your views here. diff --git a/backend/settings/base.py b/backend/settings/base.py index 41a14537..55c0e81c 100644 --- a/backend/settings/base.py +++ b/backend/settings/base.py @@ -70,6 +70,7 @@ "backend.apps.account_auth", "backend.apps.account_payment.apps.PaymentConfig", "backend.apps.api.v1", + "backend.apps.user_notifications", "backend.apps.core", "backend.apps.chatbot", ] From 299ede1fc0c36390d94e59e3f1dc8477daed8c7a Mon Sep 17 00:00:00 2001 From: Luiz Eduardo Date: Wed, 14 Jan 2026 17:44:21 -0300 Subject: [PATCH 2/2] check_for_updates_and_send_emails only production --- .../management/commands/send_notification.py | 2 +- backend/apps/user_notifications/tasks.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/apps/user_notifications/management/commands/send_notification.py b/backend/apps/user_notifications/management/commands/send_notification.py index 8beba3a4..390de5dc 100644 --- a/backend/apps/user_notifications/management/commands/send_notification.py +++ b/backend/apps/user_notifications/management/commands/send_notification.py @@ -17,7 +17,7 @@ def check_for_updates(subscription: TableUpdateSubscription) -> TableUpdateSubsc if subscription.updated_at < table.last_updated_at: return subscription - return subscription + return False def send_update_notification_email(user: Account, subscriptions: list, date_today: dj_timezone): diff --git a/backend/apps/user_notifications/tasks.py b/backend/apps/user_notifications/tasks.py index d4ac8570..99d5ca66 100644 --- a/backend/apps/user_notifications/tasks.py +++ b/backend/apps/user_notifications/tasks.py @@ -3,7 +3,10 @@ from huey import crontab from huey.contrib.djhuey import db_periodic_task +from backend.custom.environment import production_task + @db_periodic_task(crontab(day_of_week="1-5", hour="7", minute="0")) +@production_task def check_for_updates_and_send_emails(): call_command("send_notification")