profile: Support custom profile data.

Implements backend for #1760.
This commit is contained in:
Umair Khan 2017-03-17 14:07:22 +05:00 committed by Tim Abbott
parent d0f907f9da
commit cf3b6c6ca9
9 changed files with 573 additions and 7 deletions

View File

@ -37,9 +37,11 @@ from zerver.models import Realm, RealmEmoji, Stream, UserProfile, UserActivity,
email_allowed_for_realm, email_to_username, display_recipient_cache_key, \
get_user_profile_by_email, get_stream_cache_key, \
UserActivityInterval, get_active_user_dicts_in_realm, get_active_streams, \
realm_filters_for_realm, RealmFilter, ScheduledJob, get_owned_bot_dicts, \
realm_filters_for_realm, RealmFilter, receives_offline_notifications, \
ScheduledJob, get_owned_bot_dicts, \
get_old_unclaimed_attachments, get_cross_realm_emails, \
Reaction, EmailChangeStatus
Reaction, EmailChangeStatus, CustomProfileField, custom_profile_fields_for_realm, \
CustomProfileFieldValue
from zerver.lib.alert_words import alert_words_in_realm
from zerver.lib.avatar import avatar_url
@ -3303,3 +3305,41 @@ def check_attachment_reference_change(prev_content, message):
to_add = list(new_attachments - prev_attachments)
if len(to_add) > 0:
do_claim_attachments(message)
def notify_realm_custom_profile_fields(realm):
# type: (Realm) -> None
fields = custom_profile_fields_for_realm(realm.id)
event = dict(type="custom_profile_fields",
fields=[f.as_dict() for f in fields])
send_event(event, active_user_ids(realm))
def try_add_realm_custom_profile_field(realm, name, field_type):
# type: (Realm, Text, int) -> CustomProfileField
field = CustomProfileField(realm=realm, name=name, field_type=field_type)
field.save()
notify_realm_custom_profile_fields(realm)
return field
def do_remove_realm_custom_profile_field(realm, field):
# type: (Realm, CustomProfileField) -> None
"""
Deleting a field will also delete the user profile data
associated with it in CustomProfileFieldValue model.
"""
field.delete()
notify_realm_custom_profile_fields(realm)
def try_update_realm_custom_profile_field(realm, field, name):
# type: (Realm, CustomProfileField, Text) -> None
field.name = name
field.save(update_fields=['name'])
notify_realm_custom_profile_fields(realm)
def do_update_user_custom_profile_data(user_profile, data):
# type: (UserProfile, List[Dict[str, Union[int, Text]]]) -> None
with transaction.atomic():
update_or_create = CustomProfileFieldValue.objects.update_or_create
for field in data:
update_or_create(user_profile=user_profile,
field_id=field['id'],
defaults={'value': field['value']})

View File

