Skip to content

ref(control_silo): Move UserOption model to users module #76231

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/sentry/api/endpoints/user_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/api/endpoints/user_emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/api/endpoints/user_notification_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/api/endpoints/user_notification_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/api/serializers/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/sentry/models/options/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
265 changes: 2 additions & 263 deletions src/sentry/models/options/user_option.py
Original file line number Diff line number Diff line change
@@ -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",)
2 changes: 1 addition & 1 deletion src/sentry/plugins/bases/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/plugins/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
2 changes: 1 addition & 1 deletion src/sentry/receivers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/testutils/cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/testutils/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading