diff --git a/django_project_base/base/event.py b/django_project_base/base/event.py index 93f98d50..ad0b5809 100644 --- a/django_project_base/base/event.py +++ b/django_project_base/base/event.py @@ -1,3 +1,4 @@ +import copy import datetime from abc import ABC, abstractmethod @@ -6,7 +7,7 @@ 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: @@ -45,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): @@ -117,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/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/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 80f2ed60..7862ba7c 100644 --- a/django_project_base/settings.py +++ b/django_project_base/settings.py @@ -65,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/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 @@