@ -32,7 +32,7 @@ from zerver.tornado.event_queue import request_event_queue, get_user_events
from zerver.models import Client, Message, UserProfile, \
get_user_profile_by_email, get_user_profile_by_id, \
get_active_user_dicts_in_realm, realm_filters_for_realm, \
get_owned_bot_dicts
get_owned_bot_dicts, custom_profile_fields_for_realm
from version import ZULIP_VERSION
@ -71,6 +71,10 @@ def fetch_initial_state_data(user_profile, event_types, queue_id,
if want('alert_words'):
state['alert_words'] = user_alert_words(user_profile)
if want('custom_profile_fields'):
fields = custom_profile_fields_for_realm(user_profile.realm.id)
state['custom_profile_fields'] = [f.as_dict() for f in fields]
if want('attachments'):
state['attachments'] = user_attachments(user_profile)
@ -188,6 +192,8 @@ def apply_event(state, event, user_profile, include_subscribers):
state['max_message_id'] = max(state['max_message_id'], event['message']['id'])
elif event['type'] == "hotspots":
state['hotspots'] = event['hotspots']
elif event['type'] == "custom_profile_fields":
state['custom_profile_fields'] = event['fields']
elif event['type'] == "pointer":
state['pointer'] = max(state['pointer'], event['pointer'])
elif event['type'] == "realm_user":

View File

@ -42,6 +42,14 @@ def check_string(var_name, val):
return _('%s is not a string') % (var_name,)
return None
def check_short_string(var_name, val):
# type: (str, Any) -> Optional[str]
max_length = 200
if len(val) >= max_length:
return _("{var_name} is longer than {max_length}.".format(
var_name=var_name, max_length=max_length))
return check_string(var_name, val)
def check_int(var_name, val):
# type: (str, Any) -> Optional[str]
if not isinstance(val, int):

View File

@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-04-17 06:49
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('zerver', '0072_realmauditlog_add_index_event_time'),
]
operations = [
migrations.CreateModel(
name='CustomProfileField',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('field_type', models.PositiveSmallIntegerField(choices=[(1, 'Integer'), (2, 'Float'), (3, 'Short Text'), (4, 'Long Text')], default=3)),
('realm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='zerver.Realm')),
],
),
migrations.CreateModel(
name='CustomProfileFieldValue',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('value', models.TextField()),
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='zerver.CustomProfileField')),
('user_profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.AlterUniqueTogether(
name='customprofilefieldvalue',
unique_together=set([('user_profile', 'field')]),
),
migrations.AlterUniqueTogether(
name='customprofilefield',
unique_together=set([('realm', 'name')]),
),
]

View File

@ -1,18 +1,19 @@
from __future__ import absolute_import
from typing import Any, DefaultDict, Dict, List, Set, Tuple, TypeVar, Text, \
Union, Optional, Sequence, AbstractSet, Pattern, AnyStr
Union, Optional, Sequence, AbstractSet, Pattern, AnyStr, Callable
from typing.re import Match
from zerver.lib.str_utils import NonBinaryStr
from django.db import models
from django.db.models.query import QuerySet
from django.db.models import Manager
from django.db.models import Manager, CASCADE
from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser, UserManager, \
PermissionsMixin
import django.contrib.auth
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
from django.core.validators import URLValidator, MinLengthValidator, \
RegexValidator
from django.dispatch import receiver
from zerver.lib.cache import cache_with_key, flush_user_profile, flush_realm, \
user_profile_by_id_cache_key, user_profile_by_email_cache_key, \
@ -29,9 +30,10 @@ from django.utils.timezone import now as timezone_now
from django.contrib.sessions.models import Session
from zerver.lib.timestamp import datetime_to_timestamp
from django.db.models.signals import pre_save, post_save, post_delete
from django.core.validators import MinLengthValidator, RegexValidator
from django.utils.translation import ugettext_lazy as _
from zerver.lib import cache
from zerver.lib.validator import check_int, check_float, check_string, \
check_short_string
from bitfield import BitField
from bitfield.types import BitHandler
@ -642,6 +644,27 @@ class UserProfile(ModelReprMixin, AbstractBaseUser, PermissionsMixin):
twenty_four_hour_time=bool,
)
@property
def profile_data(self):
# type: () -> List[Dict[str, Union[int, float, Text]]]
values = CustomProfileFieldValue.objects.filter(user_profile=self)
user_data = {v.field_id: v.value for v in values}
data = [] # type: List[Dict[str, Union[int, float, Text]]]
for field in custom_profile_fields_for_realm(self.realm_id):
value = user_data.get(field.id, None)
field_type = field.field_type
if value is not None:
converter = field.FIELD_CONVERTERS[field_type]
value = converter(value)
field_data = {} # type: Dict[str, Union[int, float, Text]]
for k, v in field.as_dict().items():
field_data[k] = v
field_data['value'] = value
data.append(field_data)
return data
def can_admin_user(self, target_user):
# type: (UserProfile) -> bool
"""Returns whether this user has permission to modify target_user"""
@ -1605,3 +1628,50 @@ class UserHotspot(models.Model):
class Meta(object):
unique_together = ("user", "hotspot")
class CustomProfileField(models.Model):
realm = models.ForeignKey(Realm, on_delete=CASCADE) # type: Realm
name = models.CharField(max_length=100) # type: Text
INTEGER = 1
FLOAT = 2
SHORT_TEXT = 3
LONG_TEXT = 4
FIELD_TYPE_DATA = [
# Type, Name, Validator, Converter
(INTEGER, u'Integer', check_int, int),
(FLOAT, u'Float', check_float, float),
(SHORT_TEXT, u'Short Text', check_short_string, str),
(LONG_TEXT, u'Long Text', check_string, str),
] # type: List[Tuple[int, Text, Callable[[str, Any], str], Callable[[Any], Any]]]
FIELD_VALIDATORS = {item[0]: item[2] for item in FIELD_TYPE_DATA} # type: Dict[int, Callable[[str, Any], str]]
FIELD_CONVERTERS = {item[0]: item[3] for item in FIELD_TYPE_DATA} # type: Dict[int, Callable[[Any], Any]]
FIELD_TYPE_CHOICES = [(item[0], item[1]) for item in FIELD_TYPE_DATA] # type: List[Tuple[int, Text]]
field_type = models.PositiveSmallIntegerField(choices=FIELD_TYPE_CHOICES,
default=SHORT_TEXT) # type: int
class Meta(object):
unique_together = ('realm', 'name')
def as_dict(self):
# type: () -> Dict[str, Union[int, Text]]
return {
'id': self.id,
'name': self.name,
'type': self.field_type,
}
def custom_profile_fields_for_realm(realm_id):
# type: (int) -> List[CustomProfileField]
return CustomProfileField.objects.filter(realm=realm_id).order_by('name')
class CustomProfileFieldValue(models.Model):
user_profile = models.ForeignKey(UserProfile, on_delete=CASCADE) # type: UserProfile
field = models.ForeignKey(CustomProfileField, on_delete=CASCADE) # type: CustomProfileField
value = models.TextField() # type: Text
class Meta(object):
unique_together = ('user_profile', 'field')

