mirror of https://github.com/zulip/zulip.git
settings: Add support for customizing the top-left logo.
This adds a new realm_logo field, which is a horizontal-format logo to be displayed in the top-left corner of the webapp, and any other places where we might want a wide-format branding of the organization. Tweaked significantly by tabbott to rebase, fix styling, etc. Fixing the styling of this feature's loading indicator caused me to notice the loading indicator for the realm_icon feature was also ugly, so I fixed that too. Fixes #7995.
This commit is contained in:
parent
34f5218a0d
commit
ad1df0ebeb
|
@ -117,6 +117,7 @@
|
|||
"pygments_data": false,
|
||||
"reactions": false,
|
||||
"realm_icon": false,
|
||||
"realm_logo": false,
|
||||
"recent_senders": false,
|
||||
"reload": false,
|
||||
"reload_state": false,
|
||||
|
|
|
@ -55,6 +55,10 @@ const _ui_report = {
|
|||
},
|
||||
};
|
||||
|
||||
const _realm_logo = {
|
||||
build_realm_logo_widget: noop,
|
||||
};
|
||||
|
||||
set_global('channel', _channel);
|
||||
set_global('csrf_token', 'token-stub');
|
||||
set_global('FormData', _FormData);
|
||||
|
@ -63,6 +67,7 @@ set_global('loading', _loading);
|
|||
set_global('overlays', _overlays);
|
||||
set_global('page_params', _page_params);
|
||||
set_global('realm_icon', _realm_icon);
|
||||
set_global('realm_logo', _realm_logo);
|
||||
set_global('templates', _templates);
|
||||
set_global('ui_report', _ui_report);
|
||||
|
||||
|
|
|
@ -49,6 +49,8 @@ exports.build_page = function () {
|
|||
is_guest: page_params.is_guest,
|
||||
realm_icon_source: page_params.realm_icon_source,
|
||||
realm_icon_url: page_params.realm_icon_url,
|
||||
realm_logo_source: page_params.realm_logo_source,
|
||||
realm_logo_url: page_params.realm_logo_url,
|
||||
realm_mandatory_topics: page_params.realm_mandatory_topics,
|
||||
realm_send_welcome_emails: page_params.realm_send_welcome_emails,
|
||||
realm_default_twenty_four_hour_time: page_params.realm_default_twenty_four_hour_time,
|
||||
|
|
|
@ -166,6 +166,7 @@ import "js/templates.js";
|
|||
import "js/upload_widget.js";
|
||||
import "js/avatar.js";
|
||||
import "js/realm_icon.js";
|
||||
import "js/realm_logo.js";
|
||||
import 'js/reminder.js';
|
||||
import 'js/confirm_dialog.js';
|
||||
import "js/settings_account.js";
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
/* eslint indent: "off" */
|
||||
var realm_logo = (function () {
|
||||
|
||||
var exports = {};
|
||||
|
||||
exports.build_realm_logo_widget = function (upload_function) {
|
||||
var get_file_input = function () {
|
||||
return $('#realm_logo_file_input').expectOne();
|
||||
};
|
||||
|
||||
if (page_params.realm_logo_source === 'D') {
|
||||
$("#realm_logo_delete_button").hide();
|
||||
} else {
|
||||
$("#realm_logo_delete_button").show();
|
||||
}
|
||||
$("#realm_logo_delete_button").on('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
channel.del({
|
||||
url: '/json/realm/logo',
|
||||
});
|
||||
});
|
||||
|
||||
return upload_widget.build_direct_upload_widget(
|
||||
get_file_input,
|
||||
$("#realm_logo_file_input_error").expectOne(),
|
||||
$("#realm_logo_upload_button").expectOne(),
|
||||
upload_function,
|
||||
page_params.max_logo_file_size
|
||||
);
|
||||
};
|
||||
|
||||
exports.rerender = function () {
|
||||
$("#realm-settings-logo").attr("src", page_params.realm_logo_url);
|
||||
$("#realm-logo").attr("src", page_params.realm_logo_url);
|
||||
if (page_params.realm_logo_source === 'U') {
|
||||
$("#realm_logo_delete_button").show();
|
||||
} else {
|
||||
$("#realm_logo_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_logo_file_input");
|
||||
file_input.val('');
|
||||
}
|
||||
};
|
||||
|
||||
return exports;
|
||||
}());
|
||||
|
||||
if (typeof module !== 'undefined') {
|
||||
module.exports = realm_logo;
|
||||
}
|
||||
window.realm_logo = realm_logo;
|
|
@ -160,6 +160,10 @@ exports.dispatch_normal_event = function dispatch_normal_event(event) {
|
|||
if (electron_bridge !== undefined) {
|
||||
electron_bridge.send_event('realm_icon_url', event.data.icon_url);
|
||||
}
|
||||
} else if (event.op === 'update_dict' && event.property === 'logo') {
|
||||
page_params.realm_logo_url = event.data.logo_url;
|
||||
page_params.realm_logo_source = event.data.logo_source;
|
||||
realm_logo.rerender();
|
||||
} else if (event.op === 'deactivated') {
|
||||
window.location.href = "/accounts/deactivated/";
|
||||
}
|
||||
|
|
|
@ -1054,8 +1054,11 @@ exports.build_page = function () {
|
|||
form_data.append('file-' + i, file);
|
||||
});
|
||||
|
||||
var error_field = $("#realm_icon_file_input_error");
|
||||
error_field.hide();
|
||||
var spinner = $("#upload_icon_spinner").expectOne();
|
||||
loading.make_indicator(spinner, {text: i18n.t("Uploading icon.")});
|
||||
$("#upload_icon_button_text").expectOne().hide();
|
||||
|
||||
channel.post({
|
||||
url: '/json/realm/icon',
|
||||
|
@ -1065,12 +1068,52 @@ exports.build_page = function () {
|
|||
contentType: false,
|
||||
success: function () {
|
||||
loading.destroy_indicator($("#upload_icon_spinner"));
|
||||
$("#upload_icon_button_text").expectOne().show();
|
||||
},
|
||||
error: function (xhr) {
|
||||
loading.destroy_indicator($("#upload_logo_spinner"));
|
||||
$("#upload_logo_button_text").expectOne().show();
|
||||
ui_report.error("", xhr, error_field);
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
realm_icon.build_realm_icon_widget(upload_realm_icon);
|
||||
|
||||
function upload_realm_logo(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 error_field = $("#realm_logo_file_input_error");
|
||||
error_field.hide();
|
||||
var spinner = $("#upload_logo_spinner").expectOne();
|
||||
loading.make_indicator(spinner, {text: i18n.t("Uploading logo.")});
|
||||
$("#upload_logo_button_text").expectOne().hide();
|
||||
|
||||
channel.post({
|
||||
url: '/json/realm/logo',
|
||||
data: form_data,
|
||||
cache: false,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
success: function () {
|
||||
loading.destroy_indicator($("#upload_logo_spinner"));
|
||||
$("#upload_logo_button_text").expectOne().show();
|
||||
},
|
||||
error: function (xhr) {
|
||||
loading.destroy_indicator($("#upload_logo_spinner"));
|
||||
$("#upload_logo_button_text").expectOne().show();
|
||||
ui_report.error("", xhr, error_field);
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
realm_logo.build_realm_logo_widget(upload_realm_logo);
|
||||
|
||||
$('#deactivate_realm_button').on('click', function (e) {
|
||||
if (!overlays.is_modal_open()) {
|
||||
e.preventDefault();
|
||||
|
|
|
@ -81,10 +81,15 @@ label {
|
|||
}
|
||||
|
||||
.user-avatar-section,
|
||||
.realm-logo-section,
|
||||
.realm-icon-section {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.realm-logo-block {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.user-avatar-section {
|
||||
float: right;
|
||||
}
|
||||
|
@ -101,8 +106,9 @@ label {
|
|||
}
|
||||
|
||||
.user-avatar-section .inline-block,
|
||||
.realm-logo-section .inline-block,
|
||||
.realm-icon-section .inline-block {
|
||||
margin: 10px 20px 0px 0px;
|
||||
margin: 0px 20px 0px 0px;
|
||||
vertical-align: top;
|
||||
|
||||
border-radius: 4px;
|
||||
|
@ -458,6 +464,11 @@ input[type=checkbox] + .inline-block {
|
|||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.realm-logo-section {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.realm-icon-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
@ -937,6 +948,7 @@ input[type=checkbox].inline-block {
|
|||
}
|
||||
|
||||
#upload_avatar_spinner,
|
||||
#upload_logo_spinner,
|
||||
#upload_icon_spinner {
|
||||
font-size: 14px;
|
||||
margin: auto;
|
||||
|
@ -1071,6 +1083,15 @@ input[type=checkbox].inline-block {
|
|||
height: 100px;
|
||||
}
|
||||
|
||||
#realm-settings-logo {
|
||||
border-radius: 5px;
|
||||
box-shadow: 0px 0px 10px hsla(0, 0%, 0%, 0.2);
|
||||
/* We allow actual images up to 800x100 in the main display, but the
|
||||
settings UI looks bad beyond ~730px, so we limit the width here */
|
||||
height: 100px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.invite-user-link i {
|
||||
text-decoration: none;
|
||||
margin-right: 5px;
|
||||
|
@ -1636,6 +1657,13 @@ input[type=text]#settings_search {
|
|||
margin: 0 0 0 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
#realm-settings-logo {
|
||||
max-width: 600px;
|
||||
height: 75px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 953px) {
|
||||
.user-avatar-section,
|
||||
.realm-icon-section {
|
||||
|
@ -1655,6 +1683,11 @@ input[type=text]#settings_search {
|
|||
.subsection-failed-status p {
|
||||
margin: 5px 0 0 0;
|
||||
}
|
||||
|
||||
#realm-settings-logo {
|
||||
max-width: 400px;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 786px) {
|
||||
|
|
|
@ -2157,6 +2157,27 @@ div.floating_recipient {
|
|||
display: inline-block;
|
||||
}
|
||||
|
||||
.settings-section .realm-icon-section .loading_indicator_text,
|
||||
.settings-section .realm-logo-section .loading_indicator_text {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
vertical-align: middle;
|
||||
line-height: 20px;
|
||||
display: inline-block;
|
||||
float: none;
|
||||
margin-top: 0px;
|
||||
margin-left: -12px;
|
||||
}
|
||||
|
||||
.settings-section .realm-logo-section .loading_indicator_spinner,
|
||||
.settings-section .realm-icon-section .loading_indicator_spinner {
|
||||
width: 10%;
|
||||
height: 16px;
|
||||
margin-top: 2px;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.settings-section #default_language {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
|
|
@ -23,23 +23,47 @@
|
|||
</div>
|
||||
|
||||
<h3>{{t "Organization avatar" }}</h3>
|
||||
<p>{{t "A square logo used to brand your Zulip organization." }}</p>
|
||||
|
||||
<div class="realm-icon-section">
|
||||
<div class="inline-block">
|
||||
<img id="realm-settings-icon" src="{{ realm_icon_url }}"/>
|
||||
<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 avatar-controls">
|
||||
<div id="realm_icon_file_input_error" class="alert text-error"></div>
|
||||
<button class="button rounded sea-green w-200 block input-size"
|
||||
id="realm_icon_upload_button">{{t 'Upload new icon' }}</button>
|
||||
<div id="realm_icon_file_input_error" class="text-error"></div>
|
||||
id="realm_icon_upload_button">
|
||||
<span id="upload_icon_button_text">{{t 'Upload new icon' }}</span>
|
||||
<span id="upload_icon_spinner"></span>
|
||||
</button>
|
||||
<button class="button rounded btn-danger w-200 m-t-10 block input-size"
|
||||
id="realm_icon_delete_button">{{t 'Delete icon' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>{{t "Organization logo" }}</h3>
|
||||
<p>{{t "A wide image, replacing the Zulip logo in the upper left corner of the Zulip apps." }}</p>
|
||||
|
||||
<div class="realm-logo-section">
|
||||
<div class="block realm-logo-block">
|
||||
<img id="realm-settings-logo" src="{{ realm_logo_url }}"/>
|
||||
<input type="file" name="realm_logo_file_input" class="notvisible"
|
||||
id="realm_logo_file_input" value="{{t 'Upload logo' }}"/>
|
||||
</div>
|
||||
<div class="block avatar-controls">
|
||||
<div id="realm_logo_file_input_error" class="alert text-error"></div>
|
||||
<button class="button rounded sea-green w-200 block input-size"
|
||||
id="realm_logo_upload_button">
|
||||
<span id="upload_logo_button_text">{{t 'Upload new logo' }}</span>
|
||||
<span id="upload_logo_spinner"></span>
|
||||
</button>
|
||||
<button class="button rounded btn-danger w-200 m-t-10 block input-size"
|
||||
id="realm_logo_delete_button">{{t 'Delete logo' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="light">{{t "Deactivate organization" }}</h3>
|
||||
<div class="deactivate-realm-section">
|
||||
<div class="input-group">
|
||||
|
|
|
@ -50,7 +50,12 @@ curl {{ api_url }}/v1/server_settings \
|
|||
enabled with a username and password combination.
|
||||
* `realm_uri`: the organization's canonical URI.
|
||||
* `realm_name`: the organization's name (for display purposes).
|
||||
* `realm_icon`: the URI of the organization's icon (usually a logo).
|
||||
* `realm_icon`: the URI of the organization's logo as a square image,
|
||||
used for identifying the organization in small locations in the
|
||||
mobile and desktop apps.
|
||||
* `realm_logo`: the URI of the organization's logo as a horizontal
|
||||
format image (displayed in the top-left corner of the logged-in
|
||||
webapp).
|
||||
* `realm_description`: HTML description of the organization, as configured by
|
||||
the [organization profile](/help/create-your-organization-profile).
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@
|
|||
<nav class="header-main rightside-userlist" id="top_navbar">
|
||||
<div class="column-left">
|
||||
<a class="brand no-style" href="#">
|
||||
<img src="/static/images/logo/zulip-org-logo.png" alt="" class="nav-logo no-drag">
|
||||
<img id="realm-logo" src="{{ realm_logo }}" alt="" class="nav-logo no-drag">
|
||||
</a>
|
||||
</div>
|
||||
<div class="column-middle" id="navbar-middle">
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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'
|
||||
|
|
|
@ -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": "<p>The Zulip development environment default organization. It's great for testing!</p>",
|
||||
"email_auth_enabled": true,
|
||||
"zulip_version": "1.9.0-rc1+git",
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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)
|
|
@ -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.
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Reference in New Issue