|
1 |
| -from __future__ import annotations |
| 1 | +from sentry.users.models.user_option import UserOption |
2 | 2 |
|
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",) |
0 commit comments