View File

@ -0,0 +1,267 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import
from typing import Union, List, Dict, Text, Any
from mock import patch
from zerver.lib.actions import get_realm, try_add_realm_custom_profile_field, \
do_update_user_custom_profile_data, do_remove_realm_custom_profile_field
from zerver.lib.test_classes import ZulipTestCase
from zerver.models import CustomProfileField, get_user_profile_by_email, \
custom_profile_fields_for_realm
import ujson
class CustomProfileFieldTest(ZulipTestCase):
def test_list(self):
# type: () -> None
self.login(u"iago@zulip.com")
realm = get_realm('zulip')
try_add_realm_custom_profile_field(realm, u"Phone",
CustomProfileField.SHORT_TEXT)
result = self.client_get("/json/realm/profile_fields")
self.assert_json_success(result)
self.assertEqual(200, result.status_code)
content = result.json()
self.assertEqual(len(content["custom_fields"]), 1)
def test_create(self):
# type: () -> None
self.login(u"iago@zulip.com")
data = {"name": u"Phone", "field_type": "text id"} # type: Dict[str, Any]
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_error(result, u'argument "field_type" is not valid json.')
data["name"] = ""
data["field_type"] = 100
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_error(result, u'Name cannot be blank.')
data["name"] = "Phone"
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_error(result, u'Invalid field type.')
data["name"] = "Phone"
data["field_type"] = CustomProfileField.SHORT_TEXT
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_success(result)
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_error(result,
u'A field with that name already exists.')
def test_not_realm_admin(self):
# type: () -> None
self.login("hamlet@zulip.com")
result = self.client_post("/json/realm/profile_fields")
self.assert_json_error(result, u'Must be a realm administrator')
result = self.client_delete("/json/realm/profile_fields/1")
self.assert_json_error(result, 'Must be a realm administrator')
def test_delete(self):
# type: () -> None
self.login("iago@zulip.com")
realm = get_realm('zulip')
field = try_add_realm_custom_profile_field(
realm,
"Phone",
CustomProfileField.SHORT_TEXT
)
result = self.client_delete("/json/realm/profile_fields/100")
self.assert_json_error(result, 'Field id 100 not found.')
self.assertEqual(CustomProfileField.objects.count(), 1)
result = self.client_delete(
"/json/realm/profile_fields/{}".format(field.id))
self.assert_json_success(result)
self.assertEqual(CustomProfileField.objects.count(), 0)
def test_update(self):
# type: () -> None
self.login("iago@zulip.com")
realm = get_realm('zulip')
result = self.client_patch(
"/json/realm/profile_fields/100",
info={'name': '',
'field_type': CustomProfileField.SHORT_TEXT}
)
self.assert_json_error(result, u'Name cannot be blank.')
result = self.client_patch(
"/json/realm/profile_fields/100",
info={'name': 'Phone Number',
'field_type': CustomProfileField.SHORT_TEXT}
)
self.assert_json_error(result, u'Field id 100 not found.')
field = try_add_realm_custom_profile_field(
realm,
u"Phone",
CustomProfileField.SHORT_TEXT
)
self.assertEqual(CustomProfileField.objects.count(), 1)
result = self.client_patch(
"/json/realm/profile_fields/{}".format(field.id),
info={'name': 'Phone Number',
'field_type': CustomProfileField.SHORT_TEXT})
self.assert_json_success(result)
self.assertEqual(CustomProfileField.objects.count(), 1)
field = CustomProfileField.objects.first()
self.assertEqual(field.name, 'Phone Number')
self.assertEqual(field.field_type, CustomProfileField.SHORT_TEXT)
def test_update_is_aware_of_uniqueness(self):
# type: () -> None
self.login(u"iago@zulip.com")
realm = get_realm('zulip')
try_add_realm_custom_profile_field(realm, u"Phone",
CustomProfileField.SHORT_TEXT)
field = try_add_realm_custom_profile_field(
realm,
u"Phone 1",
CustomProfileField.SHORT_TEXT
)
self.assertEqual(CustomProfileField.objects.count(), 2)
result = self.client_patch(
"/json/realm/profile_fields/{}".format(field.id),
info={'name': 'Phone', 'field_type': CustomProfileField.SHORT_TEXT})
self.assert_json_error(
result, u'A field with that name already exists.')
class CustomProfileDataTest(ZulipTestCase):
def test_update_invalid(self):
# type: () -> None
self.login(u"iago@zulip.com")
data = [{'id': 1234, 'value': '12'}]
result = self.client_patch("/json/users/me/profile_data", {
'data': ujson.dumps(data)
})
self.assert_json_error(result,
u"Field id 1234 not found.")
def test_update_invalid_value(self):
# type: () -> None
self.login(u"iago@zulip.com")
realm = get_realm('zulip')
age_field = try_add_realm_custom_profile_field(
realm,
u"age",
CustomProfileField.INTEGER
)
data = [{'id': age_field.id, 'value': 'text'}]
result = self.client_patch("/json/users/me/profile_data", {
'data': ujson.dumps(data)
})
self.assert_json_error(
result,
u"value[4] is not an integer".format(age_field.id))
def test_update_invalid_double(self):
# type: () -> None
self.login(u"iago@zulip.com")
realm = get_realm('zulip')
field = try_add_realm_custom_profile_field(
realm,
u"distance",
CustomProfileField.FLOAT
)
data = [{'id': field.id, 'value': 'text'}]
result = self.client_patch("/json/users/me/profile_data", {
'data': ujson.dumps(data)
})
self.assert_json_error(
result,
u"value[{}] is not a float".format(field.id))
def test_update_invalid_short_text(self):
# type: () -> None
self.login(u"iago@zulip.com")
realm = get_realm('zulip')
field = try_add_realm_custom_profile_field(
realm,
u"description",
CustomProfileField.SHORT_TEXT
)
data = [{'id': field.id, 'value': 't' * 201}]
result = self.client_patch("/json/users/me/profile_data", {
'data': ujson.dumps(data)
})
self.assert_json_error(
result,
u"value[{}] is longer than 200.".format(field.id))
def test_update_profile_data(self):
# type: () -> None
self.login(u"iago@zulip.com")
realm = get_realm('zulip')
fields = [
(CustomProfileField.SHORT_TEXT, 'name 1', 'short text data'),
(CustomProfileField.LONG_TEXT, 'name 2', 'long text data'),
(CustomProfileField.INTEGER, 'name 3', 1),
(CustomProfileField.FLOAT, 'name 4', 2.0),
]
data = []
for i, field_value in enumerate(fields):
field_type, name, value = field_value
field = try_add_realm_custom_profile_field(realm, name, field_type)
data.append({
'id': field.id,
'value': value,
})
result = self.client_patch("/json/users/me/profile_data",
{'data': ujson.dumps(data)})
self.assert_json_success(result)
iago = get_user_profile_by_email('iago@zulip.com')
expected_value = {f['id']: f['value'] for f in data}
for field in iago.profile_data:
self.assertEqual(field['value'], expected_value[field['id']])
for k in ['id', 'type', 'name']:
self.assertIn(k, field)
self.assertEqual(len(iago.profile_data), 4)
# Update value of field
field = CustomProfileField.objects.get(name='name 1', realm=realm)
data = [{
'id': field.id,
'value': 'foobar',
}]
result = self.client_patch("/json/users/me/profile_data",
{'data': ujson.dumps(data)})
self.assert_json_success(result)
for f in iago.profile_data:
if f['id'] == field.id:
self.assertEqual(f['value'], 'foobar')
def test_delete(self):
# type: () -> None
user_profile = get_user_profile_by_email('iago@zulip.com')
realm = user_profile.realm
field = try_add_realm_custom_profile_field(
realm,
u"Phone",
CustomProfileField.SHORT_TEXT
)
data = [{'id': field.id, 'value': u'123456'}] # type: List[Dict[str, Union[int, Text]]]
do_update_user_custom_profile_data(user_profile, data)
self.assertEqual(len(custom_profile_fields_for_realm(realm.id)), 1)
self.assertEqual(user_profile.customprofilefieldvalue_set.count(), 1)
do_remove_realm_custom_profile_field(realm, field)
self.assertEqual(len(custom_profile_fields_for_realm(realm.id)), 0)
self.assertEqual(user_profile.customprofilefieldvalue_set.count(), 0)

