From bac843aa29401b8a100ea6ed851030951b0c3a1d Mon Sep 17 00:00:00 2001 From: KlemenSpruk Date: Thu, 12 Oct 2023 10:06:43 +0200 Subject: [PATCH] added management commands for list setting and confirm settings --- django_project_base/base/event.py | 40 ++++- django_project_base/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/confirm_setting.py | 35 ++++ .../commands/list_pending_settings.py | 34 ++++ django_project_base/rest/project.py | 67 +++++++- vue/components/project-settings.vue | 155 ++++++++++++++---- 7 files changed, 283 insertions(+), 48 deletions(-) create mode 100644 django_project_base/management/__init__.py create mode 100644 django_project_base/management/commands/__init__.py create mode 100644 django_project_base/management/commands/confirm_setting.py create mode 100644 django_project_base/management/commands/list_pending_settings.py diff --git a/django_project_base/base/event.py b/django_project_base/base/event.py index a5c6453b..3f4c37f0 100644 --- a/django_project_base/base/event.py +++ b/django_project_base/base/event.py @@ -149,14 +149,36 @@ def trigger(self, payload=None, **kwargs): return from django_project_base.aws.ses import AwsSes - if ( - payload.name == EMAIL_SENDER_ID_SETTING_NAME - and payload.python_pending_value in AwsSes.list_verified_sender_emails() + 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 ): - payload.value = copy.copy(payload.python_pending_value) - payload.pending_value = None - payload.save(update_fields=["value", "pending_value"]) + confirm(payload) if payload.name == SMS_SENDER_ID_SETTING_NAME: - a = 9 - a = 9 - print(a) + if not self.user: + confirm(payload) + + +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 + + payload.pending_value = None + payload.save(update_fields=["value", "pending_value"]) + + if payload.name == EMAIL_SENDER_ID_SETTING_NAME: + if payload.python_pending_value in AwsSes.list_sender_emails(): + AwsSes.remove_sender_email(payload.python_pending_value) + if payload.name == SMS_SENDER_ID_SETTING_NAME: + pass 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..a8f429de --- /dev/null +++ b/django_project_base/management/commands/confirm_setting.py @@ -0,0 +1,35 @@ +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 + # 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..668ca092 --- /dev/null +++ b/django_project_base/management/commands/list_pending_settings.py @@ -0,0 +1,34 @@ +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 = "Lists pending project settings. Example: python manage.py list_pending_settings 2" + + def add_arguments(self, parser) -> None: + parser.add_argument("project-id", type=str, help="Project identifier") + + 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 + # 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/rest/project.py b/django_project_base/rest/project.py index c274813e..b86de0db 100644 --- a/django_project_base/rest/project.py +++ b/django_project_base/rest/project.py @@ -9,6 +9,7 @@ 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 Column, Layout, Row @@ -21,7 +22,11 @@ from rest_framework.response import Response from django_project_base.account.middleware import ProjectNotSelectedError -from django_project_base.base.event import ProjectSettingConfirmedEvent, SmsSenderChangedEvent +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 @@ -176,11 +181,26 @@ 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( + 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, + ), + ), + ) 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) table_value = fields.CharField( source="value", @@ -225,7 +245,17 @@ def to_representation(self, instance, row_data=None): class Meta: model = swapper.load_model("django_project_base", "ProjectSettings") - fields = get_pk_name(model), "name", "table_value", "description", "value_type", "reserved", "value", "project" + fields = ( + get_pk_name(model), + "name", + "table_value", + "description", + "value_type", + "reserved", + "value", + "project", + "pending_value", + ) layout = Layout( Row(Column("name")), Row(Column("value")), @@ -317,7 +347,7 @@ def list(self, request, *args, **kwargs): 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))))}", + f"{'*'.join(list(map(str, pending_settings.values_list(pk_name, flat=True))))}", expires=24 * 60 * 60, samesite="Lax", ) @@ -325,6 +355,14 @@ def list(self, request, *args, **kwargs): 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( @@ -334,10 +372,21 @@ def list(self, request, *args, **kwargs): url_path="confirm-setting", ) def confirm_pending_setting(self, request) -> Response: - 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")]}) - ProjectSettingConfirmedEvent(user=request.user).trigger(payload=get_object_or_404(model, pk=pk)) + 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() diff --git a/vue/components/project-settings.vue b/vue/components/project-settings.vue index 6b97ec3e..ddfdec23 100644 --- a/vue/components/project-settings.vue +++ b/vue/components/project-settings.vue @@ -5,8 +5,8 @@ import { ComponentDisplay, ConsumerLogicApi, dfModal, - FilteredActions, - gettext, + FilteredActions, FormPayload, + gettext, useActionHandler, } from '@velis/dynamicforms'; import _ from 'lodash'; import { storeToRefs } from 'pinia'; @@ -15,26 +15,24 @@ import { useCookies } from 'vue3-cookies'; import { apiClient } from '../apiClient'; -import { PROJECT_TABLE_PRIMARY_KEY_PROPERTY_NAME } from './user-session/data-types'; +import { PROFILE_TABLE_PRIMARY_KEY_PROPERTY_NAME } from './user-session/data-types'; import useUserSessionStore from './user-session/state'; const userSession = useUserSessionStore(); const settingsLogic = ref(new ConsumerLogicApi('/project-settings', false)); -const settingsLogicTC = settingsLogic; +const settingsLogicTC = settingsLogic; const { selectedProjectId } = storeToRefs(userSession); +const { cookies } = useCookies(); + function refreshSettingsLogic() { settingsLogic.value.getFullDefinition(); settingsLogic.value.reload(); } -if (selectedProjectId.value) refreshSettingsLogic(); - -const { cookies } = useCookies(); - interface ProjectSetting { - [PROJECT_TABLE_PRIMARY_KEY_PROPERTY_NAME]: any; + [PROFILE_TABLE_PRIMARY_KEY_PROPERTY_NAME]: any; value: string; pending_value: string; name: string; @@ -43,8 +41,6 @@ interface ProjectSetting { const settingsConfirmationVisible = ref(false); async function confirmationEmail(setting: ProjectSetting, message: Array) { - console.log('mail setting', setting); - console.log('confirm email'); if (settingsConfirmationVisible.value) { return; } @@ -63,26 +59,95 @@ async function confirmationEmail(setting: ProjectSetting, message: Array) { position: 'FORM_FOOTER', }), })); + const reqData = {}; + // @ts-ignore + reqData[PROFILE_TABLE_PRIMARY_KEY_PROPERTY_NAME] = setting[PROFILE_TABLE_PRIMARY_KEY_PROPERTY_NAME]; if (emailConfirmation.action.name === 'confirm') { apiClient.post( '/project-settings/confirm-setting', - { PROJECT_TABLE_PRIMARY_KEY_PROPERTY_NAME: setting[PROJECT_TABLE_PRIMARY_KEY_PROPERTY_NAME] }, - ).then((res) => { - console.log(res.data); - console.log('confirmation respnse'); + reqData, + ).then(() => { + const cookie = cookies.get('setting-verification'); + if (_.size(cookie)) { + cookies.set( + 'setting-verification', + _.join( + _.filter(cookie.split('*'), (i) => i !== setting[PROFILE_TABLE_PRIMARY_KEY_PROPERTY_NAME].toString()), + '*', + ), + ); + } + apiClient.get( + `/project-settings/${setting[PROFILE_TABLE_PRIMARY_KEY_PROPERTY_NAME]}`, + ).then((res) => { + if (_.size(res.data.pending_value)) { + dfModal.message('', gettext('We have attempted to verify the sender email address, ' + + 'but it doesn\'t work yet. Please click “try again” in about a minute or so'), new FilteredActions({ + cancel: new Action({ + name: 'cancel', + label: gettext('Cancel, use the old sender email'), + displayStyle: { asButton: true, showLabel: true, showIcon: true }, + position: 'FORM_FOOTER', + }), + confirm: new Action({ + name: 'ok', + label: gettext('OK'), + displayStyle: { asButton: true, showLabel: true, showIcon: true }, + position: 'FORM_FOOTER', + }), + })).then((action: Action) => { + if (action.action.name === 'cancel') { + apiClient.post( + '/project-settings/reset-pending', + reqData, + ).finally(() => { + refreshSettingsLogic(); + settingsConfirmationVisible.value = false; + }); + return; + } + refreshSettingsLogic(); + settingsConfirmationVisible.value = false; + }); + return; + } + settingsConfirmationVisible.value = false; + }).catch(() => { + settingsConfirmationVisible.value = false; + }); + }).catch(() => { + settingsConfirmationVisible.value = false; }); + return; } settingsConfirmationVisible.value = false; } -function confirmationSms(setting: ProjectSetting) { - console.log('sms setting', setting); - console.log('confirm sms'); +async function confirmationSms(setting: ProjectSetting, message: Array) { + if (settingsConfirmationVisible.value) { + return; + } + settingsConfirmationVisible.value = true; + await dfModal.message('', () => message, new FilteredActions({ + cancel: new Action({ + name: 'ok', + label: gettext('OK'), + displayStyle: { asButton: true, showLabel: true, showIcon: true }, + position: 'FORM_FOOTER', + }), + })); + settingsConfirmationVisible.value = false; } function confirmSetting(setting: ProjectSetting) { if (setting.name === 'sms-sender-id') { - confirmationSms(setting); + confirmationSms(setting, [ + h('h2', { class: 'mt-n6 mb-4' }, gettext('SMS settings pending for confirmation')), + h('br'), + h('h4', { class: 'mt-n6 mb-4' }, gettext('Sms sender settings is pending for confirmation. ' + + 'It can take up to two weeks for setting to become active. You will be ' + + 'notified when settings will be activated.')), + ]); return; } if (setting.name === 'email-sender-id') { @@ -90,28 +155,58 @@ function confirmSetting(setting: ProjectSetting) { h('h2', { class: 'mt-n6 mb-4' }, gettext('Email settings pending for confirmation')), h('br'), h('h4', { class: 'mt-n6 mb-4' }, gettext('You received an email with confirmation link ' + - 'for email sender address. Please check your mail and click on confirmation link. Check also spam folder ' + - 'if you do not find confirmation link.')), + 'for email sender address. Please check your mail and click on confirmation link. Check also spam folder ' + + 'if you do not find confirmation link.')), ]); } } -watch(selectedProjectId, refreshSettingsLogic); +function checkSettings() { + const cookie = cookies.get('setting-verification'); + if (_.size(cookie) && _.size(cookie.split('*'))) { + apiClient.get(`/project-settings/${_.first(cookie.split('*'))}.json`).then((res) => { + confirmSetting(res.data); + }); + } +} + let intervalCheckPendingChanges: NodeJS.Timeout | undefined; onMounted(() => { intervalCheckPendingChanges = setInterval(() => { - const cookie = cookies.get('setting-verification'); - if (_.size(cookie) && _.size(cookie.split(','))) { - apiClient.get(`project-settings/${_.first(cookie.split(','))}`).then((res) => { - console.log(res.data); - confirmSetting(res.data); - }); - } - }, 2500); + checkSettings(); + }, 45000); }); onUnmounted(() => clearInterval(intervalCheckPendingChanges)); +function refreshSettingsLogicAndCheckSettings() { + refreshSettingsLogic(); + checkSettings(); +} + +if (selectedProjectId.value) refreshSettingsLogicAndCheckSettings(); + +watch(selectedProjectId, refreshSettingsLogicAndCheckSettings); + +const actionResetPending = async (action:Action, payload: FormPayload) => { + console.log(action, payload); + const resetData = {}; + // @ts-ignore + resetData[PROFILE_TABLE_PRIMARY_KEY_PROPERTY_NAME] = payload[PROFILE_TABLE_PRIMARY_KEY_PROPERTY_NAME]; + apiClient.post( + '/project-settings/reset-pending', + resetData, + ).then(() => { + refreshSettingsLogic(); + }); + return true; +}; + +const { handler } = useActionHandler(); + +handler + .register('reset-pending', actionResetPending); +