realm-icon: Add realm icon feature.

- Add realm icon fields to realm model.
- Add migration for new realm model's field.
- Add views for icon uploading and deleting.
- Add routes for realm icons views.
- Add JS widget for realm icon upload setting.
- Add realm icon upload to administration
  organization setting.
- Add tests for realm icons.

Fixes #3660.
This commit is contained in:
K.Kanakhin 2017-02-21 08:41:20 +06:00 committed by Tim Abbott
parent 20b655016d
commit 257bb40698
20 changed files with 489 additions and 9 deletions

View File

@ -58,6 +58,7 @@
"viewport": false, "viewport": false,
"upload_widget": false, "upload_widget": false,
"avatar": false, "avatar": false,
"realm_icon": false,
"feature_flags": false, "feature_flags": false,
"search_suggestion": false, "search_suggestion": false,
"referral": false, "referral": false,

View File

@ -323,6 +323,8 @@ function _setup_page() {
realm_default_language: page_params.realm_default_language, realm_default_language: page_params.realm_default_language,
realm_waiting_period_threshold: page_params.realm_waiting_period_threshold, realm_waiting_period_threshold: page_params.realm_waiting_period_threshold,
is_admin: page_params.is_admin, is_admin: page_params.is_admin,
realm_icon_source: page_params.realm_icon_source,
realm_icon: page_params.realm_icon,
}; };
var admin_tab = templates.render('admin_tab', options); var admin_tab = templates.render('admin_tab', options);
@ -1050,6 +1052,31 @@ function _setup_page() {
}); });
}); });
function upload_realm_icon(file_input) {
var form_data = new FormData();
form_data.append('csrfmiddlewaretoken', csrf_token);
jQuery.each(file_input[0].files, function (i, file) {
form_data.append('file-'+i, file);
});
var spinner = $("#upload_icon_spinner").expectOne();
loading.make_indicator(spinner, {text: i18n.t("Uploading icon.")});
channel.put({
url: '/json/realm/icon',
data: form_data,
cache: false,
processData: false,
contentType: false,
success: function () {
loading.destroy_indicator($("#upload_icon_spinner"));
},
});
}
realm_icon.build_realm_icon_widget(upload_realm_icon);
} }
exports.launch_page = function (tab) { exports.launch_page = function (tab) {

34
static/js/realm_icon.js Normal file
View File

@ -0,0 +1,34 @@
var realm_icon = (function () {
var exports = {};
exports.build_realm_icon_widget = function (upload_function) {
var get_file_input = function () {
return $('#realm_icon_file_input').expectOne();
};
if (page_params.realm_icon_source === 'G') {
$("#realm_icon_delete_button").hide();
}
$("#realm_icon_delete_button").on('click', function (e) {
e.preventDefault();
e.stopPropagation();
channel.del({
url: '/json/realm/icon',
});
});
return upload_widget.build_direct_upload_widget(
get_file_input,
$("#realm_icon_file_input_error").expectOne(),
$("#realm_icon_upload_button").expectOne(),
upload_function
);
};
return exports;
}());
if (typeof module !== 'undefined') {
module.exports = realm_icon;
}

View File

@ -245,6 +245,19 @@ function dispatch_normal_event(event) {
break; break;
} }
break; break;
case 'realm_change_icon':
$("#realm-settings-icon").attr("src", event.url);
if (event.source === 'U') {
$("#realm_icon_delete_button").show();
} else {
$("#realm_icon_delete_button").hide();
// Need to clear input because of a small edge case
// where you try to upload the same image you just deleted.
var file_input = $("#realm_icon_file_input");
file_input.val('');
}
page_params.realm_icon = event.url;
page_params.icon_source = event.source;
} }
} }

View File

