From 257bb4069844c4c0898ca706fd53ff29b091c4e4 Mon Sep 17 00:00:00 2001 From: "K.Kanakhin" Date: Tue, 21 Feb 2017 08:41:20 +0600 Subject: [PATCH] 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. --- .eslintrc.json | 1 + static/js/admin.js | 27 ++++ static/js/realm_icon.js | 34 +++++ static/js/server_events.js | 13 ++ static/styles/settings.css | 32 +++- .../organization-settings-admin.handlebars | 17 +++ tools/test-backend | 1 + zerver/lib/actions.py | 18 +++ zerver/lib/events.py | 8 + zerver/lib/realm_icon.py | 22 +++ zerver/lib/upload.py | 63 ++++++++ zerver/migrations/0054_realm_icon.py | 27 ++++ zerver/models.py | 10 ++ zerver/tests/test_events.py | 15 +- zerver/tests/test_upload.py | 142 +++++++++++++++++- zerver/tests/tests.py | 3 + zerver/views/home.py | 3 + zerver/views/realm_icon.py | 55 +++++++ zproject/settings.py | 1 + zproject/urls.py | 6 + 20 files changed, 489 insertions(+), 9 deletions(-) create mode 100644 static/js/realm_icon.js create mode 100644 zerver/lib/realm_icon.py create mode 100644 zerver/migrations/0054_realm_icon.py create mode 100644 zerver/views/realm_icon.py diff --git a/.eslintrc.json b/.eslintrc.json index d7a9640e07..d844701080 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -58,6 +58,7 @@ "viewport": false, "upload_widget": false, "avatar": false, + "realm_icon": false, "feature_flags": false, "search_suggestion": false, "referral": false, diff --git a/static/js/admin.js b/static/js/admin.js index 85154985bd..6613aa56d9 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -323,6 +323,8 @@ function _setup_page() { realm_default_language: page_params.realm_default_language, realm_waiting_period_threshold: page_params.realm_waiting_period_threshold, 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); @@ -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) { diff --git a/static/js/realm_icon.js b/static/js/realm_icon.js new file mode 100644 index 0000000000..4d581ac89f --- /dev/null +++ b/static/js/realm_icon.js @@ -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; +} diff --git a/static/js/server_events.js b/static/js/server_events.js index 848866ceaa..5366bf1a40 100644 --- a/static/js/server_events.js +++ b/static/js/server_events.js @@ -245,6 +245,19 @@ function dispatch_normal_event(event) { 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; } } diff --git a/static/styles/settings.css b/static/styles/settings.css index 78fad5238b..058841900e 100644 --- a/static/styles/settings.css +++ b/static/styles/settings.css @@ -31,6 +31,10 @@ label { margin-top: 20px; } +.new-style .m-t-10 { + margin-top: 10px; +} + .new-style .grid label { width: 200px; } @@ -58,14 +62,14 @@ label { width: 214px; } -.user-avatar-section { +.user-avatar-section, .realm-icon-section { float: right; background-color: #fff; margin: 20px 0px; padding: 20px; } -.user-avatar-section .inline-block { +.user-avatar-section .inline-block, .realm-icon-section .inline-block { display: block; } @@ -213,7 +217,7 @@ input[type=checkbox] + .inline-block { margin-left: 10px; } -#user-settings-avatar { +#user-settings-avatar, #realm-icon-section { border-radius: 5px; box-shadow: 0px 0px 10px rgba(0,0,0,0.1); } @@ -530,7 +534,7 @@ input[type=checkbox].inline-block { margin-right: 20px; } -#upload_avatar_spinner { +#upload_avatar_spinner, #upload_icon_spinner { font-size: 14px; margin: auto; } @@ -590,6 +594,13 @@ input[type=checkbox].inline-block { 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 -- */ #settings_overlay_container { pointer-events: none; @@ -832,20 +843,27 @@ input[type=text]#settings_search { /* -- end new settings overlay -- */ @media (max-width: 1215px) { - .user-avatar-section { + .user-avatar-section, .realm-icon-section { float: none; display: inline-block; } - .user-avatar-section .inline-block { + .user-avatar-section .inline-block, .realm-icon-section .inline-block { display: inline-block; vertical-align: top; + } + + .user-avatar-section .inline-block { margin: 0px 10px; } + + .realm-icon-section .inline-block { + margin: 0px 25px; + } } @media (max-width: 856px) { - .user-avatar-section .inline-block { + .user-avatar-section .inline-block, .realm-icon-section .inline-block { display: block; margin: 0; } diff --git a/static/templates/settings/organization-settings-admin.handlebars b/static/templates/settings/organization-settings-admin.handlebars index 21b25f5aac..196b8b484d 100644 --- a/static/templates/settings/organization-settings-admin.handlebars +++ b/static/templates/settings/organization-settings-admin.handlebars @@ -12,6 +12,7 @@
+
+
+
+
+ +
+ +
+
+
+ + +
+
diff --git a/tools/test-backend b/tools/test-backend index af6542c477..8377650c1c 100755 --- a/tools/test-backend +++ b/tools/test-backend @@ -30,6 +30,7 @@ target_fully_covered = {path for target in [ 'zerver/lib/mention.py', 'zerver/lib/message.py', 'zerver/lib/name_restrictions.py', + 'zerver/lib/realm_icon.py', 'zerver/lib/retention.py', 'zerver/lib/streams.py', 'zerver/lib/users.py', diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py index ea497ab609..270c66350b 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -25,6 +25,7 @@ from zerver.lib.message import ( message_to_dict, render_markdown, ) +from zerver.lib.realm_icon import realm_icon_url from zerver.models import Realm, RealmEmoji, Stream, UserProfile, UserActivity, RealmAlias, \ Subscription, Recipient, Message, Attachment, UserMessage, \ 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), 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): # type: (UserProfile, Optional[Stream]) -> None # Any user can have a None default stream diff --git a/zerver/lib/events.py b/zerver/lib/events.py index a3b3cd7079..dc78e5c6c6 100644 --- a/zerver/lib/events.py +++ b/zerver/lib/events.py @@ -21,6 +21,7 @@ from zerver.lib.alert_words import user_alert_words from zerver.lib.attachments import user_attachments from zerver.lib.avatar import get_avatar_url 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.actions import validate_user_access_to_subscribers_helper, \ 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_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 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'] elif event['notification_name'] == "enable_digest_emails": state['enable_digest_emails'] = event['setting'] + elif event['type'] == "realm_change_icon": + state['realm_icon'] = event['url'] + state['realm_icon_source'] = event['source'] else: raise ValueError("Unexpected event type %s" % (event['type'],)) diff --git a/zerver/lib/realm_icon.py b/zerver/lib/realm_icon.py new file mode 100644 index 0000000000..7fa1e7fd05 --- /dev/null +++ b/zerver/lib/realm_icon.py @@ -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' diff --git a/zerver/lib/upload.py b/zerver/lib/upload.py index 3eeddcc0b6..45220df20e 100644 --- a/zerver/lib/upload.py +++ b/zerver/lib/upload.py @@ -118,6 +118,15 @@ class ZulipUploadBackend(object): # type: (Text) -> None 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 def get_bucket(conn, bucket_name): @@ -269,6 +278,38 @@ class S3UploadBackend(ZulipUploadBackend): # ?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) + 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): # type: (Text) -> None user_profile = get_user_profile_by_email(email) @@ -359,6 +400,24 @@ class LocalUploadBackend(ZulipUploadBackend): medium_suffix = "-medium" if medium else "" 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): # type: (Text) -> None email_hash = user_avatar_hash(email) @@ -386,6 +445,10 @@ def upload_avatar_image(user_file, user_profile, email): # type: (File, UserProfile, Text) -> None 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): # type: (Text, Optional[Text], binary_type, UserProfile, Optional[Realm]) -> Text return upload_backend.upload_message_image(uploaded_file_name, content_type, file_data, diff --git a/zerver/migrations/0054_realm_icon.py b/zerver/migrations/0054_realm_icon.py new file mode 100644 index 0000000000..ce3d215188 --- /dev/null +++ b/zerver/migrations/0054_realm_icon.py @@ -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), + ), + ] diff --git a/zerver/models.py b/zerver/models.py index 573e70b43e..6c9e5ecf7d 100644 --- a/zerver/models.py +++ b/zerver/models.py @@ -140,6 +140,16 @@ class Realm(ModelReprMixin, models.Model): default=2**31 - 1) # type: BitHandler 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' def authentication_methods_dict(self): diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index 63c80a391b..abcfd0717b 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -62,7 +62,7 @@ from zerver.lib.actions import ( do_add_realm_alias, do_change_realm_alias, do_remove_realm_alias, -) + do_change_icon_source) from zerver.lib.events import ( apply_events, fetch_initial_state_data, @@ -959,6 +959,19 @@ class EventsRegisterTest(ZulipTestCase): error = self.realm_bot_schema('avatar_url', check_string)('events[0]', events[0]) 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): # type: () -> None bot = self.create_bot('test-bot@zulip.com') diff --git a/zerver/tests/test_upload.py b/zerver/tests/test_upload.py index 638624c890..eab723f4e0 100644 --- a/zerver/tests/test_upload.py +++ b/zerver/tests/test_upload.py @@ -6,6 +6,7 @@ from unittest import skip from zerver.lib.avatar import avatar_url 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_helpers import avatar_disk_path, get_test_image_file 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 import zerver.lib.upload 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 import ujson @@ -473,6 +474,145 @@ class AvatarTest(UploadSerializeMixin, ZulipTestCase): # type: () -> None 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): def test_file_upload_local(self): diff --git a/zerver/tests/tests.py b/zerver/tests/tests.py index 65d0844d31..4d3d9315c3 100644 --- a/zerver/tests/tests.py +++ b/zerver/tests/tests.py @@ -1947,6 +1947,8 @@ class HomeTest(ZulipTestCase): "realm_default_streams", "realm_emoji", "realm_filters", + "realm_icon", + "realm_icon_source", "realm_invite_by_admins_only", "realm_invite_required", "realm_message_content_edit_limit_seconds", @@ -1991,6 +1993,7 @@ class HomeTest(ZulipTestCase): page_params = self._get_page_params(result) actual_keys = sorted([str(k) for k in page_params.keys()]) + self.assertEqual(actual_keys, expected_keys) # TODO: Inspect the page_params data further. diff --git a/zerver/views/home.py b/zerver/views/home.py index cfb57d2f36..c946abd1ac 100644 --- a/zerver/views/home.py +++ b/zerver/views/home.py @@ -12,6 +12,7 @@ from six.moves import zip_longest, zip, range from version import ZULIP_VERSION from zerver.decorator import zulip_login_required, process_client from zerver.forms import ToSForm +from zerver.lib.realm_icon import realm_icon_url from zerver.models import Message, UserProfile, Stream, Subscription, Huddle, \ Recipient, Realm, UserMessage, DefaultStream, RealmEmoji, RealmAlias, \ RealmFilter, PreregistrationUser, UserActivity, \ @@ -225,6 +226,8 @@ def home_real(request): realm_restricted_to_domain = register_ret['realm_restricted_to_domain'], realm_default_language = register_ret['realm_default_language'], 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, user_id = user_profile.id, left_side_userlist = register_ret['left_side_userlist'], diff --git a/zerver/views/realm_icon.py b/zerver/views/realm_icon.py new file mode 100644 index 0000000000..4abb75e61d --- /dev/null +++ b/zerver/views/realm_icon.py @@ -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) diff --git a/zproject/settings.py b/zproject/settings.py index f870bab368..ff70200552 100644 --- a/zproject/settings.py +++ b/zproject/settings.py @@ -859,6 +859,7 @@ JS_SPECS = { 'js/templates.js', 'js/upload_widget.js', 'js/avatar.js', + 'js/realm_icon.js', 'js/settings.js', 'js/admin.js', 'js/tab_bar.js', diff --git a/zproject/urls.py b/zproject/urls.py index 4d4a22ba61..780739731f 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -185,6 +185,12 @@ v1_api_and_json_patterns = [ {'PUT': 'zerver.views.realm_emoji.upload_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 url(r'^realm/filters$', rest_dispatch, {'GET': 'zerver.views.realm_filters.list_filters',