diff --git a/engage/channels/models.py b/engage/channels/models.py
index 351acc4846..0f586942b2 100644
--- a/engage/channels/models.py
+++ b/engage/channels/models.py
@@ -29,7 +29,7 @@ def create(
config = {}
#endif
# P4-3462
- if URN.TEL_SCHEME in schemes:
+ if schemes and URN.TEL_SCHEME in schemes:
config[Channel.CONFIG_ALLOW_INTERNATIONAL] = True
#endif
return cls.getOrigClsAttr('create')(org, user, country, channel_type, name, address, config, role, schemes, **kwargs)
diff --git a/engage/channels/types/vonage_client.py b/engage/channels/types/vonage_client.py
index b45e2e7785..b23f05519f 100644
--- a/engage/channels/types/vonage_client.py
+++ b/engage/channels/types/vonage_client.py
@@ -1,9 +1,14 @@
+import logging
+
from django.urls import reverse
from engage.utils.class_overrides import ClassOverrideMixinMustBeFirst
from temba.channels.types.vonage.client import VonageClient
+
+logger = logging.getLogger(__name__)
+
class VonageClientOverrides(ClassOverrideMixinMustBeFirst, VonageClient):
def create_application(self, domain, channel_uuid):
diff --git a/engage/channels/types/vonage_views.py b/engage/channels/types/vonage_views.py
new file mode 100644
index 0000000000..05f1040ab2
--- /dev/null
+++ b/engage/channels/types/vonage_views.py
@@ -0,0 +1,69 @@
+import logging
+import phonenumbers
+import re
+
+from engage.utils.class_overrides import ClassOverrideMixinMustBeFirst
+
+from temba.channels.models import Channel
+from temba.channels.types.vonage.type import VonageType
+from temba.channels.types.vonage.views import ClaimView
+
+
+logger = logging.getLogger(__name__)
+
+class ClaimViewOverrides(ClassOverrideMixinMustBeFirst, ClaimView):
+
+ def get_existing_numbers(self, org):
+ numbers = []
+ client = org.get_vonage_client()
+ if client:
+ try:
+ account_numbers = client.get_numbers(size=100)
+ #logger.debug(' TRACE[account_numbers]='+str(account_numbers))
+ uuid_pattern = r"(\w{8}-\w{4}-\w{4}-\w{4}-\w{12})/receive$"
+ account_uuids = []
+ for number in account_numbers:
+ if number["type"] == "mobile-shortcode": # pragma: needs cover
+ phone_number = number["msisdn"]
+ else:
+ parsed = phonenumbers.parse(number["msisdn"], number["country"])
+ phone_number = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.INTERNATIONAL)
+ #endif shortcode
+
+ # mark accounts used/unused by checking the db for uuid
+ channel_uuid = '1'
+ if 'moHttpUrl' in number:
+ # 'moHttpUrl': 'https://engage.dev.istresearch.com/c/nx/742c11f1-72fb-4994-8156-8848e8a63e55/receive',
+ match = re.search(uuid_pattern, number["moHttpUrl"])
+ channel_uuid = match.group(1) if match else '1'
+ #endif key in list
+ account_uuids.append(channel_uuid)
+
+ numbers.append(dict(
+ number=phone_number,
+ country=number["country"],
+ uuid=channel_uuid,
+ in_use=False,
+ ))
+ #endfor numbers
+
+ # query db for "in use" numbers
+ qs = Channel.objects.filter(
+ channel_type=VonageType.code,
+ uuid__in=account_uuids,
+ ).values_list('uuid', flat=True)
+ for channel_uuid in qs:
+ idx = account_uuids.index(channel_uuid)
+ numbers[idx]['in_use'] = True
+ #endfor each channel found
+ #logger.debug(' TRACE[nums]='+str(numbers))
+ except Exception as e:
+ logger.error(f"fail: {str(e)}", exc_info=True)
+ raise e
+ #endtry
+
+ #endif client
+ return numbers
+ #enddef get_existing_numbers
+
+#endclass ClaimViewOverrides
diff --git a/engage/hamls/channels/channel_claim_number.haml b/engage/hamls/channels/channel_claim_number.haml
new file mode 100644
index 0000000000..5e027fa942
--- /dev/null
+++ b/engage/hamls/channels/channel_claim_number.haml
@@ -0,0 +1,140 @@
+-extends "smartmin/form.html"
+
+-load compress temba
+-load i18n
+
+-block title-icon
+
+-block title
+ -trans "Connect a Phone Number"
+
+-block content
+ - block claim-numbers-description
+ -blocktrans trimmed
+ Once you connect a number you will immediately be able to send and receive messages. Contacts who send messages
+ to your number will be charged according to their plan.
+
+ - block account-trial-warning
+
+ - block numbers-search-form
+ .card.mt-6
+ %form#search-form
+ .flex.items-end
+ .country.w-64.mr-3
+ -block country-select
+ %temba-select#country(name="country" label='{{_("Country")|escapejs}}')
+ -for country in search_countries
+ %temba-option(name="{{country.label}}" value="{{country.key}}")
+
+ .pattern.w-32.mr-3
+ -block search-pattern
+ %temba-textinput#pattern(type="text" maxlength="3" name="pattern" label='{{_("Pattern")|escapejs}}')
+
+ %input.button-primary{ type:"submit", value:"{% trans 'Search' %}" }
+
+ .twilio-numbers-title
+
+ #throbber.my-6{ style: "display:none;" }
+ %temba-loading
+ #results.my-6
+
+
+
+ -if form.errors
+ -if form.errors.upgrade
+ :javascript
+ document.location.href = '{% url 'orgs.org_upgrade_plan' %}?from=twilio'
+ -else
+ .alert-error.my-4
+ {{ form.errors.phone_number }}
+
+ #claim-message.alert-warning{ style: "display:none;margin-top:10px;" }
+ -if error
+ {{ error }}
+
+ -if account_numbers
+ #account-numbers.card.mt-3.mb-3
+ .title
+ -trans "Existing Numbers"
+ .mb-3
+ -trans "Select a number you already own to connect it to your account."
+ -for number in account_numbers
+ - if number.country in supported_country_iso_codes or number.number|length <= 6
+ .lbl.mt-3.mr-2.linked{class:"phone-number", data-number:"{{ number.number}}", data-country:"{{ number.country}}" }
+ {{ number.number }}
+ ({{ number.country }})
+ {% if number.in_use %}
+
+ {% else %}
+
+ {% endif %}
+ - else
+ .lbl.mt-3.mr-2{class:"unsupported-number", data-number:"{{ number.number}}", data-country:"{{ number.country}}" }
+ {{ number.number }}
+ -trans "(Unsupported)"
+
+ %form#claim-form{ style: "display:none;", method:"POST", action:"{{ claim_url }}" }
+ {% csrf_token %}
+ %input#claim-country{ type:"text", name:"country" }
+ %input#phone-number{ type:"text", name:"phone_number" }
+
+-block extra-script
+ {{ block.super }}
+
+ :javascript
+ $("#results").on('click', ".phone-number", function(e){
+ var country = document.querySelector("#country").values[0].value;
+
+ $("#phone-number").val($(this).data("number"));
+ $("#claim-country").val(country);
+ $("#claim-form").submit();
+ });
+
+ $("#account-numbers").on('click', ".phone-number", function(e){
+ $("#phone-number").val($(this).data("number"));
+ $("#claim-country").val($(this).data("country"));
+ $("#claim-form").submit();
+ });
+
+ function searchNumbers(e){
+ var pattern = document.querySelector('#pattern').value;
+ var country = document.querySelector("#country").values[0].value;
+
+ $("#claim-message").hide();
+ $("#results").empty();
+ $("#throbber").show();
+
+ $.ajax({
+ type: "POST",
+ url: "{{ search_url }}",
+ data: { pattern: pattern, country: country },
+ dataType: "json",
+ success: function(data, status, xhr){
+ $("#throbber").hide();
+ if (data.length > 0){
+ $("#claim-country").val(country);
+ for (var i=0; i < data.length; i++){
+ $("#results").append("
" + data[i] + "
");
+ }
+ $("#results").show();
+ } else if ('error' in data) {
+ $("#claim-message").text(data['error']);
+ $("#claim-message").show();
+ } else {
+ $("#claim-message").text("{% trans 'Sorry, no numbers found, please enter another pattern and try again.' %}");
+ $("#claim-message").show();
+ }
+ },
+ failure: function(req){
+ $("#throbber").hide();
+ $("#claim-message").show();
+ }
+ });
+
+ e.preventDefault();
+ return false;
+ }
+
+ $(function(){
+ $("#search-form").on('submit', searchNumbers);
+ });
diff --git a/engage/hamls/orgs/user_list.haml b/engage/hamls/orgs/user_list.haml
new file mode 100644
index 0000000000..ae86dba6d1
--- /dev/null
+++ b/engage/hamls/orgs/user_list.haml
@@ -0,0 +1,114 @@
+-extends "smartmin/list.html"
+-load smartmin sms temba compress contacts i18n humanize
+
+-block extra-style
+ {{block.super}}
+ :css
+ temba-button {
+ display: block;
+ }
+ tr:nth-child(even) {background: #FFF}
+ .button-light{
+ padding-bottom: 8px;
+ padding-top: 8px;
+ padding-left: 18px;
+ padding-right: 18px;
+ }
+
+ .list_channels_channel {
+ width:100%;
+ }
+ tr {
+ text-align: left;
+ }
+ table .headerSortUp, table .headerSortDown {
+ background-color: rgba(141, 192, 219, 0.25);
+ text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75);
+ }
+
+ table .headerSortDown {
+ background-image: url({{STATIC_URL}}img/sort_dsc.png);
+ background-repeat: no-repeat;
+ background-position: 98% 50%;
+ }
+
+ table .headerSortUp {
+ background-image: url({{STATIC_URL}}img/sort_asc.png);
+ background-repeat: no-repeat;
+ background-position: 98% 50%;
+ }
+
+-block page-top
+
+-block content
+
+ .lp-frame
+ .right
+ .flex.w-full.items-end.mb-4
+ .flex-grow.ml-2.items-center
+ -block title-text
+ .page-title.leading-tight
+ {{title}}
+ .gear-links
+ -include "gear_links_include.haml"
+
+ %form#search-form.mb-4(method="get")
+ %temba-textinput.w-full(placeholder='{% trans "Search" %}' name="search" value="{{search}}")
+
+ -block user-list
+ %table.list.object-list.lined
+ %thead
+ -# extra: sortable header clickies!
+ %tr
+ - for field in fields
+ %th{ class:'{% if field not in view.non_sort_fields %}header{% if field == view.sort_field %} {% if not view.sort_order or view.sort_order == "asc" %}headerSortUp{% else %}headerSortDown{% endif %}{% endif %}{% endif %}', id:'header-{{field}}' }
+ - if field not in view.non_sort_fields
+ {% get_label field %}
+ - else
+ {% get_label field %}
+ %tbody
+ -for object in object_list
+ %tr.object-row{id: 'id-row-{{object.id}}', data-object-id:'{{ object.id }}'}
+ %td.whitespace-nowrap
+ {% get_value object 'username' %}
+ %td.w-full
+ .flex.flex-wrap.flex-end.items-center.justify-end
+ .flex-grow.inline
+ {% get_value object 'orgs' %}
+ %td
+ .flex.w-full.items-end.justify-end.pr-4
+ .time.whitespace-nowrap
+ {% format_datetime object.date_joined %}
+
+ - block paginator
+ -if object_list.count
+ -block search-details
+ .flex.mx-4.mt-3.mb-16
+ .text-gray-700
+ -if not paginator or paginator.num_pages <= 1
+ -if search
+ -blocktrans trimmed with results_count=paginator.count|intcomma count cc=paginator.count
+ Found {{ results_count }} user matching {{search}}.
+ -plural
+ Found {{ results_count }} users matching {{search}}.
+ -else
+ -blocktrans trimmed with results_count=paginator.count|intcomma count cc=paginator.count
+ {{ results_count }} user.
+ -plural
+ {{ results_count }} users.
+
+ - else
+
+ -if search
+ -blocktrans trimmed with results_count=paginator.count|intcomma start=page_obj.start_index|intcomma end=page_obj.end_index|intcomma count cc=paginator.count
+ Found {{ results_count }} user matching {{search}}.
+ -plural
+ {{ start }} - {{ end }} of {{ results_count }} results for {{search}}.
+ -else
+ -blocktrans trimmed with results_count=paginator.count|intcomma start=page_obj.start_index|intcomma end=page_obj.end_index|intcomma count cc=paginator.count
+ {{ results_count }} user.
+ -plural
+ {{ start }} - {{ end }} of {{ results_count }} users.
+
+ .flex-grow
+ -include "includes/pages.html"
diff --git a/engage/msgs/views/inbox.py b/engage/msgs/views/inbox.py
index 95c797c5c9..4df17ff36e 100644
--- a/engage/msgs/views/inbox.py
+++ b/engage/msgs/views/inbox.py
@@ -56,6 +56,11 @@ def on_apply_overrides(under_cls) -> None:
#enddef on_apply_overrides
def get_context_data(self, **kwargs):
+ org = self.request.user.get_org()
+ if not org:
+ from engage.utils.middleware import redirect_to
+ redirect_to('/')
+ #endif superuser
context = self.orig_get_context_data(**kwargs)
context['object_list'] = self._sanitizeMsgList(context['object_list'])
return context
diff --git a/engage/orgs/models.py b/engage/orgs/models.py
index 89b5eba7f8..d6b93c096e 100644
--- a/engage/orgs/models.py
+++ b/engage/orgs/models.py
@@ -1,6 +1,10 @@
import functools
import operator
+from django.db import transaction
+
+from engage.utils.class_overrides import ClassOverrideMixinMustBeFirst, ignoreDjangoModelAttrs
+
from temba.orgs.models import Org, OrgRole
@@ -13,6 +17,8 @@ def get_user_org(user):
:return: the org object as a property of the user obj or from the db.
"""
return Org.get_org(user)
+#enddef get_user_org
+
def get_user_orgs(user, brands=None):
"""
@@ -35,3 +41,20 @@ def get_user_orgs(user, brands=None):
user_orgs = user_orgs.filter(brand__in=brands)
return user_orgs.filter(is_active=True).distinct().order_by("name")
+#enddef get_user_orgs
+
+
+class OrgModelOverride(ClassOverrideMixinMustBeFirst, Org):
+ override_ignore = ignoreDjangoModelAttrs(Org)
+
+ # we do not want Django to perform any magic inheritance
+ class Meta:
+ abstract = True
+
+ def release(self, user, **kwargs):
+ with transaction.atomic():
+ self.getOrigClsAttr('release')(self, user, **kwargs)
+ #endwith
+ #enddef release
+
+#endclass OrgModelOverride
diff --git a/engage/orgs/views/user_delete.py b/engage/orgs/views/user_delete.py
index e623e6065a..cfbda6e651 100644
--- a/engage/orgs/views/user_delete.py
+++ b/engage/orgs/views/user_delete.py
@@ -3,11 +3,13 @@
from django.contrib import messages
from django.http import HttpResponseRedirect
from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
from engage.utils.class_overrides import ClassOverrideMixinMustBeFirst
from temba.orgs.views import UserCRUDL
+
class UserViewDeleteOverride(ClassOverrideMixinMustBeFirst, UserCRUDL.Delete):
fields = ("id",)
permission = "auth.user_update"
diff --git a/engage/orgs/views/user_list.py b/engage/orgs/views/user_list.py
new file mode 100644
index 0000000000..0088c3f7df
--- /dev/null
+++ b/engage/orgs/views/user_list.py
@@ -0,0 +1,50 @@
+import logging
+
+from engage.utils.class_overrides import ClassOverrideMixinMustBeFirst
+
+from temba.orgs.views import UserCRUDL
+from temba.utils import get_anonymous_user
+
+
+logger = logging.getLogger(__name__)
+
+class OrgViewListUserOverrides(ClassOverrideMixinMustBeFirst, UserCRUDL.List):
+ fields = ("username", "orgs", "date_joined",)
+ link_fields = ("username",)
+ ordering = ("-date_joined",)
+ search_fields = ("username__icontains", "email__icontains",)
+ paginate_by = 25
+ sort_field = "username"
+ non_sort_fields = ('orgs',)
+ sort_order = None
+
+ @staticmethod
+ def on_apply_overrides(under_cls) -> None:
+ ClassOverrideMixinMustBeFirst.setOrigMethod(under_cls, 'get_queryset')
+ #enddef on_apply_overrides
+
+ def get_queryset(self, **kwargs):
+ """
+ override to fix sort order bug (descending uses a leading "-" which fails "if in fields" check.
+ """
+ queryset = self.orig_get_queryset(**kwargs)
+
+ # org users see channels for their org, superuser sees all
+ if not self.request.user.is_superuser:
+ org = self.request.user.get_org()
+ queryset = queryset.filter(org=org)
+
+ theOrderByColumn = self.sort_field
+ if 'sort_on' in self.request.GET:
+ theSortField = self.request.GET.get('sort_on')
+ if theSortField in self.fields and theSortField not in self.non_sort_fields:
+ self.sort_field = theSortField
+ theSortOrder = self.request.GET.get("sort_order")
+ self.sort_order = theSortOrder if theSortOrder in ('asc', 'desc') else None
+ theSortOrderFlag = '-' if theSortOrder == 'desc' else ''
+ theOrderByColumn = "{}{}".format(theSortOrderFlag, self.sort_field)
+
+ return queryset.filter(is_active=True).order_by(theOrderByColumn, 'username').exclude(id=get_anonymous_user().id)
+
+
+#endclass OrgViewListUserOverrides
diff --git a/engage/static/engage/less/engage.less b/engage/static/engage/less/engage.less
index 0cb66240b1..9af9e85af9 100644
--- a/engage/static/engage/less/engage.less
+++ b/engage/static/engage/less/engage.less
@@ -370,3 +370,14 @@ temba-modax#send-via-pm_signal > div.send-via-btn {
#assign-user-name .controls {
display: inline-block;
}
+
+.num_in_use {
+ &:after {
+ content: "☎❌";
+ }
+}
+.num_not_in_use {
+ &:after {
+ content: "🔵";
+ }
+}
diff --git a/engage/utils/middleware.py b/engage/utils/middleware.py
new file mode 100644
index 0000000000..2bc353a359
--- /dev/null
+++ b/engage/utils/middleware.py
@@ -0,0 +1,24 @@
+from django import shortcuts
+
+class RedirectTo(Exception):
+ def __init__(self, url):
+ self.url = url
+
+def redirect_to(url):
+ raise RedirectTo(url)
+
+class RedirectMiddleware:
+
+ def __init__(self, get_response):
+ self.get_response = get_response
+
+ def __call__(self, request):
+ return self.get_response(request)
+
+ def process_exception(self, request, ex):
+ if isinstance(ex, RedirectTo):
+ return shortcuts.redirect(ex.url)
+ #endif
+ #enddef process_exception
+
+#endclass RedirectMiddleware
diff --git a/engage/utils/overrides.py b/engage/utils/overrides.py
index 266e4a8c33..4fa27f4b30 100644
--- a/engage/utils/overrides.py
+++ b/engage/utils/overrides.py
@@ -49,7 +49,7 @@ def RunEngageOverrides(cls):
(TembaURN.VK_SCHEME, _("VK Identifier")),
(TembaURN.ROCKETCHAT_SCHEME, _("RocketChat Identifier")),
(TembaURN.DISCORD_SCHEME, _("Discord Identifier")),
- ) + tuple([ t[1] for t in sorted((lambda x: [[y[1].split(" ")[1], y] for y in x])(PM_Scheme_Labels)) ]) # Sort PM alphabetically
+ ) + tuple([t[1] for t in sorted((lambda x: [[y[1].split(" ")[1], y] for y in x])(PM_Scheme_Labels))]) # Sort PM alphabetically
# URN is a static-only class, add in our needs
from temba.contacts.models import URN as TembaURN
@@ -71,11 +71,14 @@ def RunEngageOverrides(cls):
from engage.orgs.bandwidth import BandwidthOrgModelOverrides
BandwidthOrgModelOverrides.setClassOverrides()
-
+ from engage.orgs.models import OrgModelOverride
+ OrgModelOverride.setClassOverrides()
from engage.orgs.views.user_assign import OrgViewAssignUserMixin
OrgViewAssignUserMixin.setClassOverrides()
from engage.orgs.views.user_delete import UserViewDeleteOverride
UserViewDeleteOverride.setClassOverrides()
+ from engage.orgs.views.user_list import OrgViewListUserOverrides
+ OrgViewListUserOverrides.setClassOverrides()
from engage.orgs.views.bandwidth import BandwidthChannelViewsMixin
BandwidthChannelViewsMixin.setClassOverrides()
from engage.orgs.views.home import HomeOverrides
@@ -124,6 +127,8 @@ def RunEngageOverrides(cls):
AndroidTypeOverrides.setClassOverrides()
from engage.channels.types.vonage_client import VonageClientOverrides
VonageClientOverrides.setClassOverrides()
+ from engage.channels.types.vonage_views import ClaimViewOverrides
+ ClaimViewOverrides.setClassOverrides()
from engage.channels.update_channel_form import UpdateChannelFormOverrides
UpdateChannelFormOverrides.setClassOverrides()
from engage.channels.views import ChannelCRUDLOverrides
@@ -135,7 +140,6 @@ def RunEngageOverrides(cls):
from engage.channels.views import ChannelClaimAllOverrides
ChannelClaimAllOverrides.setClassOverrides()
-
cls.ENGAGE_OVERRIDES_RAN = True
#enddef RunEngageOverrides
#endclass EngageOverrides
diff --git a/scm/utils.sh b/scm/utils.sh
index 92c40434ca..2b0027e553 100755
--- a/scm/utils.sh
+++ b/scm/utils.sh
@@ -259,7 +259,7 @@ function DockerImageTagExists
{
TOKEN=$(curl -s -H "Content-Type: application/json" -X POST -d '{"username": "'${DOCKER_USER}'", "password": "'${DOCKER_PASS}'"}' https://hub.docker.com/v2/users/login/ | jq -r .token)
sleep 1
- EXISTS=$(curl -s -H "Authorization: JWT ${TOKEN}" https://hub.docker.com/v2/repositories/$1/tags/?page_size=10000 | jq -r "[.results | .[] | .name == \"$2\"] | any")
+ EXISTS=$(curl -s -H "Authorization: JWT ${TOKEN}" "https://hub.docker.com/v2/repositories/$1/tags/$2" | jq ".id != null" )
test $EXISTS = true
}
diff --git a/temba/settings_engage.py b/temba/settings_engage.py
index b31a59956a..080b8165d3 100644
--- a/temba/settings_engage.py
+++ b/temba/settings_engage.py
@@ -431,3 +431,5 @@
ORG_PLAN_ENGAGE = 'managed'
# Default plan for new orgs
DEFAULT_PLAN = ORG_PLAN_ENGAGE
+
+MIDDLEWARE += ("engage.utils.middleware.RedirectMiddleware",)