diff --git a/zerver/context_processors.py b/zerver/context_processors.py
index 2e2076adf7..cc024d6e39 100644
--- a/zerver/context_processors.py
+++ b/zerver/context_processors.py
@@ -19,6 +19,7 @@ from zerver.lib.bugdown import convert as bugdown_convert
from zerver.lib.send_email import FromAddress
from zerver.lib.subdomains import get_subdomain
from zerver.lib.realm_icon import get_realm_icon_url
+from zerver.lib.realm_logo import get_realm_logo_url
from version import ZULIP_VERSION, LATEST_RELEASE_VERSION, \
LATEST_RELEASE_ANNOUNCEMENT, LATEST_MAJOR_VERSION
@@ -55,6 +56,7 @@ def zulip_default_context(request: HttpRequest) -> Dict[str, Any]:
realm_uri = settings.ROOT_DOMAIN_URI
realm_name = None
realm_icon = None
+ realm_logo = None
realm_description = None
realm_invite_required = False
realm_plan_type = 0
@@ -62,6 +64,7 @@ def zulip_default_context(request: HttpRequest) -> Dict[str, Any]:
realm_uri = realm.uri
realm_name = realm.name
realm_icon = get_realm_icon_url(realm)
+ realm_logo = get_realm_logo_url(realm)
realm_description_raw = realm.description or "The coolest place in the universe."
realm_description = bugdown_convert(realm_description_raw, message_realm=realm)
realm_invite_required = realm.invite_required
@@ -116,6 +119,7 @@ def zulip_default_context(request: HttpRequest) -> Dict[str, Any]:
'realm_uri': realm_uri,
'realm_name': realm_name,
'realm_icon': realm_icon,
+ 'realm_logo': realm_logo,
'realm_description': realm_description,
'realm_plan_type': realm_plan_type,
'root_domain_uri': settings.ROOT_DOMAIN_URI,
diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py
index 661685d262..2f7c63f25d 100644
--- a/zerver/lib/actions.py
+++ b/zerver/lib/actions.py
@@ -43,6 +43,7 @@ from zerver.lib.message import (
update_first_visible_message_id,
)
from zerver.lib.realm_icon import realm_icon_url
+from zerver.lib.realm_logo import realm_logo_url
from zerver.lib.retention import move_messages_to_archive
from zerver.lib.send_email import send_email, FromAddress, send_email_to_admins
from zerver.lib.stream_subscription import (
@@ -3086,6 +3087,22 @@ def do_change_icon_source(realm: Realm, icon_source: str, log: bool=True) -> Non
icon_url=realm_icon_url(realm))),
active_user_ids(realm.id))
+def do_change_logo_source(realm: Realm, logo_source: str) -> None:
+ realm.logo_source = logo_source
+ realm.logo_version += 1
+ realm.save(update_fields=["logo_source", "logo_version"])
+
+ RealmAuditLog.objects.create(event_type=RealmAuditLog.REALM_LOGO_CHANGED,
+ realm=realm, event_time=timezone_now())
+
+ send_event(realm,
+ dict(type='realm',
+ op='update_dict',
+ property="logo",
+ data=dict(logo_source=realm.logo_source,
+ logo_url=realm_logo_url(realm))),
+ active_user_ids(realm.id))
+
def do_change_plan_type(realm: Realm, plan_type: int) -> None:
old_value = realm.plan_type
realm.plan_type = plan_type
diff --git a/zerver/lib/events.py b/zerver/lib/events.py
index 7fecc030d5..227da7e5fc 100644
--- a/zerver/lib/events.py
+++ b/zerver/lib/events.py
@@ -30,6 +30,7 @@ from zerver.lib.narrow import check_supported_events_narrow_filter
from zerver.lib.push_notifications import push_notifications_enabled
from zerver.lib.soft_deactivation import maybe_catch_up_soft_deactivated_user
from zerver.lib.realm_icon import realm_icon_url
+from zerver.lib.realm_logo import realm_logo_url
from zerver.lib.request import JsonableError
from zerver.lib.topic import TOPIC_NAME
from zerver.lib.topic_mutes import get_topic_mutes
@@ -172,6 +173,9 @@ def fetch_initial_state_data(user_profile: UserProfile,
state['realm_icon_url'] = realm_icon_url(realm)
state['realm_icon_source'] = realm.icon_source
state['max_icon_file_size'] = settings.MAX_ICON_FILE_SIZE
+ state['realm_logo_url'] = realm_logo_url(realm)
+ state['realm_logo_source'] = realm.logo_source
+ state['max_logo_file_size'] = settings.MAX_LOGO_FILE_SIZE
state['realm_bot_domain'] = realm.get_bot_domain()
state['realm_uri'] = realm.uri
state['realm_available_video_chat_providers'] = realm.VIDEO_CHAT_PROVIDERS
diff --git a/zerver/lib/realm_logo.py b/zerver/lib/realm_logo.py
new file mode 100644
index 0000000000..9e2dfe089a
--- /dev/null
+++ b/zerver/lib/realm_logo.py
@@ -0,0 +1,13 @@
+from django.conf import settings
+
+from zerver.lib.upload import upload_backend
+from zerver.models import Realm
+
+def realm_logo_url(realm: Realm) -> str:
+ return get_realm_logo_url(realm)
+
+def get_realm_logo_url(realm: Realm) -> str:
+ if realm.logo_source == 'U':
+ return upload_backend.get_realm_logo_url(realm.id, realm.logo_version)
+ else:
+ return settings.DEFAULT_LOGO_URI+'?version=0'
diff --git a/zerver/lib/upload.py b/zerver/lib/upload.py
index 9ff4c27537..f82377d497 100644
--- a/zerver/lib/upload.py
+++ b/zerver/lib/upload.py
@@ -116,6 +116,19 @@ def resize_avatar(image_data: bytes, size: int=DEFAULT_AVATAR_SIZE) -> bytes:
im.save(out, format='png')
return out.getvalue()
+def resize_logo(image_data: bytes) -> bytes:
+ try:
+ im = Image.open(io.BytesIO(image_data))
+ im = exif_rotate(im)
+ im.thumbnail((8*DEFAULT_AVATAR_SIZE, DEFAULT_AVATAR_SIZE), Image.ANTIALIAS)
+ except IOError:
+ raise BadImageError("Could not decode image; did you upload an image file?")
+ out = io.BytesIO()
+ if im.mode == 'CMYK':
+ im = im.convert('RGB')
+ im.save(out, format='png')
+ return out.getvalue()
+
def resize_gif(im: GifImageFile, size: int=DEFAULT_EMOJI_SIZE) -> bytes:
frames = []
@@ -187,6 +200,12 @@ class ZulipUploadBackend:
def get_realm_icon_url(self, realm_id: int, version: int) -> str:
raise NotImplementedError()
+ def upload_realm_logo_image(self, logo_file: File, user_profile: UserProfile) -> None:
+ raise NotImplementedError()
+
+ def get_realm_logo_url(self, realm_id: int, version: int) -> str:
+ raise NotImplementedError()
+
def upload_emoji_image(self, emoji_file: File, emoji_file_name: str, user_profile: UserProfile) -> None:
raise NotImplementedError()
@@ -423,6 +442,36 @@ class S3UploadBackend(ZulipUploadBackend):
# ?x=x allows templates to append additional parameters with &s
return "https://%s.s3.amazonaws.com/%s/realm/icon.png?version=%s" % (bucket, realm_id, version)
+ def upload_realm_logo_image(self, logo_file: File, user_profile: UserProfile) -> None:
+ content_type = guess_type(logo_file.name)[0]
+ bucket_name = settings.S3_AVATAR_BUCKET
+ s3_file_name = os.path.join(str(user_profile.realm.id), 'realm', 'logo')
+
+ image_data = logo_file.read()
+ upload_image_to_s3(
+ bucket_name,
+ s3_file_name + ".original",
+ content_type,
+ user_profile,
+ image_data,
+ )
+
+ resized_data = resize_logo(image_data)
+ upload_image_to_s3(
+ bucket_name,
+ s3_file_name + ".png",
+ '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_logo_url(self, realm_id: int, version: int) -> str:
+ bucket = settings.S3_AVATAR_BUCKET
+ # ?x=x allows templates to append additional parameters with &s
+ return "https://%s.s3.amazonaws.com/%s/realm/logo.png?version=%s" % (bucket, realm_id, version)
+
def ensure_medium_avatar_image(self, user_profile: UserProfile) -> None:
file_path = user_avatar_path(user_profile)
s3_file_name = file_path
@@ -576,6 +625,22 @@ class LocalUploadBackend(ZulipUploadBackend):
# ?x=x allows templates to append additional parameters with &s
return "/user_avatars/%s/realm/icon.png?version=%s" % (realm_id, version)
+ def upload_realm_logo_image(self, logo_file: File, user_profile: UserProfile) -> None:
+ upload_path = os.path.join('avatars', str(user_profile.realm.id), 'realm')
+
+ image_data = logo_file.read()
+ write_local_file(
+ upload_path,
+ 'logo.original',
+ image_data)
+
+ resized_data = resize_logo(image_data)
+ write_local_file(upload_path, 'logo.png', resized_data)
+
+ def get_realm_logo_url(self, realm_id: int, version: int) -> str:
+ # ?x=x allows templates to append additional parameters with &s
+ return "/user_avatars/%s/realm/logo.png?version=%s" % (realm_id, version)
+
def ensure_medium_avatar_image(self, user_profile: UserProfile) -> None:
file_path = user_avatar_path(user_profile)
@@ -633,6 +698,9 @@ def copy_avatar(source_profile: UserProfile, target_profile: UserProfile) -> Non
def upload_icon_image(user_file: File, user_profile: UserProfile) -> None:
upload_backend.upload_realm_icon_image(user_file, user_profile)
+def upload_logo_image(user_file: File, user_profile: UserProfile) -> None:
+ upload_backend.upload_realm_logo_image(user_file, user_profile)
+
def upload_emoji_image(emoji_file: File, emoji_file_name: str, user_profile: UserProfile) -> None:
upload_backend.upload_emoji_image(emoji_file, emoji_file_name, user_profile)
diff --git a/zerver/migrations/0196_add_realm_logo_fields.py b/zerver/migrations/0196_add_realm_logo_fields.py
new file mode 100644
index 0000000000..1f38fa20aa
--- /dev/null
+++ b/zerver/migrations/0196_add_realm_logo_fields.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.14 on 2018-08-16 00:34
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('zerver', '0195_realm_first_visible_message_id'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='realm',
+ name='logo_source',
+ field=models.CharField(choices=[('D', 'Default to Zulip'), ('U', 'Uploaded by administrator')], default='D', max_length=1),
+ ),
+ migrations.AddField(
+ model_name='realm',
+ name='logo_version',
+ field=models.PositiveSmallIntegerField(default=1),
+ ),
+ ]
diff --git a/zerver/models.py b/zerver/models.py
index 3633e34884..0273945025 100644
--- a/zerver/models.py
+++ b/zerver/models.py
@@ -291,6 +291,7 @@ class Realm(models.Model):
waiting_period_threshold=int,
) # type: Dict[str, Union[type, Tuple[type, ...]]]
+ # Icon is the square mobile icon.
ICON_FROM_GRAVATAR = u'G'
ICON_UPLOADED = u'U'
ICON_SOURCES = (
@@ -301,6 +302,17 @@ class Realm(models.Model):
max_length=1) # type: str
icon_version = models.PositiveSmallIntegerField(default=1) # type: int
+ # Logo is the horizonal logo we show in top-left of webapp navbar UI.
+ LOGO_DEFAULT = u'D'
+ LOGO_UPLOADED = u'U'
+ LOGO_SOURCES = (
+ (LOGO_DEFAULT, 'Default to Zulip'),
+ (LOGO_UPLOADED, 'Uploaded by administrator'),
+ )
+ logo_source = models.CharField(default=LOGO_DEFAULT, choices=LOGO_SOURCES,
+ max_length=1) # type: str
+ logo_version = models.PositiveSmallIntegerField(default=1) # type: int
+
BOT_CREATION_POLICY_TYPES = [
BOT_CREATION_EVERYONE,
BOT_CREATION_LIMIT_GENERIC_BOTS,
@@ -2299,6 +2311,7 @@ class RealmAuditLog(models.Model):
REALM_REACTIVATED = 'realm_reactivated'
REALM_SCRUBBED = 'realm_scrubbed'
REALM_PLAN_TYPE_CHANGED = 'realm_plan_type_changed'
+ REALM_LOGO_CHANGED = 'realm_logo_changed'
SUBSCRIPTION_CREATED = 'subscription_created'
SUBSCRIPTION_ACTIVATED = 'subscription_activated'
diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml
index c60c83c92a..e05e1aeaea 100644
--- a/zerver/openapi/zulip.yaml
+++ b/zerver/openapi/zulip.yaml
@@ -1749,8 +1749,12 @@ paths:
description: The realm's name.
realm_icon:
type: string
- description: The URI of the organization's icon (usually
- a logo).
+ description: The URI of the organization's mobile icon (usually
+ a square version of the logo).
+ realm_logo:
+ type: string
+ description: The URI of the organization's top-left navbar logo
+ (usually a wide rectangular version of the logo).
realm_description:
type: string
description: HTML description of the organization, as
@@ -1762,6 +1766,7 @@ paths:
"push_notifications_enabled": false,
"msg": "",
"realm_icon": "https://secure.gravatar.com/avatar/62429d594b6ffc712f54aee976a18b44?d=identicon",
+ "realm_logo": "/static/images/logo/zulip-org-logo.png",
"realm_description": "
The Zulip development environment default organization. It's great for testing!
",
"email_auth_enabled": true,
"zulip_version": "1.9.0-rc1+git",
diff --git a/zerver/tests/test_auth_backends.py b/zerver/tests/test_auth_backends.py
index a3ac27b775..aa576b13d6 100644
--- a/zerver/tests/test_auth_backends.py
+++ b/zerver/tests/test_auth_backends.py
@@ -1556,6 +1556,7 @@ class FetchAuthBackends(ZulipTestCase):
('realm_name', check_string),
('realm_description', check_string),
('realm_icon', check_string),
+ ('realm_logo', check_string),
])
def test_fetch_auth_backend_format(self) -> None:
diff --git a/zerver/tests/test_home.py b/zerver/tests/test_home.py
index df1b3aa3b6..d74e9f2b92 100644
--- a/zerver/tests/test_home.py
+++ b/zerver/tests/test_home.py
@@ -96,6 +96,7 @@ class HomeTest(ZulipTestCase):
"login_page",
"max_avatar_file_size",
"max_icon_file_size",
+ "max_logo_file_size",
"max_message_id",
"maxfilesize",
"message_content_in_email_notifications",
@@ -148,6 +149,8 @@ class HomeTest(ZulipTestCase):
"realm_invite_by_admins_only",
"realm_invite_required",
"realm_is_zephyr_mirror_realm",
+ "realm_logo_source",
+ "realm_logo_url",
"realm_mandatory_topics",
"realm_message_content_delete_limit_seconds",
"realm_message_content_edit_limit_seconds",
diff --git a/zerver/tests/test_upload.py b/zerver/tests/test_upload.py
index 7625bec7c0..36fa9113f0 100644
--- a/zerver/tests/test_upload.py
+++ b/zerver/tests/test_upload.py
@@ -10,6 +10,7 @@ from zerver.lib.avatar import (
from zerver.lib.avatar_hash import user_avatar_path
from zerver.lib.bugdown import url_filename
from zerver.lib.realm_icon import realm_icon_url
+from zerver.lib.realm_logo import realm_logo_url
from zerver.lib.test_classes import ZulipTestCase, UploadSerializeMixin
from zerver.lib.test_helpers import (
avatar_disk_path,
@@ -32,6 +33,7 @@ from zerver.models import Attachment, get_user, \
RealmDomain, RealmEmoji, get_realm, get_system_bot, \
validate_attachment_request
from zerver.lib.actions import (
+ do_change_plan_type,
do_delete_old_unclaimed_attachments,
internal_send_private_message,
)
@@ -807,6 +809,8 @@ class AvatarTest(UploadSerializeMixin, ZulipTestCase):
"/user_avatars/hash-medium.png?x=x")
self.assertEqual(backend.get_realm_icon_url(15, 1),
"/user_avatars/15/realm/icon.png?version=1")
+ self.assertEqual(backend.get_realm_logo_url(15, 1),
+ "/user_avatars/15/realm/logo.png?version=1")
with self.settings(S3_AVATAR_BUCKET="bucket"):
backend = S3UploadBackend()
@@ -816,6 +820,8 @@ class AvatarTest(UploadSerializeMixin, ZulipTestCase):
"https://bucket.s3.amazonaws.com/hash-medium.png?x=x")
self.assertEqual(backend.get_realm_icon_url(15, 1),
"https://bucket.s3.amazonaws.com/15/realm/icon.png?version=1")
+ self.assertEqual(backend.get_realm_logo_url(15, 1),
+ "https://bucket.s3.amazonaws.com/15/realm/logo.png?version=1")
def test_multiple_upload_failure(self) -> None:
"""
@@ -1238,6 +1244,145 @@ class RealmIconTest(UploadSerializeMixin, ZulipTestCase):
def tearDown(self) -> None:
destroy_uploads()
+class RealmLogoTest(UploadSerializeMixin, ZulipTestCase):
+
+ def test_multiple_upload_failure(self) -> None:
+ """
+ Attempting to upload two files should fail.
+ """
+ # Log in as admin
+ self.login(self.example_email("iago"))
+ with get_test_image_file('img.png') as fp1, \
+ get_test_image_file('img.png') as fp2:
+ result = self.client_post("/json/realm/logo", {'f1': fp1, 'f2': fp2})
+ self.assert_json_error(result, "You must upload exactly one logo.")
+
+ def test_no_file_upload_failure(self) -> None:
+ """
+ Calling this endpoint with no files should fail.
+ """
+ self.login(self.example_email("iago"))
+
+ result = self.client_post("/json/realm/logo")
+ self.assert_json_error(result, "You must upload exactly one logo.")
+
+ 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'),
+ ('cmyk.jpg', None)
+ ]
+ corrupt_files = ['text.txt', 'corrupt.png', 'corrupt.gif']
+
+ def test_no_admin_user_upload(self) -> None:
+ self.login(self.example_email("hamlet"))
+ with get_test_image_file(self.correct_files[0][0]) as fp:
+ result = self.client_post("/json/realm/logo", {'file': fp})
+ self.assert_json_error(result, 'Must be an organization administrator')
+
+ def test_upload_limited_plan_type(self) -> None:
+ user_profile = self.example_user("iago")
+ do_change_plan_type(user_profile.realm, Realm.LIMITED)
+ self.login(user_profile.email)
+ with get_test_image_file(self.correct_files[0][0]) as fp:
+ result = self.client_post("/json/realm/logo", {'file': fp})
+ self.assert_json_error(result, 'Feature unavailable on your current plan.')
+
+ def test_get_default_logo(self) -> None:
+ self.login(self.example_email("hamlet"))
+ realm = get_realm('zulip')
+ realm.logo_source = Realm.LOGO_DEFAULT
+ realm.save()
+
+ response = self.client_get("/json/realm/logo?foo=bar")
+ redirect_url = response['Location']
+ self.assertEqual(redirect_url, realm_logo_url(realm) + '&foo=bar')
+
+ def test_get_realm_logo(self) -> None:
+ self.login(self.example_email("hamlet"))
+
+ realm = get_realm('zulip')
+ realm.logo_source = Realm.LOGO_UPLOADED
+ realm.save()
+ response = self.client_get("/json/realm/logo?foo=bar")
+ redirect_url = response['Location']
+ self.assertTrue(redirect_url.endswith(realm_logo_url(realm) + '&foo=bar'))
+
+ def test_valid_logos(self) -> None:
+ """
+ A PUT request to /json/realm/logo with a valid file should return a url
+ and actually create an realm logo.
+ """
+ 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(self.example_email("iago"))
+ with get_test_image_file(fname) as fp:
+ result = self.client_post("/json/realm/logo", {'file': fp})
+ realm = get_realm('zulip')
+ self.assert_json_success(result)
+ self.assertIn("logo_url", result.json())
+ base = '/user_avatars/%s/realm/logo.png' % (realm.id,)
+ url = result.json()['logo_url']
+ self.assertEqual(base, url[:len(base)])
+
+ if rfname is not None:
+ response = self.client_get(url)
+ data = b"".join(response.streaming_content)
+ # size should be 100 x 100 because thumbnail keeps aspect ratio
+ # while trying to fit in a 800 x 100 box without losing part of the image
+ self.assertEqual(Image.open(io.BytesIO(data)).size, (100, 100))
+
+ def test_invalid_logos(self) -> None:
+ """
+ A PUT request to /json/realm/logo with an invalid file should fail.
+ """
+ for fname in self.corrupt_files:
+ # with self.subTest(fname=fname):
+ self.login(self.example_email("iago"))
+ with get_test_image_file(fname) as fp:
+ result = self.client_post("/json/realm/logo", {'file': fp})
+
+ self.assert_json_error(result, "Could not decode image; did you upload an image file?")
+
+ def test_delete_logo(self) -> None:
+ """
+ A DELETE request to /json/realm/logo should delete the realm logo and return gravatar URL
+ """
+ self.login(self.example_email("iago"))
+ realm = get_realm('zulip')
+ realm.logo_source = Realm.LOGO_UPLOADED
+ realm.save()
+
+ result = self.client_delete("/json/realm/logo")
+
+ self.assert_json_success(result)
+ self.assertIn("logo_url", result.json())
+ realm = get_realm('zulip')
+ self.assertEqual(result.json()["logo_url"], realm_logo_url(realm))
+ self.assertEqual(realm.logo_source, Realm.LOGO_DEFAULT)
+
+ def test_realm_logo_version(self) -> None:
+ self.login(self.example_email("iago"))
+ realm = get_realm('zulip')
+ logo_version = realm.logo_version
+ self.assertEqual(logo_version, 1)
+ with get_test_image_file(self.correct_files[0][0]) as fp:
+ self.client_post("/json/realm/logo", {'file': fp})
+ realm = get_realm('zulip')
+ self.assertEqual(realm.logo_version, logo_version + 1)
+
+ def test_realm_logo_upload_file_size_error(self) -> None:
+ self.login(self.example_email("iago"))
+ with get_test_image_file(self.correct_files[0][0]) as fp:
+ with self.settings(MAX_LOGO_FILE_SIZE=0):
+ result = self.client_post("/json/realm/logo", {'file': fp})
+ self.assert_json_error(result, "Uploaded file is larger than the allowed limit of 0 MB")
+
+ def tearDown(self) -> None:
+ destroy_uploads()
+
class LocalStorageTest(UploadSerializeMixin, ZulipTestCase):
def test_file_upload_local(self) -> None:
@@ -1506,6 +1651,26 @@ class S3Test(ZulipTestCase):
resized_path_id = os.path.join(str(user_profile.realm.id), "realm", "icon.png")
resized_data = bucket.get_key(resized_path_id).read()
+ # resized image size should be 100 x 100 because thumbnail keeps aspect ratio
+ # while trying to fit in a 800 x 100 box without losing part of the image
+ resized_image = Image.open(io.BytesIO(resized_data)).size
+ self.assertEqual(resized_image, (DEFAULT_AVATAR_SIZE, DEFAULT_AVATAR_SIZE))
+
+ @use_s3_backend
+ def test_upload_realm_logo_image(self) -> None:
+ bucket = create_s3_buckets(settings.S3_AVATAR_BUCKET)[0]
+
+ user_profile = self.example_user("hamlet")
+ image_file = get_test_image_file("img.png")
+ zerver.lib.upload.upload_backend.upload_realm_logo_image(image_file, user_profile)
+
+ original_path_id = os.path.join(str(user_profile.realm.id), "realm", "logo.original")
+ original_key = bucket.get_key(original_path_id)
+ image_file.seek(0)
+ self.assertEqual(image_file.read(), original_key.get_contents_as_string())
+
+ resized_path_id = os.path.join(str(user_profile.realm.id), "realm", "logo.png")
+ resized_data = bucket.get_key(resized_path_id).read()
resized_image = Image.open(io.BytesIO(resized_data)).size
self.assertEqual(resized_image, (DEFAULT_AVATAR_SIZE, DEFAULT_AVATAR_SIZE))
diff --git a/zerver/views/auth.py b/zerver/views/auth.py
index 66caf098ca..1cc36ffa01 100644
--- a/zerver/views/auth.py
+++ b/zerver/views/auth.py
@@ -871,6 +871,7 @@ def api_get_server_settings(request: HttpRequest) -> HttpResponse:
"realm_uri",
"realm_name",
"realm_icon",
+ "realm_logo",
"realm_description"]:
if context[settings_item] is not None:
result[settings_item] = context[settings_item]
diff --git a/zerver/views/realm_logo.py b/zerver/views/realm_logo.py
new file mode 100644
index 0000000000..abe1960a48
--- /dev/null
+++ b/zerver/views/realm_logo.py
@@ -0,0 +1,58 @@
+from django.conf import settings
+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_logo_source
+from zerver.lib.realm_logo import realm_logo_url
+from zerver.lib.response import json_error, json_success
+from zerver.lib.upload import upload_logo_image
+from zerver.models import Realm, UserProfile
+
+
+@require_realm_admin
+def upload_logo(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
+ if user_profile.realm.plan_type == Realm.LIMITED:
+ return json_error(_("Feature unavailable on your current plan."))
+
+ if len(request.FILES) != 1:
+ return json_error(_("You must upload exactly one logo."))
+
+ logo_file = list(request.FILES.values())[0]
+ if ((settings.MAX_LOGO_FILE_SIZE * 1024 * 1024) < logo_file.size):
+ return json_error(_("Uploaded file is larger than the allowed limit of %s MB") % (
+ settings.MAX_LOGO_FILE_SIZE))
+ upload_logo_image(logo_file, user_profile)
+ do_change_logo_source(user_profile.realm, user_profile.realm.LOGO_UPLOADED)
+ logo_url = realm_logo_url(user_profile.realm)
+
+ json_result = dict(
+ logo_url=logo_url
+ )
+ return json_success(json_result)
+
+
+@require_realm_admin
+def delete_logo_backend(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
+ # We don't actually delete the logo because it might still
+ # be needed if the URL was cached and it is rewrited
+ # in any case after next update.
+ do_change_logo_source(user_profile.realm, user_profile.realm.LOGO_DEFAULT)
+ default_url = realm_logo_url(user_profile.realm)
+ json_result = dict(
+ logo_url=default_url
+ )
+ return json_success(json_result)
+
+
+def get_logo_backend(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
+ url = realm_logo_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_logo_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 f06148f440..45e9a7abf1 100644
--- a/zproject/settings.py
+++ b/zproject/settings.py
@@ -178,6 +178,7 @@ DEFAULT_SETTINGS = {
# File uploads and avatars
'DEFAULT_AVATAR_URI': '/static/images/default-avatar.png',
+ 'DEFAULT_LOGO_URI': '/static/images/logo/zulip-org-logo.png',
'S3_AVATAR_BUCKET': '',
'S3_AUTH_UPLOADS_BUCKET': '',
'S3_REGION': '',
@@ -349,6 +350,7 @@ DEFAULT_SETTINGS.update({
'DATA_UPLOAD_MAX_MEMORY_SIZE': 25 * 1024 * 1024,
'MAX_AVATAR_FILE_SIZE': 5,
'MAX_ICON_FILE_SIZE': 5,
+ 'MAX_LOGO_FILE_SIZE': 5,
'MAX_EMOJI_FILE_SIZE': 5,
# Limits to help prevent spam, in particular by sending invitations.
diff --git a/zproject/urls.py b/zproject/urls.py
index 556ec84e5a..26c2489738 100644
--- a/zproject/urls.py
+++ b/zproject/urls.py
@@ -97,6 +97,12 @@ v1_api_and_json_patterns = [
'DELETE': 'zerver.views.realm_icon.delete_icon_backend',
'GET': 'zerver.views.realm_icon.get_icon_backend'}),
+ # realm/logo -> zerver.views.realm_logo_
+ url(r'^realm/logo$', rest_dispatch,
+ {'POST': 'zerver.views.realm_logo.upload_logo',
+ 'DELETE': 'zerver.views.realm_logo.delete_logo_backend',
+ 'GET': 'zerver.views.realm_logo.get_logo_backend'}),
+
# realm/filters -> zerver.views.realm_filters
url(r'^realm/filters$', rest_dispatch,
{'GET': 'zerver.views.realm_filters.list_filters',