Compare commits

...

42 Commits
3.x ... 1.8.1

Author SHA1 Message Date
Tim Abbott
8a1e20f734 Release Zulip Server 1.8.1. 2018-05-07 15:25:25 -07:00
Tim Abbott
93d4c807a9 Add changelog for 1.8.1 release. 2018-05-07 15:24:45 -07:00
Tim Abbott
4741e683ce generate_secrets: Fix handling of an empty secrets file.
This is now a condition that happens during installation, because we
now create an empty file for this in puppet.
2018-05-07 09:38:05 -07:00
Tim Abbott
d5252ff0c9 puppet: Ensure zulip user owns key /etc/zulip files.
The main purpose of this change is to make it guaranteed that
`manage.py register_server --rotate-key` can edit the
/etc/zulip/zulip-secrets.conf configuration via crudini.

But it also adds value by ensuring zulip-secrets.conf is not readable
by other users.
2018-05-07 09:38:05 -07:00
Tim Abbott
3d003a8f34 zilencer: Add automated signup system for push notifications.
With backported bug fixes to the tool included.

Based on an initial version by Rishi Gupta.

Fixes #7325.
2018-05-07 09:38:05 -07:00
Tim Abbott
1ec0414786 management: Refactor checkconfig code to live in library.
This makes it possible to call this from other management commands.
2018-05-07 09:38:05 -07:00
Rohitt Vashishtha
fd06380701 push_notifications: Format blockquotes properly in text_output.
New output is of the format:

Hamlet said:
> Polonius said:
> > This is the.
> > Second layer of nesting.
> First layer of nesting.

Fixes #9251.
2018-05-07 09:38:05 -07:00
Rohitt Vashishtha
d345564ce2 sidebars: Disable autocomplete for user and stream search inputs.
Fixes #9269.
2018-05-07 09:38:05 -07:00
Tim Abbott
dbf19ae3e3 compose: Don't auto-scroll very tall messages on click.
This fixes an issue where with very tall messages (more than about a
screen in height), one would end up scrolled to the bottom of the
message if you clicked on it, which usually felt annoying.

Fixes #8941.
2018-05-07 09:38:05 -07:00
Steve Howell
63437e89d7 Fix regression with topic edits clearing narrows.
We had a recent regression that had kind of a funny symptom.
If somebody else edited a topic while you were in a topic
narrow, even if wasn't your topic, then your narrow would
mysteriously go empty upon the event coming to you.

The root cause of this is that when topic names change,
we often want to rerender a lot of the world due to muting.
But we want to suppress the re-render for topic narrows that
don't support the internal data structures.

This commit restores a simple guard condition that got lost
in a recent refactoring:

        see 3f736c9b06

From tabbott: This is not the correct ultimate place to end up,
because if a topic-edit moves messages in or out of a topic, the new
behavior is wrong.  But the bug this fixes is a lot worse than that,
and no super local change would do the right thing here.
2018-05-07 09:38:05 -07:00
Tim Abbott
0a065636c9 message_list: Fix hiding messages edited to a muted topic.
Previously, we did a rerender without first re-computing which
messages were muted; this was incorrect, because whether a message is
muted can change if the topic changes.

Fixes #9241.
2018-05-07 09:38:05 -07:00
Tim Abbott
d61a8c96c5 message_list: Clean up API for rerender_after_muting_changes.
This was only called from two places in one function, and we can just
check muting_enabled in the caller.

This refactor is important, because we might need to update muting
after other changes (specifically, message editing to move a topic to
be muted/non-muted).
2018-05-07 09:38:04 -07:00
Tim Abbott
e346044d6a email_mirror: Fix handling of empty topic.
Also fixs some corner cases around pure-whitespace topics, and
migrates from the years-obsolete "no subject".

Fixes #9207.
2018-05-07 09:38:04 -07:00
Shubham Dhama
b1ff1633b1 settings: Make bot-settings tabs look better in dark mode.
Fixes: #9230.
2018-05-07 09:38:04 -07:00
Shubham Dhama
1b253cb9e0 org settings: Fix word-wrapping CSS in "users" section.
Fixes: #9225.
2018-05-07 09:38:04 -07:00
Shubham Dhama
f612274f91 settings: Fix escaping of HTML in checkbox labels.
Some labels like one for `translate_emoticons` which contains HTML
get escaped because of use of `{{ label }}` syntax, which escapes
the string for XSS security purpose but since labels aren't any
threat to any such security cases, we can use triple curly brackets
`{{{ label }}}` syntax.

Fixes: #9231.
2018-05-07 09:38:04 -07:00
Tim Abbott
a9e6ad5c6a ldap: Disable django-auth-ldap caching of users.
This shouldn't have a material performance impact, since we don't
query these except during login, and meanwhile this fixes an issue
where users needed to restart memcached (which usually manifested as
"needing to reboot the whole server" after updating their LDAP
configuration before a user who was migrated from one OU to another
could login).

Fixes #9057.
2018-05-07 09:38:04 -07:00
Tim Abbott
eae16d42d4 unread: Fix messages that cannot be marked as read in narrows.
If you visit a narrow that has unread messages on it that aren't part
of the home view (e.g. in a muted stream), then we were never calling
`message_util.do_unread_count_updates`, and more importantly,
`unread.process_loaded_messages` on those messages.  As a result, they
would be unread, and moving the cursor over them would never mark
those messages as read (which was visible through the little green
marker never disappearing).

I can't tell whether this fixes #8042 and/or #8236; neither of them
exactly fits the description of this issue unless the PM threads in
question were muted or something, but this does feel related.
2018-05-07 09:38:04 -07:00
Tim Abbott
ca221da997 left sidebar: Fix clipping of private message users with "g" in name.
This fixes an issue where users whose names had a "g" in them would
have the "g" clipped in the "private messages" section in the left sidebar.

We avoid a change in the effective visible line-height by shrinking
the margin.
2018-05-07 09:38:04 -07:00
Cynthia Lin
c16b252699 analytics: Eliminate slider-focused text selection in Firefox.
Fixes #9151.
2018-05-07 09:38:04 -07:00
Tim Abbott
e3f8108ca6 gitlab: Document the local network security setting.
This should help users debug issues with the GitLab webhook not
working with recent GitLab releases.
2018-05-07 09:38:04 -07:00
Cynthia Lin
5530fe8cb1 night-mode: Change coloring for compose close button. 2018-05-07 09:38:04 -07:00
Cynthia Lin
fca8479065 compose: Refactor compose box from <table> to <div> structure.
`<td>` elements are fixed-width, so we refactor the entire
`<table>` structure for responsive design.

This fixes a bug with how the `To:` block looks in other languages.

Fixes #9152.
2018-05-07 09:38:04 -07:00
Shubham Dhama
fe34001dd1 settings: Make saving spinner visible in night mode.
Fixes: #9154.
2018-05-07 09:38:03 -07:00
Tim Abbott
9a6b4aeda2 puppet: Allow manual configuration of postfix_mailname.
This allows users to configure a mailname for postfix in
/etc/zulip/zulip.conf
2018-05-07 09:38:03 -07:00
Greg Price
76957a62a5 install: Expand error message for missing SSL cert slightly.
It wasn't obvious reading this message that you can perfectly well
bring your own SSL/TLS certificate; unless you read quite a bit
between the lines where we say "could not find", or followed the link
to the detailed docs, the message sounded like you had to either use
--certbot or --self-signed-cert.

So, explicitly mention the BYO option.  Because the "complete chain"
requirement is a bit tricky, don't try to give instructions for it
in this message; just refer the reader to the docs.

Also, drop the logic to identify which of the files is missing; it
certainly makes the code more complex, and I think even the error
message is actually clearer when it just gives the complete list of
required files -- it's much more likely that the reader doesn't know
what's required than that they do and have missed one, and even then
it's easy for them to look for themselves.
2018-05-07 09:38:03 -07:00
Cynthia Lin
76f6d9aaa2 night-mode: Add borders to input pill containers. 2018-05-07 09:38:03 -07:00
Cynthia Lin
5d9eadb734 compose: Fix styling of PM recipient input pill.
Fixes #9128.
2018-05-07 09:38:03 -07:00
Tim Abbott
cb8941a081 left sidebar: Fix line-height causing clipping.
Fixes #8209.
2018-05-07 09:38:03 -07:00
Tim Abbott
062df3697a slack import: Fix issues with Slack empty files.
Fixes #9217.
2018-05-07 09:38:03 -07:00
Preston Hansen
ad113134c7 slack import: Update build_zerver_realm to use Realm defaults.
Fixes #9131.
2018-05-07 09:38:03 -07:00
Tim Abbott
c4b2e986c3 test-backend: Update coverage excludes for new import_realm.py. 2018-05-07 09:38:03 -07:00
Tim Abbott
1b49c5658c import: Split out import.py into its own module.
This should make it a bit easier to find the code.
2018-05-07 09:38:03 -07:00
Preston Hansen
cbdb3d6bbf slack import: Be less strict in check_subdomain_available.
If the sysadmin is doing something explicit in a management command,
it's OK to take a reserved or short subdomain.

Fixes #9166.
2018-05-07 09:38:03 -07:00
Tim Abbott
97ccdacb18 import: Fix ordering of subdomain availability check.
When you're importing with --destroy-rebuild-database, we need to
check subdomain availability after we've cleared out the database;
otherwise, trying to reuse the same subdomain doesn't work.
2018-05-07 09:38:03 -07:00
Tim Abbott
e96af7906d slack import: Document how to send password resets to all users.
This is likely to be an important follow-up step after one finishes
the Slack import.
2018-05-07 09:38:03 -07:00
Tim Abbott
0d1e401922 slack import: Fix documentation on path to run manage.py. 2018-05-07 09:38:02 -07:00
Tim Abbott
8b599c1ed7 slack import: Don't try to import pinned/unpinned items.
There isn't a corresponding Zulip concept, and they don't have a
"text" attribute, so there's no message content to import.
2018-05-07 09:38:02 -07:00
Tim Abbott
a852532c95 slack import: Refactor handling of dropped messages.
This is a more coherent ordering, because some messages we skip lack a
"text" attribute.
2018-05-07 09:38:02 -07:00
Tim Abbott
8e57a3958d slack import: Improve error handling for invalid messages. 2018-05-07 09:38:02 -07:00
Tim Abbott
86046ae9c3 slack import: Remove unnecessary zerver_realm_skeleton.json.
This was stored as a fixture file under zerver/fixtures, which caused
problems, since we don't show that directory under production (as its
part of the test system).

The simplest emergency fix here would be to just move the file, but
when looking at it, it's clear that we don't need or want a fixture
file here; we want a Python object, so we just do that.

A valuable follow-up improvement to this block would be to create an
actual new Realm object (not saved to the database), and dump it the
same code we use in the export tool; that should handle the vast
majority of these correctly.

Fixes #9123.
2018-05-07 09:38:02 -07:00
Tim Abbott
c0096932a6 stream_data: Fix exception when notifications_stream is private.
If notifications_stream is private and the current user has never been
subscribed, then we would throw an exception when trying to look up
notifications_stream.  In this situation, we should just treat it like
the stream doesn't exist for the purposes of this user.
2018-05-07 09:38:02 -07:00
47 changed files with 1243 additions and 925 deletions

View File

@@ -54,7 +54,7 @@ author = 'The Zulip Team'
# The short X.Y version.
version = '1.8'
# The full version, including alpha/beta/rc tags.
release = '1.8.0'
release = '1.8.1'
# This allows us to insert a warning that appears only on an unreleased
# version, e.g. to say that something is likely to have changed.

View File

@@ -7,6 +7,21 @@ All notable changes to the Zulip server are documented in this file.
This section lists notable unreleased changes; it is generally updated
in bursts.
### 1.8.1 -- 2018-05-07
- Added an automated tool (`manage.py register_server`) to sign up for
the [mobile push notifications service](../production/mobile-push-notifications.html).
- Improved rendering of block quotes in mobile push notifications.
- Improved some installer error messages.
- Fixed several minor bugs with the new Slack import feature.
- Fixed several visual bugs with the new compose input pills.
- Fixed several minor visual bugs with night mode.
- Fixed bug with visual clipping of "g" in the left sidebar.
- Fixed an issue with the LDAP backend users' Organization Unit (OU)
being cached, resulting in trouble logging in after a user was moved
between OUs.
- Fixed a couple subtle bugs with muting.
### 1.8.0 -- 2018-04-17
**Highlights:**

View File

