Skip to content

Commit 2c073f9

Browse files
ref(control_silo): Move UserOption model to users module
Part of moving control silo user related resources into the users module. Includes adding import shim for any getsentry refs. Apart of (#73856)
1 parent 73cac42 commit 2c073f9

35 files changed

+300
-296
lines changed

src/sentry/api/endpoints/user_details.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,14 @@
2121
from sentry.api.serializers.rest_framework import CamelSnakeModelSerializer
2222
from sentry.auth.elevated_mode import has_elevated_mode
2323
from sentry.constants import LANGUAGES
24-
from sentry.models.options.user_option import UserOption
2524
from sentry.models.organization import OrganizationStatus
2625
from sentry.models.organizationmapping import OrganizationMapping
2726
from sentry.models.organizationmembermapping import OrganizationMemberMapping
2827
from sentry.models.useremail import UserEmail
2928
from sentry.organizations.services.organization import organization_service
3029
from sentry.organizations.services.organization.model import RpcOrganizationDeleteState
3130
from sentry.users.models.user import User
31+
from sentry.users.models.user_option import UserOption
3232
from sentry.users.services.user.serial import serialize_generic_user
3333
from sentry.utils.dates import AVAILABLE_TIMEZONES
3434

src/sentry/api/endpoints/user_emails.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
from sentry.api.decorators import sudo_required
1111
from sentry.api.serializers import serialize
1212
from sentry.api.validators import AllowedEmailField
13-
from sentry.models.options.user_option import UserOption
1413
from sentry.models.useremail import UserEmail
1514
from sentry.users.models.user import User
15+
from sentry.users.models.user_option import UserOption
1616

1717
logger = logging.getLogger("sentry.accounts")
1818

src/sentry/api/endpoints/user_notification_details.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
from sentry.api.base import control_silo_endpoint
1010
from sentry.api.bases.user import UserEndpoint
1111
from sentry.api.serializers import Serializer, serialize
12-
from sentry.models.options.user_option import UserOption
1312
from sentry.notifications.types import UserOptionsSettingsKey
13+
from sentry.users.models.user_option import UserOption
1414

1515
USER_OPTION_SETTINGS = {
1616
UserOptionsSettingsKey.SELF_ACTIVITY: {

src/sentry/api/endpoints/user_notification_email.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
from sentry.api.api_publish_status import ApiPublishStatus
88
from sentry.api.base import control_silo_endpoint
99
from sentry.api.bases.user import UserEndpoint
10-
from sentry.models.options.user_option import UserOption
1110
from sentry.models.useremail import UserEmail
11+
from sentry.users.models.user_option import UserOption
1212

1313
INVALID_EMAIL_MSG = (
1414
"Invalid email value(s) provided. Email values must be verified emails for the given user."

src/sentry/api/serializers/models/user.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
from sentry.hybridcloud.services.organization_mapping import organization_mapping_service
2020
from sentry.models.authidentity import AuthIdentity
2121
from sentry.models.avatars.user_avatar import UserAvatar
22-
from sentry.models.options.user_option import UserOption
2322
from sentry.models.organization import OrganizationStatus
2423
from sentry.models.organizationmapping import OrganizationMapping
2524
from sentry.models.organizationmembermapping import OrganizationMemberMapping
@@ -28,6 +27,7 @@
2827
from sentry.organizations.services.organization import RpcOrganizationSummary
2928
from sentry.users.models.authenticator import Authenticator
3029
from sentry.users.models.user import User
30+
from sentry.users.models.user_option import UserOption
3131
from sentry.users.models.userrole import UserRoleUser
3232
from sentry.users.services.user import RpcUser
3333
from sentry.utils.avatar import get_gravatar_url

src/sentry/models/options/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
from sentry.users.models.user_option import UserOption
2+
13
from .option import ControlOption, Option
24
from .organization_option import OrganizationOption
35
from .project_option import ProjectOption
46
from .project_template_option import ProjectTemplateOption
5-
from .user_option import UserOption
67

78
__all__ = (
89
"Option",
Lines changed: 2 additions & 263 deletions
Original file line numberDiff line numberDiff line change
@@ -1,264 +1,3 @@
1-
from __future__ import annotations
1+
from sentry.users.models.user_option import UserOption
22

3-
from collections.abc import Mapping
4-
from typing import TYPE_CHECKING, Any, ClassVar
5-
6-
from django.conf import settings
7-
from django.db import models
8-
9-
from sentry.backup.dependencies import ImportKind, PrimaryKeyMap, get_model_name
10-
from sentry.backup.helpers import ImportFlags
11-
from sentry.backup.scopes import ImportScope, RelocationScope
12-
from sentry.db.models import FlexibleForeignKey, Model, control_silo_model, sane_repr
13-
from sentry.db.models.fields import PickledObjectField
14-
from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey
15-
from sentry.db.models.manager.option import OptionManager
16-
17-
if TYPE_CHECKING:
18-
from sentry.models.organization import Organization
19-
from sentry.models.project import Project
20-
from sentry.users.models.user import User
21-
from sentry.users.services.user import RpcUser
22-
23-
option_scope_error = "this is not a supported use case, scope to project OR organization"
24-
25-
26-
class UserOptionManager(OptionManager["UserOption"]):
27-
def _make_key( # type: ignore[override]
28-
self,
29-
user: User | RpcUser | int,
30-
project: Project | int | None = None,
31-
organization: Organization | int | None = None,
32-
) -> str:
33-
uid = user.id if user and not isinstance(user, int) else user
34-
org_id: int | None = organization.id if isinstance(organization, Model) else organization
35-
proj_id: int | None = project.id if isinstance(project, Model) else project
36-
if project:
37-
metakey = f"{uid}:{proj_id}:project"
38-
elif organization:
39-
metakey = f"{uid}:{org_id}:organization"
40-
else:
41-
metakey = f"{uid}:user"
42-
43-
return super()._make_key(metakey)
44-
45-
def get_value(
46-
self, user: User | RpcUser, key: str, default: Any | None = None, **kwargs: Any
47-
) -> Any:
48-
project = kwargs.get("project")
49-
organization = kwargs.get("organization")
50-
51-
if organization and project:
52-
raise NotImplementedError(option_scope_error)
53-
if organization:
54-
result = self.get_all_values(user, None, organization)
55-
else:
56-
result = self.get_all_values(user, project)
57-
return result.get(key, default)
58-
59-
def unset_value(self, user: User, project: Project, key: str) -> None:
60-
"""
61-
This isn't implemented for user-organization scoped options yet, because it hasn't been needed.
62-
"""
63-
self.filter(user=user, project=project, key=key).delete()
64-
65-
if not hasattr(self, "_metadata"):
66-
return
67-
68-
metakey = self._make_key(user, project=project)
69-
70-
if metakey not in self._option_cache:
71-
return
72-
self._option_cache[metakey].pop(key, None)
73-
74-
def set_value(self, user: User | int, key: str, value: Any, **kwargs: Any) -> None:
75-
project = kwargs.get("project")
76-
organization = kwargs.get("organization")
77-
project_id = kwargs.get("project_id", None)
78-
organization_id = kwargs.get("organization_id", None)
79-
if project is not None:
80-
project_id = project.id
81-
if organization is not None:
82-
organization_id = organization.id
83-
84-
if organization and project:
85-
raise NotImplementedError(option_scope_error)
86-
87-
inst, created = self.get_or_create(
88-
user_id=user.id if user and not isinstance(user, int) else user,
89-
project_id=project_id,
90-
organization_id=organization_id,
91-
key=key,
92-
defaults={"value": value},
93-
)
94-
if not created and inst.value != value:
95-
inst.update(value=value)
96-
97-
metakey = self._make_key(user, project=project, organization=organization)
98-
99-
if metakey not in self._option_cache:
100-
return
101-
self._option_cache[metakey][key] = value
102-
103-
def get_all_values(
104-
self,
105-
user: User | RpcUser | int,
106-
project: Project | int | None = None,
107-
organization: Organization | int | None = None,
108-
force_reload: bool = False,
109-
) -> Mapping[str, Any]:
110-
if organization and project:
111-
raise NotImplementedError(option_scope_error)
112-
113-
uid = user.id if user and not isinstance(user, int) else user
114-
metakey = self._make_key(user, project=project, organization=organization)
115-
project_id: int | None = project.id if isinstance(project, Model) else project
116-
organization_id: int | None = (
117-
organization.id if isinstance(organization, Model) else organization
118-
)
119-
120-
if metakey not in self._option_cache or force_reload:
121-
result = {
122-
i.key: i.value
123-
for i in self.filter(
124-
user_id=uid, project_id=project_id, organization_id=organization_id
125-
)
126-
}
127-
self._option_cache[metakey] = result
128-
129-
return self._option_cache.get(metakey, {})
130-
131-
def post_save(self, *, instance: UserOption, created: bool, **kwargs: object) -> None:
132-
self.get_all_values(
133-
instance.user, instance.project_id, instance.organization_id, force_reload=True
134-
)
135-
136-
def post_delete(self, instance: UserOption, **kwargs: Any) -> None:
137-
self.get_all_values(
138-
instance.user, instance.project_id, instance.organization_id, force_reload=True
139-
)
140-
141-
142-
# TODO(dcramer): the NULL UNIQUE constraint here isn't valid, and instead has to
143-
# be manually replaced in the database. We should restructure this model.
144-
@control_silo_model
145-
class UserOption(Model):
146-
"""
147-
User options apply only to a user, and optionally a project OR an organization.
148-
149-
Options which are specific to a plugin should namespace
150-
their key. e.g. key='myplugin:optname'
151-
152-
Keeping user feature state
153-
key: "feature:assignment"
154-
value: { updated: datetime, state: bool }
155-
156-
where key is one of:
157-
(please add to this list if adding new keys)
158-
- clock_24_hours
159-
- 12hr vs. 24hr
160-
- issue:defaults
161-
- only used in Jira, set default reporter field
162-
- issues:defaults:jira
163-
- unused
164-
- issues:defaults:jira_server
165-
- unused
166-
- issue_details_new_experience_q4_2023
167-
- Whether the user has opted into the new issue details experience (boolean)
168-
- language
169-
- which language to display the app in
170-
- mail:email
171-
- which email address to send an email to
172-
- reports:disabled-organizations
173-
- which orgs to not send weekly reports to
174-
- seen_release_broadcast
175-
- unused
176-
- self_assign_issue
177-
- "Claim Unassigned Issues I've Resolved"
178-
- self_notifications
179-
- "Notify Me About My Own Activity"
180-
- stacktrace_order
181-
- default, most recent first, most recent last
182-
- subscribe_by_default
183-
- "Only On Issues I Subscribe To", "Only On Deploys With My Commits"
184-
- subscribe_notes
185-
- unused
186-
- timezone
187-
- user's timezone to display timestamps
188-
- theme
189-
- dark, light, or default
190-
- twilio:alert
191-
- unused
192-
- workflow_notifications
193-
- unused
194-
"""
195-
196-
__relocation_scope__ = RelocationScope.User
197-
198-
user = FlexibleForeignKey(settings.AUTH_USER_MODEL)
199-
project_id = HybridCloudForeignKey("sentry.Project", null=True, on_delete="CASCADE")
200-
organization_id = HybridCloudForeignKey("sentry.Organization", null=True, on_delete="CASCADE")
201-
key = models.CharField(max_length=64)
202-
value = PickledObjectField()
203-
204-
objects: ClassVar[UserOptionManager] = UserOptionManager()
205-
206-
class Meta:
207-
app_label = "sentry"
208-
db_table = "sentry_useroption"
209-
unique_together = (("user", "project_id", "key"), ("user", "organization_id", "key"))
210-
211-
__repr__ = sane_repr("user_id", "project_id", "organization_id", "key", "value")
212-
213-
@classmethod
214-
def get_relocation_ordinal_fields(self, json_model: Any) -> list[str] | None:
215-
# "global" user options (those with no organization and/or project scope) get a custom
216-
# ordinal; non-global ones use the default ordering.
217-
org_id = json_model["fields"].get("organization_id", None)
218-
project_id = json_model["fields"].get("project_id", None)
219-
if org_id is None and project_id is None:
220-
return ["user", "key"]
221-
222-
return None
223-
224-
def normalize_before_relocation_import(
225-
self, pk_map: PrimaryKeyMap, scope: ImportScope, flags: ImportFlags
226-
) -> int | None:
227-
from sentry.users.models.user import User
228-
229-
old_user_id = self.user_id
230-
old_pk = super().normalize_before_relocation_import(pk_map, scope, flags)
231-
if old_pk is None:
232-
return None
233-
234-
# If we are merging users, ignore the imported options and use the existing user's
235-
# options instead.
236-
if pk_map.get_kind(get_model_name(User), old_user_id) == ImportKind.Existing:
237-
return None
238-
239-
return old_pk
240-
241-
def write_relocation_import(
242-
self, scope: ImportScope, flags: ImportFlags
243-
) -> tuple[int, ImportKind] | None:
244-
# TODO(getsentry/team-ospo#190): This circular import is a bit gross. See if we can't find a
245-
# better place for this logic to live.
246-
from sentry.api.endpoints.user_details import UserOptionsSerializer
247-
248-
serializer_options = UserOptionsSerializer(data={self.key: self.value}, partial=True)
249-
serializer_options.is_valid(raise_exception=True)
250-
251-
# TODO(getsentry/team-ospo#190): Find a more general solution to one-off indices such as
252-
# this. We currently have this constraint on prod, but not in Django, probably from legacy
253-
# SQL manipulation.
254-
#
255-
# Ensure that global (ie: `organization_id` and `project_id` both `NULL`) constraints are
256-
# not duplicated on import.
257-
if self.organization_id is None and self.project_id is None:
258-
colliding_global_user_option = self.objects.filter(
259-
user=self.user, key=self.key, organization_id__isnull=True, project_id__isnull=True
260-
).first()
261-
if colliding_global_user_option is not None:
262-
return None
263-
264-
return super().write_relocation_import(scope, flags)
3+
__all__ = ("UserOption",)

src/sentry/plugins/bases/notify.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ def notify_about_activity(self, activity):
111111
pass
112112

113113
def get_notification_recipients(self, project, user_option: str) -> set:
114-
from sentry.models.options.user_option import UserOption
114+
from sentry.users.models.user_option import UserOption
115115

116116
alert_settings = {
117117
o.user_id: int(o.value)

src/sentry/plugins/helpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
from sentry import options
44
from sentry.models.options.project_option import ProjectOption
5-
from sentry.models.options.user_option import UserOption
65
from sentry.models.project import Project
76
from sentry.projects.services.project import RpcProject, project_service
7+
from sentry.users.models.user_option import UserOption
88

99
__all__ = ("set_option", "get_option", "unset_option")
1010

src/sentry/receivers/auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from django.contrib.auth.signals import user_logged_in
55
from django.db.utils import DatabaseError
66

7-
from sentry.models.options.user_option import UserOption
7+
from sentry.users.models.user_option import UserOption
88

99

1010
# Set user language if set

src/sentry/testutils/cases.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,6 @@
103103
from sentry.models.notificationsettingoption import NotificationSettingOption
104104
from sentry.models.notificationsettingprovider import NotificationSettingProvider
105105
from sentry.models.options.project_option import ProjectOption
106-
from sentry.models.options.user_option import UserOption
107106
from sentry.models.organization import Organization
108107
from sentry.models.organizationmember import OrganizationMember
109108
from sentry.models.project import Project
@@ -146,6 +145,7 @@
146145
from sentry.testutils.pytest.selenium import Browser
147146
from sentry.types.condition_activity import ConditionActivity, ConditionActivityType
148147
from sentry.users.models.user import User
148+
from sentry.users.models.user_option import UserOption
149149
from sentry.utils import json
150150
from sentry.utils.auth import SsoSession
151151
from sentry.utils.json import dumps_htmlsafe

0 commit comments

Comments
 (0)