Browse Source

feat(avatars) Add URLs to avatar bearing resources (#58797)

Instead of having to pass an assortment or props around in UI
components, we can pass `avatarUrl` and `avatarType` and have enough
context to show uploaded images.

Once this is safely deployed, I'll revise UI code.
Mark Story 1 year ago
parent
commit
f36f5e731b

+ 3 - 3
api-docs/paths/organizations/users.json

@@ -44,7 +44,7 @@
                   "isActive": true,
                   "has2fa": false,
                   "name": "OtherTest McTestuser",
-                  "avatarUrl": "https:  //secure.gravatar.com/avatar/1eb103c0e899f372a85eb0a44f0a0f42?s=32&d=mm",
+                  "avatarUrl": "https://secure.gravatar.com/avatar/1eb103c0e899f372a85eb0a44f0a0f42?s=32&d=mm",
                   "dateJoined": "2019-05-09T18:06:01.443Z",
                   "emails": [
                     {
@@ -54,8 +54,8 @@
                     }
                   ],
                   "avatar": {
-                    "avatarUuid": null,
-                    "avatarType": "letter_avatar"
+                    "avatarType": "letter_avatar",
+                    "avatarUuid": null
                   },
                   "hasPasswordAuth": false,
                   "email": "testEmail@test.com"

+ 1 - 0
src/sentry/api/serializers/models/doc_integration_avatar.py

@@ -13,4 +13,5 @@ class DocIntegrationAvatarSerializer(Serializer):
         return {
             "avatarType": obj.get_avatar_type_display(),
             "avatarUuid": obj.ident,
+            "avatarUrl": obj.absolute_url(),
         }

+ 2 - 1
src/sentry/api/serializers/models/organization.py

@@ -263,9 +263,10 @@ class OrganizationSerializer(Serializer):
             avatar = {
                 "avatarType": attrs["avatar"].get_avatar_type_display(),
                 "avatarUuid": attrs["avatar"].ident if attrs["avatar"].file_id else None,
+                "avatarUrl": attrs["avatar"].absolute_url(),
             }
         else:
-            avatar = {"avatarType": "letter_avatar", "avatarUuid": None}
+            avatar = {"avatarType": "letter_avatar", "avatarUuid": None, "avatarUrl": None}
 
         status = OrganizationStatus(obj.status)
 

+ 1 - 0
src/sentry/api/serializers/models/sentry_app_avatar.py

@@ -13,5 +13,6 @@ class SentryAppAvatarSerializer(Serializer):
         return {
             "avatarType": obj.get_avatar_type_display(),
             "avatarUuid": obj.ident,
+            "avatarUrl": obj.absolute_url(),
             "color": obj.color,
         }

+ 3 - 2
src/sentry/api/serializers/models/user.py

@@ -165,7 +165,7 @@ class UserSerializer(Serializer):
         return data
 
     def serialize(
-        self, obj: User, attrs: MutableMapping[User, Any], user: User | AnonymousUser | RpcUser
+        self, obj: User, attrs: MutableMapping[str, Any], user: User | AnonymousUser | RpcUser
     ) -> Union[UserSerializerResponse, UserSerializerResponseSelf]:
         experiment_assignments = experiments.all(user=user)
 
@@ -214,9 +214,10 @@ class UserSerializer(Serializer):
             avatar: SerializedAvatarFields = {
                 "avatarType": attrs["avatar"].get_avatar_type_display(),
                 "avatarUuid": attrs["avatar"].ident if attrs["avatar"].get_file_id() else None,
+                "avatarUrl": attrs["avatar"].absolute_url(),
             }
         else:
-            avatar = {"avatarType": "letter_avatar", "avatarUuid": None}
+            avatar = {"avatarType": "letter_avatar", "avatarUuid": None, "avatarUrl": None}
         d["avatar"] = avatar
 
         # TODO(dcramer): move this to DetailedUserSerializer

+ 2 - 1
src/sentry/api/serializers/types.py

@@ -4,9 +4,10 @@ from typing import List, Optional
 from typing_extensions import TypedDict
 
 
-class SerializedAvatarFields(TypedDict):
+class SerializedAvatarFields(TypedDict, total=False):
     avatarType: str
     avatarUuid: Optional[str]
+    avatarUrl: Optional[str]
 
 
 class _Status(TypedDict):

+ 21 - 0
src/sentry/models/avatars/base.py

@@ -2,6 +2,7 @@ from __future__ import annotations
 
 from io import BytesIO
 from typing import ClassVar
+from urllib.parse import urljoin
 from uuid import uuid4
 
 from django.core.exceptions import ObjectDoesNotExist
@@ -10,11 +11,13 @@ from django.utils.encoding import force_bytes
 from PIL import Image
 from typing_extensions import Self
 
+from sentry import options
 from sentry.backup.scopes import RelocationScope
 from sentry.db.models import BoundedBigIntegerField, Model
 from sentry.models.files.file import File
 from sentry.silo import SiloMode
 from sentry.tasks.files import copy_file_to_control_and_update_model
+from sentry.types.region import get_local_region
 from sentry.utils.cache import cache
 from sentry.utils.db import atomic_transaction
 
@@ -40,6 +43,8 @@ class AvatarBase(Model):
     class Meta:
         abstract = True
 
+    url_path = "avatar"
+
     def save(self, *args, **kwargs):
         if not self.ident:
             self.ident = uuid4().hex
@@ -128,6 +133,22 @@ class AvatarBase(Model):
         """
         return "file_id"
 
+    def absolute_url(self) -> str:
+        """
+        Get the absolute URL to an avatar.
+
+        Use the implementing class's silo_limit to infer which
+        host name should be used.
+        """
+        cls = type(self)
+
+        url_base = options.get("system.url-prefix")
+        silo_limit = getattr(cls._meta, "silo_limit", None)
+        if silo_limit is not None and SiloMode.REGION in silo_limit.modes:
+            url_base = get_local_region().to_url("")
+
+        return urljoin(url_base, f"/{self.url_path}/{self.ident}/")
+
     @classmethod
     def save_avatar(cls, relation, type, avatar=None, filename=None, color=None) -> Self:
         if avatar:

+ 2 - 0
src/sentry/models/avatars/doc_integration_avatar.py

@@ -26,5 +26,7 @@ class DocIntegrationAvatar(ControlAvatarBase):
         app_label = "sentry"
         db_table = "sentry_docintegrationavatar"
 
+    url_path = "doc-integration-avatar"
+
     def get_cache_key(self, size):
         return f"doc_integration_avatar:{self.doc_integration_id}:{size}"

+ 2 - 0
src/sentry/models/avatars/organization_avatar.py

@@ -21,6 +21,8 @@ class OrganizationAvatar(AvatarBase):
     organization = FlexibleForeignKey("sentry.Organization", unique=True, related_name="avatar")
     avatar_type = models.PositiveSmallIntegerField(default=0, choices=AVATAR_TYPES)
 
+    url_path = "organization-avatar"
+
     class Meta:
         app_label = "sentry"
         db_table = "sentry_organizationavatar"

+ 2 - 0
src/sentry/models/avatars/sentry_app_avatar.py

@@ -58,6 +58,8 @@ class SentryAppAvatar(ControlAvatarBase):
         app_label = "sentry"
         db_table = "sentry_sentryappavatar"
 
+    url_path = "sentry-app-avatar"
+
     def get_cache_key(self, size):
         color_identifier = "color" if self.color else "simple"
         return f"sentry_app_avatar:{self.sentry_app_id}:{color_identifier}:{size}"

Some files were not shown because too many files changed in this diff