custom_profile_fields: Add "display_in_profile_summary" field in model.

To allow `custom_profile_field` to display in user profile popover,
added new boolean field "display_in_profile_summary" in its model class.

In `custom_profile_fields.py`, functions are edited as per conditions,
like currently we can display max 2 `custom_profile_fields` except
`LONG_TEXT` and `USER` type fields.

Default external account custom profile fields made updatable for only
this new field, as previous they were not updatable.

Fixes part of: #21215
This commit is contained in:
yogesh sirsat 2022-07-13 00:34:47 +05:30 committed by Tim Abbott
parent 2e9cd20380
commit 543f36b7da
11 changed files with 234 additions and 17 deletions

View File

@ -20,6 +20,14 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 6.0
**Feature level 146**
* [`POST /realm/profile_fields`](/api/create-custom-profile-field),
[`GET /realm/profile_fields`](/api/get-custom-profile-fields): Added a
new parameter `display_in_profile_summary`, which clients use to
decide whether to display the field in a small/summary section of the
user's profile.
**Feature level 145**
* [`DELETE users/me/subscriptions`](/api/unsubscribe): Normal users can

View File

@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.4.3"
# Changes should be accompanied by documentation explaining what the
# new level means in templates/zerver/api/changelog.md, as well as
# "**Changes**" entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 145
API_FEATURE_LEVEL = 146
# 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