@@ -18,28 +18,28 @@ support forwarding push notifications to a central push notification
forwarding service. You can enable this for your Zulip server as
follows:
1. First, contact support@zulipchat.com with the `zulip_org_id` and
`zulip_org_key` values from your `/etc/zulip/zulip-secrets.conf` file, as
well as a hostname and contact email address you'd like us to use in case
of any issues (we hope to have a nice web flow available for this soon).
2. We'll enable push notifications for your server on our end. Look for a
reply from Zulipchat support within 24 hours.
3. Uncomment the `PUSH_NOTIFICATION_BOUNCER_URL = "https://push.zulipchat.com"`
line in your `/etc/zulip/settings.py` file, and
1. Uncomment the `PUSH_NOTIFICATION_BOUNCER_URL =
'https://push.zulipchat.com'` line in your `/etc/zulip/settings.py`
file (i.e. remove the `#` at the start of the line), and
[restart your Zulip server](../production/maintain-secure-upgrade.html#updating-settings).
Note that if you installed Zulip older than 1.6, you'll need to add
the line (it won't be there to uncomment).
If you installed your Zulip server with a version older than 1.6,
you'll need to add the line (it won't be there to uncomment).
4. If you or your users have already set up the Zulip mobile app,
1. If you're running Zulip 1.8.1 or newer, you can run `manage.py
register_server` from `/home/zulip/deployments/current`. This
command will print the registration data it would send to the
mobile push notifications service, ask you to accept the terms of
service, and if you accept, register your server. Otherwise, see
the [legacy signup instructions](#legacy-signup).
1. If you or your users have already set up the Zulip mobile app,
you'll each need to log out and log back in again in order to start
getting push notifications.
That should be all you need to do!
Congratulations! You've successful setup the service.
If you'd like to verify the full pipeline, you can do the following.
Please follow the instructions carefully:
If you'd like to verify that everything is working, you can do the
following. Please follow the instructions carefully:
* [Configure mobile push notifications to always be sent][notification-settings]
(normally they're only sent if you're idle, which isn't ideal for
@@ -57,9 +57,19 @@ in the Android notification area.
[notification-settings]: https://zulipchat.com/help/configure-mobile-notifications
Note that use of the push notification bouncer is subject to the
[Zulipchat Terms of Service](https://zulipchat.com/terms/). By using push
notifications, you agree to those terms.
## Updating your server's registration
Your server's registration includes the server's hostname and contact
email address (from `EXTERNAL_HOST` and `ZULIP_ADMINISTRATOR` in
`/etc/zulip/settings.py`, aka the `--hostname` and `--email` options
in the installer). You can update your server's registration data by
running `manage.py register_server` again.
If you'd like to rotate your server's API key for this service
(`zulip_org_key`), you need to use `manage.py register_server
--rotate-key` option; it will automatically generate a new
`zulip_org_key` and store that new key in
`/etc/zulip/zulip-secrets.conf`.
## Why this is necessary
@@ -77,11 +87,22 @@ notification forwarding service, which allows registered Zulip servers
to send push notifications to the Zulip app indirectly (through the
forwarding service).
## Security and privacy implications
## Security and privacy
Use of the push notification bouncer is subject to the
[Zulipchat Terms of Service](https://zulipchat.com/terms/). By using
push notifications, you agree to those terms.
We've designed this push notification bouncer service with security
and privacy in mind:
* A central design goal of the the Push Notification Service is to
avoid any message content being stored or logged by the service,
even in error cases. We store only the necessary metadata for
delivering the notifications. This includes the tokens needed to
push notifications to the devices, and user ID numbers generated by
your Zulip server. These user ID numbers are are opaque to the Push
Notification Service, since it has no other data about those users.
* All of the network requests (both from Zulip servers to the Push
Notification Service and from the Push Notification Service to the
relevant Google and Apple services) are encrypted over the wire with
@@ -89,17 +110,69 @@ and privacy in mind:
* The code for the push notification forwarding service is 100% open
source and available as part of the
[Zulip server project on GitHub](https://github.com/zulip/zulip).
The Push Notification Service is designed to avoid any message
content being stored or logged, even in error cases.
* The push notification forwarding servers are professionally managed
by a small team of security experts.
* There's a `PUSH_NOTIFICATION_REDACT_CONTENT` setting available to
disable any message content being sent via the push notification
bouncer (i.e. message content will be replaced with
`***REDACTED***`). Note that this setting makes push notifications
significantly less usable. We plan to
by a small team of security expert engineers.
* If you'd like an extra layer of protection, there's a
`PUSH_NOTIFICATION_REDACT_CONTENT` setting available to disable any
message content being sent via the push notification bouncer
(i.e. message content will be replaced with `***REDACTED***`). Note
that this setting makes push notifications significantly less
usable. We plan to
[replace this feature with end-to-end encryption](https://github.com/zulip/zulip/issues/6954)
which would eliminate that usability tradeoff.
If you have any questions about the security model, contact
support@zulipchat.com.
## Legacy signup
Here are the legacy instructions for signing a server up for push
notifications:
1. First, contact support@zulipchat.com with the `zulip_org_id` and
`zulip_org_key` values from your `/etc/zulip/zulip-secrets.conf` file, as
well as a `hostname` and `contact email` address you'd like us to use in case
of any issues (we hope to have a nice web flow available for this soon).
2. We'll enable push notifications for your server on our end. Look for a
reply from Zulipchat support within 24 hours.
## Sending push notifications directly from your server
As we discussed above, it is impossible for a single app in their
stores to receive push notifications from multiple, mutually
untrusted, servers. The Mobile Push Notification Service is one of
the possible solutions to this problem. The other possible solution
is for an individual Zulip server's administrators to build and
distribute their own copy of the Zulip mobile apps, hardcoding a key
that they possess.
This solution is possible with Zulip, but it requires the server
administrators to publish their own copies of
the Zulip mobile apps (and there's nothing the Zulip team can do to
eliminate this onorous requirement).
The main work is distributing your own copies of the Zulip mobile apps
configured to use APNS/GCM keys that you generate. This is not for
the faint of heart! If you haven't done this before, be warned that
one can easily spend hundreds of dollars (on things like a DUNS number
registration) and a week struggling through the hoops Apple requires
to build and distribute an app through the Apple app store, even if
you're making no code modifications to an app already present in the
store (as would be the case here). The Zulip mobile app also gets
frequent updates that you will have to either forgo or republish to
the app stores yourself.
If you've done that work, the Zulip server configuration for sending
push notifications through the new app is quite straightforward:
* Create a
[GCM push notifications](https://developers.google.com/cloud-messaging/android/client)
key in the Google Developer console and set `android_gcm_api_key` in
`/etc/zulip/zulip-secrets.conf` to that key.
* Register for a
[mobile push notification certificate][apple-docs]
from Apple's developer console. Set `APNS_SANDBOX=False` and
`APNS_CERT_FILE` to be the path of your APNS certificate file in
`/etc/zulip/settings.py`.
* Restart the Zulip server.
[apple-docs]: https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/APNSOverview.html

View File

@@ -645,6 +645,13 @@ zrequire('marked', 'third/marked/lib/marked');
assert(!page_params.never_subscribed);
assert.equal(page_params.notifications_stream, "");
// Simulate a private stream the user isn't subscribed to
initialize();
page_params.realm_notifications_stream_id = 89;
stream_data.initialize_from_page_params();
assert.equal(page_params.notifications_stream, "");
// Now actually subscribe the user to the stream
initialize();
var foo = {
name: 'foo',
@@ -652,7 +659,6 @@ zrequire('marked', 'third/marked/lib/marked');
};
stream_data.add_sub('foo', foo);
page_params.realm_notifications_stream_id = 89;
stream_data.initialize_from_page_params();
assert.equal(page_params.notifications_stream, "foo");

View File

@@ -80,6 +80,20 @@ class zulip::base {
owner => 'zulip',
group => 'zulip',
}
file { ['/etc/zulip/zulip.conf', '/etc/zulip/settings.py']:
ensure => 'file',
require => File['/etc/zulip'],
mode => '0644',
owner => 'zulip',
group => 'zulip',
}
file { '/etc/zulip/zulip-secrets.conf':
ensure => 'file',
require => File['/etc/zulip'],
mode => '0640',
owner => 'zulip',
group => 'zulip',
}
file { '/etc/security/limits.conf':
ensure => file,

View File

@@ -4,6 +4,7 @@ class zulip::postfix_localmail {
if $fqdn == '' {
fail("Your system does not have a fully-qualified domain name defined. See hostname(1).")
}
$postfix_mailname = zulipconf("postfix", "mailname", $fqdn)
package { $postfix_packages:
ensure => "installed",
require => File['/etc/mailname'],

View File

@@ -20,7 +20,7 @@ alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
transport_maps = hash:/etc/postfix/transport
myorigin = /etc/mailname
mydestination = localhost, <%= @fqdn %>
mydestination = localhost, <%= @postfix_mailname %>
relayhost =
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
mailbox_size_limit = 0

View File

@@ -112,15 +112,20 @@ fi
# Check early for missing SSL certificates
if [ "$PUPPET_CLASSES" = "zulip::voyager" ] && [ -z "$USE_CERTBOT""$SELF_SIGNED_CERT" ] && { ! [ -e "/etc/ssl/private/zulip.key" ] || ! [ -e "/etc/ssl/certs/zulip.combined-chain.crt" ]; }; then
set +x
echo
echo "Could not find SSL certificates!"
for f in "/etc/ssl/private/zulip.key" "/etc/ssl/certs/zulip.combined-chain.crt"; do
[ -e "$f" ] || echo " - $f is missing!"
done
cat <<EOF
See https://zulip.readthedocs.io/en/latest/production/ssl-certificates.html for help.
For non-production testing, try the --self-signed-cert option.
No SSL certificate found. One or both required files is missing:
/etc/ssl/private/zulip.key
/etc/ssl/certs/zulip.combined-chain.crt
Suggested solutions:
* For most sites, the --certbot option is recommended.
* If you have your own key and cert, see docs linked below
for how to install them.
* For non-production testing, try the --self-signed-cert option.
For help and more details, see our SSL documentation:
https://zulip.readthedocs.io/en/latest/production/ssl-certificates.html
Once fixed, just rerun scripts/setup/install; it'll pick up from here!

View File

@@ -53,7 +53,7 @@ def generate_django_secretkey():
def get_old_conf(output_filename):
# type: (str) -> Dict[str, Text]
if not os.path.exists(output_filename):
if not os.path.exists(output_filename) or os.path.getsize(output_filename) == 0:
return {}
secrets_file = configparser.RawConfigParser()

View File

@@ -148,12 +148,20 @@ exports.maybe_scroll_up_selected_message = function () {
return;
}
var selected_row = current_msg_list.selected_row();
if (selected_row.height() > message_viewport.height() - 100) {
// For very tall messages whose height is close to the entire
// height of the viewport, don't auto-scroll the viewport to
// the end of the message (since that makes it feel annoying
// to work with very tall messages). See #8941 for details.
return;
}
var cover = selected_row.offset().top + selected_row.height()
- $("#compose").offset().top;
if (cover > 0) {
message_viewport.user_initiated_animate_scroll(cover+5);
}
};
function fill_in_opts_from_current_narrowed_view(msg_type, opts) {

View File

@@ -253,11 +253,11 @@ exports.update_messages = function update_messages(events) {
// propagated edits to be updated (since the topic edits can have
// changed the correct grouping of messages).
if (topic_edited) {
home_msg_list.rerender();
home_msg_list.update_muting_and_rerender();
// However, we don't need to rerender message_list.narrowed if
// we just changed the narrow earlier in this function.
if (!changed_narrow && current_msg_list === message_list.narrowed) {
message_list.narrowed.rerender();
message_list.narrowed.update_muting_and_rerender();
}
} else {
// If the content of the message was edited, we do a special animation.

View File

@@ -30,11 +30,17 @@ function process_result(data, opts) {
_.each(messages, message_store.set_message_booleans);
messages = _.map(messages, message_store.add_message_metadata);
// In case any of the newly fetched messages are new, add them to
// our unread data structures. It's important that this run even
// when fetching in a narrow, since we might return unread
// messages that aren't in the home view data set (e.g. on a muted
// stream).
message_util.do_unread_count_updates(messages);
// If we're loading more messages into the home view, save them to
// the message_list.all as well, as the home_msg_list is reconstructed
// from message_list.all.
if (opts.msg_list === home_msg_list) {
message_util.do_unread_count_updates(messages);
message_util.add_messages(messages, message_list.all, {messages_are_new: false});
}

View File

@@ -564,11 +564,10 @@ exports.MessageList.prototype = {
}
},
rerender_after_muting_changes: function MessageList_rerender_after_muting_changes() {
update_muting_and_rerender: function MessageList_update_muting_and_rerender() {
if (!this.muting_enabled) {
return;
}
this._items = this.unmuted_messages(this._all_items);
this.rerender();
},

View File

@@ -15,9 +15,11 @@ exports.rerender = function () {
// re-doing a mute or unmute is a pretty recoverable thing.
stream_list.update_streams_sidebar();
current_msg_list.rerender_after_muting_changes();
if (current_msg_list.muting_enabled) {
current_msg_list.update_muting_and_rerender();
}
if (current_msg_list !== home_msg_list) {
home_msg_list.rerender_after_muting_changes();
home_msg_list.update_muting_and_rerender();
}
};

View File

@@ -518,8 +518,15 @@ exports.initialize_from_page_params = function () {
// Migrate the notifications stream from the new API structure to
// what the frontend expects.
if (page_params.realm_notifications_stream_id !== -1) {
page_params.notifications_stream =
exports.get_sub_by_id(page_params.realm_notifications_stream_id).name;
var notifications_stream_obj =
exports.get_sub_by_id(page_params.realm_notifications_stream_id);
if (notifications_stream_obj) {
// This happens when the notifications stream is a private
// stream the current user is not subscribed to.
page_params.notifications_stream = notifications_stream_obj.name;
} else {
page_params.notifications_stream = "";
}
} else {
page_params.notifications_stream = "";
}

View File

@@ -69,6 +69,7 @@
padding: 0px;
display: flex;
align-items: center;
width: 100%;
}
.compose_table .right_part .icon-vector-narrow {
@@ -82,25 +83,18 @@
}
.compose_table .pm_recipient {
margin: 0px 20px 0px 10px;
margin-left: 5px;
margin-right: 20px;
display: flex;
width: 100%;
}
.compose_table #private-message .to_text {
width: 65px;
vertical-align: top;
vertical-align: middle;
font-weight: 600;
}
.compose_table #private-message .to_text span {
display: flex;
align-items: center;
position: relative;
top: -1px;
}
.compose_table #compose-lock-icon {
position: relative;
left: 5px;
@@ -179,7 +173,6 @@ table.compose_table {
display: none;
position: absolute;
right: 0px;
top: 5px;
}
#compose_invite_users,
@@ -393,7 +386,7 @@ input.recipient_box {
#stream-message,
#private-message {
display: none;
display: flex;
}
.compose_table .drafts-link {

View File

@@ -109,7 +109,7 @@
opacity: 0.5;
}
.pm_recipient .pill-container .pill + .input:focus:empty::before {
.pm_recipient .pill-container .pill + .input:empty::before {
content: attr(data-some-recipients-text);
opacity: 0.5;
}

View File

@@ -204,7 +204,6 @@ li.active-sub-filter {
.conversation-partners,
.topic-name {
display: block;
line-height: 1.1;
width: calc(100% - 5px);
white-space: nowrap;
overflow: hidden;
@@ -212,6 +211,11 @@ li.active-sub-filter {
padding-right: 2px;
}
.topic-name {
/* TODO: We should figure out how to remove this without changing the spacing */
line-height: 1.1;
}
.left-sidebar li a.topic-name:hover {
text-decoration: underline;
}
@@ -312,7 +316,6 @@ ul.filters li.out_of_home_view li.muted_topic {
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.1;
position: relative;
width: 100%;
padding-right: 2px;
@@ -369,7 +372,7 @@ li.expanded_private_message {
}
li.expanded_private_message a {
margin: 2px 0px;
margin: 1px 0px;
}
.show-all-streams a {

View File

@@ -99,6 +99,11 @@ body.night-mode .private_message_count {
background-color: hsla(105, 2%, 50%, 0.5);
}
body.night-mode .pill-container {
border-style: solid;
border-width: 1px;
}
body.night-mode .pm_recipient .pill-container .pill {
color: inherit;
border: 1px solid hsla(0, 0%, 0%, 0.50);
@@ -210,10 +215,12 @@ body.night-mode .popover.right .arrow {
border-right-color: hsl(235, 18%, 7%);
}
body.night-mode .close,
body.night-mode ul.filters li:hover .arrow {
color: hsl(236, 33%, 80%);
}
body.night-mode .close:hover,
body.night-mode ul.filters li .arrow:hover {
color: hsl(0, 0%, 100%);
}
@@ -478,3 +485,10 @@ body.night-mode .ps__rail-y {
background-color: hsla(0, 0%, 0%, 0.2);
}
}
body.night-mode #bots_lists_navbar .active a {
color: #ddd;
background-color: hsl(212, 28%, 18%);
border-color: #ddd;
border-bottom-color: transparent;
}

View File

@@ -135,7 +135,7 @@ label {
.wrapped-table {
table-layout: fixed;
word-break: break-all;
word-break: break-word;
word-wrap: break-word;
white-space: -moz-pre-wrap !important;
white-space: -webkit-pre-wrap;
@@ -583,7 +583,11 @@ input[type=checkbox].inline-block {
width: 13px;
height: 20px;
margin: 0;
margin-right: 3px;
}
/* make the spinner green like the text and box. */
#settings_page .alert-notification .loading_indicator_spinner svg path {
fill: hsl(178, 100%, 40%);
}
#settings_page .alert-notification .loading_indicator_text {

View File

@@ -43,6 +43,13 @@ hr {
border-width: 2px;
}
.rangeslider-container {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.rangeselector text {
font-weight: 400;
}

View File

@@ -893,6 +893,7 @@ td.pointer {
border-radius: 3px 0px 0px 0px;
/* box-shadow: 0px 2px 3px hsl(0, 0%, 80%); */
box-shadow: inset 0px 2px 1px -2px hsl(0, 0%, 20%), inset 2px 0px 1px -2px hsl(0, 0%, 20%) !important;
width: 10px;
}
.summary_row_private_message .summary_colorblock {

View File

@@ -8,7 +8,7 @@
<span></span>
</label>
<label for="{{prefix}}{{setting_name}}" class="inline-block" id="{{prefix}}{{setting_name}}_label">
{{label}}
{{{label}}}
</label>
{{{end_content}}}
</div>

View File

@@ -56,80 +56,58 @@
<button type="button" class="close" id='compose_close' title="{{ _('Cancel compose') }} (Esc)">&times;</button>
<form id="send_message_form" action="/json/messages" method="post">
{{ csrf_input }}
<table class="compose_table">
<tbody>
<tr class="ztable_layout_row">
<td class="ztable_comp_col1" />
<td class="ztable_comp_col2" />
</tr>
<tr id="stream-message">
<td class="message_header_colorblock message_header_stream left_part">
</td>
<td class="right_part">
<span id="compose-lock-icon">
<i class="icon-vector-lock" title="{{ _('This is an invite-only stream') }}"></i>
</span>
<input type="text" class="recipient_box" name="stream" id="stream"
maxlength="30"
value="" placeholder="{{ _('Stream') }}" autocomplete="off" tabindex="0" aria-label="{{ _('Stream') }}"/>
<i class="icon-vector-narrow icon-vector-small"></i>
<input type="text" class="recipient_box" name="subject" id="subject"
maxlength="60"
value="" placeholder="{{ _('Topic') }}" autocomplete="off" tabindex="0" aria-label="{{ _('Topic') }}"/>
</td>
</tr>
<tr id="private-message">
<td class="to_text">
<span>{{ _('To') }}:</span>
</td>
<td class="right_part">
<div class="pm_recipient">
<div class="pill-container" data-before="{{ _('You and') }}">
<div class="input" contenteditable="true" id="private_message_recipient" name="recipient"
data-no-recipients-text="{{ _('Add one or more users') }}" data-some-recipients-text="{{ _('Add another user...') }}"></div>
</div>
<div class="compose_table">
<div id="stream-message">
<div class="message_header_colorblock message_header_stream left_part"></div>
<div class="right_part">
<span id="compose-lock-icon">
<i class="icon-vector-lock" title="{{ _('This is an invite-only stream') }}"></i>
</span>
<input type="text" class="recipient_box" name="stream" id="stream" maxlength="30" value="" placeholder="{{ _('Stream') }}" autocomplete="off" tabindex="0" aria-label="{{ _('Stream') }}" />
<i class="icon-vector-narrow icon-vector-small"></i>
<input type="text" class="recipient_box" name="subject" id="subject" maxlength="60" value="" placeholder="{{ _('Topic') }}" autocomplete="off" tabindex="0" aria-label="{{ _('Topic') }}" />
</div>
</div>
<div id="private-message">
<div class="to_text">
<span>{{ _('To') }}:</span>
</div>
<div class="right_part">
<div class="pm_recipient">
<div class="pill-container" data-before="{{ _('You and') }}">
<div class="input" contenteditable="true" id="private_message_recipient" name="recipient" data-no-recipients-text="{{ _('Add one or more users') }}" data-some-recipients-text="{{ _('Add another user...') }}"></div>
</div>
</td>
</tr>
<tr>
<td class="messagebox" colspan="2">
<textarea class="new_message_textarea" name="content" id='compose-textarea'
value="" placeholder="{{ _('Compose your message here') }}" tabindex="0" maxlength="10000" aria-label="{{ _('Compose your message here...') }}"></textarea>
<div class="scrolling_list" id="preview_message_area" style="display:none;">
<div id="markdown_preview_spinner"></div>
<div id="preview_content"></div>
</div>
</div>
</div>
<div>
<div class="messagebox" colspan="2">
<textarea class="new_message_textarea" name="content" id='compose-textarea' value="" placeholder="{{ _('Compose your message here') }}" tabindex="0" maxlength="10000" aria-label="{{ _('Compose your message here...') }}"></textarea>
<div class="scrolling_list" id="preview_message_area" style="display:none;">
<div id="markdown_preview_spinner"></div>
<div id="preview_content"></div>
</div>
<div class="drag"></div>
<div id="below-compose-content">
<input type="file" id="file_input" class="notvisible pull-left" multiple />
<a class="message-control-button icon-vector-smile" id="emoji_map" href="#" title="{{ _('Add emoji') }}"></a>
<a class="message-control-button icon-vector-font" title="{{ _('Formatting') }}" data-overlay-trigger="markdown-help"></a>
<a class="message-control-button icon-vector-paper-clip notdisplayed" id="attach_files" href="#" title="{{ _('Attach files') }}"></a> {% if jitsi_server_url %}
<a class="message-control-button fa fa-video-camera" id="video_link" href="#" title="{{ _('Add video call') }}"></a> {% endif %}
<a id="undo_markdown_preview" class="message-control-button icon-vector-edit" style="display:none;" title="{{ _('Write') }}"></a>
<a id="markdown_preview" class="message-control-button icon-vector-eye-open" title="{{ _('Preview') }}"></a>
<a class="drafts-link" href="#drafts" title="{{ _('Drafts') }} (d)">{{ _('Drafts') }}</a>
<span id="sending-indicator"></span>
<div id="send_controls" class="new-style">
<label id="enter-sends-label" class="compose_checkbox_label">
<input type="checkbox" id="enter_sends" />{{ _('Press Enter to send') }}
</label>
<button type="submit" id="compose-send-button" class="button small send_message" tabindex="150" title="{{ _('Send') }} (Ctrl + Enter)">{{ _('Send') }}</button>
</div>
<div class="drag"></div>
<div id="below-compose-content">
<input type="file" id="file_input" class="notvisible pull-left" multiple />
<a class="message-control-button icon-vector-smile"
id="emoji_map" href="#" title="{{ _('Add emoji') }}"></a>
<a class="message-control-button icon-vector-font"
title="{{ _('Formatting') }}" data-overlay-trigger="markdown-help"></a>
<a class="message-control-button icon-vector-paper-clip notdisplayed"
id="attach_files" href="#" title="{{ _('Attach files') }}"></a>
{% if jitsi_server_url %}
<a class="message-control-button fa fa-video-camera"
id="video_link" href="#" title="{{ _('Add video call') }}"></a>
{% endif %}
<a id="undo_markdown_preview"
class="message-control-button icon-vector-edit"
style="display:none;" title="{{ _('Write') }}"></a>
<a id="markdown_preview" class="message-control-button icon-vector-eye-open"
title="{{ _('Preview') }}"></a>
<a class="drafts-link" href="#drafts" title="{{ _('Drafts') }} (d)">{{ _('Drafts') }}</a>
<span id="sending-indicator"></span>
<div id="send_controls" class="new-style">
<label id="enter-sends-label" class="compose_checkbox_label">
<input type="checkbox" id="enter_sends" />{{ _('Press Enter to send') }}
</label>
<button type="submit" id="compose-send-button" class="button small send_message" tabindex="150" title="{{ _('Send') }} (Ctrl + Enter)">{{ _('Send') }}</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</form>
</div>
</div>

View File

@@ -26,7 +26,7 @@ Log in to your Zulip server as the `zulip` user. Run the following
commands, replacing `<token>` with the value generated above:
```
cd ~/zulip
cd /home/zulip/deployments/current
./manage.py convert_slack_data slack_data.zip --token <token> --output converted_slack_data
./manage.py import --destroy-rebuild-database '' converted_slack_data
```
@@ -42,11 +42,34 @@ commands, replacing `<token>` with the value generated above, and
Zulip organization.
```
cd ~/zulip
cd /home/zulip/deployments/current
./manage.py convert_slack_data slack_data.zip --token <token> --output converted_slack_data
./manage.py import --import-into-nonempty <subdomain> converted_slack_data
```
## Logging in
Once the import completes, all your users will have accounts in your
new Zulip organization, but those accounts won't have passwords yet
(since for very good security reasons, passwords are not exported).
Your users will need to either authenticate using something like
Google auth, or start by resetting their passwords.
You can use the `./manage.py send_password_reset_email` command to
send password reset emails to your users. We
recommend starting with sending one to yourself for testing:
```
./manage.py send_password_reset_email -u username@example.com
```
and then once you're ready, you can email them to everyone using e.g.
```
./manage.py send_password_reset_email -r '' --all-users
```
(replace `''` with your subdomain if you're using one).
## Caveats
- Slack doesn't export private channels or direct messages unless you pay

View File

@@ -55,7 +55,7 @@
</div>
<div id="stream-filters-container" class="scrolling_list">
<div class="input-append notdisplayed">
<input class="stream-list-filter" type="text" placeholder="{{ _('Search streams') }}" />
<input class="stream-list-filter" type="text" autocomplete="off" placeholder="{{ _('Search streams') }}" />
<button type="button" class="btn clear_search_button" id="clear_search_stream_button">
<i class="icon-vector-remove"></i>
</button>

View File

@@ -13,7 +13,7 @@
<i id="user_filter_icon" class='fa fa-search' aria-label="{{ _('Filter users') }}" data-toggle="tooltip" title="{{ _('Filter users') }} (w)"></i>
</div>
<div class="input-append notdisplayed">
<input class="user-list-filter" type="text" placeholder="{{ _('Search people') }}" />
<input class="user-list-filter" type="text" autocomplete="off" placeholder="{{ _('Search people') }}" />
<button type="button" class="btn clear_search_button" id="clear_search_people_button">
<i class="icon-vector-remove"></i>
</button>

View File

@@ -78,6 +78,7 @@ not_yet_fully_covered = {
'zerver/lib/feedback.py',
'zerver/lib/fix_unreads.py',
'zerver/lib/html_diff.py',
'zerver/lib/import_realm.py',
'zerver/lib/logging_util.py',
'zerver/lib/migrate.py',
'zerver/lib/outgoing_webhook.py',

View File

@@ -1,4 +1,4 @@
ZULIP_VERSION = "1.8.0"
ZULIP_VERSION = "1.8.1"
# Bump the minor PROVISION_VERSION to indicate that folks should provision
# only when going from an old version of the code to a newer version. Bump

View File

@@ -52,20 +52,20 @@
"name": "fenced_quote",
"input": "Hamlet said:\n~~~ quote\nTo be or **not** to be.\n\nThat is the question\n~~~",
"expected_output": "<p>Hamlet said:</p>\n<blockquote>\n<p>To be or <strong>not</strong> to be.</p>\n<p>That is the question</p>\n</blockquote>",
"text_content": "Hamlet said:\n\nTo be or not to be.\nThat is the question\n"
"text_content": "Hamlet said:\n> To be or not to be.\n> That is the question\n"
},
{
"name": "fenced_nested_quote",
"input": "Hamlet said:\n~~~ quote\nPolonius said:\n> This above all: to thine ownself be true,\nAnd it must follow, as the night the day,\nThou canst not then be false to any man.\n\nWhat good advice!\n~~~",
"expected_output": "<p>Hamlet said:</p>\n<blockquote>\n<p>Polonius said:</p>\n<blockquote>\n<p>This above all: to thine ownself be true,<br>\nAnd it must follow, as the night the day,<br>\nThou canst not then be false to any man.</p>\n</blockquote>\n<p>What good advice!</p>\n</blockquote>",
"text_content": "Hamlet said:\n\nPolonius said:\n\nThis above all: to thine ownself be true,\nAnd it must follow, as the night the day,\nThou canst not then be false to any man.\n\nWhat good advice!\n"
"text_content": "Hamlet said:\n> Polonius said:\n> > This above all: to thine ownself be true,\n> > And it must follow, as the night the day,\n> > Thou canst not then be false to any man.\n> What good advice!\n"
},
{
"name": "complexly_nested_quote",
"input": "I heard about this second hand...\n~~~ quote\n\nHe said:\n~~~ quote\nThe customer is complaining.\n\nThey looked at this code:\n``` \ndef hello(): print 'hello\n```\nThey would prefer:\n~~~\ndef hello()\n puts 'hello'\nend\n~~~\n\nPlease advise.\n~~~\n\nShe said:\n~~~ quote\nJust send them this:\n```\necho \"hello\n\"\n```\n~~~",
"expected_output": "<p>I heard about this second hand...</p>\n<blockquote>\n<p>He said:</p>\n<blockquote>\n<p>The customer is complaining.</p>\n<p>They looked at this code:</p>\n<div class=\"codehilite\"><pre><span></span>def hello(): print &#39;hello\n</pre></div>\n\n\n<p>They would prefer:</p>\n</blockquote>\n<p>def hello()<br>\n puts 'hello'<br>\nend</p>\n</blockquote>\n<p>Please advise.</p>\n<div class=\"codehilite\"><pre><span></span>She said:\n~~~ quote\nJust send them this:\n```\necho &quot;hello\n&quot;\n```\n</pre></div>",
"marked_expected_output": "<p>I heard about this second hand...</p>\n<blockquote>\n<p>He said:</p>\n<blockquote>\n<p>The customer is complaining.</p>\n<p>They looked at this code:</p>\n<div class=\"codehilite\"><pre><span></span>def hello(): print &#39;hello\n</pre></div>\n\n\n<p>They would prefer:</p>\n</blockquote>\n<p>def hello()<br>\n puts &#39;hello&#39;<br>\nend</p>\n</blockquote>\n<p>Please advise.</p>\n<div class=\"codehilite\"><pre><span></span>\nShe said:\n~~~ quote\nJust send them this:\n```\necho &quot;hello\n&quot;\n```\n</pre></div>",
"text_content": "I heard about this second hand...\n\nHe said:\n\nThe customer is complaining.\nThey looked at this code:\ndef hello(): print 'hello\n\n\n\nThey would prefer:\n\ndef hello()\n puts 'hello'\nend\n\nPlease advise.\nShe said:\n~~~ quote\nJust send them this:\n```\necho \"hello\n\"\n```\n"
"text_content": "I heard about this second hand...\n> He said:\n> > The customer is complaining.\n> > They looked at this code:\n> > def hello(): print 'hello\n> > They would prefer:\n> def hello()\n> puts 'hello'\n> end\n\nPlease advise.\nShe said:\n~~~ quote\nJust send them this:\n```\necho \"hello\n\"\n```\n"
},
{
"name": "fenced_quotes_inside_mathblock",
@@ -92,7 +92,7 @@
"name": "fenced_quote_with_hashtag",
"input": "```quote\n# line 1\n# line 2\n```",
"expected_output": "<blockquote>\n<p># line 1<br>\n# line 2</p>\n</blockquote>",
"text_content": "\n# line 1\n# line 2\n"
"text_content": "> # line 1\n> # line 2\n"
},
{
"name": "dangerous_block",
@@ -285,7 +285,7 @@
"input": ">Google logo today:\n>https://www.google.com/images/srpr/logo4w.png\n>Kinda boring",
"expected_output": "<blockquote>\n<p>Google logo today:<br>\n<a href=\"https://www.google.com/images/srpr/logo4w.png\" target=\"_blank\" title=\"https://www.google.com/images/srpr/logo4w.png\">https://www.google.com/images/srpr/logo4w.png</a><br>\nKinda boring</p>\n<div class=\"message_inline_image\"><a href=\"https://www.google.com/images/srpr/logo4w.png\" target=\"_blank\" title=\"https://www.google.com/images/srpr/logo4w.png\"><img src=\"https://www.google.com/images/srpr/logo4w.png\"></a></div></blockquote>",
"backend_only_rendering": true,
"text_content": "\nGoogle logo today:\nhttps:\/\/www.google.com\/images\/srpr\/logo4w.png\nKinda boring\n"
"text_content": "> Google logo today:\n> https:\/\/www.google.com\/images\/srpr\/logo4w.png\n> Kinda boring\n"
},
{
"name": "two_inline_images",

View File

@@ -1,35 +0,0 @@
[{
"message_retention_days": null,
"inline_image_preview": true,
"name_changes_disabled": false,
"icon_version": 1,
"waiting_period_threshold": 0,
"email_changes_disabled": false,
"deactivated": false,
"notifications_stream": null,
"restricted_to_domain": false,
"show_digest_email": true,
"allow_message_editing": true,
"description": "Organization imported from Slack!",
"default_language": "en",
"icon_source": "G",
"invite_required": false,
"invite_by_admins_only": false,
"create_stream_by_admins_only": false,
"mandatory_topics": false,
"inline_url_embed_preview": true,
"message_content_edit_limit_seconds": 600,
"authentication_methods": [
["Google", true],
["Email", true],
["GitHub", true],
["LDAP", true],
["Dev", true],
["RemoteUser", true]
],
"name": "",
"org_type": 1,
"add_emoji_by_admins_only": false,
"date_created": 0.0,
"id": 1
}]

View File

@@ -55,7 +55,7 @@ def email_is_not_mit_mailing_list(email: Text) -> None:
else:
raise AssertionError("Unexpected DNS error")
def check_subdomain_available(subdomain: str) -> None:
def check_subdomain_available(subdomain: str, from_management_command: bool=False) -> None:
error_strings = {
'too short': _("Subdomain needs to have length 3 or greater."),
'extremal dash': _("Subdomain cannot start or end with a '-'."),
@@ -70,6 +70,8 @@ def check_subdomain_available(subdomain: str) -> None:
raise ValidationError(error_strings['extremal dash'])
if not re.match('^[a-z0-9-]*$', subdomain):
raise ValidationError(error_strings['bad character'])
if from_management_command:
return
if len(subdomain) < 3:
raise ValidationError(error_strings['too short'])
if is_reserved_subdomain(subdomain) or \

View File

@@ -314,7 +314,9 @@ def process_missed_message(to: Text, message: message.Message, pre_checked: bool
send_to_missed_message_address(to, message)
def process_message(message: message.Message, rcpt_to: Optional[Text]=None, pre_checked: bool=False) -> None:
subject_header = message.get("Subject", "(no subject)")
subject_header = str(message.get("Subject", "")).strip()
if subject_header == "":
subject_header = "(no topic)"
encoded_subject, encoding = decode_header(subject_header)[0]
if encoding is None:
subject = force_text(encoded_subject) # encoded_subject has type str when encoding is None

View File

@@ -1,31 +1,22 @@
import datetime
from boto.s3.key import Key
from boto.s3.connection import S3Connection
from django.conf import settings
from django.db import connection
from django.forms.models import model_to_dict
from django.utils.timezone import make_aware as timezone_make_aware
from django.utils.timezone import utc as timezone_utc
from django.utils.timezone import is_naive as timezone_is_naive
from django.db.models.query import QuerySet
import glob
import logging
import os
import ujson
import shutil
import subprocess
import tempfile
from zerver.lib.upload import random_name, sanitize_name
from zerver.lib.avatar_hash import user_avatar_hash, user_avatar_path_from_ids
from zerver.lib.upload import S3UploadBackend, LocalUploadBackend
from zerver.lib.create_user import random_api_key
from zerver.lib.bulk_create import bulk_create_users
from zerver.lib.avatar_hash import user_avatar_path_from_ids
from zerver.models import UserProfile, Realm, Client, Huddle, Stream, \
UserMessage, Subscription, Message, RealmEmoji, RealmFilter, \
RealmDomain, Recipient, DefaultStream, get_user_profile_by_id, \
UserPresence, UserActivity, UserActivityInterval, Reaction, \
CustomProfileField, CustomProfileFieldValue, \
get_display_recipient, Attachment, get_system_bot, email_to_username
UserPresence, UserActivity, UserActivityInterval, \
get_display_recipient, Attachment, get_system_bot
from zerver.lib.parallel import run_parallel
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, \
Iterable, Text
@@ -1205,675 +1196,3 @@ def export_messages_single_user(user_profile: UserProfile, output_dir: Path, chu
write_message_export(message_filename, output)
min_id = max(user_message_ids)
dump_file_id += 1
# Code from here is the realm import code path
# id_maps is a dictionary that maps table names to dictionaries
# that map old ids to new ids. We use this in
# re_map_foreign_keys and other places.
#
# We explicity initialize id_maps with the tables that support
# id re-mapping.
#
# Code reviewers: give these tables extra scrutiny, as we need to
# make sure to reload related tables AFTER we re-map the ids.
id_maps = {
'client': {},
'user_profile': {},
'realm': {},
'stream': {},
'recipient': {},
'subscription': {},
'defaultstream': {},
'reaction': {},
'realmemoji': {},
'realmdomain': {},
'realmfilter': {},
'message': {},
'user_presence': {},
'useractivity': {},
'useractivityinterval': {},
'usermessage': {},
'customprofilefield': {},
'customprofilefield_value': {},
'attachment': {},
} # type: Dict[str, Dict[int, int]]
path_maps = {
'attachment_path': {},
} # type: Dict[str, Dict[str, str]]
def update_id_map(table: TableName, old_id: int, new_id: int) -> None:
if table not in id_maps:
raise Exception('''
Table %s is not initialized in id_maps, which could
mean that we have not thought through circular
dependencies.
''' % (table,))
id_maps[table][old_id] = new_id
def fix_datetime_fields(data: TableData, table: TableName) -> None:
for item in data[table]:
for field_name in DATE_FIELDS[table]:
if item[field_name] is not None:
item[field_name] = datetime.datetime.fromtimestamp(item[field_name], tz=timezone_utc)
def fix_upload_links(data: TableData, message_table: TableName) -> None:
"""
Because the URLs for uploaded files encode the realm ID of the
organization being imported (which is only determined at import
time), we need to rewrite the URLs of links to uploaded files
during the import process.
"""
for message in data[message_table]:
if message['has_attachment'] is True:
for key, value in path_maps['attachment_path'].items():
if key in message['content']:
message['content'] = message['content'].replace(key, value)
if message['rendered_content']:
message['rendered_content'] = message['rendered_content'].replace(key, value)
def current_table_ids(data: TableData, table: TableName) -> List[int]:
"""
Returns the ids present in the current table
"""
id_list = []
for item in data[table]:
id_list.append(item["id"])
return id_list
def idseq(model_class: Any) -> str:
if model_class == RealmDomain:
return 'zerver_realmalias_id_seq'
return '{}_id_seq'.format(model_class._meta.db_table)
def allocate_ids(model_class: Any, count: int) -> List[int]:
"""
Increases the sequence number for a given table by the amount of objects being
imported into that table. Hence, this gives a reserved range of ids to import the
converted slack objects into the tables.
"""
conn = connection.cursor()
sequence = idseq(model_class)
conn.execute("select nextval('%s') from generate_series(1,%s)" %
(sequence, str(count)))
query = conn.fetchall() # Each element in the result is a tuple like (5,)
conn.close()
# convert List[Tuple[int]] to List[int]
return [item[0] for item in query]
def convert_to_id_fields(data: TableData, table: TableName, field_name: Field) -> None:
'''
When Django gives us dict objects via model_to_dict, the foreign
key fields are `foo`, but we want `foo_id` for the bulk insert.
This function handles the simple case where we simply rename
the fields. For cases where we need to munge ids in the
database, see re_map_foreign_keys.
'''
for item in data[table]:
item[field_name + "_id"] = item[field_name]
del item[field_name]
def re_map_foreign_keys(data: TableData,
table: TableName,
field_name: Field,
related_table: TableName,
verbose: bool=False,
id_field: bool=False,
recipient_field: bool=False) -> None:
"""
This is a wrapper function for all the realm data tables
and only avatar and attachment records need to be passed through the internal function
because of the difference in data format (TableData corresponding to realm data tables
and List[Record] corresponding to the avatar and attachment records)
"""
re_map_foreign_keys_internal(data[table], table, field_name, related_table, verbose, id_field,
recipient_field)
def re_map_foreign_keys_internal(data_table: List[Record],
table: TableName,
field_name: Field,
related_table: TableName,
verbose: bool=False,
id_field: bool=False,
recipient_field: bool=False) -> None:
'''
We occasionally need to assign new ids to rows during the
import/export process, to accommodate things like existing rows
already being in tables. See bulk_import_client for more context.
The tricky part is making sure that foreign key references
are in sync with the new ids, and this fixer function does
the re-mapping. (It also appends `_id` to the field.)
'''
lookup_table = id_maps[related_table]
for item in data_table:
if recipient_field:
if related_table == "stream" and item['type'] == 2:
pass
elif related_table == "user_profile" and item['type'] == 1:
pass
else:
continue
old_id = item[field_name]
if old_id in lookup_table:
new_id = lookup_table[old_id]
if verbose:
logging.info('Remapping %s %s from %s to %s' % (table,
field_name + '_id',
old_id,
new_id))
else:
new_id = old_id
if not id_field:
item[field_name + "_id"] = new_id
del item[field_name]
else:
item[field_name] = new_id
def fix_bitfield_keys(data: TableData, table: TableName, field_name: Field) -> None:
for item in data[table]:
item[field_name] = item[field_name + '_mask']
del item[field_name + '_mask']
def fix_realm_authentication_bitfield(data: TableData, table: TableName, field_name: Field) -> None:
"""Used to fixup the authentication_methods bitfield to be a string"""
for item in data[table]:
values_as_bitstring = ''.join(['1' if field[1] else '0' for field in
item[field_name]])
values_as_int = int(values_as_bitstring, 2)
item[field_name] = values_as_int
def update_model_ids(model: Any, data: TableData, table: TableName, related_table: TableName) -> None:
old_id_list = current_table_ids(data, table)
allocated_id_list = allocate_ids(model, len(data[table]))
for item in range(len(data[table])):
update_id_map(related_table, old_id_list[item], allocated_id_list[item])
re_map_foreign_keys(data, table, 'id', related_table=related_table, id_field=True)
def bulk_import_model(data: TableData, model: Any, table: TableName,
dump_file_id: Optional[str]=None) -> None:
# TODO, deprecate dump_file_id
model.objects.bulk_create(model(**item) for item in data[table])
if dump_file_id is None:
logging.info("Successfully imported %s from %s." % (model, table))
else:
logging.info("Successfully imported %s from %s[%s]." % (model, table, dump_file_id))
# Client is a table shared by multiple realms, so in order to
# correctly import multiple realms into the same server, we need to
# check if a Client object already exists, and so we need to support
# remap all Client IDs to the values in the new DB.
def bulk_import_client(data: TableData, model: Any, table: TableName) -> None:
for item in data[table]:
try:
client = Client.objects.get(name=item['name'])
except Client.DoesNotExist:
client = Client.objects.create(name=item['name'])
update_id_map(table='client', old_id=item['id'], new_id=client.id)
def import_uploads_local(import_dir: Path, processing_avatars: bool=False,
processing_emojis: bool=False) -> None:
records_filename = os.path.join(import_dir, "records.json")
with open(records_filename) as records_file:
records = ujson.loads(records_file.read())
re_map_foreign_keys_internal(records, 'records', 'realm_id', related_table="realm",
id_field=True)
if not processing_emojis:
re_map_foreign_keys_internal(records, 'records', 'user_profile_id',
related_table="user_profile", id_field=True)
for record in records:
if processing_avatars:
# For avatars, we need to rehash the user ID with the
# new server's avatar salt
avatar_path = user_avatar_path_from_ids(record['user_profile_id'], record['realm_id'])
file_path = os.path.join(settings.LOCAL_UPLOADS_DIR, "avatars", avatar_path)
if record['s3_path'].endswith('.original'):
file_path += '.original'
else:
file_path += '.png'
elif processing_emojis:
# For emojis we follow the function 'upload_emoji_image'
emoji_path = RealmEmoji.PATH_ID_TEMPLATE.format(
realm_id=record['realm_id'],
emoji_file_name=record['file_name'])
file_path = os.path.join(settings.LOCAL_UPLOADS_DIR, "avatars", emoji_path)
else:
# Should be kept in sync with its equivalent in zerver/lib/uploads in the
# function 'upload_message_image'
s3_file_name = "/".join([
str(record['realm_id']),
random_name(18),
sanitize_name(os.path.basename(record['path']))
])
file_path = os.path.join(settings.LOCAL_UPLOADS_DIR, "files", s3_file_name)
path_maps['attachment_path'][record['path']] = s3_file_name
orig_file_path = os.path.join(import_dir, record['path'])
if not os.path.exists(os.path.dirname(file_path)):
subprocess.check_call(["mkdir", "-p", os.path.dirname(file_path)])
shutil.copy(orig_file_path, file_path)
if processing_avatars:
# Ensure that we have medium-size avatar images for every
# avatar. TODO: This implementation is hacky, both in that it
# does get_user_profile_by_id for each user, and in that it
# might be better to require the export to just have these.
upload_backend = LocalUploadBackend()
for record in records:
if record['s3_path'].endswith('.original'):
user_profile = get_user_profile_by_id(record['user_profile_id'])
avatar_path = user_avatar_path_from_ids(user_profile.id, record['realm_id'])
medium_file_path = os.path.join(settings.LOCAL_UPLOADS_DIR, "avatars",
avatar_path) + '-medium.png'
if os.path.exists(medium_file_path):
# We remove the image here primarily to deal with
# issues when running the import script multiple
# times in development (where one might reuse the
# same realm ID from a previous iteration).
os.remove(medium_file_path)
upload_backend.ensure_medium_avatar_image(user_profile=user_profile)
def import_uploads_s3(bucket_name: str, import_dir: Path, processing_avatars: bool=False,
processing_emojis: bool=False) -> None:
upload_backend = S3UploadBackend()
conn = S3Connection(settings.S3_KEY, settings.S3_SECRET_KEY)
bucket = conn.get_bucket(bucket_name, validate=True)
records_filename = os.path.join(import_dir, "records.json")
with open(records_filename) as records_file:
records = ujson.loads(records_file.read())
re_map_foreign_keys_internal(records, 'records', 'realm_id', related_table="realm",
id_field=True)
re_map_foreign_keys_internal(records, 'records', 'user_profile_id',
related_table="user_profile", id_field=True)
for record in records:
key = Key(bucket)
if processing_avatars:
# For avatars, we need to rehash the user's email with the
# new server's avatar salt
avatar_path = user_avatar_path_from_ids(record['user_profile_id'], record['realm_id'])
key.key = avatar_path
if record['s3_path'].endswith('.original'):
key.key += '.original'
if processing_emojis:
# For emojis we follow the function 'upload_emoji_image'
emoji_path = RealmEmoji.PATH_ID_TEMPLATE.format(
realm_id=record['realm_id'],
emoji_file_name=record['file_name'])
key.key = emoji_path
else:
# Should be kept in sync with its equivalent in zerver/lib/uploads in the
# function 'upload_message_image'
s3_file_name = "/".join([
str(record['realm_id']),
random_name(18),
sanitize_name(os.path.basename(record['path']))
])
key.key = s3_file_name
path_maps['attachment_path'][record['path']] = s3_file_name
user_profile_id = int(record['user_profile_id'])
# Support email gateway bot and other cross-realm messages
if user_profile_id in id_maps["user_profile"]:
logging.info("Uploaded by ID mapped user: %s!" % (user_profile_id,))
user_profile_id = id_maps["user_profile"][user_profile_id]
user_profile = get_user_profile_by_id(user_profile_id)
key.set_metadata("user_profile_id", str(user_profile.id))
key.set_metadata("realm_id", str(user_profile.realm_id))
key.set_metadata("orig_last_modified", record['last_modified'])
headers = {'Content-Type': record['content_type']}
key.set_contents_from_filename(os.path.join(import_dir, record['path']), headers=headers)
if processing_avatars:
# TODO: Ideally, we'd do this in a separate pass, after
# all the avatars have been uploaded, since we may end up
# unnecssarily resizing images just before the medium-size
# image in the export is uploaded. See the local uplods
# code path for more notes.
upload_backend.ensure_medium_avatar_image(user_profile=user_profile)
def import_uploads(import_dir: Path, processing_avatars: bool=False,
processing_emojis: bool=False) -> None:
if processing_avatars:
logging.info("Importing avatars")
elif processing_emojis:
logging.info("Importing emojis")
else:
logging.info("Importing uploaded files")
if settings.LOCAL_UPLOADS_DIR:
import_uploads_local(import_dir, processing_avatars=processing_avatars,
processing_emojis=processing_emojis)
else:
if processing_avatars or processing_emojis:
bucket_name = settings.S3_AVATAR_BUCKET
else:
bucket_name = settings.S3_AUTH_UPLOADS_BUCKET
import_uploads_s3(bucket_name, import_dir, processing_avatars=processing_avatars)
# Importing data suffers from a difficult ordering problem because of
# models that reference each other circularly. Here is a correct order.
#
# * Client [no deps]
# * Realm [-notifications_stream]
# * Stream [only depends on realm]
# * Realm's notifications_stream
# * Now can do all realm_tables
# * UserProfile, in order by ID to avoid bot loop issues
# * Huddle
# * Recipient
# * Subscription
# * Message
# * UserMessage
#
# Because the Python object => JSON conversion process is not fully
# faithful, we have to use a set of fixers (e.g. on DateTime objects
# and Foreign Keys) to do the import correctly.
def do_import_realm(import_dir: Path, subdomain: str) -> Realm:
logging.info("Importing realm dump %s" % (import_dir,))
if not os.path.exists(import_dir):
raise Exception("Missing import directory!")
realm_data_filename = os.path.join(import_dir, "realm.json")
if not os.path.exists(realm_data_filename):
raise Exception("Missing realm.json file!")
logging.info("Importing realm data from %s" % (realm_data_filename,))
with open(realm_data_filename) as f:
data = ujson.load(f)
update_model_ids(Stream, data, 'zerver_stream', 'stream')
re_map_foreign_keys(data, 'zerver_realm', 'notifications_stream', related_table="stream")
fix_datetime_fields(data, 'zerver_realm')
# Fix realm subdomain information
data['zerver_realm'][0]['string_id'] = subdomain
data['zerver_realm'][0]['name'] = subdomain
fix_realm_authentication_bitfield(data, 'zerver_realm', 'authentication_methods')
update_model_ids(Realm, data, 'zerver_realm', 'realm')
realm = Realm(**data['zerver_realm'][0])
if realm.notifications_stream_id is not None:
notifications_stream_id = int(realm.notifications_stream_id) # type: Optional[int]
else:
notifications_stream_id = None
realm.notifications_stream_id = None
realm.save()
bulk_import_client(data, Client, 'zerver_client')
# Email tokens will automatically be randomly generated when the
# Stream objects are created by Django.
fix_datetime_fields(data, 'zerver_stream')
re_map_foreign_keys(data, 'zerver_stream', 'realm', related_table="realm")
bulk_import_model(data, Stream, 'zerver_stream')
realm.notifications_stream_id = notifications_stream_id
realm.save()
re_map_foreign_keys(data, 'zerver_defaultstream', 'stream', related_table="stream")
re_map_foreign_keys(data, 'zerver_realmemoji', 'author', related_table="user_profile")
for (table, model, related_table) in realm_tables:
re_map_foreign_keys(data, table, 'realm', related_table="realm")
update_model_ids(model, data, table, related_table)
bulk_import_model(data, model, table)
# Remap the user IDs for notification_bot and friends to their
# appropriate IDs on this server
for item in data['zerver_userprofile_crossrealm']:
logging.info("Adding to ID map: %s %s" % (item['id'], get_system_bot(item['email']).id))
new_user_id = get_system_bot(item['email']).id
update_id_map(table='user_profile', old_id=item['id'], new_id=new_user_id)
# Merge in zerver_userprofile_mirrordummy
data['zerver_userprofile'] = data['zerver_userprofile'] + data['zerver_userprofile_mirrordummy']
del data['zerver_userprofile_mirrordummy']
data['zerver_userprofile'].sort(key=lambda r: r['id'])
# To remap foreign key for UserProfile.last_active_message_id
update_message_foreign_keys(import_dir)
fix_datetime_fields(data, 'zerver_userprofile')
update_model_ids(UserProfile, data, 'zerver_userprofile', 'user_profile')
re_map_foreign_keys(data, 'zerver_userprofile', 'realm', related_table="realm")
re_map_foreign_keys(data, 'zerver_userprofile', 'bot_owner', related_table="user_profile")
re_map_foreign_keys(data, 'zerver_userprofile', 'default_sending_stream',
related_table="stream")
re_map_foreign_keys(data, 'zerver_userprofile', 'default_events_register_stream',
related_table="stream")
re_map_foreign_keys(data, 'zerver_userprofile', 'last_active_message_id',
related_table="message", id_field=True)
for user_profile_dict in data['zerver_userprofile']:
user_profile_dict['password'] = None
user_profile_dict['api_key'] = random_api_key()
# Since Zulip doesn't use these permissions, drop them
del user_profile_dict['user_permissions']
del user_profile_dict['groups']
user_profiles = [UserProfile(**item) for item in data['zerver_userprofile']]
for user_profile in user_profiles:
user_profile.set_unusable_password()
UserProfile.objects.bulk_create(user_profiles)
if 'zerver_huddle' in data:
bulk_import_model(data, Huddle, 'zerver_huddle')
re_map_foreign_keys(data, 'zerver_recipient', 'type_id', related_table="stream",
recipient_field=True, id_field=True)
re_map_foreign_keys(data, 'zerver_recipient', 'type_id', related_table="user_profile",
recipient_field=True, id_field=True)
update_model_ids(Recipient, data, 'zerver_recipient', 'recipient')
bulk_import_model(data, Recipient, 'zerver_recipient')
re_map_foreign_keys(data, 'zerver_subscription', 'user_profile', related_table="user_profile")
re_map_foreign_keys(data, 'zerver_subscription', 'recipient', related_table="recipient")
update_model_ids(Subscription, data, 'zerver_subscription', 'subscription')
bulk_import_model(data, Subscription, 'zerver_subscription')
fix_datetime_fields(data, 'zerver_userpresence')
re_map_foreign_keys(data, 'zerver_userpresence', 'user_profile', related_table="user_profile")
re_map_foreign_keys(data, 'zerver_userpresence', 'client', related_table='client')
update_model_ids(UserPresence, data, 'zerver_userpresence', 'user_presence')
bulk_import_model(data, UserPresence, 'zerver_userpresence')
fix_datetime_fields(data, 'zerver_useractivity')
re_map_foreign_keys(data, 'zerver_useractivity', 'user_profile', related_table="user_profile")
re_map_foreign_keys(data, 'zerver_useractivity', 'client', related_table='client')
update_model_ids(UserActivity, data, 'zerver_useractivity', 'useractivity')
bulk_import_model(data, UserActivity, 'zerver_useractivity')
fix_datetime_fields(data, 'zerver_useractivityinterval')
re_map_foreign_keys(data, 'zerver_useractivityinterval', 'user_profile', related_table="user_profile")
update_model_ids(UserActivityInterval, data, 'zerver_useractivityinterval',
'useractivityinterval')
bulk_import_model(data, UserActivityInterval, 'zerver_useractivityinterval')
if 'zerver_customprofilefield' in data:
# As the export of Custom Profile fields is not supported, Zulip exported
# data would not contain this field.
# However this is supported in slack importer script
re_map_foreign_keys(data, 'zerver_customprofilefield', 'realm', related_table="realm")
update_model_ids(CustomProfileField, data, 'zerver_customprofilefield',
related_table="customprofilefield")
bulk_import_model(data, CustomProfileField, 'zerver_customprofilefield')
re_map_foreign_keys(data, 'zerver_customprofilefield_value', 'user_profile',
related_table="user_profile")
re_map_foreign_keys(data, 'zerver_customprofilefield_value', 'field',
related_table="customprofilefield")
update_model_ids(CustomProfileFieldValue, data, 'zerver_customprofilefield_value',
related_table="customprofilefield_value")
bulk_import_model(data, CustomProfileFieldValue, 'zerver_customprofilefield_value')
# Import uploaded files and avatars
import_uploads(os.path.join(import_dir, "avatars"), processing_avatars=True)
import_uploads(os.path.join(import_dir, "uploads"))
# We need to have this check as the emoji files are only present in the data
# importer from slack
# For Zulip export, this doesn't exist
if os.path.exists(os.path.join(import_dir, "emoji")):
import_uploads(os.path.join(import_dir, "emoji"), processing_emojis=True)
# Import zerver_message and zerver_usermessage
import_message_data(import_dir)
# Do attachments AFTER message data is loaded.
# TODO: de-dup how we read these json files.
fn = os.path.join(import_dir, "attachment.json")
if not os.path.exists(fn):
raise Exception("Missing attachment.json file!")
logging.info("Importing attachment data from %s" % (fn,))
with open(fn) as f:
data = ujson.load(f)
import_attachments(data)
return realm
# create_users and do_import_system_bots differ from their equivalent in
# zerver/management/commands/initialize_voyager_db.py because here we check if the bots
# don't already exist and only then create a user for these bots.
def do_import_system_bots(realm: Any) -> None:
internal_bots = [(bot['name'], bot['email_template'] % (settings.INTERNAL_BOT_DOMAIN,))
for bot in settings.INTERNAL_BOTS]
create_users(realm, internal_bots, bot_type=UserProfile.DEFAULT_BOT)
names = [(settings.FEEDBACK_BOT_NAME, settings.FEEDBACK_BOT)]
create_users(realm, names, bot_type=UserProfile.DEFAULT_BOT)
print("Finished importing system bots.")
def create_users(realm: Realm, name_list: Iterable[Tuple[Text, Text]],
bot_type: Optional[int]=None) -> None:
user_set = set()
for full_name, email in name_list:
short_name = email_to_username(email)
if not UserProfile.objects.filter(email=email):
user_set.add((email, full_name, short_name, True))
bulk_create_users(realm, user_set, bot_type)
def update_message_foreign_keys(import_dir: Path) -> None:
dump_file_id = 1
while True:
message_filename = os.path.join(import_dir, "messages-%06d.json" % (dump_file_id,))
if not os.path.exists(message_filename):
break
with open(message_filename) as f:
data = ujson.load(f)
update_model_ids(Message, data, 'zerver_message', 'message')
dump_file_id += 1
def import_message_data(import_dir: Path) -> None:
dump_file_id = 1
while True:
message_filename = os.path.join(import_dir, "messages-%06d.json" % (dump_file_id,))
if not os.path.exists(message_filename):
break
with open(message_filename) as f:
data = ujson.load(f)
logging.info("Importing message dump %s" % (message_filename,))
re_map_foreign_keys(data, 'zerver_message', 'sender', related_table="user_profile")
re_map_foreign_keys(data, 'zerver_message', 'recipient', related_table="recipient")
re_map_foreign_keys(data, 'zerver_message', 'sending_client', related_table='client')
fix_datetime_fields(data, 'zerver_message')
# Parser to update message content with the updated attachment urls
fix_upload_links(data, 'zerver_message')
re_map_foreign_keys(data, 'zerver_message', 'id', related_table='message', id_field=True)
bulk_import_model(data, Message, 'zerver_message')
# Due to the structure of these message chunks, we're
# guaranteed to have already imported all the Message objects
# for this batch of UserMessage objects.
re_map_foreign_keys(data, 'zerver_usermessage', 'message', related_table="message")
re_map_foreign_keys(data, 'zerver_usermessage', 'user_profile', related_table="user_profile")
fix_bitfield_keys(data, 'zerver_usermessage', 'flags')
update_model_ids(UserMessage, data, 'zerver_usermessage', 'usermessage')
bulk_import_model(data, UserMessage, 'zerver_usermessage')
# As the export of Reactions is not supported, Zulip exported
# data would not contain this field.
# However this is supported in slack importer script
if 'zerver_reaction' in data:
re_map_foreign_keys(data, 'zerver_reaction', 'message', related_table="message")
re_map_foreign_keys(data, 'zerver_reaction', 'user_profile', related_table="user_profile")
for reaction in data['zerver_reaction']:
if reaction['reaction_type'] == Reaction.REALM_EMOJI:
re_map_foreign_keys(data, 'zerver_reaction', 'emoji_code',
related_table="realmemoji", id_field=True)
update_model_ids(Reaction, data, 'zerver_reaction', 'reaction')
bulk_import_model(data, Reaction, 'zerver_reaction')
dump_file_id += 1
def import_attachments(data: TableData) -> None:
# Clean up the data in zerver_attachment that is not
# relevant to our many-to-many import.
fix_datetime_fields(data, 'zerver_attachment')
re_map_foreign_keys(data, 'zerver_attachment', 'owner', related_table="user_profile")
re_map_foreign_keys(data, 'zerver_attachment', 'realm', related_table="realm")
# Configure ourselves. Django models many-to-many (m2m)
# relations asymmetrically. The parent here refers to the
# Model that has the ManyToManyField. It is assumed here
# the child models have been loaded, but we are in turn
# responsible for loading the parents and the m2m rows.
parent_model = Attachment
parent_db_table_name = 'zerver_attachment'
parent_singular = 'attachment'
child_singular = 'message'
child_plural = 'messages'
m2m_table_name = 'zerver_attachment_messages'
parent_id = 'attachment_id'
child_id = 'message_id'
update_model_ids(parent_model, data, parent_db_table_name, 'attachment')
# First, build our list of many-to-many (m2m) rows.
# We do this in a slightly convoluted way to anticipate
# a future where we may need to call re_map_foreign_keys.
m2m_rows = [] # type: List[Record]
for parent_row in data[parent_db_table_name]:
for fk_id in parent_row[child_plural]:
m2m_row = {} # type: Record
m2m_row[parent_singular] = parent_row['id']
m2m_row[child_singular] = id_maps['message'][fk_id]
m2m_rows.append(m2m_row)
# Create our table data for insert.
m2m_data = {m2m_table_name: m2m_rows} # type: TableData
convert_to_id_fields(m2m_data, m2m_table_name, parent_singular)
convert_to_id_fields(m2m_data, m2m_table_name, child_singular)
m2m_rows = m2m_data[m2m_table_name]
# Next, delete out our child data from the parent rows.
for parent_row in data[parent_db_table_name]:
del parent_row[child_plural]
# Update 'path_id' for the attachments
for attachment in data[parent_db_table_name]:
attachment['path_id'] = path_maps['attachment_path'][attachment['path_id']]
# Next, load the parent rows.
bulk_import_model(data, parent_model, parent_db_table_name)
# Now, go back to our m2m rows.
# TODO: Do this the kosher Django way. We may find a
# better way to do this in Django 1.9 particularly.
with connection.cursor() as cursor:
sql_template = '''
insert into %s (%s, %s) values(%%s, %%s);''' % (m2m_table_name,
parent_id,
child_id)
tups = [(row[parent_id], row[child_id]) for row in m2m_rows]
cursor.executemany(sql_template, tups)
logging.info('Successfully imported M2M table %s' % (m2m_table_name,))

700
zerver/lib/import_realm.py Normal file
View File

@@ -0,0 +1,700 @@
import datetime
import logging
import os
import ujson
import shutil
import subprocess
from boto.s3.connection import S3Connection
from boto.s3.key import Key
from django.conf import settings
from django.db import connection
from django.utils.timezone import utc as timezone_utc
from typing import Any, Dict, List, Optional, Set, Tuple, \
Iterable, Text
from zerver.lib.avatar_hash import user_avatar_path_from_ids
from zerver.lib.bulk_create import bulk_create_users
from zerver.lib.create_user import random_api_key
from zerver.lib.export import DATE_FIELDS, realm_tables, \
Record, TableData, TableName, Field, Path
from zerver.lib.upload import random_name, sanitize_name, \
S3UploadBackend, LocalUploadBackend
from zerver.models import UserProfile, Realm, Client, Huddle, Stream, \
UserMessage, Subscription, Message, RealmEmoji, \
RealmDomain, Recipient, get_user_profile_by_id, \
UserPresence, UserActivity, UserActivityInterval, Reaction, \
CustomProfileField, CustomProfileFieldValue, \
Attachment, get_system_bot, email_to_username
# Code from here is the realm import code path
# id_maps is a dictionary that maps table names to dictionaries
# that map old ids to new ids. We use this in
# re_map_foreign_keys and other places.
#
# We explicity initialize id_maps with the tables that support
# id re-mapping.
#
# Code reviewers: give these tables extra scrutiny, as we need to
# make sure to reload related tables AFTER we re-map the ids.
id_maps = {
'client': {},
'user_profile': {},
'realm': {},
'stream': {},
'recipient': {},
'subscription': {},
'defaultstream': {},
'reaction': {},
'realmemoji': {},
'realmdomain': {},
'realmfilter': {},
'message': {},
'user_presence': {},
'useractivity': {},
'useractivityinterval': {},
'usermessage': {},
'customprofilefield': {},
'customprofilefield_value': {},
'attachment': {},
} # type: Dict[str, Dict[int, int]]
path_maps = {
'attachment_path': {},
} # type: Dict[str, Dict[str, str]]
def update_id_map(table: TableName, old_id: int, new_id: int) -> None:
if table not in id_maps:
raise Exception('''
Table %s is not initialized in id_maps, which could
mean that we have not thought through circular
dependencies.
''' % (table,))
id_maps[table][old_id] = new_id
def fix_datetime_fields(data: TableData, table: TableName) -> None:
for item in data[table]:
for field_name in DATE_FIELDS[table]:
if item[field_name] is not None:
item[field_name] = datetime.datetime.fromtimestamp(item[field_name], tz=timezone_utc)
def fix_upload_links(data: TableData, message_table: TableName) -> None:
"""
Because the URLs for uploaded files encode the realm ID of the
organization being imported (which is only determined at import
time), we need to rewrite the URLs of links to uploaded files
during the import process.
"""
for message in data[message_table]:
if message['has_attachment'] is True:
for key, value in path_maps['attachment_path'].items():
if key in message['content']:
message['content'] = message['content'].replace(key, value)
if message['rendered_content']:
message['rendered_content'] = message['rendered_content'].replace(key, value)
def current_table_ids(data: TableData, table: TableName) -> List[int]:
"""
Returns the ids present in the current table
"""
id_list = []
for item in data[table]:
id_list.append(item["id"])
return id_list
def idseq(model_class: Any) -> str:
if model_class == RealmDomain:
return 'zerver_realmalias_id_seq'
return '{}_id_seq'.format(model_class._meta.db_table)
def allocate_ids(model_class: Any, count: int) -> List[int]:
"""
Increases the sequence number for a given table by the amount of objects being
imported into that table. Hence, this gives a reserved range of ids to import the
converted slack objects into the tables.
"""
conn = connection.cursor()
sequence = idseq(model_class)
conn.execute("select nextval('%s') from generate_series(1,%s)" %
(sequence, str(count)))
query = conn.fetchall() # Each element in the result is a tuple like (5,)
conn.close()
# convert List[Tuple[int]] to List[int]
return [item[0] for item in query]
def convert_to_id_fields(data: TableData, table: TableName, field_name: Field) -> None:
'''
When Django gives us dict objects via model_to_dict, the foreign
key fields are `foo`, but we want `foo_id` for the bulk insert.
This function handles the simple case where we simply rename
the fields. For cases where we need to munge ids in the
database, see re_map_foreign_keys.
'''
for item in data[table]:
item[field_name + "_id"] = item[field_name]
del item[field_name]
def re_map_foreign_keys(data: TableData,
table: TableName,
field_name: Field,
related_table: TableName,
verbose: bool=False,
id_field: bool=False,
recipient_field: bool=False) -> None:
"""
This is a wrapper function for all the realm data tables
and only avatar and attachment records need to be passed through the internal function
because of the difference in data format (TableData corresponding to realm data tables
and List[Record] corresponding to the avatar and attachment records)
"""
re_map_foreign_keys_internal(data[table], table, field_name, related_table, verbose, id_field,
recipient_field)
def re_map_foreign_keys_internal(data_table: List[Record],
table: TableName,
field_name: Field,
related_table: TableName,
verbose: bool=False,
id_field: bool=False,
recipient_field: bool=False) -> None:
'''
We occasionally need to assign new ids to rows during the
import/export process, to accommodate things like existing rows
already being in tables. See bulk_import_client for more context.
The tricky part is making sure that foreign key references
are in sync with the new ids, and this fixer function does
the re-mapping. (It also appends `_id` to the field.)
'''
lookup_table = id_maps[related_table]
for item in data_table:
if recipient_field:
if related_table == "stream" and item['type'] == 2:
pass
elif related_table == "user_profile" and item['type'] == 1:
pass
else:
continue
old_id = item[field_name]
if old_id in lookup_table:
new_id = lookup_table[old_id]
if verbose:
logging.info('Remapping %s %s from %s to %s' % (table,
field_name + '_id',
old_id,
new_id))
else:
new_id = old_id
if not id_field:
item[field_name + "_id"] = new_id
del item[field_name]
else:
item[field_name] = new_id
def fix_bitfield_keys(data: TableData, table: TableName, field_name: Field) -> None:
for item in data[table]:
item[field_name] = item[field_name + '_mask']
del item[field_name + '_mask']
def fix_realm_authentication_bitfield(data: TableData, table: TableName, field_name: Field) -> None:
"""Used to fixup the authentication_methods bitfield to be a string"""
for item in data[table]:
values_as_bitstring = ''.join(['1' if field[1] else '0' for field in
item[field_name]])
values_as_int = int(values_as_bitstring, 2)
item[field_name] = values_as_int
def update_model_ids(model: Any, data: TableData, table: TableName, related_table: TableName) -> None:
old_id_list = current_table_ids(data, table)
allocated_id_list = allocate_ids(model, len(data[table]))
for item in range(len(data[table])):
update_id_map(related_table, old_id_list[item], allocated_id_list[item])
re_map_foreign_keys(data, table, 'id', related_table=related_table, id_field=True)
def bulk_import_model(data: TableData, model: Any, table: TableName,
dump_file_id: Optional[str]=None) -> None:
# TODO, deprecate dump_file_id
model.objects.bulk_create(model(**item) for item in data[table])
if dump_file_id is None:
logging.info("Successfully imported %s from %s." % (model, table))
else:
logging.info("Successfully imported %s from %s[%s]." % (model, table, dump_file_id))
# Client is a table shared by multiple realms, so in order to
# correctly import multiple realms into the same server, we need to
# check if a Client object already exists, and so we need to support
# remap all Client IDs to the values in the new DB.
def bulk_import_client(data: TableData, model: Any, table: TableName) -> None:
for item in data[table]:
try:
client = Client.objects.get(name=item['name'])
except Client.DoesNotExist:
client = Client.objects.create(name=item['name'])
update_id_map(table='client', old_id=item['id'], new_id=client.id)
def import_uploads_local(import_dir: Path, processing_avatars: bool=False,
processing_emojis: bool=False) -> None:
records_filename = os.path.join(import_dir, "records.json")
with open(records_filename) as records_file:
records = ujson.loads(records_file.read())
re_map_foreign_keys_internal(records, 'records', 'realm_id', related_table="realm",
id_field=True)
if not processing_emojis:
re_map_foreign_keys_internal(records, 'records', 'user_profile_id',
related_table="user_profile", id_field=True)
for record in records:
if processing_avatars:
# For avatars, we need to rehash the user ID with the
# new server's avatar salt
avatar_path = user_avatar_path_from_ids(record['user_profile_id'], record['realm_id'])
file_path = os.path.join(settings.LOCAL_UPLOADS_DIR, "avatars", avatar_path)
if record['s3_path'].endswith('.original'):
file_path += '.original'
else:
file_path += '.png'
elif processing_emojis:
# For emojis we follow the function 'upload_emoji_image'
emoji_path = RealmEmoji.PATH_ID_TEMPLATE.format(
realm_id=record['realm_id'],
emoji_file_name=record['file_name'])
file_path = os.path.join(settings.LOCAL_UPLOADS_DIR, "avatars", emoji_path)
else:
# Should be kept in sync with its equivalent in zerver/lib/uploads in the
# function 'upload_message_image'
s3_file_name = "/".join([
str(record['realm_id']),
random_name(18),
sanitize_name(os.path.basename(record['path']))
])
file_path = os.path.join(settings.LOCAL_UPLOADS_DIR, "files", s3_file_name)
path_maps['attachment_path'][record['path']] = s3_file_name
orig_file_path = os.path.join(import_dir, record['path'])
if not os.path.exists(os.path.dirname(file_path)):
subprocess.check_call(["mkdir", "-p", os.path.dirname(file_path)])
shutil.copy(orig_file_path, file_path)
if processing_avatars:
# Ensure that we have medium-size avatar images for every
# avatar. TODO: This implementation is hacky, both in that it
# does get_user_profile_by_id for each user, and in that it
# might be better to require the export to just have these.
upload_backend = LocalUploadBackend()
for record in records:
if record['s3_path'].endswith('.original'):
user_profile = get_user_profile_by_id(record['user_profile_id'])
avatar_path = user_avatar_path_from_ids(user_profile.id, record['realm_id'])
medium_file_path = os.path.join(settings.LOCAL_UPLOADS_DIR, "avatars",
avatar_path) + '-medium.png'
if os.path.exists(medium_file_path):
# We remove the image here primarily to deal with
# issues when running the import script multiple
# times in development (where one might reuse the
# same realm ID from a previous iteration).
os.remove(medium_file_path)
upload_backend.ensure_medium_avatar_image(user_profile=user_profile)
def import_uploads_s3(bucket_name: str, import_dir: Path, processing_avatars: bool=False,
processing_emojis: bool=False) -> None:
upload_backend = S3UploadBackend()
conn = S3Connection(settings.S3_KEY, settings.S3_SECRET_KEY)
bucket = conn.get_bucket(bucket_name, validate=True)
records_filename = os.path.join(import_dir, "records.json")
with open(records_filename) as records_file:
records = ujson.loads(records_file.read())
re_map_foreign_keys_internal(records, 'records', 'realm_id', related_table="realm",
id_field=True)
re_map_foreign_keys_internal(records, 'records', 'user_profile_id',
related_table="user_profile", id_field=True)
for record in records:
key = Key(bucket)
if processing_avatars:
# For avatars, we need to rehash the user's email with the
# new server's avatar salt
avatar_path = user_avatar_path_from_ids(record['user_profile_id'], record['realm_id'])
key.key = avatar_path
if record['s3_path'].endswith('.original'):
key.key += '.original'
if processing_emojis:
# For emojis we follow the function 'upload_emoji_image'
emoji_path = RealmEmoji.PATH_ID_TEMPLATE.format(
realm_id=record['realm_id'],
emoji_file_name=record['file_name'])
key.key = emoji_path
else:
# Should be kept in sync with its equivalent in zerver/lib/uploads in the
# function 'upload_message_image'
s3_file_name = "/".join([
str(record['realm_id']),
random_name(18),
sanitize_name(os.path.basename(record['path']))
])
key.key = s3_file_name
path_maps['attachment_path'][record['path']] = s3_file_name
user_profile_id = int(record['user_profile_id'])
# Support email gateway bot and other cross-realm messages
if user_profile_id in id_maps["user_profile"]:
logging.info("Uploaded by ID mapped user: %s!" % (user_profile_id,))
user_profile_id = id_maps["user_profile"][user_profile_id]
user_profile = get_user_profile_by_id(user_profile_id)
key.set_metadata("user_profile_id", str(user_profile.id))
key.set_metadata("realm_id", str(user_profile.realm_id))
key.set_metadata("orig_last_modified", record['last_modified'])
headers = {'Content-Type': record['content_type']}
key.set_contents_from_filename(os.path.join(import_dir, record['path']), headers=headers)
if processing_avatars:
# TODO: Ideally, we'd do this in a separate pass, after
# all the avatars have been uploaded, since we may end up
# unnecssarily resizing images just before the medium-size
# image in the export is uploaded. See the local uplods
# code path for more notes.
upload_backend.ensure_medium_avatar_image(user_profile=user_profile)
def import_uploads(import_dir: Path, processing_avatars: bool=False,
processing_emojis: bool=False) -> None:
if processing_avatars:
logging.info("Importing avatars")
elif processing_emojis:
logging.info("Importing emojis")
else:
logging.info("Importing uploaded files")
if settings.LOCAL_UPLOADS_DIR:
import_uploads_local(import_dir, processing_avatars=processing_avatars,
processing_emojis=processing_emojis)
else:
if processing_avatars or processing_emojis:
bucket_name = settings.S3_AVATAR_BUCKET
else:
bucket_name = settings.S3_AUTH_UPLOADS_BUCKET
import_uploads_s3(bucket_name, import_dir, processing_avatars=processing_avatars)
# Importing data suffers from a difficult ordering problem because of
# models that reference each other circularly. Here is a correct order.
#
# * Client [no deps]
# * Realm [-notifications_stream]
# * Stream [only depends on realm]
# * Realm's notifications_stream
# * Now can do all realm_tables
# * UserProfile, in order by ID to avoid bot loop issues
# * Huddle
# * Recipient
# * Subscription
# * Message
# * UserMessage
#
# Because the Python object => JSON conversion process is not fully
# faithful, we have to use a set of fixers (e.g. on DateTime objects
# and Foreign Keys) to do the import correctly.
def do_import_realm(import_dir: Path, subdomain: str) -> Realm:
logging.info("Importing realm dump %s" % (import_dir,))
if not os.path.exists(import_dir):
raise Exception("Missing import directory!")
realm_data_filename = os.path.join(import_dir, "realm.json")
if not os.path.exists(realm_data_filename):
raise Exception("Missing realm.json file!")
logging.info("Importing realm data from %s" % (realm_data_filename,))
with open(realm_data_filename) as f:
data = ujson.load(f)
update_model_ids(Stream, data, 'zerver_stream', 'stream')
re_map_foreign_keys(data, 'zerver_realm', 'notifications_stream', related_table="stream")
fix_datetime_fields(data, 'zerver_realm')
# Fix realm subdomain information
data['zerver_realm'][0]['string_id'] = subdomain
data['zerver_realm'][0]['name'] = subdomain
fix_realm_authentication_bitfield(data, 'zerver_realm', 'authentication_methods')
update_model_ids(Realm, data, 'zerver_realm', 'realm')
realm = Realm(**data['zerver_realm'][0])
if realm.notifications_stream_id is not None:
notifications_stream_id = int(realm.notifications_stream_id) # type: Optional[int]
else:
notifications_stream_id = None
realm.notifications_stream_id = None
realm.save()
bulk_import_client(data, Client, 'zerver_client')
# Email tokens will automatically be randomly generated when the
# Stream objects are created by Django.
fix_datetime_fields(data, 'zerver_stream')
re_map_foreign_keys(data, 'zerver_stream', 'realm', related_table="realm")
bulk_import_model(data, Stream, 'zerver_stream')
realm.notifications_stream_id = notifications_stream_id
realm.save()
re_map_foreign_keys(data, 'zerver_defaultstream', 'stream', related_table="stream")
re_map_foreign_keys(data, 'zerver_realmemoji', 'author', related_table="user_profile")
for (table, model, related_table) in realm_tables:
re_map_foreign_keys(data, table, 'realm', related_table="realm")
update_model_ids(model, data, table, related_table)
bulk_import_model(data, model, table)
# Remap the user IDs for notification_bot and friends to their
# appropriate IDs on this server
for item in data['zerver_userprofile_crossrealm']:
logging.info("Adding to ID map: %s %s" % (item['id'], get_system_bot(item['email']).id))
new_user_id = get_system_bot(item['email']).id
update_id_map(table='user_profile', old_id=item['id'], new_id=new_user_id)
# Merge in zerver_userprofile_mirrordummy
data['zerver_userprofile'] = data['zerver_userprofile'] + data['zerver_userprofile_mirrordummy']
del data['zerver_userprofile_mirrordummy']
data['zerver_userprofile'].sort(key=lambda r: r['id'])
# To remap foreign key for UserProfile.last_active_message_id
update_message_foreign_keys(import_dir)
fix_datetime_fields(data, 'zerver_userprofile')
update_model_ids(UserProfile, data, 'zerver_userprofile', 'user_profile')
re_map_foreign_keys(data, 'zerver_userprofile', 'realm', related_table="realm")
re_map_foreign_keys(data, 'zerver_userprofile', 'bot_owner', related_table="user_profile")
re_map_foreign_keys(data, 'zerver_userprofile', 'default_sending_stream',
related_table="stream")
re_map_foreign_keys(data, 'zerver_userprofile', 'default_events_register_stream',
related_table="stream")
re_map_foreign_keys(data, 'zerver_userprofile', 'last_active_message_id',
related_table="message", id_field=True)
for user_profile_dict in data['zerver_userprofile']:
user_profile_dict['password'] = None
user_profile_dict['api_key'] = random_api_key()
# Since Zulip doesn't use these permissions, drop them
del user_profile_dict['user_permissions']
del user_profile_dict['groups']
user_profiles = [UserProfile(**item) for item in data['zerver_userprofile']]
for user_profile in user_profiles:
user_profile.set_unusable_password()
UserProfile.objects.bulk_create(user_profiles)
if 'zerver_huddle' in data:
bulk_import_model(data, Huddle, 'zerver_huddle')
re_map_foreign_keys(data, 'zerver_recipient', 'type_id', related_table="stream",
recipient_field=True, id_field=True)
re_map_foreign_keys(data, 'zerver_recipient', 'type_id', related_table="user_profile",
recipient_field=True, id_field=True)
update_model_ids(Recipient, data, 'zerver_recipient', 'recipient')
bulk_import_model(data, Recipient, 'zerver_recipient')
re_map_foreign_keys(data, 'zerver_subscription', 'user_profile', related_table="user_profile")
re_map_foreign_keys(data, 'zerver_subscription', 'recipient', related_table="recipient")
update_model_ids(Subscription, data, 'zerver_subscription', 'subscription')
bulk_import_model(data, Subscription, 'zerver_subscription')
fix_datetime_fields(data, 'zerver_userpresence')
re_map_foreign_keys(data, 'zerver_userpresence', 'user_profile', related_table="user_profile")
re_map_foreign_keys(data, 'zerver_userpresence', 'client', related_table='client')
update_model_ids(UserPresence, data, 'zerver_userpresence', 'user_presence')
bulk_import_model(data, UserPresence, 'zerver_userpresence')
fix_datetime_fields(data, 'zerver_useractivity')
re_map_foreign_keys(data, 'zerver_useractivity', 'user_profile', related_table="user_profile")
re_map_foreign_keys(data, 'zerver_useractivity', 'client', related_table='client')
update_model_ids(UserActivity, data, 'zerver_useractivity', 'useractivity')
bulk_import_model(data, UserActivity, 'zerver_useractivity')
fix_datetime_fields(data, 'zerver_useractivityinterval')
re_map_foreign_keys(data, 'zerver_useractivityinterval', 'user_profile', related_table="user_profile")
update_model_ids(UserActivityInterval, data, 'zerver_useractivityinterval',
'useractivityinterval')
bulk_import_model(data, UserActivityInterval, 'zerver_useractivityinterval')
if 'zerver_customprofilefield' in data:
# As the export of Custom Profile fields is not supported, Zulip exported
# data would not contain this field.
# However this is supported in slack importer script
re_map_foreign_keys(data, 'zerver_customprofilefield', 'realm', related_table="realm")
update_model_ids(CustomProfileField, data, 'zerver_customprofilefield',
related_table="customprofilefield")
bulk_import_model(data, CustomProfileField, 'zerver_customprofilefield')
re_map_foreign_keys(data, 'zerver_customprofilefield_value', 'user_profile',
related_table="user_profile")
re_map_foreign_keys(data, 'zerver_customprofilefield_value', 'field',
related_table="customprofilefield")
update_model_ids(CustomProfileFieldValue, data, 'zerver_customprofilefield_value',
related_table="customprofilefield_value")
bulk_import_model(data, CustomProfileFieldValue, 'zerver_customprofilefield_value')
# Import uploaded files and avatars
import_uploads(os.path.join(import_dir, "avatars"), processing_avatars=True)
import_uploads(os.path.join(import_dir, "uploads"))
# We need to have this check as the emoji files are only present in the data
# importer from slack
# For Zulip export, this doesn't exist
if os.path.exists(os.path.join(import_dir, "emoji")):
import_uploads(os.path.join(import_dir, "emoji"), processing_emojis=True)
# Import zerver_message and zerver_usermessage
import_message_data(import_dir)
# Do attachments AFTER message data is loaded.
# TODO: de-dup how we read these json files.
fn = os.path.join(import_dir, "attachment.json")
if not os.path.exists(fn):
raise Exception("Missing attachment.json file!")
logging.info("Importing attachment data from %s" % (fn,))
with open(fn) as f:
data = ujson.load(f)
import_attachments(data)
return realm
# create_users and do_import_system_bots differ from their equivalent in
# zerver/management/commands/initialize_voyager_db.py because here we check if the bots
# don't already exist and only then create a user for these bots.
def do_import_system_bots(realm: Any) -> None:
internal_bots = [(bot['name'], bot['email_template'] % (settings.INTERNAL_BOT_DOMAIN,))
for bot in settings.INTERNAL_BOTS]
create_users(realm, internal_bots, bot_type=UserProfile.DEFAULT_BOT)
names = [(settings.FEEDBACK_BOT_NAME, settings.FEEDBACK_BOT)]
create_users(realm, names, bot_type=UserProfile.DEFAULT_BOT)
print("Finished importing system bots.")
def create_users(realm: Realm, name_list: Iterable[Tuple[Text, Text]],
bot_type: Optional[int]=None) -> None:
user_set = set()
for full_name, email in name_list:
short_name = email_to_username(email)
if not UserProfile.objects.filter(email=email):
user_set.add((email, full_name, short_name, True))
bulk_create_users(realm, user_set, bot_type)
def update_message_foreign_keys(import_dir: Path) -> None:
dump_file_id = 1
while True:
message_filename = os.path.join(import_dir, "messages-%06d.json" % (dump_file_id,))
if not os.path.exists(message_filename):
break
with open(message_filename) as f:
data = ujson.load(f)
update_model_ids(Message, data, 'zerver_message', 'message')
dump_file_id += 1
def import_message_data(import_dir: Path) -> None:
dump_file_id = 1
while True:
message_filename = os.path.join(import_dir, "messages-%06d.json" % (dump_file_id,))
if not os.path.exists(message_filename):
break
with open(message_filename) as f:
data = ujson.load(f)
logging.info("Importing message dump %s" % (message_filename,))
re_map_foreign_keys(data, 'zerver_message', 'sender', related_table="user_profile")
re_map_foreign_keys(data, 'zerver_message', 'recipient', related_table="recipient")
re_map_foreign_keys(data, 'zerver_message', 'sending_client', related_table='client')
fix_datetime_fields(data, 'zerver_message')
# Parser to update message content with the updated attachment urls
fix_upload_links(data, 'zerver_message')
re_map_foreign_keys(data, 'zerver_message', 'id', related_table='message', id_field=True)
bulk_import_model(data, Message, 'zerver_message')
# Due to the structure of these message chunks, we're
# guaranteed to have already imported all the Message objects
# for this batch of UserMessage objects.
re_map_foreign_keys(data, 'zerver_usermessage', 'message', related_table="message")
re_map_foreign_keys(data, 'zerver_usermessage', 'user_profile', related_table="user_profile")
fix_bitfield_keys(data, 'zerver_usermessage', 'flags')
update_model_ids(UserMessage, data, 'zerver_usermessage', 'usermessage')
bulk_import_model(data, UserMessage, 'zerver_usermessage')
# As the export of Reactions is not supported, Zulip exported
# data would not contain this field.
# However this is supported in slack importer script
if 'zerver_reaction' in data:
re_map_foreign_keys(data, 'zerver_reaction', 'message', related_table="message")
re_map_foreign_keys(data, 'zerver_reaction', 'user_profile', related_table="user_profile")
for reaction in data['zerver_reaction']:
if reaction['reaction_type'] == Reaction.REALM_EMOJI:
re_map_foreign_keys(data, 'zerver_reaction', 'emoji_code',
related_table="realmemoji", id_field=True)
update_model_ids(Reaction, data, 'zerver_reaction', 'reaction')
bulk_import_model(data, Reaction, 'zerver_reaction')
dump_file_id += 1
def import_attachments(data: TableData) -> None:
# Clean up the data in zerver_attachment that is not
# relevant to our many-to-many import.
fix_datetime_fields(data, 'zerver_attachment')
re_map_foreign_keys(data, 'zerver_attachment', 'owner', related_table="user_profile")
re_map_foreign_keys(data, 'zerver_attachment', 'realm', related_table="realm")
# Configure ourselves. Django models many-to-many (m2m)
# relations asymmetrically. The parent here refers to the
# Model that has the ManyToManyField. It is assumed here
# the child models have been loaded, but we are in turn
# responsible for loading the parents and the m2m rows.
parent_model = Attachment
parent_db_table_name = 'zerver_attachment'
parent_singular = 'attachment'
child_singular = 'message'
child_plural = 'messages'
m2m_table_name = 'zerver_attachment_messages'
parent_id = 'attachment_id'
child_id = 'message_id'
update_model_ids(parent_model, data, parent_db_table_name, 'attachment')
# First, build our list of many-to-many (m2m) rows.
# We do this in a slightly convoluted way to anticipate
# a future where we may need to call re_map_foreign_keys.
m2m_rows = [] # type: List[Record]
for parent_row in data[parent_db_table_name]:
for fk_id in parent_row[child_plural]:
m2m_row = {} # type: Record
m2m_row[parent_singular] = parent_row['id']
m2m_row[child_singular] = id_maps['message'][fk_id]
m2m_rows.append(m2m_row)
# Create our table data for insert.
m2m_data = {m2m_table_name: m2m_rows} # type: TableData
convert_to_id_fields(m2m_data, m2m_table_name, parent_singular)
convert_to_id_fields(m2m_data, m2m_table_name, child_singular)
m2m_rows = m2m_data[m2m_table_name]
# Next, delete out our child data from the parent rows.
for parent_row in data[parent_db_table_name]:
del parent_row[child_plural]
# Update 'path_id' for the attachments
for attachment in data[parent_db_table_name]:
attachment['path_id'] = path_maps['attachment_path'][attachment['path_id']]
# Next, load the parent rows.
bulk_import_model(data, parent_model, parent_db_table_name)
# Now, go back to our m2m rows.
# TODO: Do this the kosher Django way. We may find a
# better way to do this in Django 1.9 particularly.
with connection.cursor() as cursor:
sql_template = '''
insert into %s (%s, %s) values(%%s, %%s);''' % (m2m_table_name,
parent_id,
child_id)
tups = [(row[parent_id], row[child_id]) for row in m2m_rows]
cursor.executemany(sql_template, tups)
logging.info('Successfully imported M2M table %s' % (m2m_table_name,))

View File

@@ -3,6 +3,7 @@
import sys
from argparse import ArgumentParser
from django.conf import settings
from django.core.exceptions import MultipleObjectsReturned
from django.core.management.base import BaseCommand, CommandError
from typing import Any, Dict, Optional, Text, List
@@ -16,6 +17,16 @@ def is_integer_string(val: str) -> bool:
except ValueError:
return False
def check_config() -> None:
for (setting_name, default) in settings.REQUIRED_SETTINGS:
try:
if settings.__getattr__(setting_name) != default:
continue
except AttributeError:
pass
raise CommandError("Error: You must set %s in /etc/zulip/settings.py." % (setting_name,))
class ZulipBaseCommand(BaseCommand):
def add_realm_args(self, parser: ArgumentParser, required: bool=False,
help: Optional[str]=None) -> None:

View File

@@ -445,13 +445,24 @@ def get_mobile_push_content(rendered_content: Text) -> Text:
# Handles realm emojis, avatars etc.
if elem.tag == "img":
return elem.get("alt", "")
if elem.tag == 'blockquote':
return '' # To avoid empty line before quote text
return elem.text or ''
return elem.text or ""
def format_as_quote(quote_text: Text) -> Text:
quote_text_list = filter(None, quote_text.split('\n')) # Remove empty lines
quote_text = '\n'.join(map(lambda x: "> "+x, quote_text_list))
quote_text += '\n'
return quote_text
def process(elem: LH.HtmlElement) -> Text:
plain_text = get_text(elem)
sub_text = ''
for child in elem:
plain_text += process(child)
sub_text += process(child)
if elem.tag == 'blockquote':
sub_text = format_as_quote(sub_text)
plain_text += sub_text
plain_text += elem.tail or ""
return plain_text

View File

@@ -15,9 +15,10 @@ import random
from django.conf import settings
from django.db import connection
from django.utils.timezone import now as timezone_now
from typing import Any, Dict, List, Tuple
from django.forms.models import model_to_dict
from typing import Any, Dict, List, Optional, Tuple
from zerver.forms import check_subdomain_available
from zerver.models import Reaction, RealmEmoji
from zerver.models import Reaction, RealmEmoji, Realm
from zerver.lib.slack_message_conversion import convert_to_zulip_markdown, \
get_user_full_name
from zerver.lib.parallel import run_parallel
@@ -37,7 +38,7 @@ def rm_tree(path: str) -> None:
shutil.rmtree(path)
def slack_workspace_to_realm(domain_name: str, realm_id: int, user_list: List[ZerverFieldsT],
realm_subdomain: str, fixtures_path: str, slack_data_dir: str,
realm_subdomain: str, slack_data_dir: str,
custom_emoji_list: ZerverFieldsT)-> Tuple[ZerverFieldsT, AddedUsersT,
AddedRecipientsT,
AddedChannelsT,
@@ -54,7 +55,7 @@ def slack_workspace_to_realm(domain_name: str, realm_id: int, user_list: List[Ze
"""
NOW = float(timezone_now().timestamp())
zerver_realm = build_zerver_realm(fixtures_path, realm_id, realm_subdomain, NOW)
zerver_realm = build_zerver_realm(realm_id, realm_subdomain, NOW)
realm = dict(zerver_client=[{"name": "populate_db", "id": 1},
{"name": "website", "id": 2},
@@ -99,17 +100,15 @@ def slack_workspace_to_realm(domain_name: str, realm_id: int, user_list: List[Ze
return realm, added_users, added_recipient, added_channels, avatars, emoji_url_map
def build_zerver_realm(fixtures_path: str, realm_id: int, realm_subdomain: str,
def build_zerver_realm(realm_id: int, realm_subdomain: str,
time: float) -> List[ZerverFieldsT]:
zerver_realm_skeleton = get_data_file(fixtures_path + 'zerver_realm_skeleton.json')
zerver_realm_skeleton[0]['id'] = realm_id
zerver_realm_skeleton[0]['string_id'] = realm_subdomain # subdomain / short_name of realm
zerver_realm_skeleton[0]['name'] = realm_subdomain
zerver_realm_skeleton[0]['date_created'] = time
return zerver_realm_skeleton
realm = Realm(id=realm_id, date_created=time,
name=realm_subdomain, string_id=realm_subdomain,
description="Organization imported from Slack!")
auth_methods = [[flag[0], flag[1]] for flag in realm.authentication_methods]
realm_dict = model_to_dict(realm, exclude='authentication_methods')
realm_dict['authentication_methods'] = auth_methods
return[realm_dict]
def build_realmemoji(custom_emoji_list: ZerverFieldsT,
realm_id: int) -> Tuple[List[ZerverFieldsT],
@@ -639,12 +638,25 @@ def channel_message_to_zerver_message(realm_id: int, users: List[ZerverFieldsT],
# Ignore messages without user names
# These are Sometimes produced by slack
continue
if message.get('subtype') in [
# Zulip doesn't have a pinned_item concept
"pinned_item",
"unpinned_item",
# Slack's channel join/leave notices are spammy
"channel_join",
"channel_leave",
"channel_name"
]:
continue
has_attachment = has_image = False
content, mentioned_users_id, has_link = convert_to_zulip_markdown(message['text'],
users,
added_channels,
added_users)
try:
content, mentioned_users_id, has_link = convert_to_zulip_markdown(
message['text'], users, added_channels, added_users)
except Exception:
print("Slack message unexpectedly missing text representation:")
print(json.dumps(message, indent=4))
continue
rendered_content = None
recipient_id = added_recipient[message['channel_name']]
@@ -659,14 +671,11 @@ def channel_message_to_zerver_message(realm_id: int, users: List[ZerverFieldsT],
# Process different subtypes of slack messages
if 'subtype' in message.keys():
subtype = message['subtype']
if subtype in ["channel_join", "channel_leave", "channel_name"]:
continue
# Subtypes which have only the action in the message should
# be rendered with '/me' in the content initially
# For example "sh_room_created" has the message 'started a call'
# which should be displayed as '/me started a call'
elif subtype in ["bot_add", "sh_room_created", "me_message"]:
if subtype in ["bot_add", "sh_room_created", "me_message"]:
content = ('/me %s' % (content))
# For attachments with slack download link
@@ -808,12 +817,12 @@ def build_zerver_attachment(realm_id: int, message_id: int, attachment_id: int,
file_name=fileinfo['name'])
zerver_attachment.append(attachment)
def get_message_sending_user(message: ZerverFieldsT) -> str:
try:
user = message.get('user', message['file']['user'])
except KeyError:
user = message.get('user')
return user
def get_message_sending_user(message: ZerverFieldsT) -> Optional[str]:
if 'user' in message:
return message['user']
if message.get('file'):
return message['file'].get('user')
return None
def build_zerver_usermessage(zerver_usermessage: List[ZerverFieldsT], usermessage_id: int,
zerver_subscription: List[ZerverFieldsT], recipient_id: int,
@@ -836,6 +845,7 @@ def build_zerver_usermessage(zerver_usermessage: List[ZerverFieldsT], usermessag
def do_convert_data(slack_zip_file: str, output_dir: str, token: str, threads: int=6) -> None:
# Subdomain is set by the user while running the import command
realm_subdomain = ""
realm_id = 0
domain_name = settings.EXTERNAL_HOST
slack_data_dir = slack_zip_file.replace('.zip', '')
@@ -851,11 +861,6 @@ def do_convert_data(slack_zip_file: str, output_dir: str, token: str, threads: i
# with zipfile.ZipFile(slack_zip_file, 'r') as zip_ref:
# zip_ref.extractall(slack_data_dir)
script_path = os.path.dirname(os.path.abspath(__file__)) + '/'
fixtures_path = script_path + '../fixtures/'
realm_id = 0
# We get the user data from the legacy token method of slack api, which is depreciated
# but we use it as the user email data is provided only in this method
user_list = get_slack_api_data(token, "https://slack.com/api/users.list", "members")
@@ -864,7 +869,7 @@ def do_convert_data(slack_zip_file: str, output_dir: str, token: str, threads: i
realm, added_users, added_recipient, added_channels, avatar_list, \
emoji_url_map = slack_workspace_to_realm(domain_name, realm_id, user_list,
realm_subdomain, fixtures_path,
realm_subdomain,
slack_data_dir, custom_emoji_list)
message_json, uploads_list, zerver_attachment = convert_slack_workspace_messages(

View File

@@ -5,16 +5,10 @@ from typing import Any
from django.conf import settings
from django.core.management.base import BaseCommand
from zerver.lib.management import check_config
class Command(BaseCommand):
help = """Checks your Zulip Voyager Django configuration for issues."""
def handle(self, *args: Any, **options: Any) -> None:
for (setting_name, default) in settings.REQUIRED_SETTINGS:
try:
if settings.__getattr__(setting_name) != default:
continue
except AttributeError:
pass
print("Error: You must set %s in /etc/zulip/settings.py." % (setting_name,))
sys.exit(1)
check_config()

View File

@@ -8,7 +8,7 @@ from django.conf import settings
from django.core.management import call_command
from django.core.management.base import BaseCommand, CommandParser
from zerver.lib.export import do_import_realm, do_import_system_bots
from zerver.lib.import_realm import do_import_realm, do_import_system_bots
from zerver.forms import check_subdomain_available
from zerver.models import Client, DefaultStream, Huddle, \
Message, Realm, RealmDomain, RealmFilter, Recipient, \
@@ -65,7 +65,6 @@ import a database dump from one or more JSON files."""
if subdomain is None:
print("Enter subdomain!")
exit(1)
check_subdomain_available(subdomain)
if options["destroy_rebuild_database"]:
print("Rebuilding the database!")
@@ -75,6 +74,8 @@ import a database dump from one or more JSON files."""
for model in models_to_import:
self.new_instance_check(model)
check_subdomain_available(subdomain, from_management_command=True)
for path in options['export_files']:
if not os.path.exists(path):
print("Directory not found: '%s'" % (path,))

View File

@@ -0,0 +1,94 @@
from argparse import ArgumentParser
import json
import requests
import subprocess
from typing import Any
from django.conf import settings
from django.core.management.base import CommandError
from django.utils.crypto import get_random_string
from zerver.lib.management import ZulipBaseCommand, check_config
if settings.DEVELOPMENT:
SECRETS_FILENAME = "zproject/dev-secrets.conf"
else:
SECRETS_FILENAME = "/etc/zulip/zulip-secrets.conf"
class Command(ZulipBaseCommand):
help = """Register a remote Zulip server for push notifications."""
def add_arguments(self, parser: ArgumentParser) -> None:
parser.add_argument('--agree_to_terms_of_service',
dest='agree_to_terms_of_service',
action='store_true',
default=False,
help="Agree to the Zulipchat Terms of Service: https://zulipchat.com/terms/.")
parser.add_argument('--rotate-key',
dest="rotate_key",
action='store_true',
default=False,
help="Automatically rotate your server's zulip_org_key")
def handle(self, **options: Any) -> None:
if not settings.DEVELOPMENT:
check_config()
if not options['agree_to_terms_of_service'] and not options["rotate_key"]:
raise CommandError(
"You must agree to the Zulipchat Terms of Service: https://zulipchat.com/terms/. Run as:\n"
" python manage.py register_remote_server --agree_to_terms_of_service\n")
if not settings.ZULIP_ORG_ID:
raise CommandError("Missing zulip_org_id; run scripts/setup/generate_secrets.py to generate.")
if not settings.ZULIP_ORG_KEY:
raise CommandError("Missing zulip_org_key; run scripts/setup/generate_secrets.py to generate.")
if settings.PUSH_NOTIFICATION_BOUNCER_URL is None:
if settings.DEVELOPMENT:
settings.PUSH_NOTIFICATION_BOUNCER_URL = (settings.EXTERNAL_URI_SCHEME +
settings.EXTERNAL_HOST)
else:
raise CommandError("Please uncomment PUSH_NOTIFICATION_BOUNCER_URL "
"in /etc/zulip/settings.py (remove the '#')")
request = {
"zulip_org_id": settings.ZULIP_ORG_ID,
"zulip_org_key": settings.ZULIP_ORG_KEY,
"hostname": settings.EXTERNAL_HOST,
"contact_email": settings.ZULIP_ADMINISTRATOR}
if options["rotate_key"]:
request["new_org_key"] = get_random_string(64)
print("The following data will be submitted to the push notification service:")
for key in sorted(request.keys()):
print(" %s: %s" % (key, request[key]))
print("")
if not options['agree_to_terms_of_service'] and not options["rotate_key"]:
raise CommandError(
"You must agree to the Terms of Service: https://zulipchat.com/terms/\n"
" python manage.py register_remote_server --agree_to_terms_of_service\n")
registration_url = settings.PUSH_NOTIFICATION_BOUNCER_URL + "/api/v1/remotes/server/register"
try:
response = requests.post(registration_url, params=request)
except Exception:
raise CommandError("Network error connecting to push notifications service (%s)"
% (settings.PUSH_NOTIFICATION_BOUNCER_URL,))
try:
response.raise_for_status()
except Exception:
content_dict = json.loads(response.content.decode("utf-8"))
raise CommandError("Error: " + content_dict['msg'])
if response.json()['created']:
print("You've successfully registered for the Mobile Push Notification Service!\n"
"To finish setup for sending push notifications:")
print("- Restart the server, using /home/zulip/deployments/current/scripts/restart-server")
print("- Return to the documentation to learn how to test push notifications")
else:
if options["rotate_key"]:
print("Success! Updating %s with the new key..." % (SECRETS_FILENAME,))
subprocess.check_call(["crudini", '--set', SECRETS_FILENAME, "secrets", "zulip_org_key",
request["new_org_key"]])
print("Mobile Push Notification Service registration successfully updated!")

View File

@@ -101,6 +101,30 @@ class TestStreamEmailMessagesSuccess(ZulipTestCase):
self.assertEqual(get_display_recipient(message.recipient), stream.name)
self.assertEqual(message.topic_name(), incoming_valid_message['Subject'])
def test_receive_stream_email_messages_blank_subject_success(self) -> None:
user_profile = self.example_user('hamlet')
self.login(user_profile.email)
self.subscribe(user_profile, "Denmark")
stream = get_stream("Denmark", user_profile.realm)
stream_to_address = encode_email_address(stream)
incoming_valid_message = MIMEText('TestStreamEmailMessages Body') # type: Any # https://github.com/python/typeshed/issues/275
incoming_valid_message['Subject'] = ''
incoming_valid_message['From'] = self.example_email('hamlet')
incoming_valid_message['To'] = stream_to_address
incoming_valid_message['Reply-to'] = self.example_email('othello')
process_message(incoming_valid_message)
# Hamlet is subscribed to this stream so should see the email message from Othello.
message = most_recent_message(user_profile)
self.assertEqual(message.content, "TestStreamEmailMessages Body")
self.assertEqual(get_display_recipient(message.recipient), stream.name)
self.assertEqual(message.topic_name(), "(no topic)")
class TestStreamEmailMessagesEmptyBody(ZulipTestCase):
def test_receive_stream_email_messages_empty_body(self) -> None:

View File

@@ -11,7 +11,7 @@ from django.conf import settings
from django.core.management import call_command
from django.test import TestCase, override_settings
from zerver.lib.actions import do_create_user
from zerver.lib.management import ZulipBaseCommand, CommandError
from zerver.lib.management import ZulipBaseCommand, CommandError, check_config
from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.test_helpers import stdout_suppressed
from zerver.lib.test_runner import slow
@@ -20,6 +20,12 @@ from zerver.models import get_user_profile_by_email
from zerver.models import get_realm, UserProfile, Realm
from confirmation.models import RealmCreationKey, generate_realm_creation_url
class TestCheckConfig(ZulipTestCase):
def test_check_config(self) -> None:
with self.assertRaisesRegex(CommandError, "Error: You must set ZULIP_ADMINISTRATOR in /etc/zulip/settings.py."):
check_config()
class TestZulipBaseCommand(ZulipTestCase):
def setUp(self) -> None:
self.zulip_realm = get_realm("zulip")

View File

@@ -6,6 +6,7 @@ from django.contrib.sites.models import Site
from django.http import HttpResponse
from django.test import TestCase, override_settings
from django.utils.timezone import now as timezone_now
from django.core.exceptions import ValidationError
from mock import patch, MagicMock
from zerver.lib.test_helpers import MockLDAP
@@ -14,7 +15,7 @@ from confirmation.models import Confirmation, create_confirmation_link, Multiuse
generate_key, confirmation_url, get_object_from_key, ConfirmationKeyException
from confirmation import settings as confirmation_settings
from zerver.forms import HomepageForm, WRONG_SUBDOMAIN_ERROR
from zerver.forms import HomepageForm, WRONG_SUBDOMAIN_ERROR, check_subdomain_available
from zerver.lib.actions import do_change_password
from zerver.views.auth import login_or_register_remote_user, \
redirect_and_log_into_subdomain
@@ -1521,6 +1522,15 @@ class RealmCreationTest(ZulipTestCase):
self.assert_in_success_response(["available"], result)
self.assert_not_in_success_response(["unavailable"], result)
def test_subdomain_check_management_command(self) -> None:
# Short names should work
check_subdomain_available('aa', from_management_command=True)
# So should reserved ones
check_subdomain_available('zulip', from_management_command=True)
# malformed names should still not
with self.assertRaises(ValidationError):
check_subdomain_available('-ba_d-', from_management_command=True)
class UserSignUpTest(ZulipTestCase):
def _assert_redirected_to(self, result: HttpResponse, url: Text) -> None:

View File

@@ -24,7 +24,7 @@ from zerver.lib.slack_data_to_zulip_data import (
do_convert_data,
process_avatars,
)
from zerver.lib.export import (
from zerver.lib.import_realm import (
do_import_realm,
)
from zerver.lib.avatar_hash import (
@@ -92,11 +92,10 @@ class SlackImporter(ZulipTestCase):
self.assertEqual(invalid.exception.args, ('Something went wrong. Please try again!',),)
def test_build_zerver_realm(self) -> None:
fixtures_path = os.path.dirname(os.path.abspath(__file__)) + '/../fixtures/'
realm_id = 2
realm_subdomain = "test-realm"
time = float(timezone_now().timestamp())
test_realm = build_zerver_realm(fixtures_path, realm_id, realm_subdomain, time)
test_realm = build_zerver_realm(realm_id, realm_subdomain, time)
test_zerver_realm_dict = test_realm[0]
self.assertEqual(test_zerver_realm_dict['id'], realm_id)
@@ -323,7 +322,7 @@ class SlackImporter(ZulipTestCase):
realm_id = 1
user_list = [] # type: List[Dict[str, Any]]
realm, added_users, added_recipient, added_channels, avatar_list, em = slack_workspace_to_realm(
'testdomain', realm_id, user_list, 'test-realm', './fixtures', './random_path', {})
'testdomain', realm_id, user_list, 'test-realm', './random_path', {})
test_zerver_realmdomain = [{'realm': realm_id, 'allow_subdomains': False,
'domain': 'testdomain', 'id': realm_id}]
# Functioning already tests in helper functions
@@ -398,6 +397,11 @@ class SlackImporter(ZulipTestCase):
"ts": "1463868370.000008", "channel_name": "general"},
{"text": "test message 2", "user": "U061A5N1G",
"ts": "1433868549.000010", "channel_name": "general"},
# This message will be ignored since it has no user and file is None.
# See #9217 for the situation; likely file uploads on archived channels
{'upload': False, 'file': None, 'text': 'A file was shared',
'channel_name': 'general', 'type': 'message', 'ts': '1433868549.000011',
'subtype': 'file_share'},
{"text": "random test", "user": "U061A1R2R",
"ts": "1433868669.000012", "channel_name": "general"}] # type: List[Dict[str, Any]]

View File

@@ -16,3 +16,11 @@ Receive GitLab notifications in Zulip!
{!congrats.md!}
![](/static/images/integrations/gitlab/001.png)
!!! tip ""
If your GitLab server and your Zulip server are on a local network
together, and you're running GitLab 10.5 or newer, you may need to enable
GitLab's "Allow requests to the local network from hooks and
services" setting (by default, recent GitLab versions refuse to post
webhook events to servers on the local network). You can find this
setting near the bottom of the GitLab "Settings" page in the "Admin area".

View File

@@ -133,6 +133,8 @@ DEFAULT_SETTINGS = {
# LDAP auth
'AUTH_LDAP_SERVER_URI': "",
'LDAP_EMAIL_ATTR': None,
# Disable django-auth-ldap caching, to prevent problems with OU changes.
'AUTH_LDAP_GROUP_CACHE_TIMEOUT': 0,
# Social auth
'SOCIAL_AUTH_GITHUB_KEY': None,