View File

@ -22,6 +22,7 @@ from zerver.lib.actions import (
do_add_alert_words,
check_add_realm_emoji,
check_send_typing_notification,
notify_realm_custom_profile_fields,
do_add_realm_filter,
do_add_reaction,
do_remove_reaction,
@ -576,6 +577,25 @@ class EventsRegisterTest(ZulipTestCase):
error = schema_checker('events[0]', events[0])
self.assert_on_error(error)
def test_custom_profile_fields_events(self):
# type: () -> None
schema_checker = check_dict([
('type', equals('custom_profile_fields')),
('fields', check_list(check_dict([
('id', check_int),
('type', check_int),
('name', check_string),
]))),
])
events = self.do_test(
lambda: notify_realm_custom_profile_fields(
self.user_profile.realm),
state_change_expected=False,
)
error = schema_checker('events[0]', events[0])
self.assert_on_error(error)
def test_presence_events(self):
# type: () -> None
schema_checker = check_dict([

View File

@ -0,0 +1,100 @@
from __future__ import absolute_import
from typing import Text, Union, List, Dict
import logging
from django.core.exceptions import ValidationError
from django.db import IntegrityError, connection
from django.http import HttpRequest, HttpResponse
from django.utils.translation import ugettext as _
from zerver.decorator import has_request_variables, REQ, require_realm_admin
from zerver.lib.actions import (try_add_realm_custom_profile_field,
do_remove_realm_custom_profile_field,
try_update_realm_custom_profile_field,
do_update_user_custom_profile_data)
from zerver.lib.response import json_success, json_error
from zerver.lib.validator import check_dict, check_list, check_int
from zerver.models import (custom_profile_fields_for_realm, UserProfile,
CustomProfileField, custom_profile_fields_for_realm)
def list_realm_custom_profile_fields(request, user_profile):
# type: (HttpRequest, UserProfile) -> HttpResponse
fields = custom_profile_fields_for_realm(user_profile.realm_id)
return json_success({'custom_fields': [f.as_dict() for f in fields]})
@require_realm_admin
@has_request_variables
def create_realm_custom_profile_field(request, user_profile, name=REQ(),
field_type=REQ(validator=check_int)):
# type: (HttpRequest, UserProfile, Text, int) -> HttpResponse
if not name.strip():
return json_error(_("Name cannot be blank."))
if field_type not in CustomProfileField.FIELD_VALIDATORS:
return json_error(_("Invalid field type."))
try:
field = try_add_realm_custom_profile_field(
realm=user_profile.realm,
name=name,
field_type=field_type,
)
return json_success({'id': field.id})
except IntegrityError:
return json_error(_("A field with that name already exists."))
@require_realm_admin
def delete_realm_custom_profile_field(request, user_profile, field_id):
# type: (HttpRequest, UserProfile, int) -> HttpResponse
try:
field = CustomProfileField.objects.get(id=field_id)
except CustomProfileField.DoesNotExist:
return json_error(_('Field id {id} not found.').format(id=field_id))
do_remove_realm_custom_profile_field(realm=user_profile.realm,
field=field)
return json_success()
@require_realm_admin
@has_request_variables
def update_realm_custom_profile_field(request, user_profile, field_id,
name=REQ()):
# type: (HttpRequest, UserProfile, int, Text) -> HttpResponse
if not name.strip():
return json_error(_("Name cannot be blank."))
realm = user_profile.realm
try:
field = CustomProfileField.objects.get(realm=realm, id=field_id)
except CustomProfileField.DoesNotExist:
return json_error(_('Field id {id} not found.').format(id=field_id))
try:
try_update_realm_custom_profile_field(realm, field, name)
except IntegrityError:
return json_error(_('A field with that name already exists.'))
return json_success()
@has_request_variables
def update_user_custom_profile_data(
request,
user_profile,
data=REQ(validator=check_list(check_dict([('id', check_int)])))):
# type: (HttpRequest, UserProfile, List[Dict[str, Union[int, Text]]]) -> HttpResponse
for item in data:
field_id = item['id']
try:
field = CustomProfileField.objects.get(id=field_id)
except CustomProfileField.DoesNotExist:
return json_error(_('Field id {id} not found.').format(id=field_id))
validator = CustomProfileField.FIELD_VALIDATORS[field.field_type]
result = validator('value[{}]'.format(field_id), item['value'])
if result is not None:
return json_error(result)
do_update_user_custom_profile_data(user_profile, data)
# We need to call this explicitly otherwise constraints are not check
return json_success()

View File

@ -200,6 +200,14 @@ v1_api_and_json_patterns = [
url(r'^realm/filters/(?P<filter_id>\d+)$', rest_dispatch,
{'DELETE': 'zerver.views.realm_filters.delete_filter'}),
# realm/profile_fields -> zerver.views.custom_profile_fields
url(r'^realm/profile_fields$', rest_dispatch,
{'GET': 'zerver.views.custom_profile_fields.list_realm_custom_profile_fields',
'POST': 'zerver.views.custom_profile_fields.create_realm_custom_profile_field'}),
url(r'^realm/profile_fields/(?P<field_id>\d+)$', rest_dispatch,
{'PATCH': 'zerver.views.custom_profile_fields.update_realm_custom_profile_field',
'DELETE': 'zerver.views.custom_profile_fields.delete_realm_custom_profile_field'}),
# users -> zerver.views.users
#
# Since some of these endpoints do something different if used on
@ -317,6 +325,10 @@ v1_api_and_json_patterns = [
'PUT': 'zerver.views.alert_words.add_alert_words',
'DELETE': 'zerver.views.alert_words.remove_alert_words'}),
# users/me/custom_profile_data -> zerver.views.custom_profile_data
url(r'^users/me/profile_data$', rest_dispatch,
{'PATCH': 'zerver.views.custom_profile_fields.update_user_custom_profile_data'}),
url(r'^users/me/(?P<stream_id>\d+)/topics$', rest_dispatch,
{'GET': 'zerver.views.streams.get_topics_backend'}),