diff --git a/django_project_base/account/rest/account.py b/django_project_base/account/rest/account.py index 46a96fd2..220cf089 100644 --- a/django_project_base/account/rest/account.py +++ b/django_project_base/account/rest/account.py @@ -2,19 +2,24 @@ from typing import Optional import swapper -from django.contrib.auth import get_user_model, update_session_auth_hash +from django.contrib.auth import get_user_model, login, update_session_auth_hash from django.contrib.auth.models import AnonymousUser +from django.core.cache import cache +from django.core.exceptions import ValidationError as DjangoValidationError +from django.core.validators import validate_email from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiResponse, OpenApiTypes from dynamicforms import fields as df_fields, serializers as df_serializers, viewsets as df_viewsets from dynamicforms.action import Actions from rest_framework import fields, serializers, status, viewsets from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ModelSerializer -from rest_registration.api.views import change_password, logout, register, verify_registration +from rest_registration.api.views import change_password, logout, register +from rest_registration.settings import registration_settings from social_django.models import UserSocialAuth from django_project_base.account.rest.reset_password import ResetPasswordSerializer @@ -179,15 +184,7 @@ class SuperUserChangePasswordSerializer(ChangePasswordSerializer): return response -class VerifyRegistrationSerializer(serializers.Serializer): - user_id = fields.CharField(required=True) - timestamp = fields.IntegerField(required=True) - signature = fields.CharField(required=True) - - class VerifyRegistrationViewSet(viewsets.ViewSet): - serializer_class = VerifyRegistrationSerializer() - @extend_schema( description="Verify registration with signature. The endpoint will generate an e-mail with a confirmation URL " "which the user should GET to confirm the account.", @@ -199,11 +196,53 @@ class VerifyRegistrationViewSet(viewsets.ViewSet): @action( detail=False, methods=["post"], - url_path="verify_registration", + url_path="verify-registration", url_name="verify-registration", ) def verify_registration(self, request: Request) -> Response: - return verify_registration(request._request) + if ( + (flow_id := request.COOKIES.get("register-flow")) + and (code := cache.get(flow_id)) + and (req_code := request.data.get("code")) + and code == req_code + and len(code) + and len(req_code) + and (user := cache.get(code)) + ): + user.is_active = True + user.save(update_fields=["is_active"]) + login(request, user, backend="django_project_base.base.auth_backends.UsersCachingBackend") + response = Response() + response.delete_cookie("register-flow") + return response + raise ValidationError(dict(code=[_("Code invalid")])) + + @action( + detail=False, + methods=["post"], + url_path="verify-registration-email-change", + url_name="verify-registration-email-change", + ) + def verify_registration_change_email(self, request: Request) -> Response: + if ( + (flow_id := request.COOKIES.get("register-flow")) + and (code := cache.get(flow_id)) + and len(code) + and (user := cache.get(code)) + ): + email = request.data.get("email") + if not email: + raise ValidationError(dict(email=[_("Email invalid")])) + try: + validate_email(email) + except DjangoValidationError: + raise ValidationError(dict(email=[_("Email invalid")])) + user.email = email + user.save(update_fields=["email"]) + if registration_settings.REGISTER_VERIFICATION_ENABLED: + registration_settings.REGISTER_VERIFICATION_EMAIL_SENDER(request=request, user=user) + return Response() + raise PermissionDenied class AbstractRegisterSerializer(df_serializers.Serializer): diff --git a/django_project_base/account/rest/login.py b/django_project_base/account/rest/login.py index 44ffb874..99537b7e 100644 --- a/django_project_base/account/rest/login.py +++ b/django_project_base/account/rest/login.py @@ -104,4 +104,5 @@ def create(self, request: Request, *args, **kwargs) -> Response: and (user := get_user_model().objects.filter(pk=user_id).first()) ): UserLoginEvent(user=user).trigger(payload=request) + response.delete_cookie("register-flow") return response diff --git a/django_project_base/account/rest/profile.py b/django_project_base/account/rest/profile.py index 47696565..c0912807 100644 --- a/django_project_base/account/rest/profile.py +++ b/django_project_base/account/rest/profile.py @@ -1,4 +1,5 @@ import datetime +import uuid from random import randrange import django @@ -11,6 +12,7 @@ from django.db.models import ForeignKey, Model, QuerySet from django.template.loader import render_to_string from django.utils import timezone +from django.utils.crypto import get_random_string from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiResponse from dynamicforms import fields @@ -36,7 +38,10 @@ from django_project_base.account.rest.project_profiles_utils import get_project_members from django_project_base.base.event import UserRegisteredEvent from django_project_base.constants import NOTIFY_NEW_USER_SETTING_NAME -from django_project_base.notifications.email_notification import EMailNotification, EMailNotificationWithListOfEmails +from django_project_base.notifications.email_notification import ( + EMailNotificationWithListOfEmails, + SystemEMailNotification, +) from django_project_base.notifications.models import DjangoProjectBaseMessage from django_project_base.permissions import BasePermissions from django_project_base.rest.project import ProjectSerializer, ProjectViewSet @@ -327,7 +332,17 @@ def list(self, request, *args, **kwargs): permission_classes=[], ) def register_account(self, request: Request, **kwargs): - return Response(ProfileRegisterSerializer(None, context=self.get_serializer_context()).data) + register_flow_identifier = str(uuid.uuid4()) + response = Response(ProfileRegisterSerializer(None, context=self.get_serializer_context()).data) + response.set_cookie( + "register-flow", + register_flow_identifier, + max_age=settings.CONFIRMATION_CODE_TIMEOUT, + httponly=True, + samesite="Strict", + ) + cache.set(register_flow_identifier, get_random_string(length=6), timeout=settings.CONFIRMATION_CODE_TIMEOUT) + return response @extend_schema( description="Registering new account", @@ -342,7 +357,7 @@ def register_account(self, request: Request, **kwargs): def create_new_account(self, request: Request, **kwargs): # set default values request.data["date_joined"] = datetime.datetime.now() - request.data["is_active"] = True + request.data["is_active"] = False # call serializer to do the data processing drf way - hijack serializer = ProfileRegisterSerializer( @@ -592,7 +607,7 @@ def create(self, request, *args, **kwargs): .first() ) and sett.python_value: recipients = [response.data[get_pk_name(get_user_model())]] - EMailNotification( + SystemEMailNotification( message=DjangoProjectBaseMessage( subject=_("Your account was created for you"), body=render_to_string( @@ -604,7 +619,6 @@ def create(self, request, *args, **kwargs): footer="", content_type=DjangoProjectBaseMessage.HTML, ), - raw_recipents=recipients, project=project.slug, recipients=recipients, user=self.request.user.pk, diff --git a/django_project_base/account/service/register_user_service.py b/django_project_base/account/service/register_user_service.py new file mode 100644 index 00000000..93d1095e --- /dev/null +++ b/django_project_base/account/service/register_user_service.py @@ -0,0 +1,30 @@ +from django.conf import settings +from django.core.cache import cache +from django.utils.translation import gettext as __ +from natural.date import compress +from rest_framework.request import Request + +from django_project_base.notifications.email_notification import SystemEMailNotification +from django_project_base.notifications.models import DjangoProjectBaseMessage + + +def send_register_verification_email_notification( + request: Request, + user, +) -> None: + if (flow_id := request.COOKIES.get("register-flow")) and (code := cache.get(flow_id)): + SystemEMailNotification( + message=DjangoProjectBaseMessage( + subject=f"{__('Account confirmation for')} {request.META['HTTP_HOST']}", + body=f"{__('You or someone acting as you registered an account at')} " + f"{request.META['HTTP_HOST']}. " + f"\n\n{__('Your verification code is')}: " + f"{code} \n\n {__('Code is valid for')} {compress(settings.CONFIRMATION_CODE_TIMEOUT)}.\n\n" + f"{__('If this was not you or it was unintentional, you may safely ignore this message.')}", + footer="", + content_type=DjangoProjectBaseMessage.PLAIN_TEXT, + ), + recipients=[user.pk], + user=user.pk, + ).send() + cache.set(code, user, timeout=settings.CONFIRMATION_CODE_TIMEOUT) diff --git a/django_project_base/account/service/reset_password_email_service.py b/django_project_base/account/service/reset_password_email_service.py index 8b362d4e..871601ad 100644 --- a/django_project_base/account/service/reset_password_email_service.py +++ b/django_project_base/account/service/reset_password_email_service.py @@ -12,8 +12,7 @@ from rest_registration.utils.users import get_user_verification_id from django_project_base.account.constants import RESET_USER_PASSWORD_VERIFICATION_CODE -from django_project_base.notifications.base.enums import NotificationLevel, NotificationType as NotificationTypeDPB -from django_project_base.notifications.email_notification import EMailNotification +from django_project_base.notifications.email_notification import SystemEMailNotification from django_project_base.notifications.models import DjangoProjectBaseMessage @@ -31,7 +30,7 @@ def send_reset_password_verification_email(request: Request, user, send=False) - code = get_random_string(length=6) cache.set(code_ck, code, timeout=settings.CONFIRMATION_CODE_TIMEOUT) - EMailNotification( + SystemEMailNotification( message=DjangoProjectBaseMessage( subject=f"{__('Password recovery for')} {request.META['HTTP_HOST']}", body=f"{__('You or someone acting as you requested a password reset for your account at')} " @@ -42,11 +41,6 @@ def send_reset_password_verification_email(request: Request, user, send=False) - footer="", content_type=DjangoProjectBaseMessage.PLAIN_TEXT, ), - raw_recipents=[user.pk], - project=None, - persist=True, - level=NotificationLevel.INFO, - type=NotificationTypeDPB.STANDARD, recipients=[user.pk], user=request.user.pk, ).send() diff --git a/django_project_base/base/event.py b/django_project_base/base/event.py index 77b94e5d..ad0b5809 100644 --- a/django_project_base/base/event.py +++ b/django_project_base/base/event.py @@ -1,11 +1,13 @@ +import copy import datetime from abc import ABC, abstractmethod import swapper from django.conf import settings from django.shortcuts import get_object_or_404 +from rest_registration.settings import registration_settings -from django_project_base.constants import EMAIL_SENDER_ID_SETTING_NAME +from django_project_base.constants import EMAIL_SENDER_ID_SETTING_NAME, SMS_SENDER_ID_SETTING_NAME class UserModel: @@ -44,16 +46,43 @@ def trigger_changed(self, old_state=None, new_state=None, payload=None, **kwargs super().trigger_changed(old_state, new_state, payload, **kwargs) if new_state.name == EMAIL_SENDER_ID_SETTING_NAME: - # TODO: THIS IS NOLY FOR AWS FOR NOW + # TODO: THIS IS ONLY FOR AWS FOR NOW from django_project_base.aws.ses import AwsSes if not old_state: AwsSes.add_sender_email(new_state.python_value) - return - if old_state.python_value != new_state.python_value: - AwsSes.remove_sender_email(old_state.python_value) if old_state.python_value else None - AwsSes.add_sender_email(new_state.python_value) - return + elif (old_state.python_value != new_state.python_value) or ( + new_state.python_value + and new_state.pending_value + and new_state.python_pending_value != new_state.python_value + ): + AwsSes.add_sender_email(new_state.pending_value) + + project_settings_manager = swapper.load_model("django_project_base", "ProjectSettings").objects + for sender in set(AwsSes.list_sender_emails()) - ( + set( + project_settings_manager.objects.filter(name=EMAIL_SENDER_ID_SETTING_NAME).values_list( + "value", flat=True + ) + ) + | set( + project_settings_manager.objects.filter(name=EMAIL_SENDER_ID_SETTING_NAME).values_list( + "pending_value", flat=True + ) + ) + ): + AwsSes.remove_sender_email(sender) + + +class SmsSenderChangedEvent(ProjectSettingChangedEvent): + def trigger(self, payload=None, **kwargs): + super().trigger(payload, **kwargs) + + def trigger_changed(self, old_state=None, new_state=None, payload=None, **kwargs): + super().trigger_changed(old_state, new_state, payload, **kwargs) + + if new_state.name == SMS_SENDER_ID_SETTING_NAME: + pass class UserInviteFoundEvent(BaseEvent): @@ -98,6 +127,8 @@ def trigger(self, payload=None, **kwargs): UserInviteFoundEvent(self.user).trigger(payload=invite, request=payload) return payload.session.pop("invite-pk", None) + if registration_settings.REGISTER_VERIFICATION_ENABLED: + registration_settings.REGISTER_VERIFICATION_EMAIL_SENDER(request=payload, user=self.user) class UserLoginEvent(BaseEvent): @@ -114,3 +145,78 @@ def trigger(self, payload=None, **kwargs): UserInviteFoundEvent(self.user).trigger(payload=invite, request=payload) return payload.session.pop("invite-pk", None) + + +class ProjectSettingConfirmedEvent(BaseEvent): + def trigger_changed(self, old_state=None, new_state=None, payload=None, **kwargs): + super().trigger_changed(old_state=old_state, new_state=new_state, payload=payload, **kwargs) + + def trigger(self, payload=None, **kwargs): + super().trigger(payload, **kwargs) + if not payload: + return + from django_project_base.aws.ses import AwsSes + + def confirm(item): + item.value = copy.copy(item.python_pending_value) + item.pending_value = None + item.save(update_fields=["value", "pending_value"]) + + # not self.user Event is trigerred from management command + if payload.name == EMAIL_SENDER_ID_SETTING_NAME and ( + payload.python_pending_value in AwsSes.list_verified_sender_emails() or not self.user + ): + confirm(payload) + if payload.name == SMS_SENDER_ID_SETTING_NAME: + if not self.user: + confirm(payload) + + +class ProjectSettingActionRequiredEvent(BaseEvent): + def trigger_changed(self, old_state=None, new_state=None, payload=None, **kwargs): + super().trigger_changed(old_state=old_state, new_state=new_state, payload=payload, **kwargs) + + def trigger(self, payload=None, **kwargs): + super().trigger(payload, **kwargs) + if not payload: + return + + # if to := getattr(settings, "ADMINS", getattr(settings, "MANAGERS", [])): + # # TODO: SEND THIS AS SYSTEM MSG WHEN PR IS MERGED + # EMailNotificationWithListOfEmails( + # message=DjangoProjectBaseMessage( + # subject=gettext"Project settings action required"), + # body=f"{gettext('Action required for setting')} {payload.name} in project {payload.project.name}", + # footer="", + # content_type=DjangoProjectBaseMessage.HTML, + # ), + # recipients=to, + # project=None, + # user=None, + # ).send() + + +class ProjectSettingPendingResetEvent(BaseEvent): + def trigger_changed(self, old_state=None, new_state=None, payload=None, **kwargs): + super().trigger_changed(old_state=old_state, new_state=new_state, payload=payload, **kwargs) + + def trigger(self, payload=None, **kwargs): + super().trigger(payload, **kwargs) + if not payload: + return + from django_project_base.aws.ses import AwsSes + + pending_value = copy.copy(payload.python_pending_value) + payload.pending_value = None + payload.save(update_fields=["pending_value"]) + + if payload.name == EMAIL_SENDER_ID_SETTING_NAME: + if pending_value in AwsSes.list_sender_emails(): + AwsSes.remove_sender_email(pending_value) + AwsSes.add_sender_email(payload.python_value) + if payload.python_value not in AwsSes.list_verified_sender_emails(): + payload.action_required = True + payload.save(update_fields=["action_required"]) + if payload.name == SMS_SENDER_ID_SETTING_NAME: + payload.action_required = True + payload.save(update_fields=["action_required"]) diff --git a/django_project_base/base/models.py b/django_project_base/base/models.py index 9b370d6b..476bf6db 100644 --- a/django_project_base/base/models.py +++ b/django_project_base/base/models.py @@ -252,6 +252,10 @@ class BaseProjectSettings(models.Model): def python_value(self): return self.value_validators[self.value_type](self.value) + @property + def python_pending_value(self): + return self.value_validators[self.value_type](self.pending_value) + def clean(self): validator = self.value_validators[self.value_type] try: @@ -276,10 +280,19 @@ def clean(self): swapper.get_model_name("django_project_base", "Project"), on_delete=models.CASCADE, null=False ) + pending_value = models.CharField(max_length=320, null=True, blank=True, verbose_name=_("Pending value")) + action_required = models.BooleanField(default=False, null=True, blank=True) + def save(self, force_insert=False, force_update=False, using=None, update_fields=None): self.full_clean() validator = self.value_validators[self.value_type] self.value = validator(self.value) + if self.pending_value: + self.pending_value = validator(self.pending_value) + if self.action_required: + from django_project_base.base.event import ProjectSettingActionRequiredEvent + + ProjectSettingActionRequiredEvent(user=None).trigger(payload=self) super().save(force_insert, force_update, using, update_fields) def delete(self, using=None, keep_parents=False): diff --git a/django_project_base/licensing/logic.py b/django_project_base/licensing/logic.py index b25d1e5d..5a87b8c3 100644 --- a/django_project_base/licensing/logic.py +++ b/django_project_base/licensing/logic.py @@ -93,10 +93,12 @@ def log( if not allowed_users: allowed_users = getattr(kwargs.get("settings", object()), "NOTIFICATIONS_ALLOWED_USERS", []) - if str(user_profile_pk) not in list(map(str, allowed_users)): + is_system_notification = kwargs.get("is_system_notification") + + if not is_system_notification and str(user_profile_pk) not in list(map(str, allowed_users)): raise PermissionDenied - if used >= MONTHLY_ACCESS_LIMIT_IN_CURRENCY_UNITS: # janez medja + if not is_system_notification and used >= MONTHLY_ACCESS_LIMIT_IN_CURRENCY_UNITS: # janez medja raise PermissionDenied(gettext("Your license is consumed. Please contact support.")) if on_sucess: @@ -112,7 +114,7 @@ def log( content_type_object_id=str(record.pk).replace("-", ""), content_type=content_type, amount=amount, - comment=dict(comment=comment, count=accesses_used, item_price=item_price), + comment=dict(comment=comment, count=accesses_used, item_price=item_price, sender=kwargs.get("sender", "")), ) return accesses_used diff --git a/django_project_base/management/__init__.py b/django_project_base/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django_project_base/management/commands/__init__.py b/django_project_base/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django_project_base/management/commands/confirm_setting.py b/django_project_base/management/commands/confirm_setting.py new file mode 100644 index 00000000..39683da7 --- /dev/null +++ b/django_project_base/management/commands/confirm_setting.py @@ -0,0 +1,36 @@ +import swapper +from django.core.management.base import BaseCommand +from django.shortcuts import get_object_or_404 + +from django_project_base.base.event import ProjectSettingConfirmedEvent + + +class Command(BaseCommand): + help = "Confirms project setting. Example: python manage.py confirm_setting 2 email-sender-id" + + def add_arguments(self, parser) -> None: + parser.add_argument("project-id", type=str, help="Project identifier") + parser.add_argument("setting-name", type=str, help="Setting name") + + def handle(self, *args, **options): + project = get_object_or_404(swapper.load_model("django_project_base", "Project"), pk=str(options["project-id"])) + setting = get_object_or_404( + swapper.load_model("django_project_base", "ProjectSettings"), + project=project, + name=str(options["setting-name"]), + ) + ProjectSettingConfirmedEvent(user=None).trigger(payload=setting) + # TODO: send email when owner is known + # # TODO: SEND THIS AS SYSTEM MSG WHEN PR IS MERGED + # SystemEMailNotification( + # message=DjangoProjectBaseMessage( + # subject=f"{__('Project setting confirmed')}", + # body=f"{__('Setting')} {setting.name} {__('in project')} " + # f"{project.name} {__('has been confirmed and is now active.')}", + # footer="", + # content_type=DjangoProjectBaseMessage.PLAIN_TEXT, + # ), + # recipients=[], # TODO: find project owner + # user=None, # TODO: find project owner + # ).send() + return "ok" diff --git a/django_project_base/management/commands/list_pending_settings.py b/django_project_base/management/commands/list_pending_settings.py new file mode 100644 index 00000000..0325d700 --- /dev/null +++ b/django_project_base/management/commands/list_pending_settings.py @@ -0,0 +1,35 @@ +import swapper +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = "Lists pending project settings. Example: python manage.py list_pending_settings" + + def handle(self, *args, **options): + result = dict() + for project in swapper.load_model("django_project_base", "Project").objects.all(): + for setting in ( + swapper.load_model("django_project_base", "ProjectSettings") + .objects.filter(project=project, pending_value__isnull=False) + .exclude(pending_value="") + ): + if project.name not in result: + result[project.name] = {} + result[project.name][setting.name] = { + "value": setting.python_value, + "pending_value": setting.python_pending_value, + } + # if to := getattr(settings, "ADMINS", getattr(settings, "MANAGERS", [])): + # # TODO: SEND THIS AS SYSTEM MSG WHEN PR IS MERGED + # EMailNotificationWithListOfEmails( + # message=DjangoProjectBaseMessage( + # subject=_("Pending settings report"), + # body=json.dumps(result), + # footer="", + # content_type=DjangoProjectBaseMessage.HTML, + # ), + # recipients=to, + # project=None, + # user=None, + # ).send() + self.stdout.write(self.style.WARNING(result)) diff --git a/django_project_base/notifications/base/channels/channel.py b/django_project_base/notifications/base/channels/channel.py index 8d8f5e95..6dc232d8 100644 --- a/django_project_base/notifications/base/channels/channel.py +++ b/django_project_base/notifications/base/channels/channel.py @@ -62,7 +62,8 @@ def _set_provider(self, val: ProviderIntegration): def sender(self, notification: DjangoProjectBaseNotification) -> str: _sender = getattr(notification, "sender", {}).get(self.name) - assert _sender, "Notification sender is required" + if not getattr(settings, "TESTING", False): + assert _sender, "Notification sender is required" return _sender def _find_provider( @@ -135,7 +136,8 @@ def send(self, notification: DjangoProjectBaseNotification, extra_data, **kwargs def make_send(notification_obj, rec_obj, message_str, dlr_pk) -> Optional[DeliveryReport]: try: - self.provider.client_send(self.sender(notification_obj), rec_obj, message_str, dlr_pk) + if not getattr(settings, "TESTING", False): + self.provider.client_send(self.sender(notification_obj), rec_obj, message_str, dlr_pk) sent = True except Exception as te: logger.exception(te) diff --git a/django_project_base/notifications/base/channels/integrations/aws_ses.py b/django_project_base/notifications/base/channels/integrations/aws_ses.py index f4c9c9e9..73bc71bc 100644 --- a/django_project_base/notifications/base/channels/integrations/aws_ses.py +++ b/django_project_base/notifications/base/channels/integrations/aws_ses.py @@ -89,9 +89,9 @@ def client_send(self, sender: str, recipient: Recipient, msg: dict, dlr_id: str) .client("ses") .send_email( Destination={ - "ToAddresses": [sender], + "ToAddresses": [recipient.email], "CcAddresses": [], - "BccAddresses": [recipient.email], + "BccAddresses": [], }, Message=msg, Source=sender, diff --git a/django_project_base/notifications/base/channels/mail_channel.py b/django_project_base/notifications/base/channels/mail_channel.py index dc8d8a88..acf84481 100644 --- a/django_project_base/notifications/base/channels/mail_channel.py +++ b/django_project_base/notifications/base/channels/mail_channel.py @@ -1,3 +1,4 @@ +import uuid from typing import List from django.conf import settings @@ -24,6 +25,14 @@ def send(self, notification: DjangoProjectBaseNotification, extra_data, **kwargs list(map(int, notification.recipients.split(","))) if notification.recipients else [] ) return len(recipients) + message = self.provider.get_message(notification) + sender = self.sender(notification) + self.provider.client_send( + self.sender(notification), + Recipient(identifier=str(uuid.uuid4()), phone_number="", email=sender), + message, + str(uuid.uuid4()), + ) return super().send(notification=notification, extra_data=extra_data) def get_recipients(self, notification: DjangoProjectBaseNotification, unique_identifier=""): diff --git a/django_project_base/notifications/base/notification.py b/django_project_base/notifications/base/notification.py index b4a6dc1d..842f25d2 100644 --- a/django_project_base/notifications/base/notification.py +++ b/django_project_base/notifications/base/notification.py @@ -213,6 +213,10 @@ def _ensure_channels( for channel_name in channels: # ensure dlr user and check providers channel = ChannelIdentifier.channel(channel_name, extra_data=self._extra_data, project_slug=self._project) + + if not channel and self._extra_data.get("is_system_notification"): + continue + assert channel if self.send_notification_sms and channel.name == MailChannel.name: @@ -223,4 +227,11 @@ def _ensure_channels( notification.user = self._user notification.sender = Notification._get_sender_config(self._project) + + if self._extra_data.get("is_system_notification"): + notification.sender[MailChannel.name] = getattr(settings, "SYSTEM_EMAIL_SENDER_ID", "") + from django_project_base.notifications.base.channels.sms_channel import SmsChannel + + notification.sender[SmsChannel.name] = getattr(settings, "SYSTEM_SMS_SENDER_ID", "") + return notification diff --git a/django_project_base/notifications/base/send_notification_mixin.py b/django_project_base/notifications/base/send_notification_mixin.py index 3539ac8b..c769ca7d 100644 --- a/django_project_base/notifications/base/send_notification_mixin.py +++ b/django_project_base/notifications/base/send_notification_mixin.py @@ -72,6 +72,8 @@ def make_send(self, notification: DjangoProjectBaseNotification, extra_data) -> on_sucess=lambda: channel.send(notification, extra_data), db=db_connection, settings=extra_data.get("SETTINGS", object()), + is_system_notification=extra_data.get("is_system_notification"), + sender=channel.sender(notification), ) sent_channels.append(channel) if any_sent > 0 else failed_channels.append(channel) except Exception as e: diff --git a/django_project_base/notifications/email_notification.py b/django_project_base/notifications/email_notification.py index d9127820..6f5364e5 100644 --- a/django_project_base/notifications/email_notification.py +++ b/django_project_base/notifications/email_notification.py @@ -2,6 +2,9 @@ import uuid from typing import List, Optional, Type +from django.core.cache import cache +from rest_framework.exceptions import PermissionDenied + from django_project_base.notifications.base.channels.channel import Channel from django_project_base.notifications.base.channels.mail_channel import MailChannel from django_project_base.notifications.base.enums import NotificationLevel, NotificationType @@ -89,3 +92,55 @@ def send(self) -> DjangoProjectBaseNotification: ] self.enqueue_notification(notification, self._extra_data) return notification + + +class SystemEMailNotification(EMailNotification): + system_mail_throttling_ck = "system-mail-throttling-ck" + allowed_number_of_system_requests_per_minute = 5 + + def __init__( + self, + message: DjangoProjectBaseMessage, + recipients, + **kwargs, + ) -> None: + super().__init__( + message, + raw_recipents=recipients, + project=None, + persist=True, + level=None, + locale=None, + delay=int(datetime.datetime.now().timestamp()), + type=None, + recipients=recipients, + is_system_notification=True, + **kwargs, + ) + + def _get_system_email_cache_key(self) -> str: + return f"{self.system_mail_throttling_ck}-{datetime.datetime.now().minute}" + + def _get_system_email_cache_value(self) -> List[float]: + if ck_val := cache.get(self._get_system_email_cache_key()): + return ck_val + return [] + + def _check_request_limit(self): + if len(self._get_system_email_cache_value()) > self.allowed_number_of_system_requests_per_minute: + raise PermissionDenied + + def _register_system_email(self): + if ck_val := self._get_system_email_cache_value(): + if len(ck_val) > self.allowed_number_of_system_requests_per_minute + 1: + return + ck_val.append(datetime.datetime.now().timestamp()) + cache.set(self._get_system_email_cache_key(), ck_val, timeout=70) + return + cache.set(self._get_system_email_cache_key(), [datetime.datetime.now().timestamp()], timeout=70) + + def send(self) -> DjangoProjectBaseNotification: + # throttling for system messages + self._check_request_limit() + self._register_system_email() + return super().send() diff --git a/django_project_base/rest/project.py b/django_project_base/rest/project.py index 376c0f43..317b50db 100644 --- a/django_project_base/rest/project.py +++ b/django_project_base/rest/project.py @@ -1,14 +1,19 @@ import copy +from gettext import gettext from typing import Union import swapper from django.conf import settings +from django.core.management import call_command +from django.db import transaction from django.http import Http404 +from django.shortcuts import get_object_or_404 from drf_spectacular.utils import extend_schema, OpenApiResponse from dynamicforms import fields +from dynamicforms.action import TableAction, TablePosition from dynamicforms.mixins import DisplayMode from dynamicforms.serializers import ModelSerializer -from dynamicforms.template_render.layout import Layout, Row +from dynamicforms.template_render.layout import Column, Layout, Row from dynamicforms.viewsets import ModelViewSet from rest_framework import status from rest_framework.decorators import action @@ -18,7 +23,13 @@ from rest_framework.response import Response from django_project_base.account.middleware import ProjectNotSelectedError +from django_project_base.base.event import ( + ProjectSettingConfirmedEvent, + ProjectSettingPendingResetEvent, + SmsSenderChangedEvent, +) from django_project_base.base.models import BaseProjectSettings +from django_project_base.constants import EMAIL_SENDER_ID_SETTING_NAME, SMS_SENDER_ID_SETTING_NAME from django_project_base.utils import get_pk_name @@ -145,6 +156,23 @@ def create(self, request, *args, **kwargs): create_response = super().create(request, *args, **kwargs) project = swapper.load_model("django_project_base", "Project").objects.get(slug=create_response.data["slug"]) swapper.load_model("django_project_base", "ProjectMember").objects.create(project=project, member=request.user) + project_settings_model = swapper.load_model("django_project_base", "ProjectSettings") + project_settings_model.objects.create( + name=EMAIL_SENDER_ID_SETTING_NAME, + value=getattr(settings, "DEFAULT_EMAIL_SENDER_ID_SETTING_NAME", "") or gettext("Please enter value"), + description=gettext("Email sender value for notifications"), + value_type=BaseProjectSettings.VALUE_TYPE_CHAR, + project=project, + reserved=True, + ) + project_settings_model.objects.create( + name=SMS_SENDER_ID_SETTING_NAME, + value=getattr(settings, "DEFAULT_SMS_SENDER_ID_SETTING_NAME", "") or gettext("Please enter value"), + description=gettext("Sms sender value for notifications"), + value_type=BaseProjectSettings.VALUE_TYPE_CHAR, + project=project, + reserved=True, + ) return create_response @@ -154,25 +182,107 @@ class ProjectSettingsSerializer(ModelSerializer): def __init__(self, *args, is_filter: bool = False, **kwds): super().__init__(*args, is_filter=is_filter, **kwds) self.actions.actions = [a for a in self.actions.actions if a.name != "delete"] + self.actions.actions.append( + # TODO: https://taiga.velis.si/project/velis74-dynamic-forms/issue/837 + TableAction( + position=TablePosition.FIELD_END, + label="Reset pending", + name="reset-pending", + field_name="table_value", + icon="refresh-outline", + display_style=dict( + asButton=False, + showIcon=True, + showLabel=False, + ), + ), + ) + request = self.context.get("request") + if request and (request.user.is_superuser or request.user.is_staff): + # TODO: https://taiga.velis.si/project/velis74-dynamic-forms/issue/837 + self.actions.actions.append( + TableAction( + position=TablePosition.ROW_END, + label="Confirm active", + name="confirm-setting-active", + ), + ) + id = fields.AutoGeneratedField(display=DisplayMode.HIDDEN) project = fields.PrimaryKeyRelatedField( display=DisplayMode.SUPPRESS, queryset=swapper.load_model("django_project_base", "Project").objects.all() ) + pending_value = fields.CharField( + display=DisplayMode.SUPPRESS, required=False, allow_null=True, default=False, allow_blank=True + ) + + table_value = fields.CharField( + source="value", + display_table=DisplayMode.FULL, + display_form=DisplayMode.SUPPRESS, + label=gettext("Value"), + read_only=True, + ) + value = fields.CharField( + display_table=DisplayMode.SUPPRESS, + display_form=DisplayMode.FULL, + required=True, + allow_null=False, + allow_blank=False, + ) def save(self, **kwargs): instance = copy.copy(self.instance) - saved = super().save(**kwargs) + if instance: + self.validated_data["pending_value"] = self.validated_data["value"] + self.validated_data["value"] = instance.value + from django_project_base.base.event import EmailSenderChangedEvent EmailSenderChangedEvent(self.context["request"].user).trigger_changed( - old_state=instance, new_state=saved, payload=None + old_state=instance, new_state=self.Meta.model(**self.validated_data), payload=None ) + SmsSenderChangedEvent(self.context["request"].user).trigger_changed( + old_state=instance, new_state=self.Meta.model(**self.validated_data), payload=None + ) + saved = super().save(**kwargs) return saved + def get_row_css_style(self, obj): + if obj and obj.action_required: + return "background-color:red;" + return "" + + def to_representation(self, instance, row_data=None): + representation = super().to_representation(instance, row_data) + if instance and instance.pending_value: + representation[ + "table_value" + ] = f"{representation['value']} ({gettext('Pending')}: {instance.pending_value})" + return representation + class Meta: model = swapper.load_model("django_project_base", "ProjectSettings") - exclude = () + fields = ( + get_pk_name(model), + "name", + "table_value", + "description", + "value_type", + "reserved", + "value", + "project", + "pending_value", + ) + layout = Layout( + Row(Column("name")), + Row(Column("value")), + Row(Column("description")), + Row(Column("value_type")), + Row(Column("reserved")), + size="large", + ) class ProjectSettingsViewSet(ModelViewSet): @@ -243,3 +353,78 @@ def handle_create_validation_exception(self, e, request, *args, **kwargs): if getattr(e, "model-validation", False): raise ValidationError({e.detail: e.default_code}) return super().handle_create_validation_exception(e, request, *args, **kwargs) + + def list(self, request, *args, **kwargs): + list_response = super().list(request, *args, **kwargs) + pending_settings = ( + self.get_queryset() + .filter(pending_value__isnull=False) + .exclude(pending_value="") + .order_by(f"-{get_pk_name(self.get_serializer_class().Meta.model)}") + ) + if pending_settings.exists(): + pk_name = get_pk_name(self.get_serializer_class().Meta.model) + list_response.set_cookie( + "setting-verification", + f"{'*'.join(list(map(str, pending_settings.values_list(pk_name, flat=True))))}", + expires=24 * 60 * 60, + samesite="Lax", + ) + return list_response + list_response.delete_cookie("setting-verification") + return list_response + + def _get_pk_from_request(self, request: Request) -> int: + model = self.get_serializer_class().Meta.model + pk_name = get_pk_name(model) + pk = request.data.get(pk_name) + if not pk: + raise ValidationError({pk_name: [gettext("required")]}) + return pk + + @extend_schema(exclude=True) + @transaction.atomic() + @action( + detail=False, + methods=["POST"], + url_name="confirm-setting", + url_path="confirm-setting", + ) + def confirm_pending_setting(self, request) -> Response: + ProjectSettingConfirmedEvent(user=request.user).trigger( + payload=get_object_or_404(self.get_serializer_class().Meta.model, pk=self._get_pk_from_request(request)) + ) + return Response() + + @extend_schema(exclude=True) + @transaction.atomic() + @action( + detail=False, + methods=["POST"], + url_name="reset-pending", + url_path="reset-pending", + ) + def reset_pending_setting(self, request) -> Response: + setting = get_object_or_404(self.get_serializer_class().Meta.model, pk=self._get_pk_from_request(request)) + if setting.pending_value: + ProjectSettingPendingResetEvent(user=request.user).trigger(payload=setting) + return Response() + + @extend_schema(exclude=True) + @transaction.atomic() + @action( + detail=False, + methods=["POST"], + url_name="confirm-setting-active", + url_path="confirm-setting-active", + ) + def confirm_setting_active(self, request) -> Response: + if not (request.user.is_superuser or request.user.is_staff): + raise PermissionDenied + setting = get_object_or_404(self.get_serializer_class().Meta.model, pk=self._get_pk_from_request(request)) + call_command( + "confirm_setting", + setting.project.pk, + setting.name, + ) + return Response() diff --git a/django_project_base/settings.py b/django_project_base/settings.py index 101e147a..7862ba7c 100644 --- a/django_project_base/settings.py +++ b/django_project_base/settings.py @@ -36,6 +36,8 @@ "RESET_PASSWORD_VERIFICATION_ENABLED": True, "RESET_PASSWORD_SERIALIZER_PASSWORD_CONFIRM": True, "SEND_RESET_PASSWORD_LINK_USER_FINDER": "django_project_base.account.service.reset_password_email_service.find_user_by_send_reset_password_link_data", # noqa: E501 + "REGISTER_VERIFICATION_ENABLED": True, + "REGISTER_VERIFICATION_EMAIL_SENDER": "django_project_base.account.service.register_user_service.send_register_verification_email_notification", # noqa: E501 }, }, {"name": "NOTIFICATION_SENDERS", "default": {}}, @@ -63,6 +65,14 @@ "name": "LICENSE_ACCESS_USE_CONTENT_TYPE_MODEL", "default": "notifications.DjangoProjectBaseNotification", }, + { + "name": "DEFAULT_EMAIL_SENDER_ID_SETTING_NAME", + "default": "", + }, + { + "name": "DEFAULT_SMS_SENDER_ID_SETTING_NAME", + "default": "", + }, ) USER_CACHE_KEY = "django-user-{id}" diff --git a/example/demo_django_base/migrations/0009_projectsettings_pending_value.py b/example/demo_django_base/migrations/0009_projectsettings_pending_value.py new file mode 100644 index 00000000..8f700ba0 --- /dev/null +++ b/example/demo_django_base/migrations/0009_projectsettings_pending_value.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.4 on 2023-10-10 06:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("demo_django_base", "0008_projectinvite"), + ] + + operations = [ + migrations.AddField( + model_name="projectsettings", + name="pending_value", + field=models.CharField(max_length=320, null=True, verbose_name="Pending value"), + ), + migrations.AddField( + model_name="projectsettings", + name="action_required", + field=models.BooleanField(default=False, verbose_name="Action required", null=True, blank=True), + ), + ] diff --git a/package.json b/package.json index 100de7f3..25dabe21 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "export-pdf": "press-export-pdf --debug export docs" }, "peerDependencies": { - "@velis/dynamicforms": "^0.74.18", + "@velis/dynamicforms": "^0.74.21", "axios": "^1.2.0", "lodash": "^4.17.15", "pinia": "^2.0.33", diff --git a/vue/components/notifications-editor.vue b/vue/components/notifications-editor.vue index 3354400b..63f4dcc9 100644 --- a/vue/components/notifications-editor.vue +++ b/vue/components/notifications-editor.vue @@ -2,8 +2,7 @@ import { APIConsumer, ComponentDisplay, - ConsumerLogicApi, - FormConsumerApiOneShot, gettext, + ConsumerLogicApi, FormConsumerOneShotApi, gettext, useActionHandler, } from '@velis/dynamicforms'; import { onMounted, onUnmounted, ref } from 'vue'; @@ -50,12 +49,12 @@ const notificationLogic = ref(new ConsumerLogicApi( notificationLogic.value.getFullDefinition(); const actionViewLicense = async (): Promise => { - await FormConsumerApiOneShot({ url: licenseConsumerUrl, trailingSlash: licenseConsumerUrlTrailingSlash, pk: 'new' }); + await FormConsumerOneShotApi({ url: licenseConsumerUrl, trailingSlash: licenseConsumerUrlTrailingSlash, pk: 'new' }); return true; }; const actionAddNotification = async (): Promise => { - await FormConsumerApiOneShot({ + await FormConsumerOneShotApi({ url: consumerUrl, trailingSlash: consumerTrailingSlash, pk: 'new', diff --git a/vue/components/project-settings.vue b/vue/components/project-settings.vue index 95551f42..6e3c52dd 100644 --- a/vue/components/project-settings.vue +++ b/vue/components/project-settings.vue @@ -1,24 +1,223 @@ diff --git a/vue/components/user-session/login-inline.vue b/vue/components/user-session/login-inline.vue index 0c2f5d82..7fe8b814 100644 --- a/vue/components/user-session/login-inline.vue +++ b/vue/components/user-session/login-inline.vue @@ -33,7 +33,7 @@ {{ gettext('Login') }} - {{ gettext('Register') }} + {{ gettext('Register') }} @@ -41,12 +41,15 @@