From 00b3531a30db192ed6e718f184e5dcc4491edca2 Mon Sep 17 00:00:00 2001 From: Noman Bukhari Date: Wed, 1 Nov 2023 16:54:47 -0700 Subject: [PATCH] Updated Issue 272 pull request --- saas/api/billing.py | 10 ++- saas/api/metrics.py | 88 ++++++++++++------- saas/api/plans.py | 19 ++-- saas/api/roles.py | 80 +++++++++++------ saas/api/serializers.py | 57 ++++++++++-- saas/api/subscriptions.py | 4 +- .../commands/report_weekly_revenue.py | 2 +- saas/mixins.py | 35 -------- saas/static/js/djaodjin-saas-vue.js | 4 +- saas/templates/saas/profile/roles/index.html | 4 +- 10 files changed, 185 insertions(+), 118 deletions(-) diff --git a/saas/api/billing.py b/saas/api/billing.py index 3ed01942f..8685992e4 100644 --- a/saas/api/billing.py +++ b/saas/api/billing.py @@ -46,7 +46,7 @@ CartItemUploadSerializer, ChargeSerializer, CheckoutSerializer, OrganizationCartSerializer, RedeemCouponSerializer, ValidationErrorSerializer, CartItemUpdateSerializer, - UserCartItemCreateSerializer) + UserCartItemCreateSerializer, QueryParamCartItemSerializer) LOGGER = logging.getLogger(__name__) @@ -197,8 +197,12 @@ def delete(self, request, *args, **kwargs): #pylint:disable=unused-argument plan = None email = None - plan = request.query_params.get('plan') - email = request.query_params.get('email') + query_serializer = QueryParamCartItemSerializer(data=request.query_params) + + if query_serializer.is_valid(raise_exception=True): + plan = query_serializer.validated_data.get('plan', None) + email = query_serializer.validated_data.get('email', None) + self.destroy_in_session(request, plan=plan, email=email) if is_authenticated(request): # If the user is authenticated, we delete the cart items diff --git a/saas/api/metrics.py b/saas/api/metrics.py index 0ef0ab2dd..0f2a6c40d 100644 --- a/saas/api/metrics.py +++ b/saas/api/metrics.py @@ -24,11 +24,12 @@ import logging -from rest_framework.generics import GenericAPIView, ListAPIView +from rest_framework.generics import ListAPIView from rest_framework.response import Response +from rest_framework.views import APIView from .serializers import (CartItemSerializer, LifetimeSerializer, - MetricsSerializer, PeriodSerializer, BalancesDueSerializer) + MetricsSerializer, QueryParamPeriodSerializer, BalancesDueSerializer) from .. import settings from ..compat import gettext_lazy as _, reverse, six from ..filters import DateRangeFilter @@ -49,7 +50,7 @@ class BalancesAPIView(DateRangeContextMixin, ProviderMixin, - GenericAPIView): + APIView): """ Retrieves 12-month trailing deferred balances @@ -135,10 +136,14 @@ class BalancesAPIView(DateRangeContextMixin, ProviderMixin, """ serializer_class = MetricsSerializer filter_backends = (DateRangeFilter,) - queryset = Transaction.objects.all() - @swagger_auto_schema(query_serializer=PeriodSerializer) + @swagger_auto_schema(query_serializer=QueryParamPeriodSerializer) def get(self, request, *args, **kwargs): + query_serializer = QueryParamPeriodSerializer(data=request.query_params) + query_serializer.is_valid(raise_exception=True) + + num_periods = query_serializer.validated_data.get('num_periods') + period = query_serializer.validated_data.get('period') #pylint: disable=unused-argument result = [] @@ -153,21 +158,21 @@ def get(self, request, *args, **kwargs): 'until': self.ends_at, 'tz': self.timezone, } - if self.num_periods: + if num_periods: # If a num_periods argument is passed in, we use 'nb_months' # or 'periods' depending on the period used - arg_name = 'nb_months' if self.period_func == month_periods \ + arg_name = 'nb_months' if period == month_periods \ else 'num_periods' - period_func_kwargs[arg_name] = self.num_periods + period_func_kwargs[arg_name] = num_periods # If the period is monthly, use the existing monthly # balance function - if self.period_func == month_periods: + if period == month_periods: values, _unit = abs_monthly_balances(**period_func_kwargs) # If the period is not monthly, use abs_periodic_balances else: values, _unit = abs_periodic_balances( - period_func=self.period_func, + period_func=period, **period_func_kwargs) if _unit: @@ -182,7 +187,7 @@ def get(self, request, *args, **kwargs): class RevenueMetricAPIView(DateRangeContextMixin, ProviderMixin, - GenericAPIView): + APIView): """ Retrieves 12-month trailing revenue @@ -305,19 +310,25 @@ class RevenueMetricAPIView(DateRangeContextMixin, ProviderMixin, serializer_class = MetricsSerializer filter_backends = (DateRangeFilter,) - @swagger_auto_schema(query_serializer=PeriodSerializer) + @swagger_auto_schema(query_serializer=QueryParamPeriodSerializer) def get(self, request, *args, **kwargs): #pylint:disable=unused-argument + query_serializer = QueryParamPeriodSerializer(data=request.query_params) + query_serializer.is_valid(raise_exception=True) + + num_periods = query_serializer.validated_data.get('num_periods') + period = query_serializer.validated_data.get('period') + period_func_kwargs = {'from_date': self.ends_at, 'tz': self.timezone} - if self.num_periods: - arg_name = 'nb_months' if (self.period_func == - month_periods) else 'periods' - period_func_kwargs[arg_name] = self.num_periods + if num_periods: + arg_name = 'nb_months' if period == month_periods \ + else 'periods' + period_func_kwargs[arg_name] = num_periods dates = convert_dates_to_utc( - self.period_func(**period_func_kwargs)) + period(**period_func_kwargs)) unit = settings.DEFAULT_UNIT account_table, _, _, table_unit = \ @@ -426,7 +437,7 @@ class CouponUsesAPIView(CartItemSmartListMixin, CouponUsesQuerysetMixin, class CustomerMetricAPIView(DateRangeContextMixin, ProviderMixin, - GenericAPIView): + APIView): """ Retrieves 12-month trailing customer counts @@ -527,21 +538,27 @@ class CustomerMetricAPIView(DateRangeContextMixin, ProviderMixin, serializer_class = MetricsSerializer filter_backends = (DateRangeFilter,) - @swagger_auto_schema(query_serializer=PeriodSerializer) + @swagger_auto_schema(query_serializer=QueryParamPeriodSerializer) def get(self, request, *args, **kwargs): #pylint:disable=unused-argument + query_serializer = QueryParamPeriodSerializer(data=request.query_params) + query_serializer.is_valid(raise_exception=True) + + num_periods = query_serializer.validated_data.get('num_periods') + period = query_serializer.validated_data.get('period') + account_title = 'Payments' account = Transaction.RECEIVABLE # We use ``Transaction.RECEIVABLE`` which technically counts the number # or orders, not the number of payments. period_func_kwargs = {'from_date': self.ends_at, 'tz': self.timezone} - if self.num_periods: - arg_name = 'nb_months' if self.period_func == month_periods else 'periods' - period_func_kwargs[arg_name] = self.num_periods + if num_periods: + arg_name = 'nb_months' if period == month_periods else 'periods' + period_func_kwargs[arg_name] = num_periods dates = convert_dates_to_utc( - self.period_func(**period_func_kwargs) + period(**period_func_kwargs) ) _, customer_table, customer_extra, _ = \ aggregate_transactions_change_by_period(self.provider, account, @@ -645,7 +662,7 @@ def paginate_queryset(self, queryset): return self.decorate_queryset(page if page else queryset) -class PlanMetricAPIView(DateRangeContextMixin, ProviderMixin, GenericAPIView): +class PlanMetricAPIView(DateRangeContextMixin, ProviderMixin, APIView): """ Retrieves 12-month trailing plans performance @@ -737,29 +754,34 @@ class PlanMetricAPIView(DateRangeContextMixin, ProviderMixin, GenericAPIView): serializer_class = MetricsSerializer filter_backends = (DateRangeFilter,) - @swagger_auto_schema(query_serializer=PeriodSerializer) + @swagger_auto_schema(query_serializer=QueryParamPeriodSerializer) def get(self, request, *args, **kwargs): # pylint:disable=unused-argument + query_serializer = QueryParamPeriodSerializer(data=request.query_params) + query_serializer.is_valid(raise_exception=True) + + num_periods = query_serializer.validated_data.get('num_periods') + period = query_serializer.validated_data.get('period') table = [] common_args = { 'from_date': self.ends_at, 'tz': self.timezone, } - if self.num_periods: - arg_name = 'nb_months' if self.period_func == month_periods else 'num_periods' - common_args[arg_name] = self.num_periods + if num_periods: + arg_name = 'nb_months' if period == month_periods else 'num_periods' + common_args[arg_name] = num_periods for plan in Plan.objects.filter( organization=self.provider).order_by('title'): # If we're using monthly periods, we use active_subscribers. - if self.period_func == month_periods: + if period == month_periods: values = active_subscribers(plan, **common_args) else: # For other periods, we use active_subscribers_by_period - specific_args = {'period_func': self.period_func} + specific_args = {'period_func': period} specific_args.update(common_args) values = active_subscribers_by_period(plan, **specific_args) @@ -773,12 +795,12 @@ def get(self, request, *args, **kwargs): }) # Similar to above, but for churn metrics. Monthly periods use churn_subscriber. - if self.period_func == month_periods: + if period == month_periods: extra_values = churn_subscribers(**common_args) else: # For other periods, add 'period_func' and get churn values using # churn_subscribers_by_period. - specific_args = {'period_func': self.period_func} + specific_args = {'period_func': period} specific_args.update(common_args) extra_values = churn_subscribers_by_period(**specific_args) @@ -806,7 +828,7 @@ class BalancesDueAPIView(BalancesDueMixin, ListAPIView): .. code-block:: http - GET /api/profile/cowork/due_balances HTTP/1.1 + GET /api/profile/cowork/balances-due HTTP/1.1 responds diff --git a/saas/api/plans.py b/saas/api/plans.py index 4adf91020..4bcfe31cf 100644 --- a/saas/api/plans.py +++ b/saas/api/plans.py @@ -36,7 +36,8 @@ from ..mixins import PlanMixin, CartMixin from ..filters import DateRangeFilter, OrderingFilter from ..models import Coupon, Plan, Subscription -from .serializers import PlanDetailSerializer, PlanCreateSerializer +from .serializers import (PlanDetailSerializer, PlanCreateSerializer, + QueryParamActiveSerializer) from ..utils import datetime_or_now @@ -194,6 +195,10 @@ def get_serializer_class(self): return PlanCreateSerializer return super(PlanListCreateAPIView, self).get_serializer_class() + @swagger_auto_schema(query_serializer=QueryParamActiveSerializer) + def get(self, request, *args, **kwargs): + return super().get(self, request, *args, **kwargs) + @swagger_auto_schema(responses={ 201: OpenAPIResponse("Create successful", PlanDetailSerializer)}) def post(self, request, *args, **kwargs): @@ -237,11 +242,13 @@ def post(self, request, *args, **kwargs): def get_queryset(self): queryset = self.organization.plans.all() - is_active = self.request.query_params.get('active') - truth_values = ['true', '1'] - if is_active: - value = is_active.lower() in truth_values - queryset = queryset.filter(is_active=value) + query_serializer = QueryParamActiveSerializer(data=self.request.query_params) + + if query_serializer.is_valid(raise_exception=True): + is_active = query_serializer.validated_data.get('active', None) + if is_active is not None: + queryset = queryset.filter(is_active=is_active) + # `PlanDetailSerializer` will expand `organization`, # `advance_discounts` and `use_charges`. queryset = queryset.select_related('organization').prefetch_related( diff --git a/saas/api/roles.py b/saas/api/roles.py index 11679d0e3..1808809fc 100644 --- a/saas/api/roles.py +++ b/saas/api/roles.py @@ -50,10 +50,11 @@ from ..utils import (full_name_natural_split, get_organization_model, get_role_model, get_role_serializer, generate_random_slug) from .organizations import OrganizationDecorateMixin -from .serializers import (AccessibleSerializer, ForceSerializer, +from .serializers import (AccessibleSerializer, QueryParamForceSerializer, OrganizationCreateSerializer, OrganizationDetailSerializer, RoleDescriptionSerializer, - AccessibleCreateSerializer, RoleCreateSerializer) + AccessibleCreateSerializer, RoleCreateSerializer, + QueryParamRoleStatusSerializer, QueryParamPersonalProfSerializer) LOGGER = logging.getLogger(__name__) @@ -196,7 +197,11 @@ def perform_optin(self, serializer, request, user=None): organizations = [ self.create_organization(organization_data)] if not organizations: - if not request.GET.get('force', False): + query_serializer = QueryParamForceSerializer( + data=self.request.query_params) + query_serializer.is_valid(raise_exception=True) + force = query_serializer.validated_data.get('force', False) + if not force: raise Http404(_("Profile %(organization)s does not exist." ) % {'organization': slug}) if not email: @@ -275,7 +280,12 @@ def get_queryset(self): # here instead of later in RoleInvitedListMixin. self.request.invited_count = queryset.filter( grant_key__isnull=False).count() - role_status = self.request.query_params.get('role_status', '') + query_serializer = QueryParamRoleStatusSerializer( + data=self.request.query_params) + role_status = '' + if query_serializer.is_valid(raise_exception=True): + role_status = query_serializer.validated_data.get( + 'role_status', '') stts = role_status.split(',') flt = None if 'active' in stts: @@ -304,11 +314,12 @@ class AccessibleByQuerysetMixin(UserMixin): def get_queryset(self): queryset = self.role_model.objects.filter(user=self.user) + query_serializer = QueryParamPersonalProfSerializer(data=self.request.query_params) + include_personal_profile = '' - truth_values = ['true', '1'] - personal_params = self.request.query_params.get( - 'include_personal_profile', '') - include_personal_profile = personal_params.lower() in truth_values + if query_serializer.is_valid(raise_exception=True): + include_personal_profile = query_serializer.validated_data.get( + 'include_personal_profile', '') if not include_personal_profile: queryset = queryset.exclude(organization__slug=self.user) @@ -407,7 +418,7 @@ def get_serializer_class(self): @swagger_auto_schema(responses={ 201: OpenAPIResponse("Create successful", AccessibleSerializer)}, - query_serializer=ForceSerializer) + query_serializer=QueryParamForceSerializer) def post(self, request, *args, **kwargs): """ Requests a role @@ -544,7 +555,7 @@ def get_serializer_class(self): @swagger_auto_schema(responses={ 201: OpenAPIResponse("Create successful", AccessibleSerializer)}, - query_serializer=ForceSerializer) + query_serializer=QueryParamForceSerializer) def post(self, request, *args, **kwargs): #pylint:disable=unused-argument """ Requests a role of a specified type @@ -924,7 +935,11 @@ def get_queryset(self): self.request.invited_count = queryset.filter( role_description=self.role_description, grant_key__isnull=False).count() - role_status = self.request.query_params.get('role_status', '') + query_serializer = QueryParamRoleStatusSerializer(data=self.request.query_params) + role_status = None + if query_serializer.is_valid(raise_exception=True): + role_status = query_serializer.validated_data.get('role_status', '') + stts = role_status.split(',') flt = (Q(role_description=self.role_description) | Q(request_key__isnull=False)) @@ -1025,7 +1040,11 @@ def create(self, request, *args, **kwargs): #pylint:disable=unused-argument except user_model.DoesNotExist: user = None if not user: - if not request.GET.get('force', False): + query_serializer = QueryParamForceSerializer( + data=self.request.query_params) + query_serializer.is_valid(raise_exception=True) + force = query_serializer.validated_data.get('force', False) + if not force: sep = "" not_found_msg = "Cannot find" if serializer.validated_data.get('slug'): @@ -1061,7 +1080,7 @@ def create(self, request, *args, **kwargs): #pylint:disable=unused-argument @swagger_auto_schema(responses={ 201: OpenAPIResponse("Create successful", get_role_serializer())}, - query_serializer=ForceSerializer) + query_serializer=QueryParamForceSerializer) def post(self, request, *args, **kwargs): """ Creates a role @@ -1469,7 +1488,11 @@ def create(self, request, *args, **kwargs): #pylint:disable=unused-argument validated_data = dict(serializer.validated_data) # If we're creating an organization from a personal profile - if request.query_params.get('convert-from-personal') == '1': + query_serializer = QueryParamPersonalProfSerializer(data=request.query_params) + query_serializer.is_valid(raise_exception=True) + convert_from_personal = query_serializer.validated_data.get('convert_from_personal', False) + + if convert_from_personal: organization = self.get_queryset().filter(slug__exact=self.user.username).first() if not self.is_valid_convert_to_organization_request(organization): @@ -1481,20 +1504,21 @@ def create(self, request, *args, **kwargs): #pylint:disable=unused-argument return Response({"detail": _("Successfully converted personal profile to organization.")}, status=status.HTTP_200_OK) - if 'email' not in validated_data: - # email is optional to create the profile but it is required - # to save the record in the database. - validated_data.update({'email': request.user.email}) - if 'full_name' not in validated_data: - # full_name is optional to create the profile but it is required - # to save the record in the database. - validated_data.update({'full_name': ""}) - - # creates profile - with transaction.atomic(): - organization = self.create_organization(validated_data) - organization.add_manager(self.user) - self.decorate_personal(organization) + else: + if 'email' not in validated_data: + # email is optional to create the profile but it is required + # to save the record in the database. + validated_data.update({'email': request.user.email}) + if 'full_name' not in validated_data: + # full_name is optional to create the profile but it is required + # to save the record in the database. + validated_data.update({'full_name': ""}) + + # creates profile + with transaction.atomic(): + organization = self.create_organization(validated_data) + organization.add_manager(self.user) + self.decorate_personal(organization) # returns created profile serializer = self.serializer_class( diff --git a/saas/api/serializers.py b/saas/api/serializers.py index 2d7f8b275..2ebda5826 100644 --- a/saas/api/serializers.py +++ b/saas/api/serializers.py @@ -52,6 +52,8 @@ from ..compat import gettext_lazy as _, reverse, six from ..decorators import _valid_manager from ..humanize import as_money +from ..metrics.base import (year_periods, month_periods, week_periods, day_periods, +hour_periods) from ..mixins import as_html_description, product_url, read_agreement_file from ..models import (get_broker, AdvanceDiscount, Agreement, BalanceLine, CartItem, Charge, Coupon, Plan, RoleDescription, Subscription, Transaction, @@ -382,25 +384,45 @@ class EmailChargeReceiptSerializer(NoModelSerializer): help_text=_("Feedback for the user in plain text")) -class ForceSerializer(NoModelSerializer): +class QueryParamForceSerializer(NoModelSerializer): force = serializers.BooleanField(required=False, help_text=_("Forces invite of user/organization that could"\ " not be found")) -class PeriodSerializer(NoModelSerializer): +class QueryParamPeriodSerializer(NoModelSerializer): - period = EnumField(required=False, - choices=Plan.INTERVAL_CHOICES, + period = serializers.ChoiceField( + required=False, + choices=[choice[1].lower() for choice in + Plan.INTERVAL_CHOICES], default='monthly', help_text=_("Set time granularity: 'hourly,' 'daily,' 'weekly,' " "'monthly,' or 'yearly.' Default is 'monthly.'")) - num_periods = serializers.IntegerField(required=False, - min_value=1, help_text=_("Specify the number of periods to include. " + num_periods = serializers.IntegerField( + required=False, + min_value=1, + max_value=100, + help_text=_("Specify the number of periods to include. " "Min value is 1.")) + def to_internal_value(self, data): + validated_data = super().to_internal_value(data) + period_str = validated_data.get('period') + + period_func_map = { + 'monthly': month_periods, + 'daily': day_periods, + 'hourly': hour_periods, + 'weekly': week_periods, + 'yearly': year_periods + } + validated_data['period'] = period_func_map.get(period_str, month_periods) + + return validated_data + class DatetimeValueTuple(serializers.ListField): @@ -1433,3 +1455,26 @@ class BalancesDueSerializer(OrganizationSerializer): class Meta(OrganizationSerializer.Meta): fields = OrganizationSerializer.Meta.fields + ('balances',) + +class QueryParamRoleStatusSerializer(NoModelSerializer): + role_status = serializers.CharField(required=False, default='', + allow_blank=True) + +class QueryParamActiveSerializer(NoModelSerializer): + active = serializers.BooleanField(required=False, + help_text=_("True when customers can subscribe to the plan"), + default=None, allow_null=True) + +class QueryParamPersonalProfSerializer(NoModelSerializer): + include_personal_profile = serializers.BooleanField(required=False, + help_text=_("True when a personal profile should be shown"), + default=None, allow_null=True) + + convert_from_personal = serializers.BooleanField(required=False, + default=None, allow_null=True) + +class QueryParamCartItemSerializer(NoModelSerializer): + plan = PlanRelatedField(required=False, allow_null=True, + help_text=_("Plan")) + email = serializers.EmailField(required=False, + help_text=_("E-mail address"), default=None, allow_null=True) \ No newline at end of file diff --git a/saas/api/subscriptions.py b/saas/api/subscriptions.py index 6bddcf3d1..e4382601b 100644 --- a/saas/api/subscriptions.py +++ b/saas/api/subscriptions.py @@ -44,7 +44,7 @@ from ..models import Subscription from ..utils import generate_random_slug, datetime_or_now from .roles import ListOptinAPIView -from .serializers import (ForceSerializer, +from .serializers import (QueryParamForceSerializer, ProvidedSubscriptionSerializer, ProvidedSubscriptionCreateSerializer, ProvidedSubscriptionDetailSerializer,SubscribedSubscriptionSerializer) @@ -633,7 +633,7 @@ def add_relations(self, organizations, user, ends_at=None): @swagger_auto_schema(responses={ 201: OpenAPIResponse("created", ProvidedSubscriptionSerializer)}, - query_serializer=ForceSerializer) + query_serializer=QueryParamForceSerializer) def post(self, request, *args, **kwargs): """ Grants a subscription diff --git a/saas/management/commands/report_weekly_revenue.py b/saas/management/commands/report_weekly_revenue.py index ca74b8448..a28337636 100644 --- a/saas/management/commands/report_weekly_revenue.py +++ b/saas/management/commands/report_weekly_revenue.py @@ -26,7 +26,7 @@ import logging -from dateutil.relativedelta import relativedelta, SU +from dateutil.relativedelta import relativedelta from django.core.management.base import BaseCommand from ... import settings diff --git a/saas/mixins.py b/saas/mixins.py index c0c253b0c..a9ef0d15a 100644 --- a/saas/mixins.py +++ b/saas/mixins.py @@ -46,8 +46,6 @@ full_name_natural_split, get_organization_model, get_role_model, handle_uniq_error, update_context_urls, validate_redirect_url) from .extras import OrganizationMixinBase -from .metrics.base import (hour_periods, day_periods, week_periods, - month_periods, year_periods) from .metrics.transactions import get_balances_due LOGGER = logging.getLogger(__name__) @@ -602,16 +600,6 @@ class DateRangeContextMixin(object): forced_date_range = True - # A function-map that uses the older month_periods function - # and newer functions for the rest of the periods - PERIOD_FUNC_MAP = { - 'monthly': month_periods, - 'daily': day_periods, - 'hourly': hour_periods, - 'weekly': week_periods, - 'yearly': year_periods - } - @property def start_at(self): if not hasattr(self, '_start_at'): @@ -644,29 +632,6 @@ def get_context_data(self, **kwargs): context.update({'ends_at': self.ends_at}) return context - @property - def period_func(self): - # Returns the period function from the request with a default - # set to month_periods - period = self.request.GET.get('period') - return self.PERIOD_FUNC_MAP.get(period, month_periods) - - @property - def num_periods(self): - if not hasattr(self, '_num_periods'): - num_periods = self.request.GET.get('num_periods', None) - try: - # Keeping the maximum value of num_periods to 100 - if num_periods and 0 < int(num_periods) < 100: - self._num_periods = int(num_periods) - else: - self._num_periods = None - except ValueError: - # In case a string or other non-integer values are - # passed in - self._num_periods = None - return self._num_periods - class InvoicablesMixin(OrganizationMixin): """ diff --git a/saas/static/js/djaodjin-saas-vue.js b/saas/static/js/djaodjin-saas-vue.js index 452a2ae9c..f737199a9 100644 --- a/saas/static/js/djaodjin-saas-vue.js +++ b/saas/static/js/djaodjin-saas-vue.js @@ -1504,7 +1504,7 @@ Vue.component('role-profile-list', { showRequested: false, params: { role_status: "", - include_personal_profile: "1", + include_personal_profile: true, }, } }, @@ -2283,7 +2283,7 @@ Vue.component('profile-update', { }, convertToOrganization: function() { var vm = this; - vm.reqPost(vm.profile_url + `?convert-from-personal=1`, { full_name: vm.formFields.full_name }, + vm.reqPost(vm.profile_url + `?convert_from_personal=1`, { full_name: vm.formFields.full_name }, function(resp) { if ( resp.detail ) { vm.showMessages([resp.detail], "success"); diff --git a/saas/templates/saas/profile/roles/index.html b/saas/templates/saas/profile/roles/index.html index 6f7dd1bc0..1e83e22bb 100644 --- a/saas/templates/saas/profile/roles/index.html +++ b/saas/templates/saas/profile/roles/index.html @@ -34,11 +34,11 @@
-
+

Add a new type of role

-