models: Add `external_account` in custom profile field types.

Add new custom profile field type, External account.
External account field links user's social media
profile with account. e.g. GitHub, Twitter, etc.

Fixes part of #12302
This commit is contained in:
Yashashvi Dave 2019-05-27 14:29:55 +05:30 committed by Tim Abbott
parent 3368589df2
commit d7ee2aced1
20 changed files with 384 additions and 12 deletions

View File

@ -7,17 +7,27 @@ set_global('Sortable', {create: () => {}});
const SHORT_TEXT_ID = 1; const SHORT_TEXT_ID = 1;
const CHOICE_ID = 3; const CHOICE_ID = 3;
const EXTERNAL_ACCOUNT_ID = 7;
const SHORT_TEXT_NAME = "Short Text";
const CHOICE_NAME = "Choice";
const EXTERNAL_ACCOUNT_NAME = "External account";
page_params.custom_profile_fields = {}; page_params.custom_profile_fields = {};
page_params.realm_default_external_accounts = JSON.stringify({});
page_params.custom_profile_field_types = { page_params.custom_profile_field_types = {
SHORT_TEXT: { SHORT_TEXT: {
id: SHORT_TEXT_ID, id: SHORT_TEXT_ID,
name: "Short Text", name: SHORT_TEXT_NAME,
}, },
CHOICE: { CHOICE: {
id: CHOICE_ID, id: CHOICE_ID,
name: "Choice", name: CHOICE_NAME,
},
EXTERNAL_ACCOUNT: {
id: EXTERNAL_ACCOUNT_ID,
name: EXTERNAL_ACCOUNT_NAME,
}, },
}; };
@ -78,6 +88,25 @@ run_test('populate_profile_fields', () => {
}, },
]), ]),
}, },
{
type: EXTERNAL_ACCOUNT_ID,
id: 20,
name: 'github profile',
hint: 'username only',
field_data: JSON.stringify({
subtype: 'github',
}),
},
{
type: EXTERNAL_ACCOUNT_ID,
id: 21,
name: 'zulip profile',
hint: 'username only',
field_data: JSON.stringify({
subtype: 'custom',
url_pattern: 'https://chat.zulip.com/%(username)s',
}),
},
]; ];
const expected_template_data = [ const expected_template_data = [
{ {
@ -85,25 +114,59 @@ run_test('populate_profile_fields', () => {
id: 10, id: 10,
name: 'favorite color', name: 'favorite color',
hint: 'blue?', hint: 'blue?',
type: 'Short Text', type: SHORT_TEXT_NAME,
choices: [], choices: [],
is_choice_field: false, is_choice_field: false,
is_external_account_field: false,
}, },
can_modify: true, can_modify: true,
realm_default_external_accounts:
page_params.realm_default_external_accounts,
}, },
{ {
profile_field: { profile_field: {
id: 30, id: 30,
name: 'meal', name: 'meal',
hint: 'lunch', hint: 'lunch',
type: 'Choice', type: CHOICE_NAME,
choices: [ choices: [
{order: 0, value: 0, text: 'lunch'}, {order: 0, value: 0, text: 'lunch'},
{order: 1, value: 1, text: 'dinner'}, {order: 1, value: 1, text: 'dinner'},
], ],
is_choice_field: true, is_choice_field: true,
is_external_account_field: false,
}, },
can_modify: true, can_modify: true,
realm_default_external_accounts:
page_params.realm_default_external_accounts,
},
{
profile_field: {
id: 20,
name: 'github profile',
hint: 'username only',
type: EXTERNAL_ACCOUNT_NAME,
choices: [],
is_choice_field: false,
is_external_account_field: true,
},
can_modify: true,
realm_default_external_accounts:
page_params.realm_default_external_accounts,
},
{
profile_field: {
id: 21,
name: 'zulip profile',
hint: 'username only',
type: EXTERNAL_ACCOUNT_NAME,
choices: [],
is_choice_field: false,
is_external_account_field: true,
},
can_modify: true,
realm_default_external_accounts:
page_params.realm_default_external_accounts,
}, },
]; ];

View File

