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..93f98d50 100644
--- a/django_project_base/base/event.py
+++ b/django_project_base/base/event.py
@@ -4,6 +4,7 @@
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
@@ -98,6 +99,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):
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/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/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/settings.py b/django_project_base/settings.py
index 101e147a..80f2ed60 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": {}},
diff --git a/vue/components/notifications-editor.vue b/vue/components/notifications-editor.vue
index 3354400b..fd79ca88 100644
--- a/vue/components/notifications-editor.vue
+++ b/vue/components/notifications-editor.vue
@@ -2,8 +2,7 @@
import {
APIConsumer,
ComponentDisplay,
- ConsumerLogicApi,
- FormConsumerApiOneShot, gettext,
+ ConsumerLogicApi, FormConsumerApiOneShot, gettext,
useActionHandler,
} from '@velis/dynamicforms';
import { onMounted, onUnmounted, ref } from 'vue';
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 @@