@ -26,7 +26,9 @@ def notify_realm_custom_profile_fields(realm: Realm) -> None:
def try_add_realm_default_custom_profile_field(
realm: Realm, field_subtype: str
realm: Realm,
field_subtype: str,
display_in_profile_summary: bool = False,
) -> CustomProfileField:
field_data = DEFAULT_EXTERNAL_ACCOUNTS[field_subtype]
custom_profile_field = CustomProfileField(
@ -35,6 +37,7 @@ def try_add_realm_default_custom_profile_field(
field_type=CustomProfileField.EXTERNAL_ACCOUNT,
hint=field_data.hint,
field_data=orjson.dumps(dict(subtype=field_subtype)).decode(),
display_in_profile_summary=display_in_profile_summary,
)
custom_profile_field.save()
custom_profile_field.order = custom_profile_field.id
@ -49,8 +52,14 @@ def try_add_realm_custom_profile_field(
field_type: int,
hint: str = "",
field_data: Optional[ProfileFieldData] = None,
display_in_profile_summary: bool = False,
) -> CustomProfileField:
custom_profile_field = CustomProfileField(realm=realm, name=name, field_type=field_type)
custom_profile_field = CustomProfileField(
realm=realm,
name=name,
field_type=field_type,
display_in_profile_summary=display_in_profile_summary,
)
custom_profile_field.hint = hint
if (
custom_profile_field.field_type == CustomProfileField.SELECT
@ -95,9 +104,11 @@ def try_update_realm_custom_profile_field(
name: str,
hint: str = "",
field_data: Optional[ProfileFieldData] = None,
display_in_profile_summary: bool = False,
) -> None:
field.name = name
field.hint = hint
field.display_in_profile_summary = display_in_profile_summary
if (
field.field_type == CustomProfileField.SELECT
or field.field_type == CustomProfileField.EXTERNAL_ACCOUNT

View File

@ -171,6 +171,9 @@ custom_profile_field_type = DictType(
("field_data", str),
("order", int),
],
optional_keys=[
("display_in_profile_summary", bool),
],
)
custom_profile_fields_event = event_dict_type(

View File

@ -29,11 +29,12 @@ RealmUserValidator = Callable[[int, object, bool], List[int]]
ProfileDataElementValue = Union[str, List[int]]
class ProfileDataElementBase(TypedDict):
class ProfileDataElementBase(TypedDict, total=False):
id: int
name: str
type: int
hint: str
display_in_profile_summary: bool
field_data: str
order: int

View File

@ -0,0 +1,18 @@
# Generated by Django 4.0.7 on 2022-09-19 17:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0411_alter_muteduser_muted_user_and_more"),
]
operations = [
migrations.AddField(
model_name="customprofilefield",
name="display_in_profile_summary",
field=models.BooleanField(default=False),
),
]

View File

@ -4540,13 +4540,20 @@ class CustomProfileField(models.Model):
HINT_MAX_LENGTH = 80
NAME_MAX_LENGTH = 40
MAX_DISPLAY_IN_PROFILE_SUMMARY_FIELDS = 2
id: int = models.AutoField(auto_created=True, primary_key=True, verbose_name="ID")
realm: Realm = models.ForeignKey(Realm, on_delete=CASCADE)
name: str = models.CharField(max_length=NAME_MAX_LENGTH)
hint: str = models.CharField(max_length=HINT_MAX_LENGTH, default="")
# Sort order for display of custom profile fields.
order: int = models.IntegerField(default=0)
# Whether the field should be displayed in smaller summary
# sections of a page displaying custom profile fields.
display_in_profile_summary: bool = models.BooleanField(default=False)
SHORT_TEXT = 1
LONG_TEXT = 2
SELECT = 3
@ -4619,7 +4626,7 @@ class CustomProfileField(models.Model):
unique_together = ("realm", "name")
def as_dict(self) -> ProfileDataElementBase:
return {
data_as_dict: ProfileDataElementBase = {
"id": self.id,
"name": self.name,
"type": self.field_type,
@ -4627,6 +4634,10 @@ class CustomProfileField(models.Model):
"field_data": self.field_data,
"order": self.order,
}
if self.display_in_profile_summary:
data_as_dict["display_in_profile_summary"] = True
return data_as_dict
def is_renderable(self) -> bool:
if self.field_type in [CustomProfileField.SHORT_TEXT, CustomProfileField.LONG_TEXT]:

View File

@ -1666,6 +1666,7 @@ paths:
"hint": "",
"field_data": '{"0":{"text":"Vim","order":"1"},"1":{"text":"Emacs","order":"2"}}',
"order": 4,
"display_in_profile_summary": true,
},
{
"id": 5,
@ -1682,6 +1683,7 @@ paths:
"hint": "Or your personal blog's URL",
"field_data": "",
"order": 6,
"display_in_profile_summary": true,
},
{
"id": 7,
@ -8250,6 +8252,7 @@ paths:
"hint": "",
"field_data": '{"0":{"text":"Vim","order":"1"},"1":{"text":"Emacs","order":"2"}}',
"order": 4,
"display_in_profile_summary": true,
},
{
"id": 5,
@ -8266,6 +8269,7 @@ paths:
"hint": "Or your personal blog's URL",
"field_data": "",
"order": 6,
"display_in_profile_summary": true,
},
{
"id": 7,
@ -8378,6 +8382,25 @@ paths:
"python": {"text": "Python", "order": "1"},
"java": {"text": "Java", "order": "2"},
}
- name: display_in_profile_summary
in: query
description: |
Whether clients should display this profile field in a summary section of a
user's profile (or in a more easily accessible "small profile").
At most 2 profile fields may have this property be true in a given
organization. The "Long text" [profile field types][profile-field-types]
profile field types cannot be selected to be displayed in profile summaries.
The "Person picker" profile field is also not supported, but that is likely to
be temporary.
[profile-field-types]: /help/add-custom-profile-fields#profile-field-types
**Changes**: New in Zulip 6.0 (feature level 146).
schema:
type: boolean
example: true
responses:
"200":
description: Success.
@ -15418,6 +15441,15 @@ components:
dropdown UI for individual users to select an option.
The interface for field type 7 is not yet stabilized.
display_in_profile_summary:
type: boolean
description: |
Whether the custom profile field, display or not in the user profile summary.
Currently it's value not allowed to be `true` of `Long text` and `Person picker`
[profile field types](/help/add-custom-profile-fields#profile-field-types).
**Changes**: New in Zulip 6.0 (feature level 146).
Hotspot:
type: object
additionalProperties: false

View File

@ -60,11 +60,19 @@ class CreateCustomProfileFieldTest(CustomProfileFieldTestCase):
data["hint"] = "*" * 81
data["field_type"] = CustomProfileField.SHORT_TEXT
result = self.client_post("/json/realm/profile_fields", info=data)
msg = "hint is too long (limit: 80 characters)"
self.assert_json_error(result, msg)
self.assert_json_error(result, "hint is too long (limit: 80 characters)")
data["name"] = "Phone"
data["hint"] = "Contact number"
data["field_type"] = CustomProfileField.LONG_TEXT
data["display_in_profile_summary"] = "true"
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_error(result, "Field type not supported for display in profile summary.")
data["field_type"] = CustomProfileField.USER
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_error(result, "Field type not supported for display in profile summary.")
data["field_type"] = CustomProfileField.SHORT_TEXT
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_success(result)
@ -75,12 +83,19 @@ class CreateCustomProfileFieldTest(CustomProfileFieldTestCase):
data["name"] = "Name "
data["hint"] = "Some name"
data["field_type"] = CustomProfileField.SHORT_TEXT
data["display_in_profile_summary"] = "true"
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_success(result)
field = CustomProfileField.objects.get(name="Name", realm=realm)
self.assertEqual(field.id, field.order)
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_error(
result, "Only 2 custom profile fields can be displayed in the profile summary."
)
data["display_in_profile_summary"] = "false"
result = self.client_post("/json/realm/profile_fields", info=data)
self.assert_json_error(result, "A field with that label already exists.")
@ -202,7 +217,7 @@ class CreateCustomProfileFieldTest(CustomProfileFieldTestCase):
)
self.assert_json_success(result)
# Default external account field data cannot be updated
# Default external account field data cannot be updated except "display_in_profile_summary" field
field = CustomProfileField.objects.get(name="Twitter username", realm=realm)
result = self.client_patch(
f"/json/realm/profile_fields/{field.id}",
@ -210,6 +225,18 @@ class CreateCustomProfileFieldTest(CustomProfileFieldTestCase):
)
self.assert_json_error(result, "Default custom field cannot be updated.")
result = self.client_patch(
f"/json/realm/profile_fields/{field.id}",
info={
"name": field.name,
"hint": field.hint,
"field_type": field_type,
"field_data": field_data,
"display_in_profile_summary": "true",
},
)
self.assert_json_success(result)
result = self.client_delete(f"/json/realm/profile_fields/{field.id}")
self.assert_json_success(result)
@ -470,6 +497,18 @@ class UpdateCustomProfileFieldTest(CustomProfileFieldTestCase):
info={
"name": "New phone number",
"hint": "New contact number",
"display_in_profile_summary": "invalid value",
},
)
msg = 'Argument "display_in_profile_summary" is not valid JSON.'
self.assert_json_error(result, msg)
result = self.client_patch(
f"/json/realm/profile_fields/{field.id}",
info={
"name": "New phone number",
"hint": "New contact number",
"display_in_profile_summary": "true",
},
)
self.assert_json_success(result)
@ -479,14 +518,16 @@ class UpdateCustomProfileFieldTest(CustomProfileFieldTestCase):
self.assertEqual(field.name, "New phone number")
self.assertEqual(field.hint, "New contact number")
self.assertEqual(field.field_type, CustomProfileField.SHORT_TEXT)
self.assertEqual(field.display_in_profile_summary, True)
result = self.client_patch(
f"/json/realm/profile_fields/{field.id}",
info={"name": "Name "},
info={"name": "Name ", "display_in_profile_summary": "true"},
)
self.assert_json_success(result)
field.refresh_from_db()
self.assertEqual(field.name, "Name")
self.assertEqual(field.display_in_profile_summary, True)
field = CustomProfileField.objects.get(name="Favorite editor", realm=realm)
result = self.client_patch(
@ -516,10 +557,27 @@ class UpdateCustomProfileFieldTest(CustomProfileFieldTestCase):
).decode()
result = self.client_patch(
f"/json/realm/profile_fields/{field.id}",
info={"name": "Favorite editor", "field_data": field_data},
info={
"name": "Favorite editor",
"field_data": field_data,
"display_in_profile_summary": "true",
},
)
self.assert_json_success(result)
field = CustomProfileField.objects.get(name="Birthday", realm=realm)
result = self.client_patch(
f"/json/realm/profile_fields/{field.id}",
info={
"name": field.name,
"hint": field.hint,
"display_in_profile_summary": "true",
},
)
self.assert_json_error(
result, "Only 2 custom profile fields can be displayed in the profile summary."
)
def test_update_is_aware_of_uniqueness(self) -> None:
self.login("iago")
realm = get_realm("zulip")

View File

@ -985,9 +985,12 @@ class NormalActionsTest(BaseAction):
field = realm.customprofilefield_set.get(realm=realm, name="Biography")
name = field.name
hint = "Biography of the user"
display_in_profile_summary = False
events = self.verify_action(
lambda: try_update_realm_custom_profile_field(realm, field, name, hint=hint)
lambda: try_update_realm_custom_profile_field(
realm, field, name, hint=hint, display_in_profile_summary=display_in_profile_summary
)
)
check_custom_profile_fields("events[0]", events[0])

View File

@ -1,4 +1,4 @@
from typing import List, cast
from typing import List, Optional, cast
import orjson
from django.core.exceptions import ValidationError
@ -23,6 +23,7 @@ from zerver.lib.response import json_success
from zerver.lib.types import ProfileDataElementUpdateDict, ProfileFieldData, Validator
from zerver.lib.users import validate_user_custom_profile_data
from zerver.lib.validator import (
check_bool,
check_capped_string,
check_dict,
check_dict_only,
@ -70,6 +71,19 @@ def validate_custom_field_data(field_type: int, field_data: ProfileFieldData) ->
raise JsonableError(error.message)
def validate_display_in_profile_summary_field(
field_type: int, display_in_profile_summary: bool
) -> None:
if not display_in_profile_summary:
return
# The LONG_TEXT field type doesn't make sense visually for profile
# field summaries. The USER field type will require some further
# client support.
if field_type == CustomProfileField.LONG_TEXT or field_type == CustomProfileField.USER:
raise JsonableError(_("Field type not supported for display in profile summary."))
def is_default_external_field(field_type: int, field_data: ProfileFieldData) -> bool:
if field_type != CustomProfileField.EXTERNAL_ACCOUNT:
return False
@ -79,7 +93,11 @@ def is_default_external_field(field_type: int, field_data: ProfileFieldData) ->
def validate_custom_profile_field(
name: str, hint: str, field_type: int, field_data: ProfileFieldData
name: str,
hint: str,
field_type: int,
field_data: ProfileFieldData,
display_in_profile_summary: bool,
) -> None:
# Validate field data
validate_custom_field_data(field_type, field_data)
@ -94,12 +112,36 @@ def validate_custom_profile_field(
if field_type not in field_types:
raise JsonableError(_("Invalid field type."))
validate_display_in_profile_summary_field(field_type, display_in_profile_summary)
check_profile_field_data: Validator[ProfileFieldData] = check_dict(
value_validator=check_union([check_dict(value_validator=check_string), check_string])
)
def update_only_display_in_profile_summary(
requested_name: str,
requested_hint: str,
requested_field_data: ProfileFieldData,
existing_field: CustomProfileField,
) -> bool:
if (
requested_name != existing_field.name
or requested_hint != existing_field.hint
or requested_field_data != orjson.loads(existing_field.field_data)
):
return False
return True
def display_in_profile_summary_limit_reached(profile_field_id: Optional[int] = None) -> bool:
query = CustomProfileField.objects.filter(display_in_profile_summary=True)
if profile_field_id is not None:
query = query.exclude(id=profile_field_id)
return query.count() >= CustomProfileField.MAX_DISPLAY_IN_PROFILE_SUMMARY_FIELDS
@require_realm_admin
@has_request_variables
def create_realm_custom_profile_field(
@ -109,8 +151,14 @@ def create_realm_custom_profile_field(
hint: str = REQ(default=""),
field_data: ProfileFieldData = REQ(default={}, json_validator=check_profile_field_data),
field_type: int = REQ(json_validator=check_int),
display_in_profile_summary: bool = REQ(default=False, json_validator=check_bool),
) -> HttpResponse:
validate_custom_profile_field(name, hint, field_type, field_data)
if display_in_profile_summary and display_in_profile_summary_limit_reached():
raise JsonableError(
_("Only 2 custom profile fields can be displayed in the profile summary.")
)
validate_custom_profile_field(name, hint, field_type, field_data, display_in_profile_summary)
try:
if is_default_external_field(field_type, field_data):
field_subtype = field_data["subtype"]
@ -118,6 +166,7 @@ def create_realm_custom_profile_field(
field = try_add_realm_default_custom_profile_field(
realm=user_profile.realm,
field_subtype=field_subtype,
display_in_profile_summary=display_in_profile_summary,
)
return json_success(request, data={"id": field.id})
else:
@ -127,6 +176,7 @@ def create_realm_custom_profile_field(
field_data=field_data,
field_type=field_type,
hint=hint,
display_in_profile_summary=display_in_profile_summary,
)
return json_success(request, data={"id": field.id})
except IntegrityError:
@ -155,6 +205,7 @@ def update_realm_custom_profile_field(
name: str = REQ(default="", converter=lambda var_name, x: x.strip()),
hint: str = REQ(default=""),
field_data: ProfileFieldData = REQ(default={}, json_validator=check_profile_field_data),
display_in_profile_summary: bool = REQ(default=False, json_validator=check_bool),
) -> HttpResponse:
realm = user_profile.realm
try:
@ -162,13 +213,34 @@ def update_realm_custom_profile_field(
except CustomProfileField.DoesNotExist:
raise JsonableError(_("Field id {id} not found.").format(id=field_id))
if display_in_profile_summary and display_in_profile_summary_limit_reached(field.id):
raise JsonableError(
_("Only 2 custom profile fields can be displayed in the profile summary.")
)
if field.field_type == CustomProfileField.EXTERNAL_ACCOUNT:
if is_default_external_field(field.field_type, orjson.loads(field.field_data)):
# HACK: Allow changing the display_in_profile_summary property
# of default external account types, but not any others.
#
# TODO: Make the name/hint/field_data parameters optional, and
# just require that None was passed for all of them for this case.
if is_default_external_field(
field.field_type, orjson.loads(field.field_data)
) and not update_only_display_in_profile_summary(name, hint, field_data, field):
raise JsonableError(_("Default custom field cannot be updated."))
validate_custom_profile_field(name, hint, field.field_type, field_data)
validate_custom_profile_field(
name, hint, field.field_type, field_data, display_in_profile_summary
)
try:
try_update_realm_custom_profile_field(realm, field, name, hint=hint, field_data=field_data)
try_update_realm_custom_profile_field(
realm,
field,
name,
hint=hint,
field_data=field_data,
display_in_profile_summary=display_in_profile_summary,
)
except IntegrityError:
raise JsonableError(_("A field with that label already exists."))
return json_success(request)