@ -31,6 +31,10 @@ label {
margin-top: 20px; margin-top: 20px;
} }
.new-style .m-t-10 {
margin-top: 10px;
}
.new-style .grid label { .new-style .grid label {
width: 200px; width: 200px;
} }
@ -58,14 +62,14 @@ label {
width: 214px; width: 214px;
} }
.user-avatar-section { .user-avatar-section, .realm-icon-section {
float: right; float: right;
background-color: #fff; background-color: #fff;
margin: 20px 0px; margin: 20px 0px;
padding: 20px; padding: 20px;
} }
.user-avatar-section .inline-block { .user-avatar-section .inline-block, .realm-icon-section .inline-block {
display: block; display: block;
} }
@ -213,7 +217,7 @@ input[type=checkbox] + .inline-block {
margin-left: 10px; margin-left: 10px;
} }
#user-settings-avatar { #user-settings-avatar, #realm-icon-section {
border-radius: 5px; border-radius: 5px;
box-shadow: 0px 0px 10px rgba(0,0,0,0.1); box-shadow: 0px 0px 10px rgba(0,0,0,0.1);
} }
@ -530,7 +534,7 @@ input[type=checkbox].inline-block {
margin-right: 20px; margin-right: 20px;
} }
#upload_avatar_spinner { #upload_avatar_spinner, #upload_icon_spinner {
font-size: 14px; font-size: 14px;
margin: auto; margin: auto;
} }
@ -590,6 +594,13 @@ input[type=checkbox].inline-block {
height: 200px; height: 200px;
} }
#realm-settings-icon {
border-radius: 5px;
box-shadow: 0px 0px 10px rgba(0,0,0,0.1);
width: 100px;
height: 100px;
margin-left: 25%;
}
/* -- new settings overlay -- */ /* -- new settings overlay -- */
#settings_overlay_container { #settings_overlay_container {
pointer-events: none; pointer-events: none;
@ -832,20 +843,27 @@ input[type=text]#settings_search {
/* -- end new settings overlay -- */ /* -- end new settings overlay -- */
@media (max-width: 1215px) { @media (max-width: 1215px) {
.user-avatar-section { .user-avatar-section, .realm-icon-section {
float: none; float: none;
display: inline-block; display: inline-block;
} }
.user-avatar-section .inline-block { .user-avatar-section .inline-block, .realm-icon-section .inline-block {
display: inline-block; display: inline-block;
vertical-align: top; vertical-align: top;
}
.user-avatar-section .inline-block {
margin: 0px 10px; margin: 0px 10px;
} }
.realm-icon-section .inline-block {
margin: 0px 25px;
}
} }
@media (max-width: 856px) { @media (max-width: 856px) {
.user-avatar-section .inline-block { .user-avatar-section .inline-block, .realm-icon-section .inline-block {
display: block; display: block;
margin: 0; margin: 0;
} }

View File

@ -12,6 +12,7 @@
<div class="alert" id="admin-realm-default-language-status"></div> <div class="alert" id="admin-realm-default-language-status"></div>
<div class="alert" id="admin-realm-waiting_period_threshold_status"></div> <div class="alert" id="admin-realm-waiting_period_threshold_status"></div>
<div class="m-10 inline-block grid organization-settings-parent">
<div class="input-group admin-realm"> <div class="input-group admin-realm">
<label for="realm_name">{{t "Your organization's name" }}</label> <label for="realm_name">{{t "Your organization's name" }}</label>
<input type="text" id="id_realm_name" name="realm_name" class="admin-realm-name" <input type="text" id="id_realm_name" name="realm_name" class="admin-realm-name"
@ -108,5 +109,21 @@
<div class="input-group organization-submission"> <div class="input-group organization-submission">
<input type="submit" class="button" value="{{t 'Save changes' }}" /> <input type="submit" class="button" value="{{t 'Save changes' }}" />
</div> </div>
</div>
<div class="realm-icon-section box-shadow border-radius">
<div class="inline-block">
<img id="realm-settings-icon" src="{{ realm_icon }}"/>
<div id="realm_icon_file_input_error" class="text-error"></div>
<input type="file" name="realm_icon_file_input" class="notvisible"
id="realm_icon_file_input" value="{{t 'Upload icon' }}"/>
<div id="upload_icon_spinner"></div>
</div>
<div class="inline-block">
<button class="button sea-green w-200 m-t-10 block input-size"
id="realm_icon_upload_button">{{t 'Upload new ccon' }}</button>
<button class="button btn-danger w-200 m-t-10 block input-size"
id="realm_icon_delete_button">{{t 'Delete icon' }}</button>
</div>
</div>
</form> </form>
</div> </div>

View File

@ -30,6 +30,7 @@ target_fully_covered = {path for target in [
'zerver/lib/mention.py', 'zerver/lib/mention.py',
'zerver/lib/message.py', 'zerver/lib/message.py',
'zerver/lib/name_restrictions.py', 'zerver/lib/name_restrictions.py',
'zerver/lib/realm_icon.py',
'zerver/lib/retention.py', 'zerver/lib/retention.py',
'zerver/lib/streams.py', 'zerver/lib/streams.py',
'zerver/lib/users.py', 'zerver/lib/users.py',

View File

@ -25,6 +25,7 @@ from zerver.lib.message import (
message_to_dict, message_to_dict,
render_markdown, render_markdown,
) )
from zerver.lib.realm_icon import realm_icon_url
from zerver.models import Realm, RealmEmoji, Stream, UserProfile, UserActivity, RealmAlias, \ from zerver.models import Realm, RealmEmoji, Stream, UserProfile, UserActivity, RealmAlias, \
Subscription, Recipient, Message, Attachment, UserMessage, \ Subscription, Recipient, Message, Attachment, UserMessage, \
Client, DefaultStream, UserPresence, Referral, PushDeviceToken, MAX_SUBJECT_LENGTH, \ Client, DefaultStream, UserPresence, Referral, PushDeviceToken, MAX_SUBJECT_LENGTH, \
@ -1952,6 +1953,23 @@ def do_change_avatar_fields(user_profile, avatar_source, log=True):
person=payload), person=payload),
active_user_ids(user_profile.realm)) active_user_ids(user_profile.realm))
def do_change_icon_source(realm, icon_source, log=True):
# type: (Realm, Text, bool) -> None
realm.icon_source = icon_source
realm.icon_version += 1
realm.save(update_fields=["icon_source", "icon_version"])
if log:
log_event({'type': 'realm_change_icon',
'realm': realm.domain,
'icon_source': icon_source})
send_event(dict(type='realm_change_icon',
source=realm.icon_source,
url=realm_icon_url(realm)),
active_user_ids(realm))
def _default_stream_permision_check(user_profile, stream): def _default_stream_permision_check(user_profile, stream):
# type: (UserProfile, Optional[Stream]) -> None # type: (UserProfile, Optional[Stream]) -> None
# Any user can have a None default stream # Any user can have a None default stream