@ -71,6 +71,7 @@ exports.build_page = function () {
plan_includes_wide_organization_logo: page_params.plan_includes_wide_organization_logo, plan_includes_wide_organization_logo: page_params.plan_includes_wide_organization_logo,
upgrade_text_for_wide_organization_logo: upgrade_text_for_wide_organization_logo:
page_params.upgrade_text_for_wide_organization_logo, page_params.upgrade_text_for_wide_organization_logo,
realm_default_external_accounts: page_params.realm_default_external_accounts,
}; };
options.admin_settings_label = admin_settings_label; options.admin_settings_label = admin_settings_label;

View File

@ -104,6 +104,7 @@ function get_custom_profile_field_data(user, field, field_types, dateFormat) {
profile_field.name = field.name; profile_field.name = field.name;
profile_field.is_user_field = false; profile_field.is_user_field = false;
profile_field.is_link = field_type === field_types.URL.id; profile_field.is_link = field_type === field_types.URL.id;
profile_field.is_external_account = field_type === field_types.EXTERNAL_ACCOUNT.id;
profile_field.type = field_type; profile_field.type = field_type;
switch (field_type) { switch (field_type) {
@ -124,6 +125,11 @@ function get_custom_profile_field_data(user, field, field_types, dateFormat) {
profile_field.value = field_value.value; profile_field.value = field_value.value;
profile_field.rendered_value = field_value.rendered_value; profile_field.rendered_value = field_value.rendered_value;
break; break;
case field_types.EXTERNAL_ACCOUNT.id:
profile_field.value = field_value.value;
profile_field.field_data = JSON.parse(field.field_data);
profile_field.link = settings_profile_fields.get_external_account_link(profile_field);
break;
default: default:
profile_field.value = field_value.value; profile_field.value = field_value.value;
} }

View File

@ -90,6 +90,7 @@ exports.append_custom_profile_fields = function (element_id, user_id) {
all_field_template_types[all_field_types.CHOICE.id] = "choice"; all_field_template_types[all_field_types.CHOICE.id] = "choice";
all_field_template_types[all_field_types.USER.id] = "user"; all_field_template_types[all_field_types.USER.id] = "user";
all_field_template_types[all_field_types.DATE.id] = "date"; all_field_template_types[all_field_types.DATE.id] = "date";
all_field_template_types[all_field_types.EXTERNAL_ACCOUNT.id] = "text";
all_custom_fields.forEach(function (field) { all_custom_fields.forEach(function (field) {
var field_value = people.get_custom_profile_data(user_id, field.id); var field_value = people.get_custom_profile_data(user_id, field.id);

View File

@ -76,6 +76,15 @@ function read_choice_field_data_from_form(field_elem) {
return field_data; return field_data;
} }
function read_external_account_field_data(field_elem) {
var field_data = {};
field_data.subtype = $(field_elem).find('select[name=external_acc_field_type]').val();
if (field_data.subtype === "custom") {
field_data.url_pattern = $(field_elem).find('input[name=url_pattern]').val();
}
return field_data;
}
function update_choice_delete_btn(container, display_flag) { function update_choice_delete_btn(container, display_flag) {
var no_of_choice_row = container.find(".choice-row").length; var no_of_choice_row = container.find(".choice-row").length;
@ -106,12 +115,20 @@ function clear_form_data() {
create_choice_row($("#profile_field_choices")); create_choice_row($("#profile_field_choices"));
update_choice_delete_btn($("#profile_field_choices"), false); update_choice_delete_btn($("#profile_field_choices"), false);
$("#profile_field_choices_row").hide(); $("#profile_field_choices_row").hide();
// Clear external account field form
$("#custom_field_url_pattern").val("");
$("#custom_external_account_url_pattern").hide();
$("#profile_field_external_accounts").hide();
$("#profile_field_external_accounts_type").val(0);
} }
function read_field_data_from_form(field_type_id, field_elem) { function read_field_data_from_form(field_type_id, field_elem) {
// Only read field data if we are creating a choice field // Only read field data if we are creating a choice field
// or external account field.
if (field_type_id === field_types.CHOICE.id) { if (field_type_id === field_types.CHOICE.id) {
return read_choice_field_data_from_form(field_elem); return read_choice_field_data_from_form(field_elem);
} else if (field_type_id === field_types.EXTERNAL_ACCOUNT.id) {
return read_external_account_field_data(field_elem);
} }
} }
@ -121,7 +138,6 @@ function create_profile_field(e) {
var field_data = {}; var field_data = {};
var field_type = $('#profile_field_type').val(); var field_type = $('#profile_field_type').val();
var opts = { var opts = {
success_continuation: clear_form_data, success_continuation: clear_form_data,
}; };
@ -186,6 +202,15 @@ exports.parse_field_choices_from_field_data = function (field_data) {
return choices; return choices;
}; };
function set_up_external_account_field_edit_form(field_elem, url_pattern_val) {
if (field_elem.form.find('select[name=external_acc_field_type]').val() === 'custom') {
field_elem.form.find('input[name=url_pattern]').val(url_pattern_val);
field_elem.form.find('.custom_external_account_detail').show();
} else {
field_elem.form.find('.custom_external_account_detail').hide();
}
}
function set_up_choices_field_edit_form(profile_field, field_data) { function set_up_choices_field_edit_form(profile_field, field_data) {
// Re-render field choices in edit form to load initial choice data // Re-render field choices in edit form to load initial choice data
var choice_list = profile_field.form.find('.edit_profile_field_choices_container'); var choice_list = profile_field.form.find('.edit_profile_field_choices_container');
@ -229,6 +254,11 @@ function open_edit_form(e) {
set_up_choices_field_edit_form(profile_field, field_data); set_up_choices_field_edit_form(profile_field, field_data);
} }
if (parseInt(field.type, 10) === field_types.EXTERNAL_ACCOUNT.id) {
profile_field.form.find('select[name=external_acc_field_type]').val(field_data.subtype);
set_up_external_account_field_edit_form(profile_field, field_data.url_pattern);
}
profile_field.form.find('.reset').on("click", function () { profile_field.form.find('.reset').on("click", function () {
profile_field.form.hide(); profile_field.form.hide();
profile_field.row.show(); profile_field.row.show();
@ -254,6 +284,11 @@ function open_edit_form(e) {
profile_field.form.find(".edit_profile_field_choices_container").on("input", ".choice-row input", add_choice_row); profile_field.form.find(".edit_profile_field_choices_container").on("input", ".choice-row input", add_choice_row);
profile_field.form.find(".edit_profile_field_choices_container").on("click", "button.delete-choice", delete_choice_row); profile_field.form.find(".edit_profile_field_choices_container").on("click", "button.delete-choice", delete_choice_row);
$(".profile_field_external_accounts_edit select").on('change', function (e) {
var field_id = $(e.target).closest('.profile-field-form').attr('data-profile-field-id');
var field_form = get_profile_field_info(field_id);
set_up_external_account_field_edit_form(field_form, "");
});
} }
exports.reset = function () { exports.reset = function () {
@ -307,8 +342,11 @@ exports.do_populate_profile_fields = function (profile_fields_data) {
type: exports.field_type_id_to_string(profile_field.type), type: exports.field_type_id_to_string(profile_field.type),
choices: choices, choices: choices,
is_choice_field: profile_field.type === field_types.CHOICE.id, is_choice_field: profile_field.type === field_types.CHOICE.id,
is_external_account_field: profile_field.type ===
field_types.EXTERNAL_ACCOUNT.id,
}, },
can_modify: page_params.is_admin, can_modify: page_params.is_admin,
realm_default_external_accounts: page_params.realm_default_external_accounts,
} }
) )
); );
@ -355,6 +393,46 @@ function set_up_choices_field() {
$("#profile_field_choices").on("click", "button.delete-choice", delete_choice_row); $("#profile_field_choices").on("click", "button.delete-choice", delete_choice_row);
} }
function set_up_external_account_field() {
var field_elem = $("#profile_field_external_accounts");
var field_url_pattern_elem = $("#custom_external_account_url_pattern");
$('#profile_field_type').on('change', function (e) {
var selected_field_id = parseInt($(e.target).val(), 10);
if (selected_field_id === field_types.EXTERNAL_ACCOUNT.id) {
field_elem.show();
if ($("#profile_field_external_accounts_type").val() === 'custom') {
field_url_pattern_elem.show();
} else {
field_url_pattern_elem.hide();
}
} else {
field_url_pattern_elem.hide();
field_elem.hide();
}
});
$("#profile_field_external_accounts_type").on("change", function () {
if ($("#profile_field_external_accounts_type").val() === 'custom') {
field_url_pattern_elem.show();
} else {
field_url_pattern_elem.hide();
}
});
}
exports.get_external_account_link = function (field) {
var field_subtype = field.field_data.subtype;
var field_url_pattern;
if (field_subtype === 'custom') {
field_url_pattern = field.field_data.url_pattern;
} else {
field_url_pattern =
page_params.realm_default_external_accounts[field_subtype].url_pattern;
}
return field_url_pattern.replace('%(username)s', field.value);
};
exports.set_up = function () { exports.set_up = function () {
exports.build_page(); exports.build_page();
exports.maybe_disable_widgets(); exports.maybe_disable_widgets();
@ -371,6 +449,7 @@ exports.build_page = function () {
$("#profile-field-settings").on("click", "#add-custom-profile-field-btn", create_profile_field); $("#profile-field-settings").on("click", "#add-custom-profile-field-btn", create_profile_field);
$("#admin_profile_fields_table").on("click", ".open-edit-form", open_edit_form); $("#admin_profile_fields_table").on("click", ".open-edit-form", open_edit_form);
set_up_choices_field(); set_up_choices_field();
set_up_external_account_field();
clear_form_data(); clear_form_data();
}; };

View File

@ -48,6 +48,21 @@
</div> </div>
</div> </div>
{{/if}} {{/if}}
{{#if is_external_account_field}}
<div class="input-group profile_field_external_accounts_edit">
<label for="external_acc_field_type">{{t "External account type" }}</label>
<select name="external_acc_field_type">
{{#each ../realm_default_external_accounts}}
<option value='{{@key}}'>{{this.text}}</option>
{{/each}}
<option value="custom">{{t 'Custom' }}</option>
</select>
</div>
<div class="input-group custom_external_account_detail">
<label for="url_pattern">{{t "URL pattern" }}</label>
<input type="url" name="url_pattern" autocomplete="off" maxlength="80" />
</div>
{{/if}}
<div class="input-group"> <div class="input-group">
<button type="button" class="button rounded sea-green submit"> <button type="button" class="button rounded sea-green submit">
{{t 'Save changes' }} {{t 'Save changes' }}

View File

@ -41,6 +41,19 @@
<tbody id="profile_field_choices" class="profile-field-choices"></tbody> <tbody id="profile_field_choices" class="profile-field-choices"></tbody>
</table> </table>
</div> </div>
<div class="control-group" id="profile_field_external_accounts">
<label for="profile_field_external_accounts_type" class="control-label">{{t "External account type" }}</label>
<select id="profile_field_external_accounts_type" name="external_acc_field_type">
{{#each realm_default_external_accounts}}
<option value='{{@key}}'>{{this.text}}</option>
{{/each}}
<option value="custom">{{t 'Custom' }}</option>
</select>
</div>
<div class="control-group" id="custom_external_account_url_pattern">
<label for="custom_field_url_pattern" class="control-label">{{t "URL pattern" }}</label>
<input type="url" id="custom_field_url_pattern" name="url_pattern" autocomplete="off" maxlength="80" />
</div>
<button type="submit" class="button rounded sea-green" id="add-custom-profile-field-btn"> <button type="submit" class="button rounded sea-green" id="add-custom-profile-field-btn">
{{t 'Add profile field' }} {{t 'Add profile field' }}
</button> </button>

View File

@ -51,6 +51,8 @@
</div> </div>
{{else if this.is_link}} {{else if this.is_link}}
<a href={{this.value}} target="_blank" class="value">{{this.value}}</a> <a href={{this.value}} target="_blank" class="value">{{this.value}}</a>
{{else if this.is_external_account}}
<a href={{this.link}} target="_blank" class="value">{{this.value}}</a>
{{else}} {{else}}
{{#if this.rendered_value}} {{#if this.rendered_value}}
<div class="value rendered_markdown">{{{this.rendered_value}}}</div> <div class="value rendered_markdown">{{{this.rendered_value}}}</div>

View File

@ -5385,7 +5385,8 @@ def try_add_realm_custom_profile_field(realm: Realm, name: str, field_type: int,
field_data: Optional[ProfileFieldData]=None) -> CustomProfileField: field_data: Optional[ProfileFieldData]=None) -> CustomProfileField:
field = CustomProfileField(realm=realm, name=name, field_type=field_type) field = CustomProfileField(realm=realm, name=name, field_type=field_type)
field.hint = hint field.hint = hint
if field.field_type == CustomProfileField.CHOICE: if (field.field_type == CustomProfileField.CHOICE or
field.field_type == CustomProfileField.EXTERNAL_ACCOUNT):
field.field_data = ujson.dumps(field_data or {}) field.field_data = ujson.dumps(field_data or {})
field.save() field.save()
@ -5410,7 +5411,8 @@ def try_update_realm_custom_profile_field(realm: Realm, field: CustomProfileFiel
field_data: Optional[ProfileFieldData]=None) -> None: field_data: Optional[ProfileFieldData]=None) -> None:
field.name = name field.name = name
field.hint = hint field.hint = hint
if field.field_type == CustomProfileField.CHOICE: if (field.field_type == CustomProfileField.CHOICE or
field.field_type == CustomProfileField.EXTERNAL_ACCOUNT):
field.field_data = ujson.dumps(field_data or {}) field.field_data = ujson.dumps(field_data or {})
field.save() field.save()
notify_realm_custom_profile_fields(realm, 'update') notify_realm_custom_profile_fields(realm, 'update')

View File

@ -53,7 +53,7 @@ from zerver.models import Client, Message, Realm, UserPresence, UserProfile, Cus
get_default_stream_groups, CustomProfileField, Stream get_default_stream_groups, CustomProfileField, Stream
from zproject.backends import email_auth_enabled, password_auth_enabled from zproject.backends import email_auth_enabled, password_auth_enabled
from version import ZULIP_VERSION from version import ZULIP_VERSION
from zerver.lib.external_accounts import DEFAULT_EXTERNAL_ACCOUNTS
def get_raw_user_data(realm: Realm, client_gravatar: bool) -> Dict[int, Dict[str, str]]: def get_raw_user_data(realm: Realm, client_gravatar: bool) -> Dict[int, Dict[str, str]]:
user_dicts = get_realm_user_dicts(realm.id) user_dicts = get_realm_user_dicts(realm.id)
@ -218,6 +218,8 @@ def fetch_initial_state_data(user_profile: UserProfile,
state['realm_plan_type'] = realm.plan_type state['realm_plan_type'] = realm.plan_type
state['plan_includes_wide_organization_logo'] = realm.plan_type != Realm.LIMITED state['plan_includes_wide_organization_logo'] = realm.plan_type != Realm.LIMITED
state['upgrade_text_for_wide_organization_logo'] = str(Realm.UPGRADE_TEXT_STANDARD) state['upgrade_text_for_wide_organization_logo'] = str(Realm.UPGRADE_TEXT_STANDARD)
state['realm_default_external_accounts'] = DEFAULT_EXTERNAL_ACCOUNTS
if realm.notifications_stream and not realm.notifications_stream.deactivated: if realm.notifications_stream and not realm.notifications_stream.deactivated:
notifications_stream = realm.notifications_stream notifications_stream = realm.notifications_stream
state['realm_notifications_stream_id'] = notifications_stream.id state['realm_notifications_stream_id'] = notifications_stream.id

View File

@ -0,0 +1,39 @@
"""
This module stores data for "External Account" custom profile field.
"""
from typing import Optional
from django.utils.translation import ugettext as _
from zerver.lib.validator import check_required_string, \
check_url_pattern, check_dict_only
from zerver.lib.types import ProfileFieldData
DEFAULT_EXTERNAL_ACCOUNTS = {
"twitter": {
"text": "Twitter",
"url_pattern": "https://twitter.com/%(username)s"
},
"github": {
"text": 'GitHub',
"url_pattern": "https://github.com/%(username)s"
},
}
def validate_external_account_field_data(field_data: ProfileFieldData) -> Optional[str]:
field_validator = check_dict_only(
[('subtype', check_required_string)],
[('url_pattern', check_url_pattern)],
)
error = field_validator('field_data', field_data)
if error:
return error
field_subtype = field_data.get('subtype')
if field_subtype not in DEFAULT_EXTERNAL_ACCOUNTS.keys():
if field_subtype == "custom":
if 'url_pattern' not in field_data.keys():
return _("Custom external account must define url pattern")
else:
return _("Invalid external account type")
return None

View File

@ -24,4 +24,4 @@ UserFieldElement = Tuple[int, str, RealmUserValidator, Callable[[Any], Any], str
FieldTypeData = List[Union[FieldElement, ExtendedFieldElement, UserFieldElement]] FieldTypeData = List[Union[FieldElement, ExtendedFieldElement, UserFieldElement]]
ProfileFieldData = Dict[str, Dict[str, str]] ProfileFieldData = Dict[str, Union[Dict[str, str], str]]

View File

@ -228,6 +228,21 @@ def check_url(var_name: str, val: object) -> Optional[str]:
except ValidationError: except ValidationError:
return _('%s is not a URL') % (var_name,) return _('%s is not a URL') % (var_name,)
def check_url_pattern(var_name: str, val: object) -> Optional[str]:
error = check_string(var_name, val)
if error:
return error
val = cast(str, val)
if val.count('%(username)s') != 1:
return _('username should appear exactly once in pattern.')
url_val = val.replace('%(username)s', 'username')
error = check_url(var_name, url_val)
if error:
return error
return None
def validate_choice_field_data(field_data: ProfileFieldData) -> Optional[str]: def validate_choice_field_data(field_data: ProfileFieldData) -> Optional[str]:
""" """
This function is used to validate the data sent to the server while This function is used to validate the data sent to the server while

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.20 on 2019-05-30 08:18
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('zerver', '0233_userprofile_avatar_hash'),
]
operations = [
migrations.AlterField(
model_name='customprofilefield',
name='field_type',
field=models.PositiveSmallIntegerField(choices=[(1, 'Short text'), (2, 'Long text'), (4, 'Date picker'), (5, 'Link'), (7, 'External account'), (3, 'List of options'), (6, 'Person picker')], default=1),
),
]

View File

@ -2613,6 +2613,7 @@ class CustomProfileField(models.Model):
DATE = 4 DATE = 4
URL = 5 URL = 5
USER = 6 USER = 6
EXTERNAL_ACCOUNT = 7
# These are the fields whose validators require more than var_name # These are the fields whose validators require more than var_name
# and value argument. i.e. CHOICE require field_data, USER require # and value argument. i.e. CHOICE require field_data, USER require
@ -2637,6 +2638,7 @@ class CustomProfileField(models.Model):
(LONG_TEXT, str(_('Long text')), check_long_string, str, "LONG_TEXT"), (LONG_TEXT, str(_('Long text')), check_long_string, str, "LONG_TEXT"),
(DATE, str(_('Date picker')), check_date, str, "DATE"), (DATE, str(_('Date picker')), check_date, str, "DATE"),
(URL, str(_('Link')), check_url, str, "URL"), (URL, str(_('Link')), check_url, str, "URL"),
(EXTERNAL_ACCOUNT, str(_('External account')), check_short_string, str, "EXTERNAL_ACCOUNT"),
] # type: FieldTypeData ] # type: FieldTypeData
ALL_FIELD_TYPES = FIELD_TYPE_DATA + CHOICE_FIELD_TYPE_DATA + USER_FIELD_TYPE_DATA ALL_FIELD_TYPES = FIELD_TYPE_DATA + CHOICE_FIELD_TYPE_DATA + USER_FIELD_TYPE_DATA

View File

@ -141,6 +141,103 @@ class CustomProfileFieldTest(ZulipTestCase):
result = self.client_post("/json/realm/profile_fields", info=data) result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_success(result) self.assert_json_success(result)
def test_create_external_account_field(self) -> None:
self.login(self.example_email("iago"))
realm = get_realm('zulip')
data = {} # type: Dict[str, Union[str, int, Dict[str, str]]]
data["name"] = "Twitter"
data["field_type"] = CustomProfileField.EXTERNAL_ACCOUNT
data['field_data'] = 'invalid'
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_error(result, "Bad value for 'field_data': invalid")
data['field_data'] = ujson.dumps({})
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_error(result, "subtype key is missing from field_data")
data["field_data"] = ujson.dumps({
'subtype': ''
})
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_error(result, 'field_data["subtype"] cannot be blank.')
data["field_data"] = ujson.dumps({
'subtype': '123'
})
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_error(result, 'Invalid external account type')
non_default_external_account = 'linkedin'
data["field_data"] = ujson.dumps({
'subtype': non_default_external_account
})
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_error(result, 'Invalid external account type')
data["field_data"] = ujson.dumps({
'subtype': 'twitter'
})
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_success(result)
twitter_field = CustomProfileField.objects.get(name="Twitter", realm=realm)
self.assertEqual(twitter_field.field_type, CustomProfileField.EXTERNAL_ACCOUNT)
self.assertEqual(twitter_field.name, "Twitter")
self.assertEqual(ujson.loads(twitter_field.field_data)['subtype'], 'twitter')
data['name'] = 'Reddit'
data["field_data"] = ujson.dumps({
'subtype': 'custom'
})
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_error(result, 'Custom external account must define url pattern')
data["field_data"] = ujson.dumps({
'subtype': 'custom',
'url_pattern': 123,
})
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_error(result, 'field_data["url_pattern"] is not a string')
data["field_data"] = ujson.dumps({
'subtype': 'custom',
'url_pattern': 'invalid',
})
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_error(result, 'username should appear exactly once in pattern.')
data["field_data"] = ujson.dumps({
'subtype': 'custom',
'url_pattern': 'https://www.reddit.com/%(username)s/user/%(username)s',
})
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_error(result, 'username should appear exactly once in pattern.')
data["field_data"] = ujson.dumps({
'subtype': 'custom',
'url_pattern': 'reddit.com/%(username)s',
})
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_error(result, 'field_data["url_pattern"] is not a URL')
data["field_data"] = ujson.dumps({
'subtype': 'custom',
'url_pattern': 'https://www.reddit.com/user/%(username)s',
})
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_success(result)
custom_field = CustomProfileField.objects.get(name="Reddit", realm=realm)
self.assertEqual(custom_field.field_type, CustomProfileField.EXTERNAL_ACCOUNT)
self.assertEqual(custom_field.name, "Reddit")
field_data = ujson.loads(custom_field.field_data)
self.assertEqual(field_data['subtype'], 'custom')
self.assertEqual(field_data['url_pattern'], 'https://www.reddit.com/user/%(username)s')
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_error(result, "A field with that name already exists.")
def test_not_realm_admin(self) -> None: def test_not_realm_admin(self) -> None:
self.login(self.example_email("hamlet")) self.login(self.example_email("hamlet"))
result = self.client_post("/json/realm/profile_fields") result = self.client_post("/json/realm/profile_fields")
@ -416,6 +513,7 @@ class CustomProfileFieldTest(ZulipTestCase):
('Birthday', '1909-3-5'), ('Birthday', '1909-3-5'),
('Favorite website', 'https://zulipchat.com'), ('Favorite website', 'https://zulipchat.com'),
('Mentor', [self.example_user("cordelia").id]), ('Mentor', [self.example_user("cordelia").id]),
('GitHub', 'zulip-mobile')
] ]
data = [] data = []

View File

@ -129,6 +129,7 @@ class HomeTest(ZulipTestCase):
"realm_bot_domain", "realm_bot_domain",
"realm_bots", "realm_bots",
"realm_create_stream_policy", "realm_create_stream_policy",
"realm_default_external_accounts",
"realm_default_language", "realm_default_language",
"realm_default_stream_groups", "realm_default_stream_groups",
"realm_default_streams", "realm_default_streams",

View File

@ -416,6 +416,7 @@ class PermissionTest(ZulipTestCase):
'Birthday': '1909-3-5', 'Birthday': '1909-3-5',
'Favorite website': 'https://zulipchat.com', 'Favorite website': 'https://zulipchat.com',
'Mentor': [cordelia.id], 'Mentor': [cordelia.id],
'GitHub': 'timabbott',
} }
for field_name in fields: for field_name in fields:
@ -500,7 +501,8 @@ class PermissionTest(ZulipTestCase):
'Favorite editor': None, 'Favorite editor': None,
'Birthday': None, 'Birthday': None,
'Favorite website': 'https://zulip.github.io', 'Favorite website': 'https://zulip.github.io',
'Mentor': [hamlet.id] 'Mentor': [hamlet.id],
'GitHub': 'timabbott',
} }
new_profile_data = [] new_profile_data = []
for field_name in fields: for field_name in fields:

View File

@ -22,6 +22,7 @@ from zerver.models import (UserProfile,
CustomProfileField, custom_profile_fields_for_realm) CustomProfileField, custom_profile_fields_for_realm)
from zerver.lib.exceptions import JsonableError from zerver.lib.exceptions import JsonableError
from zerver.lib.users import validate_user_custom_profile_data from zerver.lib.users import validate_user_custom_profile_data
from zerver.lib.external_accounts import validate_external_account_field_data
def list_realm_custom_profile_fields(request: HttpRequest, user_profile: UserProfile) -> HttpResponse: def list_realm_custom_profile_fields(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
fields = custom_profile_fields_for_realm(user_profile.realm_id) fields = custom_profile_fields_for_realm(user_profile.realm_id)
@ -50,11 +51,12 @@ def validate_custom_field_data(field_type: int,
if len(field_data) < 1: if len(field_data) < 1:
raise JsonableError(_("Field must have at least one choice.")) raise JsonableError(_("Field must have at least one choice."))
error = validate_choice_field_data(field_data) error = validate_choice_field_data(field_data)
elif field_type == CustomProfileField.EXTERNAL_ACCOUNT:
error = validate_external_account_field_data(field_data)
if error: if error:
raise JsonableError(error) raise JsonableError(error)
@require_realm_admin @require_realm_admin
@has_request_variables @has_request_variables
def create_realm_custom_profile_field(request: HttpRequest, def create_realm_custom_profile_field(request: HttpRequest,

View File

@ -29,6 +29,7 @@ from zerver.models import CustomProfileField, DefaultStream, Message, Realm, Rea
UserMessage, UserPresence, UserProfile, clear_database, \ UserMessage, UserPresence, UserProfile, clear_database, \
email_to_username, get_client, get_huddle, get_realm, get_stream, \ email_to_username, get_client, get_huddle, get_realm, get_stream, \
get_system_bot, get_user, get_user_profile_by_id get_system_bot, get_user, get_user_profile_by_id
from zerver.lib.types import ProfileFieldData
from scripts.lib.zulip_tools import get_or_create_dev_uuid_var_path from scripts.lib.zulip_tools import get_or_create_dev_uuid_var_path
@ -348,7 +349,7 @@ class Command(BaseCommand):
field_data = { field_data = {
'vim': {'text': 'Vim', 'order': '1'}, 'vim': {'text': 'Vim', 'order': '1'},
'emacs': {'text': 'Emacs', 'order': '2'}, 'emacs': {'text': 'Emacs', 'order': '2'},
} } # type: ProfileFieldData
favorite_editor = try_add_realm_custom_profile_field(zulip_realm, favorite_editor = try_add_realm_custom_profile_field(zulip_realm,
"Favorite editor", "Favorite editor",
CustomProfileField.CHOICE, CustomProfileField.CHOICE,
@ -360,6 +361,12 @@ class Command(BaseCommand):
hint="Or your personal blog's URL") hint="Or your personal blog's URL")
mentor = try_add_realm_custom_profile_field(zulip_realm, "Mentor", mentor = try_add_realm_custom_profile_field(zulip_realm, "Mentor",
CustomProfileField.USER) CustomProfileField.USER)
external_account_field_data = {
'subtype': 'github'
} # type: ProfileFieldData
github_profile = try_add_realm_custom_profile_field(zulip_realm, "GitHub",
CustomProfileField.EXTERNAL_ACCOUNT,
field_data=external_account_field_data)
# Fill in values for Iago and Hamlet # Fill in values for Iago and Hamlet
hamlet = get_user("hamlet@zulip.com", zulip_realm) hamlet = get_user("hamlet@zulip.com", zulip_realm)
@ -371,6 +378,7 @@ class Command(BaseCommand):
{"id": birthday.id, "value": "2000-1-1"}, {"id": birthday.id, "value": "2000-1-1"},
{"id": favorite_website.id, "value": "https://zulip.readthedocs.io/en/latest/"}, {"id": favorite_website.id, "value": "https://zulip.readthedocs.io/en/latest/"},
{"id": mentor.id, "value": [hamlet.id]}, {"id": mentor.id, "value": [hamlet.id]},
{"id": github_profile.id, "value": 'zulip'},
]) ])
do_update_user_custom_profile_data(hamlet, [ do_update_user_custom_profile_data(hamlet, [
{"id": phone_number.id, "value": "+0-11-23-456-7890"}, {"id": phone_number.id, "value": "+0-11-23-456-7890"},
@ -383,6 +391,7 @@ class Command(BaseCommand):
{"id": birthday.id, "value": "1900-1-1"}, {"id": birthday.id, "value": "1900-1-1"},
{"id": favorite_website.id, "value": "https://blog.zulig.org"}, {"id": favorite_website.id, "value": "https://blog.zulig.org"},
{"id": mentor.id, "value": [iago.id]}, {"id": mentor.id, "value": [iago.id]},
{"id": github_profile.id, "value": 'zulipbot'},
]) ])
else: else:
zulip_realm = get_realm("zulip") zulip_realm = get_realm("zulip")