From 27c0b507af853791929f7533362dbc2bb24cb6ae Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Fri, 15 Dec 2023 11:57:08 -0800 Subject: [PATCH] models: Extract zerver.models.custom_profile_fields. Signed-off-by: Anders Kaseorg --- zerver/actions/custom_profile_fields.py | 9 +- zerver/lib/events.py | 2 +- zerver/models/__init__.py | 193 +---------------------- zerver/models/custom_profile_fields.py | 193 +++++++++++++++++++++++ zerver/models/users.py | 3 +- zerver/tests/test_custom_profile_data.py | 8 +- zerver/tests/test_users.py | 2 +- zerver/views/custom_profile_fields.py | 3 +- zproject/backends.py | 2 +- 9 files changed, 209 insertions(+), 206 deletions(-) create mode 100644 zerver/models/custom_profile_fields.py diff --git a/zerver/actions/custom_profile_fields.py b/zerver/actions/custom_profile_fields.py index c01ace2ba0..5fab80482c 100644 --- a/zerver/actions/custom_profile_fields.py +++ b/zerver/actions/custom_profile_fields.py @@ -9,13 +9,8 @@ from zerver.lib.external_accounts import DEFAULT_EXTERNAL_ACCOUNTS from zerver.lib.streams import render_stream_description from zerver.lib.types import ProfileDataElementUpdateDict, ProfileFieldData from zerver.lib.users import get_user_ids_who_can_access_user -from zerver.models import ( - CustomProfileField, - CustomProfileFieldValue, - Realm, - UserProfile, - custom_profile_fields_for_realm, -) +from zerver.models import CustomProfileField, CustomProfileFieldValue, Realm, UserProfile +from zerver.models.custom_profile_fields import custom_profile_fields_for_realm from zerver.models.users import active_user_ids from zerver.tornado.django_api import send_event diff --git a/zerver/lib/events.py b/zerver/lib/events.py index 5b683d62fe..d2cd1eaa16 100644 --- a/zerver/lib/events.py +++ b/zerver/lib/events.py @@ -79,9 +79,9 @@ from zerver.models import ( UserProfile, UserStatus, UserTopic, - custom_profile_fields_for_realm, ) from zerver.models.constants import MAX_TOPIC_NAME_LENGTH +from zerver.models.custom_profile_fields import custom_profile_fields_for_realm from zerver.models.linkifiers import linkifiers_for_realm from zerver.models.realm_emoji import get_all_custom_emoji_for_realm from zerver.models.realm_playgrounds import get_realm_playgrounds diff --git a/zerver/models/__init__.py b/zerver/models/__init__.py index 41d198ee4f..a05103cac5 100644 --- a/zerver/models/__init__.py +++ b/zerver/models/__init__.py @@ -1,15 +1,11 @@ -from typing import Any, Callable, Dict, List, Tuple, TypeVar, Union +from typing import Dict, List, Tuple, TypeVar, Union -import orjson -from django.core.exceptions import ValidationError from django.db import models from django.db.backends.base.base import BaseDatabaseWrapper -from django.db.models import CASCADE, QuerySet +from django.db.models import CASCADE from django.db.models.signals import post_delete, post_save from django.db.models.sql.compiler import SQLCompiler -from django.utils.translation import gettext as _ -from django.utils.translation import gettext_lazy -from django_stubs_ext import StrPromise, ValuesQuerySet +from django_stubs_ext import ValuesQuerySet from typing_extensions import override from zerver.lib.cache import ( @@ -17,26 +13,9 @@ from zerver.lib.cache import ( realm_alert_words_automaton_cache_key, realm_alert_words_cache_key, ) -from zerver.lib.types import ( - ExtendedFieldElement, - ExtendedValidator, - FieldElement, - ProfileDataElementBase, - ProfileDataElementValue, - RealmUserValidator, - UserFieldElement, - Validator, -) -from zerver.lib.validator import ( - check_date, - check_int, - check_list, - check_long_string, - check_short_string, - check_url, - validate_select_field, -) from zerver.models.clients import Client as Client +from zerver.models.custom_profile_fields import CustomProfileField as CustomProfileField +from zerver.models.custom_profile_fields import CustomProfileFieldValue as CustomProfileFieldValue from zerver.models.drafts import Draft as Draft from zerver.models.groups import GroupGroupMembership as GroupGroupMembership from zerver.models.groups import UserGroup as UserGroup @@ -96,7 +75,6 @@ from zerver.models.user_topics import UserTopic as UserTopic from zerver.models.users import RealmUserDefault as RealmUserDefault from zerver.models.users import UserBaseSettings as UserBaseSettings from zerver.models.users import UserProfile as UserProfile -from zerver.models.users import get_user_profile_by_id_in_realm @models.Field.register_lookup @@ -152,167 +130,6 @@ def query_for_ids( return query -def check_valid_user_ids(realm_id: int, val: object, allow_deactivated: bool = False) -> List[int]: - user_ids = check_list(check_int)("User IDs", val) - realm = Realm.objects.get(id=realm_id) - for user_id in user_ids: - # TODO: Structurally, we should be doing a bulk fetch query to - # get the users here, not doing these in a loop. But because - # this is a rarely used feature and likely to never have more - # than a handful of users, it's probably mostly OK. - try: - user_profile = get_user_profile_by_id_in_realm(user_id, realm) - except UserProfile.DoesNotExist: - raise ValidationError(_("Invalid user ID: {user_id}").format(user_id=user_id)) - - if not allow_deactivated and not user_profile.is_active: - raise ValidationError( - _("User with ID {user_id} is deactivated").format(user_id=user_id) - ) - - if user_profile.is_bot: - raise ValidationError(_("User with ID {user_id} is a bot").format(user_id=user_id)) - - return user_ids - - -class CustomProfileField(models.Model): - """Defines a form field for the per-realm custom profile fields feature. - - See CustomProfileFieldValue for an individual user's values for one of - these fields. - """ - - HINT_MAX_LENGTH = 80 - NAME_MAX_LENGTH = 40 - MAX_DISPLAY_IN_PROFILE_SUMMARY_FIELDS = 2 - - realm = models.ForeignKey(Realm, on_delete=CASCADE) - name = models.CharField(max_length=NAME_MAX_LENGTH) - hint = models.CharField(max_length=HINT_MAX_LENGTH, default="") - - # Sort order for display of custom profile fields. - order = 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 = models.BooleanField(default=False) - - SHORT_TEXT = 1 - LONG_TEXT = 2 - SELECT = 3 - DATE = 4 - URL = 5 - USER = 6 - EXTERNAL_ACCOUNT = 7 - PRONOUNS = 8 - - # These are the fields whose validators require more than var_name - # and value argument. i.e. SELECT require field_data, USER require - # realm as argument. - SELECT_FIELD_TYPE_DATA: List[ExtendedFieldElement] = [ - (SELECT, gettext_lazy("List of options"), validate_select_field, str, "SELECT"), - ] - USER_FIELD_TYPE_DATA: List[UserFieldElement] = [ - (USER, gettext_lazy("Person picker"), check_valid_user_ids, orjson.loads, "USER"), - ] - - SELECT_FIELD_VALIDATORS: Dict[int, ExtendedValidator] = { - item[0]: item[2] for item in SELECT_FIELD_TYPE_DATA - } - USER_FIELD_VALIDATORS: Dict[int, RealmUserValidator] = { - item[0]: item[2] for item in USER_FIELD_TYPE_DATA - } - - FIELD_TYPE_DATA: List[FieldElement] = [ - # Type, display name, validator, converter, keyword - (SHORT_TEXT, gettext_lazy("Short text"), check_short_string, str, "SHORT_TEXT"), - (LONG_TEXT, gettext_lazy("Long text"), check_long_string, str, "LONG_TEXT"), - (DATE, gettext_lazy("Date picker"), check_date, str, "DATE"), - (URL, gettext_lazy("Link"), check_url, str, "URL"), - ( - EXTERNAL_ACCOUNT, - gettext_lazy("External account"), - check_short_string, - str, - "EXTERNAL_ACCOUNT", - ), - (PRONOUNS, gettext_lazy("Pronouns"), check_short_string, str, "PRONOUNS"), - ] - - ALL_FIELD_TYPES = [*FIELD_TYPE_DATA, *SELECT_FIELD_TYPE_DATA, *USER_FIELD_TYPE_DATA] - - FIELD_VALIDATORS: Dict[int, Validator[ProfileDataElementValue]] = { - item[0]: item[2] for item in FIELD_TYPE_DATA - } - FIELD_CONVERTERS: Dict[int, Callable[[Any], Any]] = { - item[0]: item[3] for item in ALL_FIELD_TYPES - } - FIELD_TYPE_CHOICES: List[Tuple[int, StrPromise]] = [ - (item[0], item[1]) for item in ALL_FIELD_TYPES - ] - - field_type = models.PositiveSmallIntegerField( - choices=FIELD_TYPE_CHOICES, - default=SHORT_TEXT, - ) - - # A JSON blob of any additional data needed to define the field beyond - # type/name/hint. - # - # The format depends on the type. Field types SHORT_TEXT, LONG_TEXT, - # DATE, URL, and USER leave this empty. Fields of type SELECT store the - # choices' descriptions. - # - # Note: There is no performance overhead of using TextField in PostgreSQL. - # See https://www.postgresql.org/docs/9.0/static/datatype-character.html - field_data = models.TextField(default="") - - class Meta: - unique_together = ("realm", "name") - - @override - def __str__(self) -> str: - return f"{self.realm!r} {self.name} {self.field_type} {self.order}" - - def as_dict(self) -> ProfileDataElementBase: - data_as_dict: ProfileDataElementBase = { - "id": self.id, - "name": self.name, - "type": self.field_type, - "hint": self.hint, - "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]: - return True - return False - - -def custom_profile_fields_for_realm(realm_id: int) -> QuerySet[CustomProfileField]: - return CustomProfileField.objects.filter(realm=realm_id).order_by("order") - - -class CustomProfileFieldValue(models.Model): - user_profile = models.ForeignKey(UserProfile, on_delete=CASCADE) - field = models.ForeignKey(CustomProfileField, on_delete=CASCADE) - value = models.TextField() - rendered_value = models.TextField(null=True, default=None) - - class Meta: - unique_together = ("user_profile", "field") - - @override - def __str__(self) -> str: - return f"{self.user_profile!r} {self.field!r} {self.value}" - - # Interfaces for services # They provide additional functionality like parsing message to obtain query URL, data to be sent to URL, # and parsing the response. diff --git a/zerver/models/custom_profile_fields.py b/zerver/models/custom_profile_fields.py new file mode 100644 index 0000000000..8c5e27a6ad --- /dev/null +++ b/zerver/models/custom_profile_fields.py @@ -0,0 +1,193 @@ +from typing import Any, Callable, Dict, List, Tuple + +import orjson +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import CASCADE, QuerySet +from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy +from django_stubs_ext import StrPromise +from typing_extensions import override + +from zerver.lib.types import ( + ExtendedFieldElement, + ExtendedValidator, + FieldElement, + ProfileDataElementBase, + ProfileDataElementValue, + RealmUserValidator, + UserFieldElement, + Validator, +) +from zerver.lib.validator import ( + check_date, + check_int, + check_list, + check_long_string, + check_short_string, + check_url, + validate_select_field, +) +from zerver.models.realms import Realm +from zerver.models.users import UserProfile, get_user_profile_by_id_in_realm + + +def check_valid_user_ids(realm_id: int, val: object, allow_deactivated: bool = False) -> List[int]: + user_ids = check_list(check_int)("User IDs", val) + realm = Realm.objects.get(id=realm_id) + for user_id in user_ids: + # TODO: Structurally, we should be doing a bulk fetch query to + # get the users here, not doing these in a loop. But because + # this is a rarely used feature and likely to never have more + # than a handful of users, it's probably mostly OK. + try: + user_profile = get_user_profile_by_id_in_realm(user_id, realm) + except UserProfile.DoesNotExist: + raise ValidationError(_("Invalid user ID: {user_id}").format(user_id=user_id)) + + if not allow_deactivated and not user_profile.is_active: + raise ValidationError( + _("User with ID {user_id} is deactivated").format(user_id=user_id) + ) + + if user_profile.is_bot: + raise ValidationError(_("User with ID {user_id} is a bot").format(user_id=user_id)) + + return user_ids + + +class CustomProfileField(models.Model): + """Defines a form field for the per-realm custom profile fields feature. + + See CustomProfileFieldValue for an individual user's values for one of + these fields. + """ + + HINT_MAX_LENGTH = 80 + NAME_MAX_LENGTH = 40 + MAX_DISPLAY_IN_PROFILE_SUMMARY_FIELDS = 2 + + realm = models.ForeignKey(Realm, on_delete=CASCADE) + name = models.CharField(max_length=NAME_MAX_LENGTH) + hint = models.CharField(max_length=HINT_MAX_LENGTH, default="") + + # Sort order for display of custom profile fields. + order = 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 = models.BooleanField(default=False) + + SHORT_TEXT = 1 + LONG_TEXT = 2 + SELECT = 3 + DATE = 4 + URL = 5 + USER = 6 + EXTERNAL_ACCOUNT = 7 + PRONOUNS = 8 + + # These are the fields whose validators require more than var_name + # and value argument. i.e. SELECT require field_data, USER require + # realm as argument. + SELECT_FIELD_TYPE_DATA: List[ExtendedFieldElement] = [ + (SELECT, gettext_lazy("List of options"), validate_select_field, str, "SELECT"), + ] + USER_FIELD_TYPE_DATA: List[UserFieldElement] = [ + (USER, gettext_lazy("Person picker"), check_valid_user_ids, orjson.loads, "USER"), + ] + + SELECT_FIELD_VALIDATORS: Dict[int, ExtendedValidator] = { + item[0]: item[2] for item in SELECT_FIELD_TYPE_DATA + } + USER_FIELD_VALIDATORS: Dict[int, RealmUserValidator] = { + item[0]: item[2] for item in USER_FIELD_TYPE_DATA + } + + FIELD_TYPE_DATA: List[FieldElement] = [ + # Type, display name, validator, converter, keyword + (SHORT_TEXT, gettext_lazy("Short text"), check_short_string, str, "SHORT_TEXT"), + (LONG_TEXT, gettext_lazy("Long text"), check_long_string, str, "LONG_TEXT"), + (DATE, gettext_lazy("Date picker"), check_date, str, "DATE"), + (URL, gettext_lazy("Link"), check_url, str, "URL"), + ( + EXTERNAL_ACCOUNT, + gettext_lazy("External account"), + check_short_string, + str, + "EXTERNAL_ACCOUNT", + ), + (PRONOUNS, gettext_lazy("Pronouns"), check_short_string, str, "PRONOUNS"), + ] + + ALL_FIELD_TYPES = [*FIELD_TYPE_DATA, *SELECT_FIELD_TYPE_DATA, *USER_FIELD_TYPE_DATA] + + FIELD_VALIDATORS: Dict[int, Validator[ProfileDataElementValue]] = { + item[0]: item[2] for item in FIELD_TYPE_DATA + } + FIELD_CONVERTERS: Dict[int, Callable[[Any], Any]] = { + item[0]: item[3] for item in ALL_FIELD_TYPES + } + FIELD_TYPE_CHOICES: List[Tuple[int, StrPromise]] = [ + (item[0], item[1]) for item in ALL_FIELD_TYPES + ] + + field_type = models.PositiveSmallIntegerField( + choices=FIELD_TYPE_CHOICES, + default=SHORT_TEXT, + ) + + # A JSON blob of any additional data needed to define the field beyond + # type/name/hint. + # + # The format depends on the type. Field types SHORT_TEXT, LONG_TEXT, + # DATE, URL, and USER leave this empty. Fields of type SELECT store the + # choices' descriptions. + # + # Note: There is no performance overhead of using TextField in PostgreSQL. + # See https://www.postgresql.org/docs/9.0/static/datatype-character.html + field_data = models.TextField(default="") + + class Meta: + unique_together = ("realm", "name") + + @override + def __str__(self) -> str: + return f"{self.realm!r} {self.name} {self.field_type} {self.order}" + + def as_dict(self) -> ProfileDataElementBase: + data_as_dict: ProfileDataElementBase = { + "id": self.id, + "name": self.name, + "type": self.field_type, + "hint": self.hint, + "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]: + return True + return False + + +def custom_profile_fields_for_realm(realm_id: int) -> QuerySet[CustomProfileField]: + return CustomProfileField.objects.filter(realm=realm_id).order_by("order") + + +class CustomProfileFieldValue(models.Model): + user_profile = models.ForeignKey(UserProfile, on_delete=CASCADE) + field = models.ForeignKey(CustomProfileField, on_delete=CASCADE) + value = models.TextField() + rendered_value = models.TextField(null=True, default=None) + + class Meta: + unique_together = ("user_profile", "field") + + @override + def __str__(self) -> str: + return f"{self.user_profile!r} {self.field!r} {self.value}" diff --git a/zerver/models/users.py b/zerver/models/users.py index 26d7b5f93e..f731ba25e2 100644 --- a/zerver/models/users.py +++ b/zerver/models/users.py @@ -583,7 +583,8 @@ class UserProfile(AbstractBaseUser, PermissionsMixin, UserBaseSettings): return str(self.ROLE_ID_TO_NAME_MAP[self.role]) def profile_data(self) -> ProfileData: - from zerver.models import CustomProfileFieldValue, custom_profile_fields_for_realm + from zerver.models import CustomProfileFieldValue + from zerver.models.custom_profile_fields import custom_profile_fields_for_realm values = CustomProfileFieldValue.objects.filter(user_profile=self) user_data = { diff --git a/zerver/tests/test_custom_profile_data.py b/zerver/tests/test_custom_profile_data.py index e83d026015..6b8268b24f 100644 --- a/zerver/tests/test_custom_profile_data.py +++ b/zerver/tests/test_custom_profile_data.py @@ -15,12 +15,8 @@ from zerver.lib.external_accounts import DEFAULT_EXTERNAL_ACCOUNTS from zerver.lib.markdown import markdown_convert from zerver.lib.test_classes import ZulipTestCase from zerver.lib.types import ProfileDataElementUpdateDict, ProfileDataElementValue -from zerver.models import ( - CustomProfileField, - CustomProfileFieldValue, - UserProfile, - custom_profile_fields_for_realm, -) +from zerver.models import CustomProfileField, CustomProfileFieldValue, UserProfile +from zerver.models.custom_profile_fields import custom_profile_fields_for_realm from zerver.models.realms import get_realm diff --git a/zerver/tests/test_users.py b/zerver/tests/test_users.py index 87036625c8..45b2293bb2 100644 --- a/zerver/tests/test_users.py +++ b/zerver/tests/test_users.py @@ -72,9 +72,9 @@ from zerver.models import ( UserGroupMembership, UserProfile, UserTopic, - check_valid_user_ids, ) from zerver.models.clients import get_client +from zerver.models.custom_profile_fields import check_valid_user_ids from zerver.models.groups import SystemGroups from zerver.models.prereg_users import filter_to_valid_prereg_users from zerver.models.realms import InvalidFakeEmailDomainError, get_fake_email_domain, get_realm diff --git a/zerver/views/custom_profile_fields.py b/zerver/views/custom_profile_fields.py index 7618124734..aca41fffac 100644 --- a/zerver/views/custom_profile_fields.py +++ b/zerver/views/custom_profile_fields.py @@ -33,7 +33,8 @@ from zerver.lib.validator import ( check_union, validate_select_field_data, ) -from zerver.models import CustomProfileField, Realm, UserProfile, custom_profile_fields_for_realm +from zerver.models import CustomProfileField, Realm, UserProfile +from zerver.models.custom_profile_fields import custom_profile_fields_for_realm def list_realm_custom_profile_fields( diff --git a/zproject/backends.py b/zproject/backends.py index ecec01c762..f9a8ebe606 100644 --- a/zproject/backends.py +++ b/zproject/backends.py @@ -109,8 +109,8 @@ from zerver.models import ( UserGroup, UserGroupMembership, UserProfile, - custom_profile_fields_for_realm, ) +from zerver.models.custom_profile_fields import custom_profile_fields_for_realm from zerver.models.realms import ( DisposableEmailError, DomainNotAllowedForRealmError,