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 %}
+
+ {{ csrf_input }} + + Plan type:
+ + +
+
+ Discount:
+ {{ csrf_input }} + + + +
+
+
+
+ {% 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: +
+ {{ csrf_input }} + + + +
+
+ Discount:
+ {{ csrf_input }} + + + +
+
+
+ {% 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='[^{]",