diff --git a/src/sentry/api/endpoints/user_details.py b/src/sentry/api/endpoints/user_details.py index ef3ae397d54564..d325f7a2dfc23d 100644 --- a/src/sentry/api/endpoints/user_details.py +++ b/src/sentry/api/endpoints/user_details.py @@ -21,13 +21,13 @@ from sentry.api.serializers.rest_framework import CamelSnakeModelSerializer from sentry.auth.elevated_mode import has_elevated_mode from sentry.constants import LANGUAGES -from sentry.models.options.user_option import UserOption from sentry.models.organization import OrganizationStatus from sentry.models.organizationmapping import OrganizationMapping from sentry.models.organizationmembermapping import OrganizationMemberMapping from sentry.organizations.services.organization import organization_service from sentry.organizations.services.organization.model import RpcOrganizationDeleteState from sentry.users.models.user import User +from sentry.users.models.user_option import UserOption from sentry.users.models.useremail import UserEmail from sentry.users.services.user.serial import serialize_generic_user from sentry.utils.dates import AVAILABLE_TIMEZONES diff --git a/src/sentry/api/endpoints/user_emails.py b/src/sentry/api/endpoints/user_emails.py index c70745a227ea67..4bc724576bbe42 100644 --- a/src/sentry/api/endpoints/user_emails.py +++ b/src/sentry/api/endpoints/user_emails.py @@ -10,8 +10,8 @@ from sentry.api.decorators import sudo_required from sentry.api.serializers import serialize from sentry.api.validators import AllowedEmailField -from sentry.models.options.user_option import UserOption from sentry.users.models.user import User +from sentry.users.models.user_option import UserOption from sentry.users.models.useremail import UserEmail logger = logging.getLogger("sentry.accounts") diff --git a/src/sentry/api/endpoints/user_notification_details.py b/src/sentry/api/endpoints/user_notification_details.py index f6998d890c78fc..95afd419922d57 100644 --- a/src/sentry/api/endpoints/user_notification_details.py +++ b/src/sentry/api/endpoints/user_notification_details.py @@ -9,8 +9,8 @@ from sentry.api.base import control_silo_endpoint from sentry.api.bases.user import UserEndpoint from sentry.api.serializers import Serializer, serialize -from sentry.models.options.user_option import UserOption from sentry.notifications.types import UserOptionsSettingsKey +from sentry.users.models.user_option import UserOption USER_OPTION_SETTINGS = { UserOptionsSettingsKey.SELF_ACTIVITY: { diff --git a/src/sentry/api/endpoints/user_notification_email.py b/src/sentry/api/endpoints/user_notification_email.py index b3904b65d0f3b4..8d72e8817bc26b 100644 --- a/src/sentry/api/endpoints/user_notification_email.py +++ b/src/sentry/api/endpoints/user_notification_email.py @@ -7,7 +7,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import control_silo_endpoint from sentry.api.bases.user import UserEndpoint -from sentry.models.options.user_option import UserOption +from sentry.users.models.user_option import UserOption from sentry.users.models.useremail import UserEmail INVALID_EMAIL_MSG = ( diff --git a/src/sentry/api/serializers/models/user.py b/src/sentry/api/serializers/models/user.py index ea55bc301d8dcd..5c076ce791c956 100644 --- a/src/sentry/api/serializers/models/user.py +++ b/src/sentry/api/serializers/models/user.py @@ -19,13 +19,13 @@ from sentry.hybridcloud.services.organization_mapping import organization_mapping_service from sentry.models.authidentity import AuthIdentity from sentry.models.avatars.user_avatar import UserAvatar -from sentry.models.options.user_option import UserOption from sentry.models.organization import OrganizationStatus from sentry.models.organizationmapping import OrganizationMapping from sentry.models.organizationmembermapping import OrganizationMemberMapping from sentry.organizations.services.organization import RpcOrganizationSummary from sentry.users.models.authenticator import Authenticator from sentry.users.models.user import User +from sentry.users.models.user_option import UserOption from sentry.users.models.useremail import UserEmail from sentry.users.models.userpermission import UserPermission from sentry.users.models.userrole import UserRoleUser diff --git a/src/sentry/models/options/__init__.py b/src/sentry/models/options/__init__.py index e9cf93cb8d01b8..e2fc9d151c656b 100644 --- a/src/sentry/models/options/__init__.py +++ b/src/sentry/models/options/__init__.py @@ -1,8 +1,9 @@ +from sentry.users.models.user_option import UserOption + from .option import ControlOption, Option from .organization_option import OrganizationOption from .project_option import ProjectOption from .project_template_option import ProjectTemplateOption -from .user_option import UserOption __all__ = ( "Option", diff --git a/src/sentry/models/options/user_option.py b/src/sentry/models/options/user_option.py index 1a9db69a8eba87..8a74caf125abd6 100644 --- a/src/sentry/models/options/user_option.py +++ b/src/sentry/models/options/user_option.py @@ -1,264 +1,3 @@ -from __future__ import annotations +from sentry.users.models.user_option import UserOption -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, ClassVar - -from django.conf import settings -from django.db import models - -from sentry.backup.dependencies import ImportKind, PrimaryKeyMap, get_model_name -from sentry.backup.helpers import ImportFlags -from sentry.backup.scopes import ImportScope, RelocationScope -from sentry.db.models import FlexibleForeignKey, Model, control_silo_model, sane_repr -from sentry.db.models.fields import PickledObjectField -from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey -from sentry.db.models.manager.option import OptionManager - -if TYPE_CHECKING: - from sentry.models.organization import Organization - from sentry.models.project import Project - from sentry.users.models.user import User - from sentry.users.services.user import RpcUser - -option_scope_error = "this is not a supported use case, scope to project OR organization" - - -class UserOptionManager(OptionManager["UserOption"]): - def _make_key( # type: ignore[override] - self, - user: User | RpcUser | int, - project: Project | int | None = None, - organization: Organization | int | None = None, - ) -> str: - uid = user.id if user and not isinstance(user, int) else user - org_id: int | None = organization.id if isinstance(organization, Model) else organization - proj_id: int | None = project.id if isinstance(project, Model) else project - if project: - metakey = f"{uid}:{proj_id}:project" - elif organization: - metakey = f"{uid}:{org_id}:organization" - else: - metakey = f"{uid}:user" - - return super()._make_key(metakey) - - def get_value( - self, user: User | RpcUser, key: str, default: Any | None = None, **kwargs: Any - ) -> Any: - project = kwargs.get("project") - organization = kwargs.get("organization") - - if organization and project: - raise NotImplementedError(option_scope_error) - if organization: - result = self.get_all_values(user, None, organization) - else: - result = self.get_all_values(user, project) - return result.get(key, default) - - def unset_value(self, user: User, project: Project, key: str) -> None: - """ - This isn't implemented for user-organization scoped options yet, because it hasn't been needed. - """ - self.filter(user=user, project=project, key=key).delete() - - if not hasattr(self, "_metadata"): - return - - metakey = self._make_key(user, project=project) - - if metakey not in self._option_cache: - return - self._option_cache[metakey].pop(key, None) - - def set_value(self, user: User | int, key: str, value: Any, **kwargs: Any) -> None: - project = kwargs.get("project") - organization = kwargs.get("organization") - project_id = kwargs.get("project_id", None) - organization_id = kwargs.get("organization_id", None) - if project is not None: - project_id = project.id - if organization is not None: - organization_id = organization.id - - if organization and project: - raise NotImplementedError(option_scope_error) - - inst, created = self.get_or_create( - user_id=user.id if user and not isinstance(user, int) else user, - project_id=project_id, - organization_id=organization_id, - key=key, - defaults={"value": value}, - ) - if not created and inst.value != value: - inst.update(value=value) - - metakey = self._make_key(user, project=project, organization=organization) - - if metakey not in self._option_cache: - return - self._option_cache[metakey][key] = value - - def get_all_values( - self, - user: User | RpcUser | int, - project: Project | int | None = None, - organization: Organization | int | None = None, - force_reload: bool = False, - ) -> Mapping[str, Any]: - if organization and project: - raise NotImplementedError(option_scope_error) - - uid = user.id if user and not isinstance(user, int) else user - metakey = self._make_key(user, project=project, organization=organization) - project_id: int | None = project.id if isinstance(project, Model) else project - organization_id: int | None = ( - organization.id if isinstance(organization, Model) else organization - ) - - if metakey not in self._option_cache or force_reload: - result = { - i.key: i.value - for i in self.filter( - user_id=uid, project_id=project_id, organization_id=organization_id - ) - } - self._option_cache[metakey] = result - - return self._option_cache.get(metakey, {}) - - def post_save(self, *, instance: UserOption, created: bool, **kwargs: object) -> None: - self.get_all_values( - instance.user, instance.project_id, instance.organization_id, force_reload=True - ) - - def post_delete(self, instance: UserOption, **kwargs: Any) -> None: - self.get_all_values( - instance.user, instance.project_id, instance.organization_id, force_reload=True - ) - - -# TODO(dcramer): the NULL UNIQUE constraint here isn't valid, and instead has to -# be manually replaced in the database. We should restructure this model. -@control_silo_model -class UserOption(Model): - """ - User options apply only to a user, and optionally a project OR an organization. - - Options which are specific to a plugin should namespace - their key. e.g. key='myplugin:optname' - - Keeping user feature state - key: "feature:assignment" - value: { updated: datetime, state: bool } - - where key is one of: - (please add to this list if adding new keys) - - clock_24_hours - - 12hr vs. 24hr - - issue:defaults - - only used in Jira, set default reporter field - - issues:defaults:jira - - unused - - issues:defaults:jira_server - - unused - - prefers_issue_details_streamlined_ui - - Whether the user prefers the new issue details experience (boolean) - - language - - which language to display the app in - - mail:email - - which email address to send an email to - - reports:disabled-organizations - - which orgs to not send weekly reports to - - seen_release_broadcast - - unused - - self_assign_issue - - "Claim Unassigned Issues I've Resolved" - - self_notifications - - "Notify Me About My Own Activity" - - stacktrace_order - - default, most recent first, most recent last - - subscribe_by_default - - "Only On Issues I Subscribe To", "Only On Deploys With My Commits" - - subscribe_notes - - unused - - timezone - - user's timezone to display timestamps - - theme - - dark, light, or default - - twilio:alert - - unused - - workflow_notifications - - unused - """ - - __relocation_scope__ = RelocationScope.User - - user = FlexibleForeignKey(settings.AUTH_USER_MODEL) - project_id = HybridCloudForeignKey("sentry.Project", null=True, on_delete="CASCADE") - organization_id = HybridCloudForeignKey("sentry.Organization", null=True, on_delete="CASCADE") - key = models.CharField(max_length=64) - value = PickledObjectField() - - objects: ClassVar[UserOptionManager] = UserOptionManager() - - class Meta: - app_label = "sentry" - db_table = "sentry_useroption" - unique_together = (("user", "project_id", "key"), ("user", "organization_id", "key")) - - __repr__ = sane_repr("user_id", "project_id", "organization_id", "key", "value") - - @classmethod - def get_relocation_ordinal_fields(self, json_model: Any) -> list[str] | None: - # "global" user options (those with no organization and/or project scope) get a custom - # ordinal; non-global ones use the default ordering. - org_id = json_model["fields"].get("organization_id", None) - project_id = json_model["fields"].get("project_id", None) - if org_id is None and project_id is None: - return ["user", "key"] - - return None - - def normalize_before_relocation_import( - self, pk_map: PrimaryKeyMap, scope: ImportScope, flags: ImportFlags - ) -> int | None: - from sentry.users.models.user import User - - old_user_id = self.user_id - old_pk = super().normalize_before_relocation_import(pk_map, scope, flags) - if old_pk is None: - return None - - # If we are merging users, ignore the imported options and use the existing user's - # options instead. - if pk_map.get_kind(get_model_name(User), old_user_id) == ImportKind.Existing: - return None - - return old_pk - - def write_relocation_import( - self, scope: ImportScope, flags: ImportFlags - ) -> tuple[int, ImportKind] | None: - # TODO(getsentry/team-ospo#190): This circular import is a bit gross. See if we can't find a - # better place for this logic to live. - from sentry.api.endpoints.user_details import UserOptionsSerializer - - serializer_options = UserOptionsSerializer(data={self.key: self.value}, partial=True) - serializer_options.is_valid(raise_exception=True) - - # TODO(getsentry/team-ospo#190): Find a more general solution to one-off indices such as - # this. We currently have this constraint on prod, but not in Django, probably from legacy - # SQL manipulation. - # - # Ensure that global (ie: `organization_id` and `project_id` both `NULL`) constraints are - # not duplicated on import. - if self.organization_id is None and self.project_id is None: - colliding_global_user_option = self.objects.filter( - user=self.user, key=self.key, organization_id__isnull=True, project_id__isnull=True - ).first() - if colliding_global_user_option is not None: - return None - - return super().write_relocation_import(scope, flags) +__all__ = ("UserOption",) diff --git a/src/sentry/plugins/bases/notify.py b/src/sentry/plugins/bases/notify.py index 107af1532bb713..420aa39e15922b 100644 --- a/src/sentry/plugins/bases/notify.py +++ b/src/sentry/plugins/bases/notify.py @@ -111,7 +111,7 @@ def notify_about_activity(self, activity): pass def get_notification_recipients(self, project, user_option: str) -> set: - from sentry.models.options.user_option import UserOption + from sentry.users.models.user_option import UserOption alert_settings = { o.user_id: int(o.value) diff --git a/src/sentry/plugins/helpers.py b/src/sentry/plugins/helpers.py index 0e96320117ceaf..c7e7d0ce71df31 100644 --- a/src/sentry/plugins/helpers.py +++ b/src/sentry/plugins/helpers.py @@ -2,9 +2,9 @@ from sentry import options from sentry.models.options.project_option import ProjectOption -from sentry.models.options.user_option import UserOption from sentry.models.project import Project from sentry.projects.services.project import RpcProject, project_service +from sentry.users.models.user_option import UserOption __all__ = ("set_option", "get_option", "unset_option") diff --git a/src/sentry/receivers/auth.py b/src/sentry/receivers/auth.py index 8533430fab1d77..ee0c59594f301d 100644 --- a/src/sentry/receivers/auth.py +++ b/src/sentry/receivers/auth.py @@ -4,7 +4,7 @@ from django.contrib.auth.signals import user_logged_in from django.db.utils import DatabaseError -from sentry.models.options.user_option import UserOption +from sentry.users.models.user_option import UserOption # Set user language if set diff --git a/src/sentry/testutils/cases.py b/src/sentry/testutils/cases.py index 0ede53d166a5be..e716f102c0c182 100644 --- a/src/sentry/testutils/cases.py +++ b/src/sentry/testutils/cases.py @@ -103,7 +103,6 @@ from sentry.models.notificationsettingoption import NotificationSettingOption from sentry.models.notificationsettingprovider import NotificationSettingProvider from sentry.models.options.project_option import ProjectOption -from sentry.models.options.user_option import UserOption from sentry.models.organization import Organization from sentry.models.organizationmember import OrganizationMember from sentry.models.project import Project @@ -145,6 +144,7 @@ from sentry.testutils.pytest.selenium import Browser from sentry.types.condition_activity import ConditionActivity, ConditionActivityType from sentry.users.models.user import User +from sentry.users.models.user_option import UserOption from sentry.users.models.useremail import UserEmail from sentry.utils import json from sentry.utils.auth import SsoSession diff --git a/src/sentry/testutils/factories.py b/src/sentry/testutils/factories.py index 4a660c1f3a4e41..101d238532b0e6 100644 --- a/src/sentry/testutils/factories.py +++ b/src/sentry/testutils/factories.py @@ -115,7 +115,6 @@ NotificationAction, ) from sentry.models.notificationsettingprovider import NotificationSettingProvider -from sentry.models.options.user_option import UserOption from sentry.models.organization import Organization from sentry.models.organizationmapping import OrganizationMapping from sentry.models.organizationmember import OrganizationMember @@ -165,6 +164,7 @@ UptimeSubscription, ) from sentry.users.models.user import User +from sentry.users.models.user_option import UserOption from sentry.users.models.useremail import UserEmail from sentry.users.models.userpermission import UserPermission from sentry.users.models.userrole import UserRole diff --git a/src/sentry/testutils/helpers/backups.py b/src/sentry/testutils/helpers/backups.py index efde8edf033d0e..a834caa768481d 100644 --- a/src/sentry/testutils/helpers/backups.py +++ b/src/sentry/testutils/helpers/backups.py @@ -82,7 +82,6 @@ from sentry.models.options.option import ControlOption, Option from sentry.models.options.organization_option import OrganizationOption from sentry.models.options.project_template_option import ProjectTemplateOption -from sentry.models.options.user_option import UserOption from sentry.models.organization import Organization from sentry.models.organizationaccessrequest import OrganizationAccessRequest from sentry.models.organizationmember import InviteStatus, OrganizationMember @@ -112,6 +111,7 @@ from sentry.types.token import AuthTokenType from sentry.users.models.authenticator import Authenticator from sentry.users.models.user import User +from sentry.users.models.user_option import UserOption from sentry.users.models.userip import UserIP from sentry.users.models.userrole import UserRole, UserRoleUser from sentry.utils import json diff --git a/src/sentry/testutils/pytest/sentry.py b/src/sentry/testutils/pytest/sentry.py index a3bbe9ea6dee96..96b61864e0d16d 100644 --- a/src/sentry/testutils/pytest/sentry.py +++ b/src/sentry/testutils/pytest/sentry.py @@ -365,7 +365,7 @@ def pytest_runtest_teardown(item: pytest.Item) -> None: from sentry.models.options.organization_option import OrganizationOption from sentry.models.options.project_option import ProjectOption - from sentry.models.options.user_option import UserOption + from sentry.users.models.user_option import UserOption OrganizationOption.objects.clear_local_cache() ProjectOption.objects.clear_local_cache() diff --git a/src/sentry/users/models/user.py b/src/sentry/users/models/user.py index 4aa21244bb3465..a530deba0a44c0 100644 --- a/src/sentry/users/models/user.py +++ b/src/sentry/users/models/user.py @@ -339,9 +339,9 @@ def merge_to(from_user: User, to_user: User) -> None: from sentry.models.authidentity import AuthIdentity from sentry.models.avatars.user_avatar import UserAvatar from sentry.models.identity import Identity - from sentry.models.options.user_option import UserOption from sentry.models.organizationmembermapping import OrganizationMemberMapping from sentry.users.models.authenticator import Authenticator + from sentry.users.models.user_option import UserOption from sentry.users.models.useremail import UserEmail from_user_id = from_user.id diff --git a/src/sentry/users/models/user_option.py b/src/sentry/users/models/user_option.py new file mode 100644 index 00000000000000..1a9db69a8eba87 --- /dev/null +++ b/src/sentry/users/models/user_option.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, ClassVar + +from django.conf import settings +from django.db import models + +from sentry.backup.dependencies import ImportKind, PrimaryKeyMap, get_model_name +from sentry.backup.helpers import ImportFlags +from sentry.backup.scopes import ImportScope, RelocationScope +from sentry.db.models import FlexibleForeignKey, Model, control_silo_model, sane_repr +from sentry.db.models.fields import PickledObjectField +from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey +from sentry.db.models.manager.option import OptionManager + +if TYPE_CHECKING: + from sentry.models.organization import Organization + from sentry.models.project import Project + from sentry.users.models.user import User + from sentry.users.services.user import RpcUser + +option_scope_error = "this is not a supported use case, scope to project OR organization" + + +class UserOptionManager(OptionManager["UserOption"]): + def _make_key( # type: ignore[override] + self, + user: User | RpcUser | int, + project: Project | int | None = None, + organization: Organization | int | None = None, + ) -> str: + uid = user.id if user and not isinstance(user, int) else user + org_id: int | None = organization.id if isinstance(organization, Model) else organization + proj_id: int | None = project.id if isinstance(project, Model) else project + if project: + metakey = f"{uid}:{proj_id}:project" + elif organization: + metakey = f"{uid}:{org_id}:organization" + else: + metakey = f"{uid}:user" + + return super()._make_key(metakey) + + def get_value( + self, user: User | RpcUser, key: str, default: Any | None = None, **kwargs: Any + ) -> Any: + project = kwargs.get("project") + organization = kwargs.get("organization") + + if organization and project: + raise NotImplementedError(option_scope_error) + if organization: + result = self.get_all_values(user, None, organization) + else: + result = self.get_all_values(user, project) + return result.get(key, default) + + def unset_value(self, user: User, project: Project, key: str) -> None: + """ + This isn't implemented for user-organization scoped options yet, because it hasn't been needed. + """ + self.filter(user=user, project=project, key=key).delete() + + if not hasattr(self, "_metadata"): + return + + metakey = self._make_key(user, project=project) + + if metakey not in self._option_cache: + return + self._option_cache[metakey].pop(key, None) + + def set_value(self, user: User | int, key: str, value: Any, **kwargs: Any) -> None: + project = kwargs.get("project") + organization = kwargs.get("organization") + project_id = kwargs.get("project_id", None) + organization_id = kwargs.get("organization_id", None) + if project is not None: + project_id = project.id + if organization is not None: + organization_id = organization.id + + if organization and project: + raise NotImplementedError(option_scope_error) + + inst, created = self.get_or_create( + user_id=user.id if user and not isinstance(user, int) else user, + project_id=project_id, + organization_id=organization_id, + key=key, + defaults={"value": value}, + ) + if not created and inst.value != value: + inst.update(value=value) + + metakey = self._make_key(user, project=project, organization=organization) + + if metakey not in self._option_cache: + return + self._option_cache[metakey][key] = value + + def get_all_values( + self, + user: User | RpcUser | int, + project: Project | int | None = None, + organization: Organization | int | None = None, + force_reload: bool = False, + ) -> Mapping[str, Any]: + if organization and project: + raise NotImplementedError(option_scope_error) + + uid = user.id if user and not isinstance(user, int) else user + metakey = self._make_key(user, project=project, organization=organization) + project_id: int | None = project.id if isinstance(project, Model) else project + organization_id: int | None = ( + organization.id if isinstance(organization, Model) else organization + ) + + if metakey not in self._option_cache or force_reload: + result = { + i.key: i.value + for i in self.filter( + user_id=uid, project_id=project_id, organization_id=organization_id + ) + } + self._option_cache[metakey] = result + + return self._option_cache.get(metakey, {}) + + def post_save(self, *, instance: UserOption, created: bool, **kwargs: object) -> None: + self.get_all_values( + instance.user, instance.project_id, instance.organization_id, force_reload=True + ) + + def post_delete(self, instance: UserOption, **kwargs: Any) -> None: + self.get_all_values( + instance.user, instance.project_id, instance.organization_id, force_reload=True + ) + + +# TODO(dcramer): the NULL UNIQUE constraint here isn't valid, and instead has to +# be manually replaced in the database. We should restructure this model. +@control_silo_model +class UserOption(Model): + """ + User options apply only to a user, and optionally a project OR an organization. + + Options which are specific to a plugin should namespace + their key. e.g. key='myplugin:optname' + + Keeping user feature state + key: "feature:assignment" + value: { updated: datetime, state: bool } + + where key is one of: + (please add to this list if adding new keys) + - clock_24_hours + - 12hr vs. 24hr + - issue:defaults + - only used in Jira, set default reporter field + - issues:defaults:jira + - unused + - issues:defaults:jira_server + - unused + - prefers_issue_details_streamlined_ui + - Whether the user prefers the new issue details experience (boolean) + - language + - which language to display the app in + - mail:email + - which email address to send an email to + - reports:disabled-organizations + - which orgs to not send weekly reports to + - seen_release_broadcast + - unused + - self_assign_issue + - "Claim Unassigned Issues I've Resolved" + - self_notifications + - "Notify Me About My Own Activity" + - stacktrace_order + - default, most recent first, most recent last + - subscribe_by_default + - "Only On Issues I Subscribe To", "Only On Deploys With My Commits" + - subscribe_notes + - unused + - timezone + - user's timezone to display timestamps + - theme + - dark, light, or default + - twilio:alert + - unused + - workflow_notifications + - unused + """ + + __relocation_scope__ = RelocationScope.User + + user = FlexibleForeignKey(settings.AUTH_USER_MODEL) + project_id = HybridCloudForeignKey("sentry.Project", null=True, on_delete="CASCADE") + organization_id = HybridCloudForeignKey("sentry.Organization", null=True, on_delete="CASCADE") + key = models.CharField(max_length=64) + value = PickledObjectField() + + objects: ClassVar[UserOptionManager] = UserOptionManager() + + class Meta: + app_label = "sentry" + db_table = "sentry_useroption" + unique_together = (("user", "project_id", "key"), ("user", "organization_id", "key")) + + __repr__ = sane_repr("user_id", "project_id", "organization_id", "key", "value") + + @classmethod + def get_relocation_ordinal_fields(self, json_model: Any) -> list[str] | None: + # "global" user options (those with no organization and/or project scope) get a custom + # ordinal; non-global ones use the default ordering. + org_id = json_model["fields"].get("organization_id", None) + project_id = json_model["fields"].get("project_id", None) + if org_id is None and project_id is None: + return ["user", "key"] + + return None + + def normalize_before_relocation_import( + self, pk_map: PrimaryKeyMap, scope: ImportScope, flags: ImportFlags + ) -> int | None: + from sentry.users.models.user import User + + old_user_id = self.user_id + old_pk = super().normalize_before_relocation_import(pk_map, scope, flags) + if old_pk is None: + return None + + # If we are merging users, ignore the imported options and use the existing user's + # options instead. + if pk_map.get_kind(get_model_name(User), old_user_id) == ImportKind.Existing: + return None + + return old_pk + + def write_relocation_import( + self, scope: ImportScope, flags: ImportFlags + ) -> tuple[int, ImportKind] | None: + # TODO(getsentry/team-ospo#190): This circular import is a bit gross. See if we can't find a + # better place for this logic to live. + from sentry.api.endpoints.user_details import UserOptionsSerializer + + serializer_options = UserOptionsSerializer(data={self.key: self.value}, partial=True) + serializer_options.is_valid(raise_exception=True) + + # TODO(getsentry/team-ospo#190): Find a more general solution to one-off indices such as + # this. We currently have this constraint on prod, but not in Django, probably from legacy + # SQL manipulation. + # + # Ensure that global (ie: `organization_id` and `project_id` both `NULL`) constraints are + # not duplicated on import. + if self.organization_id is None and self.project_id is None: + colliding_global_user_option = self.objects.filter( + user=self.user, key=self.key, organization_id__isnull=True, project_id__isnull=True + ).first() + if colliding_global_user_option is not None: + return None + + return super().write_relocation_import(scope, flags) diff --git a/src/sentry/users/services/user_option/impl.py b/src/sentry/users/services/user_option/impl.py index d2c88b1762186a..c4cf48d724ac93 100644 --- a/src/sentry/users/services/user_option/impl.py +++ b/src/sentry/users/services/user_option/impl.py @@ -8,7 +8,7 @@ from sentry.api.serializers.base import Serializer from sentry.auth.services.auth import AuthenticationContext from sentry.hybridcloud.rpc.filter_query import FilterQueryDatabaseImpl, OpaqueSerializedResponse -from sentry.models.options.user_option import UserOption +from sentry.users.models.user_option import UserOption from sentry.users.services.user import RpcUser from sentry.users.services.user_option import RpcUserOption, UserOptionFilterArgs, UserOptionService diff --git a/tests/sentry/api/endpoints/test_organization_member_details.py b/tests/sentry/api/endpoints/test_organization_member_details.py index 6cfaff1ff681ec..c00453081358af 100644 --- a/tests/sentry/api/endpoints/test_organization_member_details.py +++ b/tests/sentry/api/endpoints/test_organization_member_details.py @@ -8,7 +8,6 @@ from sentry.auth.authenticators.recovery_code import RecoveryCodeInterface from sentry.auth.authenticators.totp import TotpInterface from sentry.models.authprovider import AuthProvider -from sentry.models.options.user_option import UserOption from sentry.models.organization import Organization from sentry.models.organizationmember import InviteStatus, OrganizationMember from sentry.models.organizationmemberteam import OrganizationMemberTeam @@ -21,6 +20,7 @@ from sentry.testutils.outbox import outbox_runner from sentry.testutils.silo import assume_test_silo_mode from sentry.users.models.authenticator import Authenticator +from sentry.users.models.user_option import UserOption from tests.sentry.api.endpoints.test_organization_member_index import ( mock_organization_roles_get_factory, ) diff --git a/tests/sentry/api/endpoints/test_user_details.py b/tests/sentry/api/endpoints/test_user_details.py index de3e2b3fd37007..97a368817ad654 100644 --- a/tests/sentry/api/endpoints/test_user_details.py +++ b/tests/sentry/api/endpoints/test_user_details.py @@ -2,7 +2,6 @@ from pytest import fixture from sentry.models.deletedorganization import DeletedOrganization -from sentry.models.options.user_option import UserOption from sentry.models.organization import Organization, OrganizationStatus from sentry.models.organizationmember import OrganizationMember from sentry.silo.base import SiloMode @@ -13,6 +12,7 @@ from sentry.testutils.outbox import outbox_runner from sentry.testutils.silo import assume_test_silo_mode, control_silo_test from sentry.users.models.user import User +from sentry.users.models.user_option import UserOption from sentry.users.models.userpermission import UserPermission from sentry.users.models.userrole import UserRole diff --git a/tests/sentry/api/endpoints/test_user_emails.py b/tests/sentry/api/endpoints/test_user_emails.py index ec396dd19e6031..ecbe59e61b465c 100644 --- a/tests/sentry/api/endpoints/test_user_emails.py +++ b/tests/sentry/api/endpoints/test_user_emails.py @@ -1,9 +1,9 @@ from django.urls import reverse -from sentry.models.options.user_option import UserOption from sentry.testutils.cases import APITestCase from sentry.testutils.silo import control_silo_test from sentry.users.models.user import User +from sentry.users.models.user_option import UserOption from sentry.users.models.useremail import UserEmail diff --git a/tests/sentry/api/endpoints/test_user_notification_email.py b/tests/sentry/api/endpoints/test_user_notification_email.py index 138dbdb9a37723..73b380ba49273c 100644 --- a/tests/sentry/api/endpoints/test_user_notification_email.py +++ b/tests/sentry/api/endpoints/test_user_notification_email.py @@ -1,6 +1,6 @@ -from sentry.models.options.user_option import UserOption from sentry.testutils.cases import APITestCase from sentry.testutils.silo import control_silo_test +from sentry.users.models.user_option import UserOption from sentry.users.models.useremail import UserEmail diff --git a/tests/sentry/api/serializers/test_group.py b/tests/sentry/api/serializers/test_group.py index e7770e0e0cf1cd..7a9d62441bde59 100644 --- a/tests/sentry/api/serializers/test_group.py +++ b/tests/sentry/api/serializers/test_group.py @@ -12,7 +12,6 @@ from sentry.models.groupsubscription import GroupSubscription from sentry.models.notificationsettingoption import NotificationSettingOption from sentry.models.notificationsettingprovider import NotificationSettingProvider -from sentry.models.options.user_option import UserOption from sentry.notifications.types import ( NotificationScopeEnum, NotificationSettingEnum, @@ -22,6 +21,7 @@ from sentry.testutils.cases import PerformanceIssueTestCase, TestCase from sentry.testutils.silo import assume_test_silo_mode from sentry.testutils.skips import requires_snuba +from sentry.users.models.user_option import UserOption pytestmark = [requires_snuba] diff --git a/tests/sentry/backup/test_imports.py b/tests/sentry/backup/test_imports.py index 346313304a9139..429ef600d77eb4 100644 --- a/tests/sentry/backup/test_imports.py +++ b/tests/sentry/backup/test_imports.py @@ -41,7 +41,6 @@ ) from sentry.models.options.option import ControlOption, Option from sentry.models.options.project_option import ProjectOption -from sentry.models.options.user_option import UserOption from sentry.models.organization import Organization, OrganizationStatus from sentry.models.organizationmapping import OrganizationMapping from sentry.models.organizationmember import OrganizationMember @@ -77,6 +76,7 @@ from sentry.users.models.email import Email from sentry.users.models.lostpasswordhash import LostPasswordHash from sentry.users.models.user import User +from sentry.users.models.user_option import UserOption from sentry.users.models.useremail import UserEmail from sentry.users.models.userip import UserIP from sentry.users.models.userpermission import UserPermission diff --git a/tests/sentry/incidents/action_handlers/test_email.py b/tests/sentry/incidents/action_handlers/test_email.py index a83b5a05c974f7..72e253cdcd1204 100644 --- a/tests/sentry/incidents/action_handlers/test_email.py +++ b/tests/sentry/incidents/action_handlers/test_email.py @@ -26,7 +26,6 @@ from sentry.incidents.models.incident import INCIDENT_STATUS, IncidentStatus, TriggerStatus from sentry.incidents.utils.types import AlertRuleActivationConditionType from sentry.models.notificationsettingoption import NotificationSettingOption -from sentry.models.options.user_option import UserOption from sentry.sentry_metrics import indexer from sentry.sentry_metrics.use_case_id_registry import UseCaseID from sentry.snuba.dataset import Dataset @@ -35,6 +34,7 @@ from sentry.testutils.helpers.datetime import freeze_time from sentry.testutils.helpers.features import with_feature from sentry.testutils.silo import assume_test_silo_mode_of +from sentry.users.models.user_option import UserOption from sentry.users.models.useremail import UserEmail from . import FireTest diff --git a/tests/sentry/issues/endpoints/test_organization_group_index.py b/tests/sentry/issues/endpoints/test_organization_group_index.py index 6dee66e4794ec7..97a98462f01fbd 100644 --- a/tests/sentry/issues/endpoints/test_organization_group_index.py +++ b/tests/sentry/issues/endpoints/test_organization_group_index.py @@ -42,7 +42,6 @@ from sentry.models.groupsnooze import GroupSnooze from sentry.models.groupsubscription import GroupSubscription from sentry.models.grouptombstone import GroupTombstone -from sentry.models.options.user_option import UserOption from sentry.models.platformexternalissue import PlatformExternalIssue from sentry.models.release import Release from sentry.models.releaseprojectenvironment import ReleaseStages @@ -63,6 +62,7 @@ from sentry.testutils.silo import assume_test_silo_mode from sentry.types.activity import ActivityType from sentry.types.group import GroupSubStatus, PriorityLevel +from sentry.users.models.user_option import UserOption from sentry.utils import json from tests.sentry.issues.test_utils import SearchIssueTestMixin diff --git a/tests/sentry/mail/activity/test_note.py b/tests/sentry/mail/activity/test_note.py index 50de95b0b3ec16..da3d30b42dfc85 100644 --- a/tests/sentry/mail/activity/test_note.py +++ b/tests/sentry/mail/activity/test_note.py @@ -1,7 +1,6 @@ from sentry.integrations.types import ExternalProviders from sentry.models.activity import Activity from sentry.models.notificationsettingoption import NotificationSettingOption -from sentry.models.options.user_option import UserOption from sentry.notifications.notifications.activity.note import NoteActivityNotification from sentry.notifications.types import GroupSubscriptionReason from sentry.silo.base import SiloMode @@ -9,6 +8,7 @@ from sentry.testutils.silo import assume_test_silo_mode from sentry.types.activity import ActivityType from sentry.types.actor import Actor +from sentry.users.models.user_option import UserOption class NoteTestCase(ActivityTestCase): diff --git a/tests/sentry/mail/test_adapter.py b/tests/sentry/mail/test_adapter.py index b1d8bbc8b93662..4dbca3875c4f0f 100644 --- a/tests/sentry/mail/test_adapter.py +++ b/tests/sentry/mail/test_adapter.py @@ -25,7 +25,6 @@ from sentry.models.notificationsettingoption import NotificationSettingOption from sentry.models.notificationsettingprovider import NotificationSettingProvider from sentry.models.options.project_option import ProjectOption -from sentry.models.options.user_option import UserOption from sentry.models.organization import Organization from sentry.models.organizationmember import OrganizationMember from sentry.models.organizationmemberteam import OrganizationMemberTeam @@ -50,6 +49,7 @@ from sentry.types.actor import Actor from sentry.types.group import GroupSubStatus from sentry.types.rules import RuleFuture +from sentry.users.models.user_option import UserOption from sentry.users.models.useremail import UserEmail from sentry.utils.email import MessageBuilder, get_email_addresses from sentry_plugins.opsgenie.plugin import OpsGeniePlugin diff --git a/tests/sentry/models/test_organization.py b/tests/sentry/models/test_organization.py index 38788a5b56e210..3857a5df0743ea 100644 --- a/tests/sentry/models/test_organization.py +++ b/tests/sentry/models/test_organization.py @@ -18,7 +18,6 @@ from sentry.models.notificationsettingoption import NotificationSettingOption from sentry.models.notificationsettingprovider import NotificationSettingProvider from sentry.models.options.organization_option import OrganizationOption -from sentry.models.options.user_option import UserOption from sentry.models.organization import Organization from sentry.models.organizationmember import OrganizationMember from sentry.silo.base import SiloMode @@ -32,6 +31,7 @@ from sentry.testutils.outbox import outbox_runner from sentry.testutils.silo import assume_test_silo_mode, assume_test_silo_mode_of from sentry.users.models.user import User +from sentry.users.models.user_option import UserOption class OrganizationTest(TestCase, HybridCloudTestMixin): diff --git a/tests/sentry/models/test_project.py b/tests/sentry/models/test_project.py index 5681e5186d4c5c..b8f04fc1b773ac 100644 --- a/tests/sentry/models/test_project.py +++ b/tests/sentry/models/test_project.py @@ -8,7 +8,6 @@ from sentry.models.notificationsettingoption import NotificationSettingOption from sentry.models.options.project_option import ProjectOption from sentry.models.options.project_template_option import ProjectTemplateOption -from sentry.models.options.user_option import UserOption from sentry.models.organizationmember import OrganizationMember from sentry.models.organizationmemberteam import OrganizationMemberTeam from sentry.models.project import Project @@ -31,6 +30,7 @@ from sentry.testutils.silo import assume_test_silo_mode, control_silo_test from sentry.types.actor import Actor from sentry.users.models.user import User +from sentry.users.models.user_option import UserOption class ProjectTest(APITestCase, TestCase): diff --git a/tests/sentry/monitors/tasks/test_detect_broken_monitor_envs.py b/tests/sentry/monitors/tasks/test_detect_broken_monitor_envs.py index 64c3e8ab5aa4b9..c8070831e230af 100644 --- a/tests/sentry/monitors/tasks/test_detect_broken_monitor_envs.py +++ b/tests/sentry/monitors/tasks/test_detect_broken_monitor_envs.py @@ -7,7 +7,6 @@ from sentry.constants import ObjectStatus from sentry.grouping.utils import hash_from_values from sentry.models.notificationsettingoption import NotificationSettingOption -from sentry.models.options.user_option import UserOption from sentry.monitors.models import ( CheckInStatus, Monitor, @@ -24,6 +23,7 @@ from sentry.testutils.cases import TestCase from sentry.testutils.helpers.datetime import before_now from sentry.testutils.silo import assume_test_silo_mode +from sentry.users.models.user_option import UserOption from sentry.users.models.useremail import UserEmail diff --git a/tests/sentry/notifications/test_notifications.py b/tests/sentry/notifications/test_notifications.py index c7cbe476e49c7a..45ac9b9586084c 100644 --- a/tests/sentry/notifications/test_notifications.py +++ b/tests/sentry/notifications/test_notifications.py @@ -19,7 +19,6 @@ from sentry.models.groupassignee import GroupAssignee from sentry.models.identity import Identity, IdentityStatus from sentry.models.notificationsettingoption import NotificationSettingOption -from sentry.models.options.user_option import UserOption from sentry.models.rule import Rule from sentry.notifications.notifications.activity.assigned import AssignedActivityNotification from sentry.notifications.notifications.activity.regression import RegressionActivityNotification @@ -31,6 +30,7 @@ from sentry.testutils.silo import assume_test_silo_mode, control_silo_test from sentry.testutils.skips import requires_snuba from sentry.types.activity import ActivityType +from sentry.users.models.user_option import UserOption from sentry.utils import json pytestmark = [requires_snuba] diff --git a/tests/sentry/receivers/test_releases.py b/tests/sentry/receivers/test_releases.py index 24178813f82fd4..d1193f5e25dca4 100644 --- a/tests/sentry/receivers/test_releases.py +++ b/tests/sentry/receivers/test_releases.py @@ -12,7 +12,6 @@ from sentry.models.groupinbox import GroupInbox, GroupInboxReason, add_group_to_inbox from sentry.models.grouplink import GroupLink from sentry.models.groupsubscription import GroupSubscription -from sentry.models.options.user_option import UserOption from sentry.models.organizationmember import OrganizationMember from sentry.models.release import Release from sentry.models.releases.release_project import ReleaseProject @@ -22,6 +21,7 @@ from sentry.testutils.cases import TestCase from sentry.testutils.silo import assume_test_silo_mode from sentry.types.activity import ActivityType +from sentry.users.models.user_option import UserOption from sentry.users.models.useremail import UserEmail diff --git a/tests/sentry/utils/email/test_message_builder.py b/tests/sentry/utils/email/test_message_builder.py index bcd2ba05f7c821..fc9724200826fa 100644 --- a/tests/sentry/utils/email/test_message_builder.py +++ b/tests/sentry/utils/email/test_message_builder.py @@ -6,11 +6,11 @@ from sentry import options from sentry.models.groupemailthread import GroupEmailThread -from sentry.models.options.user_option import UserOption from sentry.silo.base import SiloMode from sentry.testutils.cases import TestCase from sentry.testutils.silo import assume_test_silo_mode from sentry.users.models.user import User +from sentry.users.models.user_option import UserOption from sentry.users.models.useremail import UserEmail from sentry.utils import json from sentry.utils.email import MessageBuilder diff --git a/tests/snuba/api/endpoints/test_project_group_index.py b/tests/snuba/api/endpoints/test_project_group_index.py index 329dccf345ddb4..daaf66c02c55bc 100644 --- a/tests/snuba/api/endpoints/test_project_group_index.py +++ b/tests/snuba/api/endpoints/test_project_group_index.py @@ -25,7 +25,6 @@ from sentry.models.groupsnooze import GroupSnooze from sentry.models.groupsubscription import GroupSubscription from sentry.models.grouptombstone import GroupTombstone -from sentry.models.options.user_option import UserOption from sentry.models.release import Release from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase, SnubaTestCase @@ -33,6 +32,7 @@ from sentry.testutils.helpers.datetime import before_now, iso_format from sentry.testutils.silo import assume_test_silo_mode from sentry.types.activity import ActivityType +from sentry.users.models.user_option import UserOption from sentry.utils import json diff --git a/tests/snuba/api/serializers/test_group.py b/tests/snuba/api/serializers/test_group.py index 8dd0788e8bcb83..90fe986d0cffe0 100644 --- a/tests/snuba/api/serializers/test_group.py +++ b/tests/snuba/api/serializers/test_group.py @@ -14,13 +14,13 @@ from sentry.models.groupsnooze import GroupSnooze from sentry.models.groupsubscription import GroupSubscription from sentry.models.notificationsettingoption import NotificationSettingOption -from sentry.models.options.user_option import UserOption from sentry.notifications.types import NotificationSettingsOptionEnum from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase, PerformanceIssueTestCase, SnubaTestCase from sentry.testutils.helpers.datetime import before_now, iso_format from sentry.testutils.silo import assume_test_silo_mode from sentry.types.group import PriorityLevel +from sentry.users.models.user_option import UserOption from sentry.utils.samples import load_data from tests.sentry.issues.test_utils import SearchIssueTestMixin