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