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",
)
)