Add new organization type field to Realm objects.

Adds a new field org_type to Realm.  Defaults for restricted_to_domain
and invite_required are now controlled by org_type at time of realm
creation (see zerver.lib.actions.do_create_realm), rather than at the
database level.  Note that the backend defaults are all
org_type=corporate, since that matches the current assumptions in the
codebase, whereas the frontend default is org_type=community, since if
a user isn't sure they probably want community.

Since we will likely in the future enable/disable various
administrative features based on whether an organization is corporate
or community, we discuss those issues in the realm creation form.
Before we actually implement any such features, we'll want to make
sure users understand what type of organization they are a member of.

Choice of org_type (via radio button) has been added to the realm
creation flow and the realm creation management command, and the
open-realm option removed.

The database defaults have not been changed, which allows our testing code
to work unchanged.

[includes some HTML/CSS work by Brock Whittaker to make it look nice]
This commit is contained in:
Rishi Gupta 2016-09-16 10:05:14 -07:00 committed by Tim Abbott
parent dbeab6aa6f
commit 777fcaa6a0
12 changed files with 364 additions and 105 deletions

View File

@ -141,12 +141,16 @@ administrator for your new Zulip organization. After getting
oriented, we recommend visiting the special "Administration" tab
linked to from the upper-right gear menu in the Zulip app to configure
important policy settings like how users can join your new
organization. By default, your organization will be configured as
follows ([screenshot here](_images/zulip-admin-settings.png)):
organization. By default, your organization will be configured as
follows depending on what type of organization you selected:
* `restricted_to_domain=True`: Only people with emails with the same ending as yours can join.
* `invite_required=False`: An invitation is not required to join the realm.
* `invite_by_admin_only=False`: You don't need to be an admin user to invite other users.
Community Organization:
* `restricted_to_domain=False`: No restriction on user email addresses.
* `invite_required=True`: A user must be invited to join.
Corporate Organization:
* `restricted_to_domain=True`: New users must have an email address in the same domain (e.g. @acme.com) as yours.
* `invite_required=False`: No invitation is required to join.
Next, you'll likely want to do one of the following:

View File

