diff --git a/analytics/tests/test_views.py b/analytics/tests/test_views.py
index bf618d9ba0..a7389349f1 100644
--- a/analytics/tests/test_views.py
+++ b/analytics/tests/test_views.py
@@ -3,6 +3,7 @@ from typing import List, Optional
import mock
from django.utils.timezone import utc
+from django.http import HttpResponse
from analytics.lib.counts import COUNT_STATS, CountStat
from analytics.lib.time_utils import time_range
@@ -327,6 +328,94 @@ class TestGetChartData(ZulipTestCase):
{'chart_name': 'number_of_humans'})
self.assert_json_success(result)
+class TestSupportEndpoint(ZulipTestCase):
+ def test_search(self) -> None:
+ def check_hamlet_user_result(result: HttpResponse) -> None:
+ self.assert_in_success_response(['user\n', '
King Hamlet
',
+ 'Email: hamlet@zulip.com', 'Is active: True
',
+ 'Admins: iago@zulip.com
'], result)
+
+ def check_zulip_realm_result(result: HttpResponse) -> None:
+ self.assert_in_success_response(['',
+ '',
+ '',
+ 'input type="number" name="discount" value="None"'], result)
+
+ def check_lear_realm_result(result: HttpResponse) -> None:
+ self.assert_in_success_response(['',
+ '',
+ '',
+ 'input type="number" name="discount" value="None"'], result)
+
+ cordelia_email = self.example_email("cordelia")
+ self.login(cordelia_email)
+
+ result = self.client_get("/activity/support")
+ self.assertEqual(result.status_code, 302)
+ self.assertEqual(result["Location"], "/login/")
+
+ iago_email = self.example_email("iago")
+ self.login(iago_email)
+
+ result = self.client_get("/activity/support")
+ self.assert_in_success_response(['"})
+ check_hamlet_user_result(result)
+ check_zulip_realm_result(result)
+ check_lear_realm_result(result)
+
+ def test_change_plan_type(self) -> None:
+ cordelia_email = self.example_email("cordelia")
+ self.login(cordelia_email)
+
+ result = self.client_post("/activity/support", {"realm_id": "1", "plan_type": "2"})
+ self.assertEqual(result.status_code, 302)
+ self.assertEqual(result["Location"], "/login/")
+
+ iago_email = self.example_email("iago")
+ self.login(iago_email)
+
+ with mock.patch("analytics.views.do_change_plan_type") as m:
+ result = self.client_post("/activity/support", {"realm_id": "1", "plan_type": "2"})
+ m.assert_called_once_with(get_realm("zulip"), 2)
+ self.assert_in_success_response(["Plan type of Zulip Dev changed to limited from self hosted"], result)
+
+ def test_attach_discount(self) -> None:
+ cordelia_email = self.example_email("cordelia")
+ self.login(cordelia_email)
+
+ result = self.client_post("/activity/support", {"realm_id": "3", "discount": "25"})
+ self.assertEqual(result.status_code, 302)
+ self.assertEqual(result["Location"], "/login/")
+
+ iago_email = self.example_email("iago")
+ self.login(iago_email)
+
+ with mock.patch("analytics.views.attach_discount_to_realm") as m:
+ result = self.client_post("/activity/support", {"realm_id": "3", "discount": "25"})
+ m.assert_called_once_with(get_realm("lear"), 25)
+ self.assert_in_success_response(["Discount of Lear & Co. changed to 25 from None"], result)
+
class TestGetChartDataHelpers(ZulipTestCase):
# last_successful_fill is in analytics/models.py, but get_chart_data is
# the only function that uses it at the moment
diff --git a/analytics/urls.py b/analytics/urls.py
index 1c4a6b229d..4addef1d24 100644
--- a/analytics/urls.py
+++ b/analytics/urls.py
@@ -7,6 +7,8 @@ i18n_urlpatterns = [
# Server admin (user_profile.is_staff) visible stats pages
url(r'^activity$', analytics.views.get_activity,
name='analytics.views.get_activity'),
+ url(r'^activity/support$', analytics.views.support,
+ name='analytics.views.support'),
url(r'^realm_activity/(?P[\S]+)/$', analytics.views.get_realm_activity,
name='analytics.views.get_realm_activity'),
url(r'^user_activity/(?P[\S]+)/$', analytics.views.get_user_activity,
diff --git a/analytics/views.py b/analytics/views.py
index 4f558d65a9..0d666a5d4f 100644
--- a/analytics/views.py
+++ b/analytics/views.py
@@ -3,8 +3,11 @@ import itertools
import logging
import re
import time
+import urllib
from collections import defaultdict
from datetime import datetime, timedelta
+from decimal import Decimal
+
from typing import Any, Callable, Dict, List, \
Optional, Set, Tuple, Type, Union, cast
@@ -18,6 +21,8 @@ from django.shortcuts import render
from django.template import loader
from django.utils.timezone import now as timezone_now, utc as timezone_utc
from django.utils.translation import ugettext as _
+from django.core.validators import URLValidator
+from django.core.exceptions import ValidationError
from jinja2 import Markup as mark_safe
from analytics.lib.counts import COUNT_STATS, CountStat
@@ -31,6 +36,14 @@ from zerver.lib.json_encoder_for_html import JSONEncoderForHTML
from zerver.lib.request import REQ, has_request_variables
from zerver.lib.response import json_success
from zerver.lib.timestamp import convert_to_UTC, timestamp_to_datetime
+from zerver.lib.realm_icon import realm_icon_url
+from zerver.views.invite import get_invitee_emails_set
+from zerver.lib.subdomains import get_subdomain_from_hostname
+from zerver.lib.actions import do_change_plan_type
+
+if settings.BILLING_ENABLED:
+ from corporate.lib.stripe import attach_discount_to_realm, get_discount_for_realm
+
from zerver.models import Client, get_realm, Realm, \
UserActivity, UserActivityInterval, UserProfile
@@ -1013,6 +1026,66 @@ def get_activity(request: HttpRequest) -> HttpResponse:
context=dict(data=data, title=title, is_home=True),
)
+@require_server_admin
+def support(request: HttpRequest) -> HttpResponse:
+ context = {} # type: Dict[str, Any]
+ if settings.BILLING_ENABLED and request.method == "POST":
+ realm_id = request.POST.get("realm_id", None)
+ realm = Realm.objects.get(id=realm_id)
+
+ new_plan_type = request.POST.get("plan_type", None)
+ if new_plan_type is not None:
+ new_plan_type = int(new_plan_type)
+ current_plan_type = realm.plan_type
+ do_change_plan_type(realm, new_plan_type)
+ msg = "Plan type of {} changed to {} from {} ".format(realm.name, get_plan_name(new_plan_type),
+ get_plan_name(current_plan_type))
+ context["plan_type_msg"] = msg
+
+ new_discount = request.POST.get("discount", None)
+ if new_discount is not None:
+ new_discount = Decimal(new_discount)
+ current_discount = get_discount_for_realm(realm)
+ attach_discount_to_realm(realm, new_discount)
+ msg = "Discount of {} changed to {} from {} ".format(realm.name, new_discount, current_discount)
+ context["discount_msg"] = msg
+
+ query = request.GET.get("q", None)
+ if query:
+ key_words = get_invitee_emails_set(query)
+
+ users = UserProfile.objects.filter(email__in=key_words)
+ if users:
+ for user in users:
+ user.realm.realm_icon_url = realm_icon_url(user.realm)
+ user.realm.admins = UserProfile.objects.filter(realm=user.realm, is_realm_admin=True)
+ user.realm.default_discount = get_discount_for_realm(user.realm)
+ context["users"] = users
+
+ realms = set(Realm.objects.filter(string_id__in=key_words))
+
+ for key_word in key_words:
+ try:
+ URLValidator()(key_word)
+ parse_result = urllib.parse.urlparse(key_word)
+ hostname = parse_result.hostname
+ if parse_result.port:
+ hostname = "{}:{}".format(hostname, parse_result.port)
+ subdomain = get_subdomain_from_hostname(hostname)
+ realm = get_realm(subdomain)
+ if realm is not None:
+ realms.add(realm)
+ except ValidationError:
+ pass
+
+ if realms:
+ for realm in realms:
+ realm.realm_icon_url = realm_icon_url(realm)
+ realm.admins = UserProfile.objects.filter(realm=realm, is_realm_admin=True)
+ realm.default_discount = get_discount_for_realm(realm)
+ context["realms"] = realms
+ return render(request, 'analytics/support.html', context=context)
+
def get_user_activity_records_for_realm(realm: str, is_bot: bool) -> QuerySet:
fields = [
'user_profile__full_name',
diff --git a/static/styles/activity.scss b/static/styles/activity.scss
index 24d9b29bd0..2b2e880fb0 100644
--- a/static/styles/activity.scss
+++ b/static/styles/activity.scss
@@ -48,3 +48,36 @@ tr.admin td:first-child {
font-weight: bold;
color: hsl(0, 100%, 39%);
}
+
+.support-query-result {
+ background-color: hsl(210, 100%, 97%);
+ padding-left: 15px;
+ padding-top: 14px;
+ border-radius: 7px;
+ -webkit-box-shadow: 0 10px 7px -6px hsl(0, 2%, 45%);
+ -moz-box-shadow: 0 10px 7px -6px hsl(0, 2%, 45%);
+ box-shadow: 0 10px 7px -6px hsl(0, 2%, 45%);
+}
+
+.support-realm-icon {
+ max-width: 25px;
+ position: relative;
+ top: -2px;
+}
+
+.support-discount-form {
+ position: relative;
+ top: -25px;
+}
+
+.support-submit-button {
+ position: relative;
+ top: -5px;
+ border-color: hsl(0, 0%, 83%);
+ border-radius: 2px;
+}
+
+.support-search-button {
+ border-color: hsl(0, 0%, 83%);
+ border-radius: 2px;
+}
diff --git a/templates/analytics/support.html b/templates/analytics/support.html
new file mode 100644
index 0000000000..7354e8ec2e
--- /dev/null
+++ b/templates/analytics/support.html
@@ -0,0 +1,115 @@
+{% extends "zerver/base.html" %}
+
+{# User Activity. #}
+
+{% block title %}
+Info
+{% endblock %}
+
+
+{% block customhead %}
+{{ super() }}
+{{ render_bundle('activity') }}
+{% endblock %}
+
+{% block content %}
+
+
+
+
+ {% if plan_type_msg %}
+
+
+ {{ plan_type_msg }}
+
+
+ {% endif %}
+
+ {% if discount_msg %}
+
+
+ {{ discount_msg }}
+
+
+ {% endif %}
+
+
+ {% for user in users %}
+
+
user
+
{{ user.full_name }}
+
Email: {{ user.email }}
+
Date joined: {{ user.date_joined|timesince }} ago
+
Is active: {{ user.is_active }}
+
Is admin: {{ user.is_realm_admin }}
+
+
+
realm
+
{{ user.realm.name }}
+
URL:
{{ user.realm.uri }}
+
Date created: {{ user.realm.date_created|timesince }} ago
+
Is active: {{ not user.realm.deactivated }}
+
Admins: {% for admin in user.realm.admins %}{{ admin.email }} {% endfor %}
+
+
+
+
+
+ {% endfor %}
+
+ {% for realm in realms %}
+
+
realm
+
{{ realm.name }}
+
URL:
{{ realm.uri }}
+
Date created: {{ realm.date_created|timesince }} ago
+
Is active: {{ not realm.deactivated }}
+
Admins: {% for admin in realm.admins %}{{ admin.email }} {% endfor %}
+
Plan type:
+
+
+
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/tools/linter_lib/custom_check.py b/tools/linter_lib/custom_check.py
index a24fc9a3c0..699e4bd241 100644
--- a/tools/linter_lib/custom_check.py
+++ b/tools/linter_lib/custom_check.py
@@ -733,6 +733,7 @@ def build_custom_checkers(by_lang):
'description': "`placeholder` value should be translatable.",
'exclude_line': [('templates/zerver/register.html', 'placeholder="acme"'),
('templates/zerver/register.html', 'placeholder="Acme or Aκμή"')],
+ 'exclude': set(["templates/analytics/support.html"]),
'good_lines': [''],
'bad_lines': ['']},
{'pattern': "placeholder='[^{]",