mirror of
https://github.com/zulip/zulip.git
synced 2025-10-23 04:52:12 +00:00
activity: Create interface for doing support operations.
This should grow into a tool that makes it much easier to do common organization management tasks without using a manage.py shell.
This commit is contained in:
@@ -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(['<span class="label">user</span>\n', '<h3>King Hamlet</h3>',
|
||||
'<b>Email</b>: hamlet@zulip.com', '<b>Is active</b>: True<br>',
|
||||
'<b>Admins</b>: iago@zulip.com <br>'], result)
|
||||
|
||||
def check_zulip_realm_result(result: HttpResponse) -> None:
|
||||
self.assert_in_success_response(['<input type="hidden" name="realm_id" value="1"', 'Zulip Dev</h3>',
|
||||
'<option value="1" selected>Self Hosted</option>',
|
||||
'<option value="2" >Limited</option>',
|
||||
'input type="number" name="discount" value="None"'], result)
|
||||
|
||||
def check_lear_realm_result(result: HttpResponse) -> None:
|
||||
self.assert_in_success_response(['<input type="hidden" name="realm_id" value="3"', 'Lear & Co.</h3>',
|
||||
'<option value="1" selected>Self Hosted</option>',
|
||||
'<option value="2" >Limited</option>',
|
||||
'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(['<input type="text" name="q" class="input-xxlarge search-query"'], result)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": "hamlet@zulip.com"})
|
||||
check_hamlet_user_result(result)
|
||||
check_zulip_realm_result(result)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": "lear"})
|
||||
check_lear_realm_result(result)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": "http://lear.testserver"})
|
||||
check_lear_realm_result(result)
|
||||
|
||||
with self.settings(REALM_HOSTS={'zulip': 'localhost'}):
|
||||
result = self.client_get("/activity/support", {"q": "http://localhost"})
|
||||
check_zulip_realm_result(result)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": "hamlet@zulip.com, lear"})
|
||||
check_hamlet_user_result(result)
|
||||
check_zulip_realm_result(result)
|
||||
check_lear_realm_result(result)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": "lear, Hamlet <hamlet@zulip.com>"})
|
||||
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
|
||||
|
@@ -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<realm_str>[\S]+)/$', analytics.views.get_realm_activity,
|
||||
name='analytics.views.get_realm_activity'),
|
||||
url(r'^user_activity/(?P<email>[\S]+)/$', analytics.views.get_user_activity,
|
||||
|
@@ -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',
|
||||
|
@@ -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;
|
||||
}
|
||||
|
115
templates/analytics/support.html
Normal file
115
templates/analytics/support.html
Normal file
@@ -0,0 +1,115 @@
|
||||
{% extends "zerver/base.html" %}
|
||||
|
||||
{# User Activity. #}
|
||||
|
||||
{% block title %}
|
||||
<title>Info</title>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block customhead %}
|
||||
{{ super() }}
|
||||
{{ render_bundle('activity') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<br>
|
||||
<form>
|
||||
<center>
|
||||
<input type="text" name="q" class="input-xxlarge search-query" placeholder="emails, string_ids, organization urls separated by commas" value="{{ request.GET.get('q', '') }}">
|
||||
<button type="submit" class="btn support-search-button">Search</button>
|
||||
</center>
|
||||
</form>
|
||||
|
||||
{% if plan_type_msg %}
|
||||
<div class="alert alert-success">
|
||||
<center>
|
||||
{{ plan_type_msg }}
|
||||
</center>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if discount_msg %}
|
||||
<div class="alert alert-success">
|
||||
<center>
|
||||
{{ discount_msg }}
|
||||
</center>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="query-results">
|
||||
{% for user in users %}
|
||||
<div class="support-query-result">
|
||||
<span class="label">user</span>
|
||||
<h3>{{ user.full_name }}</h3>
|
||||
<b>Email</b>: {{ user.email }}<br>
|
||||
<b>Date joined</b>: {{ user.date_joined|timesince }} ago<br>
|
||||
<b>Is active</b>: {{ user.is_active }}<br>
|
||||
<b>Is admin</b>: {{ user.is_realm_admin }}<br>
|
||||
<br>
|
||||
<div>
|
||||
<span class="label">realm</span>
|
||||
<h3><img src="{{ user.realm.realm_icon_url }}" class="support-realm-icon"> {{ user.realm.name }}</h3>
|
||||
<b>URL</b>: <a target="_blank" href="{{ user.realm.uri }}">{{ user.realm.uri }}</a><br>
|
||||
<b>Date created</b>: {{ user.realm.date_created|timesince }} ago<br>
|
||||
<b>Is active</b>: {{ not user.realm.deactivated }}<br>
|
||||
<b>Admins</b>: {% for admin in user.realm.admins %}{{ admin.email }} {% endfor %}<br>
|
||||
<form method="POST">
|
||||
{{ csrf_input }}
|
||||
<input type="hidden" name="realm_id" value="{{ user.realm.id }}" />
|
||||
<b>Plan type</b>:<br>
|
||||
<select name="plan_type">
|
||||
<option value="1" {% if user.realm.plan_type == 1 %}selected{% endif %}>Self Hosted</option>
|
||||
<option value="2" {% if user.realm.plan_type == 2 %}selected{% endif %}>Limited</option>
|
||||
<option value="3" {% if user.realm.plan_type == 3 %}selected{% endif %}>Standard</option>
|
||||
<option value="4" {% if user.realm.plan_type == 4 %}selected{% endif %}>Standard Free</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-small support-submit-button">Update</button>
|
||||
</form>
|
||||
<form method="POST" class="support-discount-form">
|
||||
<b>Discount</b>:<br>
|
||||
{{ csrf_input }}
|
||||
<input type="hidden" name="realm_id" value="{{ user.realm.id }}" />
|
||||
<input type="number" name="discount" value="{{user.realm.default_discount}}" required>
|
||||
<button type="submit" class="btn btn-small support-submit-button">Update</button>
|
||||
</form>
|
||||
</div>
|
||||
<hr>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% for realm in realms %}
|
||||
<div class="support-query-result">
|
||||
<span class="label">realm</span>
|
||||
<h3><img src="{{ realm.realm_icon_url }}" class="support-realm-icon"> {{ realm.name }}</h3>
|
||||
<b>URL</b>: <a target="_blank" href="{{ realm.uri }}">{{ realm.uri }}</a><br>
|
||||
<b>Date created</b>: {{ realm.date_created|timesince }} ago<br>
|
||||
<b>Is active</b>: {{ not realm.deactivated }}<br>
|
||||
<b>Admins</b>: {% for admin in realm.admins %}{{ admin.email }} {% endfor %}<br>
|
||||
<b>Plan type</b>:
|
||||
<form method="POST">
|
||||
{{ csrf_input }}
|
||||
<input type="hidden" name="realm_id" value="{{ realm.id }}" />
|
||||
<select name="plan_type">
|
||||
<option value="1" {% if realm.plan_type == 1 %}selected{% endif %}>Self Hosted</option>
|
||||
<option value="2" {% if realm.plan_type == 2 %}selected{% endif %}>Limited</option>
|
||||
<option value="3" {% if realm.plan_type == 3 %}selected{% endif %}>Standard</option>
|
||||
<option value="4" {% if realm.plan_type == 4 %}selected{% endif %}>Standard Free</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-small support-submit-button">Update</button>
|
||||
</form>
|
||||
<form method="POST" class="support-discount-form">
|
||||
<b>Discount</b>:<br>
|
||||
{{ csrf_input }}
|
||||
<input type="hidden" name="realm_id" value="{{ realm.id }}" />
|
||||
<input type="number" name="discount" value="{{ realm.default_discount}}" required>
|
||||
<button type="submit" class="btn btn-small support-submit-button">Update</button>
|
||||
</form>
|
||||
<hr>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@@ -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': ['<input class="stream-list-filter" type="text" placeholder="{{ _(\'Search streams\') }}" />'],
|
||||
'bad_lines': ['<input placeholder="foo">']},
|
||||
{'pattern': "placeholder='[^{]",
|
||||
|
Reference in New Issue
Block a user