View File

@ -21,6 +21,7 @@ from zerver.lib.alert_words import user_alert_words
from zerver.lib.attachments import user_attachments from zerver.lib.attachments import user_attachments
from zerver.lib.avatar import get_avatar_url from zerver.lib.avatar import get_avatar_url
from zerver.lib.narrow import check_supported_events_narrow_filter from zerver.lib.narrow import check_supported_events_narrow_filter
from zerver.lib.realm_icon import realm_icon_url
from zerver.lib.request import JsonableError from zerver.lib.request import JsonableError
from zerver.lib.actions import validate_user_access_to_subscribers_helper, \ from zerver.lib.actions import validate_user_access_to_subscribers_helper, \
do_get_streams, get_default_streams_for_realm, \ do_get_streams, get_default_streams_for_realm, \
@ -156,6 +157,10 @@ def fetch_initial_state_data(user_profile, event_types, queue_id,
state['enable_online_push_notifications'] = user_profile.enable_online_push_notifications state['enable_online_push_notifications'] = user_profile.enable_online_push_notifications
state['enable_digest_emails'] = user_profile.enable_digest_emails state['enable_digest_emails'] = user_profile.enable_digest_emails
if want('realm_change_icon'):
state['realm_icon'] = realm_icon_url(user_profile.realm)
state['realm_icon_source'] = user_profile.realm.icon_source
return state return state
def apply_events(state, events, user_profile, include_subscribers=True): def apply_events(state, events, user_profile, include_subscribers=True):
@ -377,6 +382,9 @@ def apply_event(state, event, user_profile, include_subscribers):
state['enable_online_push_notifications'] = event['setting'] state['enable_online_push_notifications'] = event['setting']
elif event['notification_name'] == "enable_digest_emails": elif event['notification_name'] == "enable_digest_emails":
state['enable_digest_emails'] = event['setting'] state['enable_digest_emails'] = event['setting']
elif event['type'] == "realm_change_icon":
state['realm_icon'] = event['url']
state['realm_icon_source'] = event['source']
else: else:
raise ValueError("Unexpected event type %s" % (event['type'],)) raise ValueError("Unexpected event type %s" % (event['type'],))

22
zerver/lib/realm_icon.py Normal file
View File

@ -0,0 +1,22 @@
from __future__ import absolute_import
from django.conf import settings
from typing import Text
from zerver.lib.avatar_hash import gravatar_hash, user_avatar_hash
from zerver.lib.upload import upload_backend
from zerver.models import Realm
def realm_icon_url(realm):
# type: (Realm) -> Text
return get_realm_icon_url(realm)
def get_realm_icon_url(realm):
# type: (Realm) -> Text
if realm.icon_source == u'U':
return upload_backend.get_realm_icon_url(realm.id, realm.icon_version)
elif settings.ENABLE_GRAVATAR:
hash_key = gravatar_hash(realm.domain)
return u"https://secure.gravatar.com/avatar/%s?d=identicon" % (hash_key,)
else:
return settings.DEFAULT_AVATAR_URI+'?version=0'

View File

@ -118,6 +118,15 @@ class ZulipUploadBackend(object):
# type: (Text) -> None # type: (Text) -> None
raise NotImplementedError() raise NotImplementedError()
def upload_realm_icon_image(self, icon_file, user_profile):
# type: (File, UserProfile) -> None
raise NotImplementedError()
def get_realm_icon_url(self, realm_id, version):
# type: (int, int) -> Text
raise NotImplementedError()
### S3 ### S3
def get_bucket(conn, bucket_name): def get_bucket(conn, bucket_name):
@ -269,6 +278,38 @@ class S3UploadBackend(ZulipUploadBackend):
# ?x=x allows templates to append additional parameters with &s # ?x=x allows templates to append additional parameters with &s
return u"https://%s.s3.amazonaws.com/%s%s?x=x" % (bucket, medium_suffix, hash_key) return u"https://%s.s3.amazonaws.com/%s%s?x=x" % (bucket, medium_suffix, hash_key)
def upload_realm_icon_image(self, icon_file, user_profile):
# type: (File, UserProfile) -> None
content_type = guess_type(icon_file.name)[0]
bucket_name = settings.S3_AVATAR_BUCKET
s3_file_name = os.path.join(str(user_profile.realm.id), 'realm', 'icon')
image_data = icon_file.read()
upload_image_to_s3(
bucket_name,
s3_file_name + ".original",
content_type,
user_profile,
image_data,
)
resized_data = resize_avatar(image_data)
upload_image_to_s3(
bucket_name,
s3_file_name,
'image/png',
user_profile,
resized_data,
)
# See avatar_url in avatar.py for URL. (That code also handles the case
# that users use gravatar.)
def get_realm_icon_url(self, realm_id, version):
# type: (int, int) -> Text
bucket = settings.S3_AVATAR_BUCKET
# ?x=x allows templates to append additional parameters with &s
return u"https://%s.s3.amazonaws.com/%s/realm/icon.png?version=%s" % (bucket, realm_id, version)
def ensure_medium_avatar_image(self, email): def ensure_medium_avatar_image(self, email):
# type: (Text) -> None # type: (Text) -> None
user_profile = get_user_profile_by_email(email) user_profile = get_user_profile_by_email(email)
@ -359,6 +400,24 @@ class LocalUploadBackend(ZulipUploadBackend):
medium_suffix = "-medium" if medium else "" medium_suffix = "-medium" if medium else ""
return u"/user_avatars/%s%s.png?x=x" % (hash_key, medium_suffix) return u"/user_avatars/%s%s.png?x=x" % (hash_key, medium_suffix)
def upload_realm_icon_image(self, icon_file, user_profile):
# type: (File, UserProfile) -> None
upload_path = os.path.join('avatars', str(user_profile.realm.id), 'realm')
image_data = icon_file.read()
write_local_file(
upload_path,
'icon.original',
image_data)
resized_data = resize_avatar(image_data)
write_local_file(upload_path, 'icon.png', resized_data)
def get_realm_icon_url(self, realm_id, version):
# type: (int, int) -> Text
# ?x=x allows templates to append additional parameters with &s
return u"/user_avatars/%s/realm/icon.png?version=%s" % (realm_id, version)
def ensure_medium_avatar_image(self, email): def ensure_medium_avatar_image(self, email):
# type: (Text) -> None # type: (Text) -> None
email_hash = user_avatar_hash(email) email_hash = user_avatar_hash(email)
@ -386,6 +445,10 @@ def upload_avatar_image(user_file, user_profile, email):
# type: (File, UserProfile, Text) -> None # type: (File, UserProfile, Text) -> None
upload_backend.upload_avatar_image(user_file, user_profile, email) upload_backend.upload_avatar_image(user_file, user_profile, email)
def upload_icon_image(user_file, user_profile):
# type: (File, UserProfile) -> None
upload_backend.upload_realm_icon_image(user_file, user_profile)
def upload_message_image(uploaded_file_name, content_type, file_data, user_profile, target_realm=None): def upload_message_image(uploaded_file_name, content_type, file_data, user_profile, target_realm=None):
# type: (Text, Optional[Text], binary_type, UserProfile, Optional[Realm]) -> Text # type: (Text, Optional[Text], binary_type, UserProfile, Optional[Realm]) -> Text
return upload_backend.upload_message_image(uploaded_file_name, content_type, file_data, return upload_backend.upload_message_image(uploaded_file_name, content_type, file_data,

View File

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-02-15 06:18
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('zerver', '0053_emailchangestatus'),
]
operations = [
migrations.AddField(
model_name='realm',
name='icon_source',
field=models.CharField(
choices=[('G', 'Hosted by Gravatar'), ('U', 'Uploaded by administrator')],
default='G', max_length=1),
),
migrations.AddField(
model_name='realm',
name='icon_version',
field=models.PositiveSmallIntegerField(default=1),
),
]

View File

@ -140,6 +140,16 @@ class Realm(ModelReprMixin, models.Model):
default=2**31 - 1) # type: BitHandler default=2**31 - 1) # type: BitHandler
waiting_period_threshold = models.PositiveIntegerField(default=0) # type: int waiting_period_threshold = models.PositiveIntegerField(default=0) # type: int
ICON_FROM_GRAVATAR = u'G'
ICON_UPLOADED = u'U'
ICON_SOURCES = (
(ICON_FROM_GRAVATAR, 'Hosted by Gravatar'),
(ICON_UPLOADED, 'Uploaded by administrator'),
)
icon_source = models.CharField(default=ICON_FROM_GRAVATAR, choices=ICON_SOURCES,
max_length=1) # type: Text
icon_version = models.PositiveSmallIntegerField(default=1) # type: int
DEFAULT_NOTIFICATION_STREAM_NAME = u'announce' DEFAULT_NOTIFICATION_STREAM_NAME = u'announce'
def authentication_methods_dict(self): def authentication_methods_dict(self):

View File

@ -62,7 +62,7 @@ from zerver.lib.actions import (
do_add_realm_alias, do_add_realm_alias,
do_change_realm_alias, do_change_realm_alias,
do_remove_realm_alias, do_remove_realm_alias,
) do_change_icon_source)
from zerver.lib.events import ( from zerver.lib.events import (
apply_events, apply_events,
fetch_initial_state_data, fetch_initial_state_data,
@ -959,6 +959,19 @@ class EventsRegisterTest(ZulipTestCase):
error = self.realm_bot_schema('avatar_url', check_string)('events[0]', events[0]) error = self.realm_bot_schema('avatar_url', check_string)('events[0]', events[0])
self.assert_on_error(error) self.assert_on_error(error)
def test_change_realm_icon_source(self):
# type: () -> None
realm = get_realm('zulip')
action = lambda: do_change_icon_source(realm, realm.ICON_FROM_GRAVATAR)
events = self.do_test(action, state_change_expected=False)
schema_checker = check_dict([
('type', equals('realm_change_icon')),
('source', check_string),
('url', check_string)
])
error = schema_checker('events[0]', events[0])
self.assert_on_error(error)
def test_change_bot_default_all_public_streams(self): def test_change_bot_default_all_public_streams(self):
# type: () -> None # type: () -> None
bot = self.create_bot('test-bot@zulip.com') bot = self.create_bot('test-bot@zulip.com')

View File

@ -6,6 +6,7 @@ from unittest import skip
from zerver.lib.avatar import avatar_url from zerver.lib.avatar import avatar_url
from zerver.lib.bugdown import url_filename from zerver.lib.bugdown import url_filename
from zerver.lib.realm_icon import realm_icon_url
from zerver.lib.test_classes import ZulipTestCase, UploadSerializeMixin from zerver.lib.test_classes import ZulipTestCase, UploadSerializeMixin
from zerver.lib.test_helpers import avatar_disk_path, get_test_image_file from zerver.lib.test_helpers import avatar_disk_path, get_test_image_file
from zerver.lib.test_runner import slow from zerver.lib.test_runner import slow
@ -13,7 +14,7 @@ from zerver.lib.upload import sanitize_name, S3UploadBackend, \
upload_message_image, delete_message_image, LocalUploadBackend upload_message_image, delete_message_image, LocalUploadBackend
import zerver.lib.upload import zerver.lib.upload
from zerver.models import Attachment, Recipient, get_user_profile_by_email, \ from zerver.models import Attachment, Recipient, get_user_profile_by_email, \
get_old_unclaimed_attachments, Message, UserProfile get_old_unclaimed_attachments, Message, UserProfile, Realm, get_realm
from zerver.lib.actions import do_delete_old_unclaimed_attachments from zerver.lib.actions import do_delete_old_unclaimed_attachments
import ujson import ujson
@ -473,6 +474,145 @@ class AvatarTest(UploadSerializeMixin, ZulipTestCase):
# type: () -> None # type: () -> None
destroy_uploads() destroy_uploads()
class RealmIconTest(UploadSerializeMixin, ZulipTestCase):
def test_multiple_upload_failure(self):
# type: () -> None
"""
Attempting to upload two files should fail.
"""
# Log in as admin
self.login("iago@zulip.com")
with get_test_image_file('img.png') as fp1, \
get_test_image_file('img.png') as fp2:
result = self.client_put_multipart("/json/realm/icon", {'f1': fp1, 'f2': fp2})
self.assert_json_error(result, "You must upload exactly one icon.")
def test_no_file_upload_failure(self):
# type: () -> None
"""
Calling this endpoint with no files should fail.
"""
self.login("iago@zulip.com")
result = self.client_put_multipart("/json/realm/icon")
self.assert_json_error(result, "You must upload exactly one icon.")
correct_files = [
('img.png', 'png_resized.png'),
('img.jpg', None), # jpeg resizing is platform-dependent
('img.gif', 'gif_resized.png'),
('img.tif', 'tif_resized.png')
]
corrupt_files = ['text.txt', 'corrupt.png', 'corrupt.gif']
def test_no_admin_user_upload(self):
# type: () -> None
self.login("hamlet@zulip.com")
with get_test_image_file(self.correct_files[0][0]) as fp:
result = self.client_put_multipart("/json/realm/icon", {'file': fp})
self.assert_json_error(result, 'Must be a realm administrator')
def test_get_gravatar_icon(self):
# type: () -> None
self.login("hamlet@zulip.com")
realm = get_realm('zulip')
realm.icon_source = Realm.ICON_FROM_GRAVATAR
realm.save()
with self.settings(ENABLE_GRAVATAR=True):
response = self.client_get("/json/realm/icon?foo=bar")
redirect_url = response['Location']
self.assertEqual(redirect_url, realm_icon_url(realm) + '&foo=bar')
with self.settings(ENABLE_GRAVATAR=False):
response = self.client_get("/json/realm/icon?foo=bar")
redirect_url = response['Location']
self.assertTrue(redirect_url.endswith(realm_icon_url(realm) + '&foo=bar'))
def test_get_realm_icon(self):
# type: () -> None
self.login("hamlet@zulip.com")
realm = get_realm('zulip')
realm.icon_source = Realm.ICON_UPLOADED
realm.save()
response = self.client_get("/json/realm/icon?foo=bar")
redirect_url = response['Location']
self.assertTrue(redirect_url.endswith(realm_icon_url(realm) + '&foo=bar'))
def test_valid_icons(self):
# type: () -> None
"""
A PUT request to /json/realm/icon with a valid file should return a url
and actually create an realm icon.
"""
for fname, rfname in self.correct_files:
# TODO: use self.subTest once we're exclusively on python 3 by uncommenting the line below.
# with self.subTest(fname=fname):
self.login("iago@zulip.com")
with get_test_image_file(fname) as fp:
result = self.client_put_multipart("/json/realm/icon", {'file': fp})
realm = get_realm('zulip')
self.assert_json_success(result)
json = ujson.loads(result.content)
self.assertIn("icon_url", json)
url = json["icon_url"]
base = '/user_avatars/%s/realm/icon.png' % (realm.id,)
self.assertEqual(base, url[:len(base)])
if rfname is not None:
response = self.client_get(url)
data = b"".join(response.streaming_content)
self.assertEqual(Image.open(io.BytesIO(data)).size, (100, 100))
def test_invalid_icons(self):
# type: () -> None
"""
A PUT request to /json/realm/icon with an invalid file should fail.
"""
for fname in self.corrupt_files:
# with self.subTest(fname=fname):
self.login("iago@zulip.com")
with get_test_image_file(fname) as fp:
result = self.client_put_multipart("/json/realm/icon", {'file': fp})
self.assert_json_error(result, "Could not decode image; did you upload an image file?")
def test_delete_icon(self):
# type: () -> None
"""
A DELETE request to /json/realm/icon should delete the realm icon and return gravatar URL
"""
self.login("iago@zulip.com")
realm = get_realm('zulip')
realm.icon_source = Realm.ICON_UPLOADED
realm.save()
result = self.client_delete("/json/realm/icon")
self.assert_json_success(result)
json = ujson.loads(result.content)
self.assertIn("icon_url", json)
realm = get_realm('zulip')
self.assertEqual(json["icon_url"], realm_icon_url(realm))
self.assertEqual(realm.icon_source, Realm.ICON_FROM_GRAVATAR)
def test_realm_icon_version(self):
# type: () -> None
self.login("iago@zulip.com")
realm = get_realm('zulip')
icon_version = realm.icon_version
self.assertEqual(icon_version, 1)
with get_test_image_file(self.correct_files[0][0]) as fp:
self.client_put_multipart("/json/realm/icon", {'file': fp})
realm = get_realm('zulip')
self.assertEqual(realm.icon_version, icon_version + 1)
def tearDown(self):
# type: () -> None
destroy_uploads()
class LocalStorageTest(UploadSerializeMixin, ZulipTestCase): class LocalStorageTest(UploadSerializeMixin, ZulipTestCase):
def test_file_upload_local(self): def test_file_upload_local(self):

View File

@ -1947,6 +1947,8 @@ class HomeTest(ZulipTestCase):
"realm_default_streams", "realm_default_streams",
"realm_emoji", "realm_emoji",
"realm_filters", "realm_filters",
"realm_icon",
"realm_icon_source",
"realm_invite_by_admins_only", "realm_invite_by_admins_only",
"realm_invite_required", "realm_invite_required",
"realm_message_content_edit_limit_seconds", "realm_message_content_edit_limit_seconds",
@ -1991,6 +1993,7 @@ class HomeTest(ZulipTestCase):
page_params = self._get_page_params(result) page_params = self._get_page_params(result)
actual_keys = sorted([str(k) for k in page_params.keys()]) actual_keys = sorted([str(k) for k in page_params.keys()])
self.assertEqual(actual_keys, expected_keys) self.assertEqual(actual_keys, expected_keys)
# TODO: Inspect the page_params data further. # TODO: Inspect the page_params data further.

View File

@ -12,6 +12,7 @@ from six.moves import zip_longest, zip, range
from version import ZULIP_VERSION from version import ZULIP_VERSION
from zerver.decorator import zulip_login_required, process_client from zerver.decorator import zulip_login_required, process_client
from zerver.forms import ToSForm from zerver.forms import ToSForm
from zerver.lib.realm_icon import realm_icon_url
from zerver.models import Message, UserProfile, Stream, Subscription, Huddle, \ from zerver.models import Message, UserProfile, Stream, Subscription, Huddle, \
Recipient, Realm, UserMessage, DefaultStream, RealmEmoji, RealmAlias, \ Recipient, Realm, UserMessage, DefaultStream, RealmEmoji, RealmAlias, \
RealmFilter, PreregistrationUser, UserActivity, \ RealmFilter, PreregistrationUser, UserActivity, \
@ -225,6 +226,8 @@ def home_real(request):
realm_restricted_to_domain = register_ret['realm_restricted_to_domain'], realm_restricted_to_domain = register_ret['realm_restricted_to_domain'],
realm_default_language = register_ret['realm_default_language'], realm_default_language = register_ret['realm_default_language'],
realm_waiting_period_threshold = register_ret['realm_waiting_period_threshold'], realm_waiting_period_threshold = register_ret['realm_waiting_period_threshold'],
realm_icon = realm_icon_url(user_profile.realm),
realm_icon_source = user_profile.realm.icon_source,
enter_sends = user_profile.enter_sends, enter_sends = user_profile.enter_sends,
user_id = user_profile.id, user_id = user_profile.id,
left_side_userlist = register_ret['left_side_userlist'], left_side_userlist = register_ret['left_side_userlist'],

View File

@ -0,0 +1,55 @@
from django.shortcuts import redirect
from django.utils.translation import ugettext as _
from django.http import HttpResponse, HttpRequest
from zerver.decorator import require_realm_admin
from zerver.lib.actions import do_change_icon_source
from zerver.lib.realm_icon import realm_icon_url
from zerver.lib.response import json_error, json_success
from zerver.lib.upload import upload_icon_image
from zerver.models import UserProfile
@require_realm_admin
def upload_icon(request, user_profile):
# type: (HttpRequest, UserProfile) -> HttpResponse
if len(request.FILES) != 1:
return json_error(_("You must upload exactly one icon."))
icon_file = list(request.FILES.values())[0]
upload_icon_image(icon_file, user_profile)
do_change_icon_source(user_profile.realm, user_profile.realm.ICON_UPLOADED)
icon_url = realm_icon_url(user_profile.realm)
json_result = dict(
icon_url=icon_url
)
return json_success(json_result)
@require_realm_admin
def delete_icon_backend(request, user_profile):
# type: (HttpRequest, UserProfile) -> HttpResponse
# We don't actually delete the icon because it might still
# be needed if the URL was cached and it is rewrited
# in any case after next update.
do_change_icon_source(user_profile.realm, user_profile.realm.ICON_FROM_GRAVATAR)
gravatar_url = realm_icon_url(user_profile.realm)
json_result = dict(
icon_url=gravatar_url
)
return json_success(json_result)
def get_icon_backend(request, user_profile):
# type: (HttpRequest, UserProfile) -> HttpResponse
url = realm_icon_url(user_profile.realm)
# We can rely on the url already having query parameters. Because
# our templates depend on being able to use the ampersand to
# add query parameters to our url, get_icon_url does '?version=version_number'
# hacks to prevent us from having to jump through decode/encode hoops.
assert '?' in url
url += '&' + request.META['QUERY_STRING']
return redirect(url)

View File

@ -859,6 +859,7 @@ JS_SPECS = {
'js/templates.js', 'js/templates.js',
'js/upload_widget.js', 'js/upload_widget.js',
'js/avatar.js', 'js/avatar.js',
'js/realm_icon.js',
'js/settings.js', 'js/settings.js',
'js/admin.js', 'js/admin.js',
'js/tab_bar.js', 'js/tab_bar.js',

View File

@ -185,6 +185,12 @@ v1_api_and_json_patterns = [
{'PUT': 'zerver.views.realm_emoji.upload_emoji', {'PUT': 'zerver.views.realm_emoji.upload_emoji',
'DELETE': 'zerver.views.realm_emoji.delete_emoji'}), 'DELETE': 'zerver.views.realm_emoji.delete_emoji'}),
# realm/icon -> zerver.views.realm_icon
url(r'^realm/icon$', rest_dispatch,
{'PUT': 'zerver.views.realm_icon.upload_icon',
'DELETE': 'zerver.views.realm_icon.delete_icon_backend',
'GET': 'zerver.views.realm_icon.get_icon_backend'}),
# realm/filters -> zerver.views.realm_filters # realm/filters -> zerver.views.realm_filters
url(r'^realm/filters$', rest_dispatch, url(r'^realm/filters$', rest_dispatch,
{'GET': 'zerver.views.realm_filters.list_filters', {'GET': 'zerver.views.realm_filters.list_filters',