diff --git a/.circleci/config.yml b/.circleci/config.yml index 2a427cb776..820c444c7d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -33,7 +33,7 @@ jobs: BRANCH=${CIRCLE_BRANCH#*/} VERSION_STR=$(cat VERSION) if [[ -n $CIRCLE_TAG ]]; then - VERSION_TAG="${CIRCLE_TAG}" + VERSION_TAG="${CIRCLE_TAG#*v}" elif [[ "$BRANCH" == "develop" ]]; then VERSION_TAG="${VERSION_STR}-dev" elif [[ "$BRANCH" != "master" ]]; then diff --git a/VERSION b/VERSION index 041792eb47..87bd8829db 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.0.13 +4.0.13.1 diff --git a/docker/customizations/any/static/less/engage.less b/docker/customizations/any/static/less/engage.less index f311551726..59fdbad9cc 100644 --- a/docker/customizations/any/static/less/engage.less +++ b/docker/customizations/any/static/less/engage.less @@ -21,7 +21,7 @@ body { #splash { /* margin-top: -30px; */ - background: url("/sitestatic/brands/engage/images/splash.png") !important; + background: url("../images/splash.png") !important; background-size: cover !important; width: 100%; height: 0px; @@ -247,11 +247,11 @@ temba-modax > div.send-via-btn { } } temba-modax#send-via-pm_element > div.send-via-btn { - background: url("/sitestatic/engage/img/element-chat.svg"); + background: url("../../../engage/img/element-chat.svg"); background-size: 16px; } temba-modax#send-via-pm_signal > div.send-via-btn { - background: url("/sitestatic/engage/img/signal-chat.svg"); + background: url("../../../engage/img/signal-chat.svg"); background-size: 16px; } /* ----------------------------------------------------*/ @@ -288,10 +288,20 @@ temba-modax#send-via-pm_signal > div.send-via-btn { align-self: start; margin-right: 1em; cursor: pointer; + background-image: url("../../../engage/img/house-door.svg"); + background-position: center; + background-repeat: no-repeat; + background-size: contain; + filter: invert(1); + height: 28px; + width: 28px; + border-width: 2px; + /* desire icon, not this emoji &:after { content: "🏠"; font-size: large; } + */ } #org-name { @@ -313,20 +323,20 @@ temba-modax#send-via-pm_signal > div.send-via-btn { .state-disabled { text-decoration: line-through; } - .state-icon .state-disabled { + .state-icon.state-disabled { text-decoration: unset; padding-left: 0.5em; &:after { content: "🚫"; } } - .state-icon .state-suspended { + .state-icon.state-suspended { padding-left: 0.5em; &:after { content: "⚠️"; } } - .state-icon .state-flagged { + .state-icon.state-flagged { padding-left: 0.5em; &:after { content: "🚩"; @@ -336,4 +346,9 @@ temba-modax#send-via-pm_signal > div.send-via-btn { } @import "select2-theme-org-picker"; - +#assign-user-to-org-picker-widget #select2-id_organization-container { + min-width: 15em; +} +#assign-user-name .controls { + display: inline-block; +} diff --git a/docker/customizations/any/temba/channels/views.py b/docker/customizations/any/temba/channels/views.py index 34dd180cc2..0be97fd0a1 100644 --- a/docker/customizations/any/temba/channels/views.py +++ b/docker/customizations/any/temba/channels/views.py @@ -776,7 +776,7 @@ def get_gear_links(self): dict( id="action-purge", title="Purge Outbox", - as_btn="true", + as_btn=True, js_class="button-danger", ) ) @@ -788,7 +788,7 @@ def get_gear_links(self): title=_("Edit"), href=reverse("channels.channel_update", args=[self.object.id]), modax=_("Edit Channel"), - as_btn="true", # used to determine if placed in hamburger menu or as its own button + as_btn=True, # used to determine if placed in hamburger menu or as its own button ) ) diff --git a/docker/customizations/any/temba/settings_engage.py b/docker/customizations/any/temba/settings_engage.py index f94d63cb5e..55b03bb75e 100644 --- a/docker/customizations/any/temba/settings_engage.py +++ b/docker/customizations/any/temba/settings_engage.py @@ -31,8 +31,13 @@ INSTALLED_APPS = ( tuple(filter(lambda tup : tup not in env('REMOVE_INSTALLED_APPS', '').split(','), INSTALLED_APPS)) + ( - 'engage.utils', + 'engage.api', + 'engage.auth', 'engage.channels', + 'engage.contacts', + 'engage.msgs', + 'engage.orgs', + 'engage.utils', ) + tuple(filter(None, env('EXTRA_INSTALLED_APPS', '').split(','))) ) diff --git a/docker/customizations/any/templates/frame.haml b/docker/customizations/any/templates/frame.haml index d84d73f324..ef3ac02006 100644 --- a/docker/customizations/any/templates/frame.haml +++ b/docker/customizations/any/templates/frame.haml @@ -69,11 +69,10 @@ -if not COMPONENTS_DEV_MODE -include "components-head.html" - -# Favicon works without the need to be explicit - -# -if brand.favico - -# %link{type:"image/ico", rel:"shortcut icon", href:"{% static brand.favico %}"} - -# -else - -# %link{type:"image/ico", rel:"shortcut icon", href:"{% static 'images/favicon.ico' %}"} + -if brand.favico + %link{type:"image/ico", rel:"shortcut icon", href:"{% static brand.favico %}"} + -else + %link{type:"image/ico", rel:"shortcut icon", href:"{% static 'images/favicon.ico' %}"} -block styles -# do not want to use non-local fonts @@ -111,6 +110,7 @@ -compress css %link{rel:"stylesheet", href:"{% static 'css/tailwind.css' %}", type:"text/css"} %link{rel:"stylesheet", href:"{% static 'less/refresh.less' %}", type:"text/less"} + %link{rel:"stylesheet", href:"{% static 'engage/less/frame.less' %}", type:"text/less"} -if brand.final_style %link{rel:"stylesheet", href:"{% static brand.final_style %}", type:"text/less"} @@ -162,14 +162,12 @@ -block nav -include 'includes/nav.html' - -if messages -block messages -for msg in messages - %div{class:"alert alert-{{ message.tags }}"} + %div{class:"blert blert-{{ msg.tags }}"} {{ msg }} - -block post-header -block page-container @@ -198,7 +196,6 @@ -block post-title - .mt-6 -block content @@ -223,10 +220,9 @@ :javascript var org_home_url_format = '{% url "orgs.org_home" %}?org=%s'; + var org_chosen_url_format = '{% url "orgs.org_choose" %}?organization=%s'; {% if user.is_superuser %} - var org_chosen_url_format = '{% url "orgs.org_service" %}?organization=%s'; - {% else %} - var org_chosen_url_format = '{% url "orgs.org_choose" %}?organization=%s'; + var org_service_url_format = '{% url "orgs.org_service" %}?organization=%s'; {% endif %} {% if user_org %} {% if user_org.is_anon %} diff --git a/engage/auth/__init__.py b/engage/auth/__init__.py new file mode 100644 index 0000000000..189166840f --- /dev/null +++ b/engage/auth/__init__.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig as BaseAppConfig + +default_app_config = 'engage.auth.AppConfig' + +class AppConfig(BaseAppConfig): + """ + django app labels must be unique; so override AppConfig to ensure uniqueness + """ + name = "engage.auth" + label = "engage_auth" + verbose_name = "Engage Auth" diff --git a/engage/auth/account.py b/engage/auth/account.py new file mode 100644 index 0000000000..94c82f66e4 --- /dev/null +++ b/engage/auth/account.py @@ -0,0 +1,125 @@ +""" +Django provides a means to override the default User class, but if you started +you project before such a means was provided, it's really a lot of work to +subclass. RP decided to just continue with the Monkey Patch method of hacking +in methods and properties they want to add to the User class as defined in +temba.orgs.models. Monkey Patching is invisible to our IDEs, however, so to +help developers, this static utilities class was created so we could at least +_FIND_ what kinds of added methods were available. You can then either call +the appropriate method on the User object directly, or you can use these +static methods: e.g. user_obj.get_org() vs UserAcct.get_org(user_obj) +""" + + +class UserAcct: + """ + Provide a means to extend the Django User class without replacing it. + Alternative means to accessing the monkey patches made to User class. + """ + @staticmethod + def is_allowed(user, permission) -> bool: + """ + NOT AVAILABLE ON THE User OBJECT ITSELF! + Check to see if we have the perimssion naturally, then if org is + defined, check there, too. + :param user: The User object. + :param permission: A permission to check. + :return: Returns True if permission is granted. + """ + if user.has_perm(permission): + return True + org = user.get_org() + if org: + return user.has_org_perm(org, permission) + return False + + #### EVERYTHING BELOW THIS LINE IS A MONKEY PATCH PASSTHRU CALL ============ + + @staticmethod + def release(user, releasing_user, *, brand): + """ + Releases this user, and any orgs of which they are the sole owner + """ + return user.release(releasing_user, brand=brand) + + @staticmethod + def get_org(user): + return user.get_org() + + @staticmethod + def set_org(user, org): + return user.set_org(org) + + @staticmethod + def is_alpha(user) -> bool: + return user.is_alpha() + + @staticmethod + def is_beta(user) -> bool: + return user.is_beta() + + @staticmethod + def is_support(user) -> bool: + return user.is_support() + + @staticmethod + def get_user_orgs(user, brands=None): + return user.get_user_orgs(brands) + + @staticmethod + def get_org_group(user): + return user.get_org_group() + + @staticmethod + def get_owned_orgs(user, brands=None): + """ + Gets all the orgs where this is the only user for the current brand + """ + return user.get_owned_orgs(brands) + + @staticmethod + def has_org_perm(user, org, permission) -> bool: + """ + Determines if a user has the given permission in this org + """ + return user.has_org_perm(org, permission) + + @staticmethod + def get_settings(user): + """ + Gets or creates user settings for this user + """ + return user.get_settings() + + @staticmethod + def record_auth(user): + return user.record_auth() + + @staticmethod + def enable_2fa(user): + """ + Enables 2FA for this user + """ + return user.enable_2fa() + + @staticmethod + def disable_2fa(user): + """ + Disables 2FA for this user + """ + return user.disable_2fa() + + @staticmethod + def verify_2fa(user, *, otp: str = None, backup_token: str = None) -> bool: + """ + Verifies a user using a 2FA mechanism (OTP or backup token) + """ + return user.verify_2fa(otp=otp, backup_token=backup_token) + + @staticmethod + def as_engine_ref(user) -> dict: + return user.as_engine_ref() + + @staticmethod + def name_of(user) -> str: + return user.name diff --git a/engage/channels/manage.py b/engage/channels/manage.py index 9f2c16beff..c503cb9517 100644 --- a/engage/channels/manage.py +++ b/engage/channels/manage.py @@ -12,6 +12,12 @@ class ManageChannelMixin: + @classmethod + def get_actions(cls): + return ( + "manage", + ) + class Manage(OrgPermsMixin, SmartListView): paginate_by = settings.PAGINATE_CHANNELS_COUNT title = _("Manage Channels") diff --git a/engage/channels/purge_outbox.py b/engage/channels/purge_outbox.py index fb24f30dfc..35f7bb23c5 100644 --- a/engage/channels/purge_outbox.py +++ b/engage/channels/purge_outbox.py @@ -9,12 +9,19 @@ from temba.orgs.views import OrgPermsMixin from temba.utils import json +from engage.auth.account import UserAcct from engage.utils import get_required_arg from engage.utils.logs import OrgPermLogInfoMixin class PurgeOutboxMixin: + @classmethod + def get_actions(cls): + return ( + "purge_outbox", + ) + class PurgeOutbox(OrgPermLogInfoMixin, OrgPermsMixin, View): # pragma: no cover permission = "msgs.broadcast_send" @@ -24,26 +31,26 @@ def derive_url_pattern(cls, path, action): def dispatch(self, request: HttpRequest, *args, **kwargs): # non authenticated users without orgs get an error (not the org chooser) - user = request.user + user = self.get_user() if not user.is_authenticated: return HttpResponse('Not authorized', status=401) return super().dispatch(request, *args, **kwargs) - def get(self, request, *args, **kwargs): + def get(self, request: HttpRequest, *args, **kwargs): logger = logging.getLogger(__name__) user = self.get_user() - if user.is_authenticated and not user.derive_org(): + if not UserAcct.get_org(user): theOrgPK = request.GET.get('org') if theOrgPK: org = Org.objects.filter(id=theOrgPK).first() if org is not None: - user.set_org(org) - if not user.derive_org(): + UserAcct.set_org(user, org) + if not UserAcct.get_org(user): return HttpResponse('Org ambiguous, please specify', status=400) - if not user.has_org_perm(self.permission): + if not UserAcct.is_allowed(user, self.permission): return HttpResponse('Forbidden', status=403) # ensure we have the necessary args @@ -75,7 +82,7 @@ def get(self, request, *args, **kwargs): 'channel_type': theChannelType, 'channel_uuid': theChannelUUID, 'status_code': r.status_code, - 'message': theMessage, + 'courier_response': theMessage, })) return HttpResponse(f"The courier service returned with status {r.status_code}: {theMessage}") except ConnectionError as ex: diff --git a/engage/channels/views.py b/engage/channels/views.py index e69d6c654b..8da8288bab 100644 --- a/engage/channels/views.py +++ b/engage/channels/views.py @@ -4,8 +4,7 @@ class EngageChannelCRUDMixin(ManageChannelMixin, PurgeOutboxMixin): def __init__(self, *args, **kwargs): - self.actions = self.actions + ( - "manage", - "purge_outbox", - ) + self.actions = self.actions + \ + ManageChannelMixin.get_actions() + \ + PurgeOutboxMixin.get_actions() super().__init__(*args, **kwargs) diff --git a/engage/hamls/includes/org.haml b/engage/hamls/includes/org.haml index b7003af91c..97b0e2ed40 100644 --- a/engage/hamls/includes/org.haml +++ b/engage/hamls/includes/org.haml @@ -2,8 +2,9 @@ #org-area {% if user_orgs|length > 0 %} - #btn-org-home - -#emoji house + -if user_org + %a#btn-org-home{href:'{% url "orgs.org_home" %}?org={{ user_org.id }}'} + -# house icon {% endif %} {% if user_orgs|length > 1 %} #org-picker-widget diff --git a/engage/hamls/orgs/org_assign_user.haml b/engage/hamls/orgs/org_assign_user.haml new file mode 100644 index 0000000000..97e0fba472 --- /dev/null +++ b/engage/hamls/orgs/org_assign_user.haml @@ -0,0 +1,65 @@ +-extends 'smartmin/form.html' +-load static smartmin i18n temba compress + +-block title + {{ title }} + +-block extra-style + {{block.super}} + +-block fields + .card + .w-full.items-end + #assign-user-to-org-picker-widget.pr-4.flex-grow + -##organization.w-48 + -# {% render_field 'organization' %} + .control-group.field_organization + %label.control-label + Organization + .controls + %select#id_organization{name:"organization"} + -for org in form.fields.organization.choices + -if user_org.pk == org.pk + -if not org.is_active + %option.org{value:'{{ org.pk }}', class:'state-disabled', selected: 'true'} + {{ org.name }} + -elif org.is_suspended + %option.org{value:'{{ org.pk }}', class:'state-suspended', selected: 'true'} + {{ org.name }} + -elif org.is_flagged + %option.org{value:'{{ org.pk }}', class:'state-flagged', selected: 'true'} + {{ org.name }} + -else + %option.org{value:'{{ org.pk }}', class:'state-ok', selected: 'true'} + {{ org.name }} + -else + -if not org.is_active + %option.org{value:'{{ org.pk }}', class:'state-disabled'} + {{ org.name }} + -elif org.is_suspended + %option.org{value:'{{ org.pk }}', class:'state-suspended'} + {{ org.name }} + -elif org.is_flagged + %option.org{value:'{{ org.pk }}', class:'state-flagged'} + {{ org.name }} + -else + %option.org{value:'{{ org.pk }}', class:'state-ok'} + {{ org.name }} + #assign-user-group.w-48 + -render_field 'user_group' + #assign-user-name.w-48 + .control-group.field_username + %label.control-label + {{ form.fields.username.label }} + .controls.border + %input#id_username{name:"username", placeholder:"email address or username"} + +-block form-buttons + .form-actions.mt-4.ml-2 + %input.button-primary(type="submit" value="{{ submit_button_name }}") + +-block script + + {{block.super}} + + diff --git a/engage/orgs/assign_user.py b/engage/orgs/assign_user.py index 4283e069f5..5c763e4450 100644 --- a/engage/orgs/assign_user.py +++ b/engage/orgs/assign_user.py @@ -1,70 +1,153 @@ +import logging + from django import forms +from django.contrib import messages from django.contrib.auth.models import User from django.http import HttpResponseRedirect from django.urls import reverse from django.utils.translation import ugettext_lazy as _ +from engage.utils.logs import OrgPermLogInfoMixin + from smartmin.views import ( SmartFormView, ) from temba.api.models import APIToken -from temba.orgs.models import Org +from temba.orgs.models import Org, OrgRole +from temba.orgs.views import OrgPermsMixin + +logger = logging.getLogger(__name__) class AssignUserMixin: - class AssignUser(SmartFormView): - class ServiceForm(forms.Form): - organization = forms.ModelChoiceField(queryset=Org.objects.all(), empty_label=None) + @classmethod + def get_actions(cls): + return ( + "assign_user", + ) + + class AssignUser(OrgPermLogInfoMixin, OrgPermsMixin, SmartFormView): + permission = "orgs.org_manage_accounts" + + def has_permission(self, request, *args, **kwargs): + user = request.user + if user.is_authenticated: + if user.has_perm(self.permission): + return True + org = user.get_org() + if org is not None: + return user.has_org_perm(org, self.permission) + return False + + class AssignUserForm(forms.Form): + user = None + organization = forms.ModelChoiceField( + queryset=Org.objects.all().order_by("name", "slug"), + required=True, + empty_label=None, + ) user_group = forms.ChoiceField( - choices=(("A", _("Administrators")), ("E", _("Editors")), ("V", _("Viewers")), ("S", _("Surveyors"))), + choices=( + (OrgRole.ADMINISTRATOR.code, OrgRole.ADMINISTRATOR.display), + (OrgRole.EDITOR.code, OrgRole.EDITOR.display), + (OrgRole.VIEWER.code, OrgRole.VIEWER.display), + (OrgRole.SURVEYOR.code, OrgRole.SURVEYOR.display), + ), required=True, - initial="E", - label=_("User group"), + initial=OrgRole.EDITOR.code, + label=_("Role"), ) username = forms.CharField(required=True, label=_("Username")) - form_class = ServiceForm + def __init__(self, user, *args, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + #self.organization.choices = self.user.get_user_orgs() ### O.o "'AssignUserForm' object has no attribute 'organization'" + self.fields['organization'].choices = self.get_org_choices() + + def get_org_choices(self): + admin_orgs = OrgRole.ADMINISTRATOR.get_orgs(self.user) + return admin_orgs.filter(is_active=True).distinct().order_by("name", "slug") + + # do not need the conversion of org_pk choice to Org class when using ModelChoiceField() type of form field. + # def clean_organization(self): + # org = None + # org_pk = self.data.get("organization") + # if org_pk: + # org = Org.objects.filter(id=org_pk).first() + # return org + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["user"] = self.request.user + return kwargs + + form_class = AssignUserForm title = _("Assign User to Organization") fields = ("organization", "user_group", "username") + success_url = "@orgs.org_assign_user" + success_message = "" + submit_button_name = _("Assign User") - # valid form means we set our org and redirect to their inbox - def form_valid(self, form): - org = form.cleaned_data["organization"] - user_group = form.cleaned_data["user_group"] - username = form.cleaned_data["username"] + def assign_user(self, org, role, user): + org.add_user(user=user, role=role) + + # when a user's role changes, delete any API tokens they're no longer allowed to have + api_roles = APIToken.get_allowed_roles(org, user) + for token in APIToken.objects.filter(org=org, user=user).exclude(role__in=api_roles): + token.release() - user = User.objects.filter(username__iexact=username).first() + org.save() + logger.error("user assigned to org", extra=self.withLogInfo({ + 'assigned_to_org_uuid': org.uuid, + 'assigned_to_org_slug': org.slug, + 'assigned_user_id': user.id, + 'assigned_user_name': user.username, + 'assigned_user_email': user.email, + 'assigned_role': role.display, + })) + messages.success(self.request, + _("User '{}' successfully added to org '{}' as the role {}.").format( + user.email, org.name, role.display + ) + ) + def form_valid(self, form): + org = form.cleaned_data["organization"] if org: - if user_group == "A": - org.administrators.add(user) - org.editors.remove(user) - org.surveyors.remove(user) - org.viewers.remove(user) - elif user_group == "E": - org.editors.add(user) - org.administrators.remove(user) - org.surveyors.remove(user) - org.viewers.remove(user) - elif user_group == "S": - org.surveyors.add(user) - org.administrators.remove(user) - org.editors.remove(user) - org.viewers.remove(user) + user_group = form.cleaned_data["user_group"] + role = OrgRole.from_code(user_group) + if role: + username = form.cleaned_data["username"] + user = User.objects.filter(username__iexact=username).first() if username else None + if user: + if user.id != self.get_user().id: + self.assign_user(org, role, user) + else: + logger.warning("assigned_user cannot be self", extra=self.withLogInfo({ + 'assigned_user': self.get_user().username, + })) + messages.warning(self.request, _("You cannot re-assign yourself.")) + else: + theName = form.data.get("username") + logger.error("assigned_user not found", extra=self.withLogInfo({ + 'assigned_user': theName, + })) + messages.warning(self.request, _("Username/Email '{}' not found.").format(theName)) else: - org.viewers.add(user) - org.administrators.remove(user) - org.editors.remove(user) - org.surveyors.remove(user) - - # when a user's role changes, delete any API tokens they're no longer allowed to have - api_roles = APIToken.get_allowed_roles(org, user) - for token in APIToken.objects.filter(org=org, user=user).exclude(role__in=api_roles): - token.release() - - org.save() + theRole = user_group + logger.error("role not found", extra=self.withLogInfo({ + 'assigned_role': theRole, + })) + messages.error(self.request, _("Role '{}' not found.").format(theRole)) + else: + theOrgPK = form.data.get("organization") + logger.error("org not found", extra=self.withLogInfo({ + 'assigned_org_pk': theOrgPK, + })) + messages.error(self.request, _("Org id [{}] not found.").format(theOrgPK)) success_url = reverse("orgs.org_assign_user") return HttpResponseRedirect(success_url) diff --git a/engage/orgs/bandwidth.py b/engage/orgs/bandwidth.py index c040d06811..d6c8fd65ee 100644 --- a/engage/orgs/bandwidth.py +++ b/engage/orgs/bandwidth.py @@ -1,15 +1,19 @@ +import os + from django import forms from django.core.exceptions import ValidationError from django.http import HttpResponseRedirect from django.urls import reverse from django.utils.translation import ugettext_lazy as _ +from engage.utils.bandwidth import BandwidthRestClient + from smartmin.views import ( SmartFormView, SmartUpdateView, ) -from temba.orgs.models import ( Org, - BW_ACCOUNT_SID, BW_ACCOUNT_TOKEN, BW_ACCOUNT_SECRET, BW_APPLICATION_SID, +from temba.orgs.models import (Org, + BW_ACCOUNT_SID, BW_ACCOUNT_TOKEN, BW_ACCOUNT_SECRET, BW_APPLICATION_SID, BWI_USERNAME, BWI_PASSWORD, ) from temba.orgs.views import ( ModalMixin, @@ -20,6 +24,15 @@ class BandwidthChannelMixin: + @classmethod + def get_actions(cls): + return ( + "bandwidth_connect", + "bandwidth_international_connect", + "bandwidth_account", + "bandwidth_international_account", + ) + class BandwidthConnect(ModalMixin, InferOrgMixin, OrgPermsMixin, SmartFormView): class BandwidthConnectForm(forms.Form): bw_account_sid = forms.CharField(label="Account SID", help_text=_("Your Bandwidth Account ID")) @@ -30,8 +43,6 @@ class BandwidthConnectForm(forms.Form): bw_application_sid = forms.CharField(label="Application SID", help_text=_("Your Bandwidth Account Application ID")) def clean(self): - from temba.utils.bandwidth import BandwidthRestClient - bw_account_sid = self.cleaned_data.get("bw_account_sid", None) bw_account_token = self.cleaned_data.get("bw_account_token", None) bw_account_secret = self.cleaned_data.get("bw_account_secret", None) @@ -252,9 +263,9 @@ def derive_initial(self): org = self.get_object() bwi_username = org.config.get(BWI_USERNAME, None) bwi_password = org.config.get(BWI_PASSWORD, None) - bwi_key = os.environ.get("BWI_KEY") - initial[str.lower(BWI_USERNAME)] = AESCipher(bwi_username, bwi_key).decrypt() - initial[str.lower(BWI_PASSWORD)] = AESCipher(bwi_password, bwi_key).decrypt() + #bwi_key = os.environ.get("BWI_KEY") + initial[str.lower(BWI_USERNAME)] = str.lower(bwi_username) #AESCipher(bwi_username, bwi_key).decrypt() + initial[str.lower(BWI_PASSWORD)] = str.lower('BWI NOT AVAILABLE') #AESCipher(bwi_password, bwi_key).decrypt() initial["disconnect"] = bool(self.request.POST.get("disconnect")) return initial @@ -263,9 +274,9 @@ def get_context_data(self, **kwargs): org = self.get_object() bwi_username = org.config.get(BWI_USERNAME, None) bwi_password = org.config.get(BWI_PASSWORD, None) - bwi_key = os.environ.get("BWI_KEY") - context[str.lower(BWI_USERNAME)] = AESCipher(bwi_username, bwi_key).decrypt() - context[str.lower(BWI_PASSWORD)] = AESCipher(bwi_password, bwi_key).decrypt() + #bwi_key = os.environ.get("BWI_KEY") + context[str.lower(BWI_USERNAME)] = str.lower(bwi_username) #AESCipher(bwi_username, bwi_key).decrypt() + context[str.lower(BWI_PASSWORD)] = str.lower('BWI NOT AVAILABLE') #AESCipher(bwi_password, bwi_key).decrypt() return context def form_valid(self, form): diff --git a/engage/orgs/home.py b/engage/orgs/home.py index 8219da63ac..a737a43ae0 100644 --- a/engage/orgs/home.py +++ b/engage/orgs/home.py @@ -1,14 +1,35 @@ from django.conf import settings from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ from temba.channels.models import Channel from temba.orgs.models import IntegrationType -#from temba.orgs.views import OrgCRUDL +from temba.orgs.views import OrgCRUDL class OrgHomeMixin: - class Home: #(OrgCRUDL.TembaOrgHome): will cause a circular reference; defined and uses utils/override.py + class Home: #(OrgCRUDL.TembaOrgHome): will cause a circular reference; defined and uses utils/overrides.py + + def get_gear_links(self): + links = [] + + if self.has_org_perm("orgs.org_manage_accounts"): + links.append(dict(title=_("Assign User"), href=reverse("orgs.org_assign_user"))) + + if self.has_org_perm("channels.channel_configuration"): + links.append(dict(title=_("Manage Channels"), href=reverse("channels.channel_manage"), as_btn=True)) + + # utils/overrides will set 'orig_get_gear_links' to the original get_gear_links() + links.extend(self.orig_get_gear_links()) + + theAddChannelUrl = reverse("channels.channel_claim") + for item in links: + if item['href'] == theAddChannelUrl: + item['as_btn'] = True + break + + return links def derive_formax_sections(self, formax, context): # add the channel option if we have one diff --git a/engage/orgs/postmaster.py b/engage/orgs/postmaster.py index 9b0742ebf1..8aba7435ee 100644 --- a/engage/orgs/postmaster.py +++ b/engage/orgs/postmaster.py @@ -17,6 +17,13 @@ class PostmasterChannelMixin: + @classmethod + def get_actions(cls): + return ( + "postmaster_connect", + "postmaster_account", + ) + class PostmasterAccount(InferOrgMixin, OrgPermsMixin, SmartUpdateView): success_message = "" diff --git a/engage/orgs/views.py b/engage/orgs/views.py index 3c738e3d26..223c4be93c 100644 --- a/engage/orgs/views.py +++ b/engage/orgs/views.py @@ -1,16 +1,11 @@ from .assign_user import AssignUserMixin -from .postmaster import PostmasterChannelMixin from .bandwidth import BandwidthChannelMixin +from .postmaster import PostmasterChannelMixin class EngageOrgCRUDMixin(BandwidthChannelMixin, PostmasterChannelMixin, AssignUserMixin): def __init__(self, *args, **kwargs): - self.actions = self.actions + ( - "bandwidth_connect", - "bandwidth_international_connect", - "bandwidth_account", - "bandwidth_international_account", - "postmaster_connect", - "postmaster_account", - "assign_user", - ) + self.actions = self.actions + \ + AssignUserMixin.get_actions() + \ + BandwidthChannelMixin.get_actions() + \ + PostmasterChannelMixin.get_actions() super().__init__(*args, **kwargs) diff --git a/engage/static/engage/img/house-door.svg b/engage/static/engage/img/house-door.svg new file mode 100644 index 0000000000..c883f3487c --- /dev/null +++ b/engage/static/engage/img/house-door.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/engage/static/engage/js/frame-ready.js b/engage/static/engage/js/frame-ready.js index 08e93e2881..31dd181d75 100644 --- a/engage/static/engage/js/frame-ready.js +++ b/engage/static/engage/js/frame-ready.js @@ -18,6 +18,7 @@ $(document).ready(function() { }); } + var theOrgHomeBtn = $('#btn-org-home'); var theOrgPicker = $('#org-picker'); if ( theOrgPicker ) { function formatOption (aOpt) { @@ -26,7 +27,7 @@ $(document).ready(function() { } var theOptClasses = $(aOpt.element).attr('class'); var theOpt = $( - '' + aOpt.text + '' + '' + aOpt.text + '' ); return theOpt; }; @@ -36,17 +37,19 @@ $(document).ready(function() { theme: 'org-picker', templateResult: formatOption, }); - theOrgPicker.on('select2:select', function(e) { - var theOrgPK = e.params.data.id; - var theUrl = org_chosen_url_format.sprintf(theOrgPK); - posterize(theUrl); - }); - //$('#select2-selection__arrow').addClass('icon-menu-2'); + if ( theOrgHomeBtn ) { + theOrgPicker.on('select2:select', function(e) { + var theOrgPK = e.params.data.id; + var theUrl = org_home_url_format.sprintf(theOrgPK); + theOrgHomeBtn.prop('href', theUrl); + theUrl = org_chosen_url_format.sprintf(theOrgPK); + posterize(theUrl); + }); + } } - var theOrgHomeBtn = $('#btn-org-home'); - if ( !theOrgHomeBtn ) theOrgHomeBtn = $('#org-name'); - if ( theOrgHomeBtn ) { + if ( !theOrgHomeBtn ) { + theOrgHomeBtn = $('#org-name'); theOrgHomeBtn.on('click', function(evt) { var theOrgPK = theOrgPicker ? theOrgPicker.find(':selected').val() : '0'; var theUrl = org_home_url_format.sprintf(theOrgPK); diff --git a/engage/static/engage/js/org-assign_user-ready.js b/engage/static/engage/js/org-assign_user-ready.js new file mode 100644 index 0000000000..db255cd35a --- /dev/null +++ b/engage/static/engage/js/org-assign_user-ready.js @@ -0,0 +1,21 @@ +$(document).ready(function() { + + var theOrgPicker = $('#id_organization'); + if ( theOrgPicker ) { + function formatOption (aOpt) { + if (!aOpt.id) { + return aOpt.text; + } + var theOptClasses = $(aOpt.element).attr('class'); + var theOpt = $( + '' + aOpt.text + '' + ); + return theOpt; + }; + theOrgPicker.select2({ + width: 'fit-content', + templateResult: formatOption, + }); + } + +}); diff --git a/engage/static/engage/less/frame.less b/engage/static/engage/less/frame.less new file mode 100644 index 0000000000..ba93d6c40d --- /dev/null +++ b/engage/static/engage/less/frame.less @@ -0,0 +1,96 @@ +/* +.alert bootstrap css has been heavily modified, revert and use alt name 'blert' + */ +.blert { + padding: 15px; + margin-bottom: 20px; + border: 1px solid transparent; + border-radius: 4px; +} + +.blert h4 { + margin-top: 0; + color: inherit; +} + +.blert .blert-link { + font-weight: bold; +} + +.blert > p, +.blert > ul { + margin-bottom: 0; +} + +.blert > p + p { + margin-top: 5px; +} + +.blert-dismissable, +.blert-dismissible { + padding-right: 35px; +} + +.blert-dismissable .close, +.blert-dismissible .close { + position: relative; + top: -2px; + right: -21px; + color: inherit; +} + +.blert-success { + background-color: #dff0d8; + border-color: #d6e9c6; + color: #3c763d; +} + +.blert-success hr { + border-top-color: #c9e2b3; +} + +.blert-success .blert-link { + color: #2b542c; +} + +.blert-info { + background-color: #d9edf7; + border-color: #bce8f1; + color: #31708f; +} + +.blert-info hr { + border-top-color: #a6e1ec; +} + +.blert-info .blert-link { + color: #245269; +} + +.blert-warning { + background-color: #fcf8e3; + border-color: #faebcc; + color: #8a6d3b; +} + +.blert-warning hr { + border-top-color: #f7e1b5; +} + +.blert-warning .blert-link { + color: #66512c; +} + +.blert-danger { + background-color: #f2dede; + border-color: #ebccd1; + color: #a94442; +} + +.blert-danger hr { + border-top-color: #e4b9c0; +} + +.blert-danger .blert-link { + color: #843534; +} diff --git a/engage/utils/__init__.py b/engage/utils/__init__.py index 9dd5c60cc8..a981d6bfaa 100644 --- a/engage/utils/__init__.py +++ b/engage/utils/__init__.py @@ -38,7 +38,8 @@ def var_dump( aThing, aMsg=None ): if aMsg: print(aMsg) from pprint import pprint - pprint(_var_dump(aThing)) + from copy import deepcopy + pprint(_var_dump(deepcopy(aThing))) def get_required_arg(arg_name, kwargs, bCheckForEmpty=True): """ diff --git a/temba/utils/bandwidth.py b/engage/utils/bandwidth.py similarity index 79% rename from temba/utils/bandwidth.py rename to engage/utils/bandwidth.py index eaf75acc4d..33d27d26d0 100644 --- a/temba/utils/bandwidth.py +++ b/engage/utils/bandwidth.py @@ -2,13 +2,11 @@ from urllib.parse import urlencode from bandwidth import account, messaging, voice, version -from django.utils.encoding import force_text +from django.utils.encoding import force_text, force_str +if force_str and not force_text: + force_text = force_str from temba.utils.http import HttpEvent -from Crypto.Cipher import AES -from Crypto.Random import new as Random -from hashlib import sha256 -from base64 import b64encode,b64decode def encode_atom(atom): # pragma: no cover @@ -20,27 +18,6 @@ def encode_atom(atom): # pragma: no cover raise ValueError("list elements should be an integer, " "binary, or string") -class AESCipher: - def __init__(self,data,key): - self.block_size = 16 - self.data = data - self.key = sha256(key.encode()).digest()[:32] - self.pad = lambda s: s + (self.block_size - len(s) % self.block_size) * chr (self.block_size - len(s) % self.block_size) - self.unpad = lambda s: s[:-ord(s[len(s) - 1:])] - - def encrypt(self): - plain_text = self.pad(self.data) - iv = Random().read(AES.block_size) - cipher = AES.new(self.key,AES.MODE_OFB,iv) - return b64encode(iv + cipher.encrypt(plain_text.encode())).decode() - - def decrypt(self): - cipher_text = b64decode(self.data.encode()) - iv = cipher_text[:self.block_size] - cipher = AES.new(self.key,AES.MODE_OFB,iv) - return self.unpad(cipher.decrypt(cipher_text[self.block_size:])).decode() - - class BandwidthRestClient(account.client_module.Client): # pragma: no cover bw_application_sid = None bw_account_secret = None diff --git a/engage/utils/overrides.py b/engage/utils/overrides.py index 6c61c6d70e..a231fed1d2 100644 --- a/engage/utils/overrides.py +++ b/engage/utils/overrides.py @@ -67,6 +67,8 @@ def RunEngageOverrides(): # cannot use OrgHomeMixin due to circular unit reference; override def here. from temba.orgs.views import OrgCRUDL as TembaOrgViews from engage.orgs.home import OrgHomeMixin as EngageOrgViews + TembaOrgViews.Home.orig_get_gear_links = TembaOrgViews.Home.get_gear_links + TembaOrgViews.Home.get_gear_links = EngageOrgViews.Home.get_gear_links TembaOrgViews.Home.derive_formax_sections = EngageOrgViews.Home.derive_formax_sections # URN is a static-only class, add in our needs diff --git a/engage/utils/wrapper.py b/engage/utils/wrapper.py new file mode 100644 index 0000000000..c8805aadec --- /dev/null +++ b/engage/utils/wrapper.py @@ -0,0 +1,56 @@ +class Wrapper(object): + """ + Wrapper class that provides proxy access to an instance of some + internal instance. + @see `StackOverflow answer `_ + + **Usage:** + + :: + + class DictWrapper(Wrapper): + __wraps__ = dict + + wrapped_dict = DictWrapper(dict(a=1, b=2, c=3)) + + # make sure it worked.... + assert "b" in wrapped_dict # __contains__ + assert wrapped_dict == dict(a=1, b=2, c=3) # __eq__ + assert "'a': 1" in str(wrapped_dict) # __str__ + assert wrapped_dict.__doc__.startswith("dict()") # __doc__ + + """ + + __wraps__ = None + __ignore__ = "class mro new init setattr getattr getattribute" + + def __init__(self, obj): + if self.__wraps__ is None: + raise TypeError("base class Wrapper may not be instantiated") + elif isinstance(obj, self.__wraps__): + self._obj = obj + else: + raise ValueError("wrapped object must be of %s" % self.__wraps__) + + # provide proxy access to regular attributes of wrapped object + def __getattr__(self, name): + if name in self.__dict__: + return getattr(self, name) + return getattr(self._obj, name) + + # create proxies for wrapped object's double-underscore attributes + class __metaclass__(type): + def __init__(cls, name, bases, dct): + + def make_proxy(name): + def proxy(self, *args): + return getattr(self._obj, name) + return proxy + + type.__init__(cls, name, bases, dct) + if cls.__wraps__: + ignore = set("__%s__" % n for n in cls.__ignore__.split()) + for name in dir(cls.__wraps__): + if name.startswith("__"): + if name not in ignore and name not in dct: + setattr(cls, name, property(make_proxy(name))) diff --git a/temba/channels/types/postmaster/views.py b/temba/channels/types/postmaster/views.py index 47611afa5f..82bc2bad2f 100644 --- a/temba/channels/types/postmaster/views.py +++ b/temba/channels/types/postmaster/views.py @@ -107,7 +107,7 @@ def get_gear_links(self): links.append( dict( title="Show App QR", - as_btn="true", + as_btn=True, js_class="mi-pm-app-qr", ) )