@ -47,9 +47,11 @@ casper.then(function () {
this.test.assertSelectorContains('.pitch', "You're almost there.");
});
this.test.assertEvalEquals(function () {
return $('.controls.fakecontrol input[type=text]').attr('placeholder');
}, email);
this.waitForSelector('#id_email', function () {
this.test.assertEvalEquals(function () {
return $('#id_email').attr('placeholder');
}, email);
});
this.waitForSelector('label[for=id_team_name]', function () {
this.test.assertSelectorHasText('label[for=id_team_name]', 'Organization name');
@ -87,9 +89,12 @@ casper.then(function () {
this.test.assertSelectorHasText('.app-main.portico-page-container', "You're the first one here!");
});
this.waitForSelector('.invite_row', function () {
this.test.assertSelectorHasText('.invite_row', domain);
});
// Getting rid of the invite page in the onboarding flow, so getting rid
// of this currently failing test. The test is implicitly expecting a
// realm created with restricted_to_domain=True, but we changed the
// default when introducting org_type
// this.waitForSelector('.invite_row', function () {
// this.test.assertSelectorHasText('.invite_row', domain); });
this.waitForSelector('#submit_invitation', function () {
this.click('#submit_invitation');

View File

@ -79,6 +79,36 @@ li {
font-style: italic;
}
.help-box {
width: 500px;
padding: 10px;
margin: 10px 0px;
font-size: 0.9rem;
font-weight: 500;
line-height: 1.2;
color: #444;
border-width: order: 1px solid #CCC;
border-radius: 4px;
background-color: #FAFAFA;
}
.display-none {
display: none;
}
.help-box .blob {
margin-top: 0px;
}
.help-inline {
font-weight: 500;
font-size: 0.9rem;
}
#company-email {
display: none;
}
@ -904,6 +934,10 @@ a.bottom-signup-button {
width: auto;
}
.input-group.margin {
margin: 10px 0px;
}
.password-reset .input-group {
margin: 15px 0px;
}
@ -936,7 +970,7 @@ a.bottom-signup-button {
.center-container {
height: calc(100vh - 94px);
min-height: 500px;
min-height: 700px;
display: flex;
align-items: center;
-wekbit-box-align: center;
@ -1148,3 +1182,101 @@ a.bottom-signup-button {
width: 25px;
vertical-align: text-bottom;
}
/* --- new settings styling --- */
.input-group input[type=radio],
.input-group input[type=checkbox] {
margin: 0 10px 0 0;
}
.input-group input[type=radio] {
position: relative;
top: 8px;
}
.input-group label {
padding: 5px 0px;
}
.input-group label {
margin: 0;
padding: 5px 0px;
}
.m-v-20 {
margin: 20px 0px;
}
.input-group.radio {
margin-left: 25px;
}
.input-group.grid label.inline-block {
width: 200px;
}
.input-group.grid {
padding: 10px 0px;
border-bottom: 1px solid #DDD;
}
label.label-title {
font-weight: 600;
}
.input-group label.inline-block {
/* eyeballing off-centered aesth. */
margin-top: 1px;
}
.display-none {
display: none;
}
.inline-block {
display: inline-block;
vertical-align: top;
}
.button-new {
padding: 8px 15px;
margin: 0;
min-width: 130px;
font-weight: 400;
background-color: #478fca;
color: #FFF;
outline: none;
border: none;
border-radius: 2px;
cursor: pointer;
transition: all 0.3s ease;
}
/* -- button states -- */
.button-new:hover {
-webkit-filter: brightness(1.1);
-moz-filter: brightness(1.1);
filter: brightness(1.1);
}
.button-new:active {
-webkit-filter: brightness(0.9);
-moz-filter: brightness(0.9);
filter: brightness(0.9);
}
.button-new.sea-green {
background-color: #24cac2;
color: #fff;
}
.float-left {
float: left;
}
.float-right {
float: right;
}

View File

@ -19,52 +19,46 @@ Form is validated both client-side using jquery-validate (see signup.js) and ser
<h1>You're almost there.</h1>
<p>
We just need you to do one last thing.
<br />
Tell us a bit about yourself.
</p>
</div>
<form method="post" class="form-horizontal" id="registration" action="{{ url('zerver.views.accounts_register') }}">
{{ csrf_input }}
<div class="control-group">
<label for="id_email" class="control-label">{{ _('Email') }}</label>
<div class="controls fakecontrol">
<input type='hidden' name='key' value='{{ key }}' />
<input type='text' disabled="true" placeholder="{{ email }}" />
</div>
<div class="input-group grid">
<label for="id_email" class="inline-block label-title">{{ _('Email') }}</label>
<input type='hidden' name='key' value='{{ key }}' />
<input id="id_email" type='text' disabled="true" placeholder="{{ email }}" />
</div>
<div class="control-group">
<label for="id_full_name" class="control-label">{{ _('Full name') }}</label>
<div class="controls">
{% if lock_name %}
<p class="fakecontrol">{{ full_name }}</p>
{% else %}
<input id="id_full_name" class="required" type="text" name="full_name"
value="{% if full_name %}{{ full_name }}{% elif form.full_name.value() %}{{ form.full_name.value() }}{% endif %}"
maxlength="100" />
{% if form.full_name.errors %}
{% for error in form.full_name.errors %}
<div class="alert alert-error">{{ error }}</div>
<div class="input-group grid">
<label for="id_full_name" class="inline-block label-title">{{ _('Full name') }}</label>
{% if lock_name %}
<p class="fakecontrol">{{ full_name }}</p>
{% else %}
<input id="id_full_name" class="required" type="text" name="full_name" placeholder='{{ "John Doe" }}'
value="{% if full_name %}{{ full_name }}{% elif form.full_name.value() %}{{ form.full_name.value() }}{% endif %}"
maxlength="100" />
{% if form.full_name.errors %}
{% for error in form.full_name.errors %}
<div class="alert alert-error">{{ error }}</div>
{% endfor %}
{% endif %}
{% endif %}
</div>
{% if creating_new_team %}
<div class="input-group grid">
<label for="id_team_name" class="inline-block label-title">{{ _('Organization name') }}</label>
<input id="id_team_name" class="required" type="text"
placeholder="{{ _("E.g. Acme") }}"
name="realm_name" maxlength="100" />
{% if form.realm_name.errors %}
{% for error in form.realm_name.errors %}
<div class="alert alert-error">{{ error }}</div>
{% endfor %}
{% endif %}
{% endif %}
</div>
</div>
{% if creating_new_team %}
<div class="control-group">
<label for="id_team_name" class="control-label">{{ _('Organization name') }}</label>
<div class="controls">
<input id="id_team_name" class="required" type="text"
placeholder="{{ _("E.g. Acme") }}"
name="realm_name" maxlength="100" />
{% if form.realm_name.errors %}
{% for error in form.realm_name.errors %}
<div class="alert alert-error">{{ error }}</div>
{% endfor %}
{% endif %}
<br /><span class="small">{{ _('You can change this later on the admin page.') }}</span>
<div class="help-box">
This can be changed later on the settings page.
</div>
</div>
{% if realms_have_subdomains %}
@ -84,11 +78,48 @@ Form is validated both client-side using jquery-validate (see signup.js) and ser
</div>
{% endif %}
{% endif %}
<div class="m-v-20">
<label for="id_org_type" class="label-title">{{ _('Organization Type') }}</label>
{% for org_type_value, org_type_text in form.realm_org_type.field.choices %}
<div class="input-group radio">
<input id="id_radio_{{ org_type_value }}" class="required inline-block" type="radio"
name="realm_org_type" value="{{ org_type_value }}"
{% if org_type_value == form.realm_org_type.value() %} checked {% endif %} />
<label for="id_radio_{{ org_type_value }}" class="inline-block">{{ org_type_text }}</label>
</div>
{% endfor %}
</div>
<div class="m-v-20">
<div class="help-box">
<div id="org_type_blob_1" class="blob display-none">
Create a corporate organization if your users will
be members of the same company, where the privacy
expectation is that the company's organization
administrators may need to have access to your
users' private message history. Zulip features that
allow organization administrators to take control of
accounts or access private message history for other
users are only available to corporate organizations.
</div>
<div id="org_type_blob_2" class="blob">
Create a community organization for an open source
project, social group, or other community where the
privacy expectation is that you and other
organization administrators won't have access to
your users' private message history. Zulip features
that allow organization administrators to take
control of accounts or access private message
history for other users are disabled in community
organizations.
</div>
</div>
</div>
{% endif %}
{% if password_auth_enabled %}
<div class="control-group">
<label for="id_password" class="control-label">{{ _('Password') }}</label>
<div class="input-group">
<label for="id_password" class="inline-block">{{ _('Password') }}</label>
<div class="controls">
<input id="id_password" class="required" type="password" name="password"
value="{% if form.password.value() %}{{ form.password.value() }}{% endif %}"
@ -103,31 +134,29 @@ Form is validated both client-side using jquery-validate (see signup.js) and ser
{% endif %}
</div>
</div>
<div class="control-group">
<label class="control-label">{{ _('Password strength') }}</label>
<div class="controls">
<div class="progress" id="pw_strength">
<div class="bar bar-danger" style="width: 10%;"></div>
</div>
<div class="input-group">
<label class="inline-block">{{ _('Password strength') }}</label>
<div class="progress" id="pw_strength">
<div class="bar bar-danger" style="width: 10%;"></div>
</div>
</div>
{% endif %}
{% if terms_of_service %}
<div class="control-group">
<div class="controls">
<label class="checkbox">
{#
This is somewhat subtle.
Checkboxes have a name and value, and when the checkbox is ticked, the form posts
with name=value. If the checkbox is unticked, the field just isn't present at all.
<div class="input-group margin">
{% if terms_of_service %}
<div class="float-left">
{#
This is somewhat subtle.
Checkboxes have a name and value, and when the checkbox is ticked, the form posts
with name=value. If the checkbox is unticked, the field just isn't present at all.
This is distinct from 'checked', which determines whether the checkbox appears
at all. (So, it's not symmetric to the code above.)
#}
<input id="id_terms" class="required" type="checkbox" name="terms"
{% if form.terms.value() %}checked="checked"{% endif %} />
This is distinct from 'checked', which determines whether the checkbox appears
at all. (So, it's not symmetric to the code above.)
#}
<input id="id_terms" class="required" type="checkbox" name="terms"
{% if form.terms.value() %}checked="checked"{% endif %} />
<label for="id_terms" class="inline-block">
{{ _('I agree to the') }} <a href="{{ server_uri }}/terms" target="_blank">{{ _('Terms of Service') }}</a>.
</label>
{% if form.terms.errors %}
@ -136,11 +165,9 @@ Form is validated both client-side using jquery-validate (see signup.js) and ser
{% endfor %}
{% endif %}
</div>
</div>
{% endif %}
<div class="submit-button-box">
<div class="controls">
<input type="submit" class="button btn btn-large btn-primary" value="Register" /><br />
{% endif %}
<div class="submit-button-box float-right">
<input type="submit" class="button-new sea-green" value="Register" /><br />
<input type="hidden" name="next" value="{{ next }}" />
</div>
</div>
@ -154,6 +181,11 @@ if ($('#id_email:visible').length) {
} else if ($('#id_full_name:visible').length) {
autofocus('#id_full_name');
}
$("[name=realm_org_type]").on("change", function () {
$(".blob").hide();
$("#org_type_blob_" + this.value).show();
});
</script>
{% endblock %}

View File

@ -84,6 +84,10 @@ class RegistrationForm(forms.Form):
required=False)
realm_name = forms.CharField(max_length=100, required=False)
realm_subdomain = forms.CharField(max_length=40, required=False)
realm_org_type = forms.ChoiceField(((Realm.COMMUNITY, 'Community'),
(Realm.CORPORATE, 'Corporate')), \
initial=Realm.COMMUNITY, required=False)
if settings.TERMS_OF_SERVICE:
terms = forms.BooleanField(required=True)

View File

@ -1942,13 +1942,35 @@ def do_change_stream_description(realm, stream_name, new_description):
value=new_description)
send_event(event, stream_user_ids(stream))
def do_create_realm(domain, name, restricted_to_domain=True, subdomain=None):
# type: (text_type, text_type, bool, Optional[text_type]) -> Tuple[Realm, bool]
def get_realm_creation_defaults(org_type=None, restricted_to_domain=None, invite_required=None):
# type: (Optional[int], Optional[bool], Optional[bool]) -> Dict[text_type, Any]
if org_type is None:
org_type = Realm.CORPORATE
# not totally clear what the defaults should be if exactly one of
# restricted_to_domain or invite_required are set. Just doing the
# least complicated thing that works when both are unset.
if restricted_to_domain is None:
restricted_to_domain = (org_type == Realm.CORPORATE)
if invite_required is None:
invite_required = not (org_type == Realm.CORPORATE)
return {'org_type': org_type,
'restricted_to_domain': restricted_to_domain,
'invite_required': invite_required}
def do_create_realm(domain, name, subdomain=None, restricted_to_domain=None,
invite_required=None, org_type=None):
# type: (text_type, text_type, Optional[text_type], Optional[bool], Optional[bool], Optional[int]) -> Tuple[Realm, bool]
realm = get_realm(domain)
created = not realm
if created:
realm = Realm(domain=domain, name=name, subdomain=subdomain,
restricted_to_domain=restricted_to_domain)
realm_params = get_realm_creation_defaults(org_type=org_type,
restricted_to_domain=restricted_to_domain,
invite_required=invite_required)
org_type = realm_params['org_type']
restricted_to_domain = realm_params['restricted_to_domain']
invite_required = realm_params['invite_required']
realm = Realm(domain=domain, name=name, org_type=org_type, subdomain=subdomain,
restricted_to_domain=restricted_to_domain, invite_required=invite_required)
realm.save()
# Create stream once Realm object has been saved
@ -1970,7 +1992,9 @@ system-generated notifications.""" % (product_name, notifications_stream.name,)
# Log the event
log_event({"type": "realm_created",
"domain": domain,
"restricted_to_domain": restricted_to_domain})
"restricted_to_domain": restricted_to_domain,
"invite_required": invite_required,
"org_type": org_type})
if settings.NEW_USER_BOT is not None:
signup_message = "Signups enabled"

View File

@ -385,8 +385,9 @@ class ZulipTestCase(TestCase):
return self.submit_reg_form_for_user(username, password, domain=domain)
def submit_reg_form_for_user(self, username, password, domain="zulip.com",
realm_name=None, realm_subdomain=None, **kwargs):
# type: (text_type, text_type, text_type, Optional[text_type], Optional[text_type], **Any) -> HttpResponse
realm_name=None, realm_subdomain=None,
realm_org_type=Realm.COMMUNITY, **kwargs):
# type: (text_type, text_type, text_type, Optional[text_type], Optional[text_type], int, **Any) -> HttpResponse
"""
Stage two of the two-step registration process.
@ -400,6 +401,7 @@ class ZulipTestCase(TestCase):
'realm_name': realm_name,
'realm_subdomain': realm_subdomain,
'key': find_key_by_email(username + '@' + domain),
'realm_org_type': realm_org_type,
'terms': True},
**kwargs)

View File

@ -6,7 +6,7 @@ from typing import Any
from django.conf import settings
from django.core.management.base import BaseCommand
from zerver.lib.actions import do_create_realm, set_default_streams
from zerver.lib.actions import Realm, do_create_realm, set_default_streams, get_realm_creation_defaults
from zerver.models import RealmAlias
if settings.ZILENCER_ENABLED:
@ -21,11 +21,6 @@ class Command(BaseCommand):
Usage: python manage.py create_realm --domain=foo.com --name='Foo, Inc.'"""
option_list = BaseCommand.option_list + (
make_option('-o', '--open-realm',
dest='open_realm',
action="store_true",
default=False,
help='Make this an open realm.'),
make_option('-d', '--domain',
dest='domain',
type='str',
@ -34,6 +29,17 @@ Usage: python manage.py create_realm --domain=foo.com --name='Foo, Inc.'"""
dest='name',
type='str',
help='The user-visible name for the realm.'),
make_option('--corporate',
dest='org_type',
action="store_const",
const=Realm.CORPORATE,
help='Is a corporate org_type'),
make_option('--community',
dest='org_type',
action="store_const",
const=Realm.COMMUNITY,
default=None,
help='Is a community org_type. Is the default.'),
make_option('--deployment',
dest='deployment_id',
type='int',
@ -60,26 +66,21 @@ Usage: python manage.py create_realm --domain=foo.com --name='Foo, Inc.'"""
def handle(self, *args, **options):
# type: (*Any, **Any) -> None
if options["domain"] is None or options["name"] is None:
domain = options["domain"]
name = options["name"]
if domain is None or name is None:
print("\033[1;31mPlease provide both a domain and name.\033[0m\n", file=sys.stderr)
self.print_help("python manage.py", "create_realm")
exit(1)
if options["open_realm"] and options["deployment_id"] is not None:
print("\033[1;31mExternal deployments cannot be open realms.\033[0m\n", file=sys.stderr)
self.print_help("python manage.py", "create_realm")
exit(1)
if options["deployment_id"] is not None and not settings.ZILENCER_ENABLED:
print("\033[1;31mExternal deployments are not supported on voyager deployments.\033[0m\n", file=sys.stderr)
exit(1)
domain = options["domain"]
name = options["name"]
self.validate_domain(domain)
realm, created = do_create_realm(
domain, name, restricted_to_domain=not options["open_realm"])
realm, created = do_create_realm(domain, name, org_type=options["org_type"])
if created:
print(domain, "created.")
if options["deployment_id"] is not None:

View File

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('zerver', '0029_realm_subdomain'),
]
operations = [
migrations.AddField(
model_name='realm',
name='org_type',
field=models.PositiveSmallIntegerField(default=1),
),
]

View File

@ -145,6 +145,11 @@ class Realm(ModelReprMixin, models.Model):
DEFAULT_MESSAGE_CONTENT_EDIT_LIMIT_SECONDS = 600 # if changed, also change in admin.js
message_content_edit_limit_seconds = models.IntegerField(default=DEFAULT_MESSAGE_CONTENT_EDIT_LIMIT_SECONDS) # type: int
# Valid org_types are {CORPORATE, COMMUNITY}
CORPORATE = 1
COMMUNITY = 2
org_type = models.PositiveSmallIntegerField(default=CORPORATE) # type: int
date_created = models.DateTimeField(default=timezone.now) # type: datetime.datetime
notifications_stream = models.ForeignKey('Stream', related_name='+', null=True, blank=True) # type: Optional[Stream]
deactivated = models.BooleanField(default=False) # type: bool

View File

@ -668,10 +668,11 @@ class RealmCreationTest(ZulipTestCase):
username = "user1"
password = "test"
domain = "test.com"
org_type = Realm.COMMUNITY
email = "user1@test.com"
# Make sure the realm does not exist
self.assertIsNone(get_realm("test.com"))
self.assertIsNone(get_realm(domain))
with self.settings(OPEN_REALM_CREATION=True):
# Create new realm with the email
@ -687,21 +688,49 @@ class RealmCreationTest(ZulipTestCase):
result = self.client_get(confirmation_url)
self.assertEquals(result.status_code, 200)
result = self.submit_reg_form_for_user(username, password, domain)
result = self.submit_reg_form_for_user(username, password, domain=domain, realm_org_type=org_type)
self.assertEquals(result.status_code, 302)
# Make sure the realm is created
realm = get_realm("test.com")
realm = get_realm(domain)
self.assertIsNotNone(realm)
self.assertEqual(realm.domain, domain)
self.assertEqual(get_user_profile_by_email(email).realm, realm)
# Check defaults
self.assertEquals(realm.org_type, Realm.COMMUNITY)
self.assertEquals(realm.restricted_to_domain, False)
self.assertEquals(realm.invite_required, True)
self.assertTrue(result["Location"].endswith("/invite/"))
result = self.client_get(result["Location"])
self.assert_in_response("You're the first one here!", result)
def test_realm_corporate_defaults(self):
# type: () -> None
username = "user1"
password = "test"
domain = "test.com"
org_type = Realm.CORPORATE
email = "user1@test.com"
# Make sure the realm does not exist
self.assertIsNone(get_realm(domain))
# Create new realm with the email
with self.settings(OPEN_REALM_CREATION=True):
self.client_post('/create_realm/', {'email': email})
confirmation_url = self.get_confirmation_url_from_outbox(email)
self.client_get(confirmation_url)
self.submit_reg_form_for_user(username, password, domain=domain, realm_org_type=org_type)
# Check corporate defaults were set correctly
realm = get_realm(domain)
self.assertEquals(realm.org_type, Realm.CORPORATE)
self.assertEquals(realm.restricted_to_domain, True)
self.assertEquals(realm.invite_required, False)
class UserSignUpTest(ZulipTestCase):
def test_user_default_language(self):

View File

@ -198,11 +198,13 @@ def accounts_register(request):
if realm_creation:
domain = split_email_to_domain(email)
realm_name = form.cleaned_data['realm_name']
org_type = int(form.cleaned_data['realm_org_type'])
if settings.REALMS_HAVE_SUBDOMAINS:
realm = do_create_realm(domain, form.cleaned_data['realm_name'],
realm = do_create_realm(domain, realm_name, org_type=org_type,
subdomain=form.cleaned_data['realm_subdomain'])[0]
else:
realm = do_create_realm(domain, form.cleaned_data['realm_name'])[0]
realm = do_create_realm(domain, realm_name, org_type=org_type)[0]
set_default_streams(realm, settings.DEFAULT_NEW_REALM_STREAMS)