diff --git a/backend/siarnaq/api/teams/admin.py b/backend/siarnaq/api/teams/admin.py index c940eb784..91526552d 100644 --- a/backend/siarnaq/api/teams/admin.py +++ b/backend/siarnaq/api/teams/admin.py @@ -12,11 +12,11 @@ class TeamProfileInline(admin.StackedInline): "auto_accept_ranked", "auto_accept_unranked", "rating", - "has_avatar", + "has_uploaded_avatar", "eligible_for", ) filter_horizontal = ("eligible_for",) - readonly_fields = ("rating", "has_avatar") + readonly_fields = ("rating", "has_uploaded_avatar") def get_queryset(self, request): return ( diff --git a/backend/siarnaq/api/teams/migrations/0004_rename_has_avatar_teamprofile_has_uploaded_avatar_and_more.py b/backend/siarnaq/api/teams/migrations/0004_rename_has_avatar_teamprofile_has_uploaded_avatar_and_more.py new file mode 100644 index 000000000..0f7637c5b --- /dev/null +++ b/backend/siarnaq/api/teams/migrations/0004_rename_has_avatar_teamprofile_has_uploaded_avatar_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.2 on 2022-12-10 20:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("teams", "0003_alter_team_name_alter_teamprofile_quote"), + ] + + operations = [ + migrations.RenameField( + model_name="teamprofile", + old_name="has_avatar", + new_name="has_uploaded_avatar", + ), + migrations.AlterField( + model_name="teamprofile", + name="avatar_uuid", + field=models.UUIDField(default=None, null=True), + ), + ] diff --git a/backend/siarnaq/api/teams/models.py b/backend/siarnaq/api/teams/models.py index bafd75e04..a14ceacb7 100644 --- a/backend/siarnaq/api/teams/models.py +++ b/backend/siarnaq/api/teams/models.py @@ -8,6 +8,8 @@ import siarnaq.api.refs as refs from siarnaq.api.teams.managers import TeamQuerySet +from siarnaq.api.user.random_avatar import generate_avatar +from siarnaq.gcloud import titan class Rating(models.Model): @@ -192,10 +194,10 @@ class TeamProfile(models.Model): biography = models.TextField(max_length=1024, blank=True) """The biography provided by the team, if any.""" - has_avatar = models.BooleanField(default=False) + has_uploaded_avatar = models.BooleanField(default=False) """Whether the team has an uploaded avatar.""" - avatar_uuid = models.UUIDField(default=uuid.uuid4) + avatar_uuid = models.UUIDField(default=None, null=True) """A unique ID to identify each new avatar upload.""" rating = models.OneToOneField(Rating, on_delete=models.PROTECT) @@ -218,24 +220,23 @@ def save(self, *args, **kwargs): self.rating = Rating.objects.create() super().save(*args, **kwargs) - def get_avatar_path(self): - """Return the path of the avatar on Google cloud storage.""" - if not self.has_avatar: - return None - return posixpath.join("team", str(self.pk), "avatar.png") - def get_avatar_url(self): """Return a cache-safe URL to the avatar.""" - # To circumvent caching of old avatars, we append a UUID that changes on each - # update. - if not self.has_avatar: - return None + + avatar_path = posixpath.join("team", str(self.pk), "avatar.png") + + if not self.avatar_uuid: + + self.avatar_uuid = uuid.uuid4() + self.save(update_fields=["has_uploaded_avatar", "avatar_uuid"]) + avatar = generate_avatar(self.avatar_uuid.int) + + titan.upload_image(avatar, avatar_path) client = storage.Client.create_anonymous_client() public_url = ( - client.bucket(settings.GCLOUD_BUCKET_PUBLIC) - .blob(self.get_avatar_path()) - .public_url + client.bucket(settings.GCLOUD_BUCKET_PUBLIC).blob(avatar_path).public_url ) + # Append UUID to public URL to prevent caching on avatar update return f"{public_url}?{self.avatar_uuid}" diff --git a/backend/siarnaq/api/teams/serializers.py b/backend/siarnaq/api/teams/serializers.py index 29476e9ab..27a7791be 100644 --- a/backend/siarnaq/api/teams/serializers.py +++ b/backend/siarnaq/api/teams/serializers.py @@ -26,14 +26,14 @@ class Meta: fields = [ "quote", "biography", - "has_avatar", + "has_uploaded_avatar", "avatar_url", "rating", "auto_accept_ranked", "auto_accept_unranked", "eligible_for", ] - read_only_fields = ["rating", "has_avatar", "avatar_url"] + read_only_fields = ["rating", "has_uploaded_avatar", "avatar_url"] def get_avatar_url(self, obj): return obj.get_avatar_url() @@ -63,14 +63,14 @@ class Meta: fields = [ "quote", "biography", - "has_avatar", + "has_uploaded_avatar", "avatar_url", "rating", "auto_accept_ranked", "auto_accept_unranked", "eligible_for", ] - read_only_fields = ["rating", "has_avatar", "avatar_url"] + read_only_fields = ["rating", "has_uploaded_avatar", "avatar_url"] def create(self, validated_data): eligible_for = validated_data.pop("eligible_for", None) diff --git a/backend/siarnaq/api/teams/views.py b/backend/siarnaq/api/teams/views.py index 2372551a0..df83b14ec 100644 --- a/backend/siarnaq/api/teams/views.py +++ b/backend/siarnaq/api/teams/views.py @@ -1,3 +1,4 @@ +import posixpath import uuid import structlog @@ -157,11 +158,12 @@ def avatar(self, request, pk=None, *, episode_id): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) avatar = serializer.validated_data["avatar"] + path = posixpath.join("team", str(self.pk), "avatar.png") with transaction.atomic(): - profile.has_avatar = True + profile.has_uploaded_avatar = True profile.avatar_uuid = uuid.uuid4() - profile.save(update_fields=["has_avatar", "avatar_uuid"]) - titan.upload_image(avatar, profile.get_avatar_path()) + profile.save(update_fields=["has_uploaded_avatar", "avatar_uuid"]) + titan.upload_image(avatar, path) return Response(None, status=status.HTTP_204_NO_CONTENT) diff --git a/backend/siarnaq/api/user/admin.py b/backend/siarnaq/api/user/admin.py index e5c3fcd70..b3841f4a8 100644 --- a/backend/siarnaq/api/user/admin.py +++ b/backend/siarnaq/api/user/admin.py @@ -16,10 +16,10 @@ class UserProfileInline(admin.StackedInline): "kerberos", "biography", "country", - "has_avatar", + "has_uploaded_avatar", "has_resume", ) - readonly_fields = ("has_avatar", "has_resume") + readonly_fields = ("has_uploaded_avatar", "has_resume") def has_delete_permission(self, request, obj): return False diff --git a/backend/siarnaq/api/user/migrations/0004_rename_has_avatar_userprofile_has_uploaded_avatar_and_more.py b/backend/siarnaq/api/user/migrations/0004_rename_has_avatar_userprofile_has_uploaded_avatar_and_more.py new file mode 100644 index 000000000..61a9c8b90 --- /dev/null +++ b/backend/siarnaq/api/user/migrations/0004_rename_has_avatar_userprofile_has_uploaded_avatar_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.2 on 2022-12-10 20:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("user", "0003_alter_userprofile_country"), + ] + + operations = [ + migrations.RenameField( + model_name="userprofile", + old_name="has_avatar", + new_name="has_uploaded_avatar", + ), + migrations.AlterField( + model_name="userprofile", + name="avatar_uuid", + field=models.UUIDField(default=None, null=True), + ), + ] diff --git a/backend/siarnaq/api/user/models.py b/backend/siarnaq/api/user/models.py index 0fb43d537..3a92e8964 100644 --- a/backend/siarnaq/api/user/models.py +++ b/backend/siarnaq/api/user/models.py @@ -8,6 +8,9 @@ from django.utils.translation import gettext_lazy as _ from django_countries.fields import CountryField +from siarnaq.api.user.random_avatar import generate_avatar +from siarnaq.gcloud import titan + class User(AbstractUser): """ @@ -76,10 +79,10 @@ class UserProfile(models.Model): kerberos = models.SlugField(max_length=16, blank=True) """The kerberos username of the user, if an MIT student.""" - has_avatar = models.BooleanField(default=False) + has_uploaded_avatar = models.BooleanField(default=False) """Whether the user has an uploaded avatar.""" - avatar_uuid = models.UUIDField(default=uuid.uuid4) + avatar_uuid = models.UUIDField(default=None, null=True) """A unique ID to identify each new avatar upload.""" has_resume = models.BooleanField(default=False) @@ -92,24 +95,21 @@ def get_resume_path(self): """Return the path of the resume on Google cloud storage.""" return posixpath.join("user", str(self.pk), "resume.pdf") - def get_avatar_path(self): - """Return the path of the avatar on Google cloud storage.""" - if not self.has_avatar: - return None - return posixpath.join("user", str(self.pk), "avatar.png") - def get_avatar_url(self): """Return a cache-safe URL to the avatar.""" - # To circumvent caching of old avatars, we append a UUID that changes on each - # update. - if not self.has_avatar: - return None + + avatar_path = posixpath.join("user", str(self.pk), "avatar.png") + if not self.avatar_uuid: + self.avatar_uuid = uuid.uuid4() + self.save(update_fields=["has_uploaded_avatar", "avatar_uuid"]) + avatar = generate_avatar(self.avatar_uuid.int) + + titan.upload_image(avatar, avatar_path) client = storage.Client.create_anonymous_client() public_url = ( - client.bucket(settings.GCLOUD_BUCKET_PUBLIC) - .blob(self.get_avatar_path()) - .public_url + client.bucket(settings.GCLOUD_BUCKET_PUBLIC).blob(avatar_path).public_url ) + # Append UUID to public URL to prevent caching on avatar update return f"{public_url}?{self.avatar_uuid}" diff --git a/backend/siarnaq/api/user/random_avatar.py b/backend/siarnaq/api/user/random_avatar.py new file mode 100644 index 000000000..0edba86f0 --- /dev/null +++ b/backend/siarnaq/api/user/random_avatar.py @@ -0,0 +1,36 @@ +import random + +from django.conf import settings +from PIL import Image + + +def generate_avatar(seed): + """Return a random avatar as a IMAGE object""" + # generate a unique seed + random.seed(seed) + + # generate unique rgb + rgb1 = tuple(int(random.random() * 255) for _ in range(3)) + rgb2 = tuple(int(random.random() * 255) for _ in range(3)) + + imgsize = settings.GCLOUD_MAX_AVATAR_SIZE # The size of the image + + avatar = Image.new("RGB", imgsize) # Create the image + + leftColor = rgb1 # Color at the right + rightColor = rgb2 # Color at the left + + for pixel_y in range(imgsize[1]): + for pixel_x in range(imgsize[0]): + + # Make it on a scale from 0 to 1 + dis = float(pixel_x + pixel_y) / (imgsize[0] + imgsize[1]) + + # Calculate r, g, and b values + r = rightColor[0] * dis + leftColor[0] * (1 - dis) + g = rightColor[1] * dis + leftColor[1] * (1 - dis) + b = rightColor[2] * dis + leftColor[2] * (1 - dis) + + avatar.putpixel((pixel_x, pixel_y), (int(r), int(g), int(b))) + + return avatar diff --git a/backend/siarnaq/api/user/serializers.py b/backend/siarnaq/api/user/serializers.py index ffe0ab7a6..fe5c327cb 100644 --- a/backend/siarnaq/api/user/serializers.py +++ b/backend/siarnaq/api/user/serializers.py @@ -9,8 +9,8 @@ class UserProfilePublicSerializer(serializers.ModelSerializer): class Meta: model = UserProfile - fields = ["school", "biography", "avatar_url", "has_avatar"] - read_only_fields = ["has_avatar", "avatar_url"] + fields = ["school", "biography", "avatar_url", "has_uploaded_avatar"] + read_only_fields = ["has_uploaded_avatar", "avatar_url"] def get_avatar_url(self, obj): return obj.get_avatar_url() @@ -45,11 +45,11 @@ class Meta: "biography", "kerberos", "avatar_url", - "has_avatar", + "has_uploaded_avatar", "has_resume", "country", ] - read_only_fields = ["has_resume", "has_avatar", "avatar_url"] + read_only_fields = ["has_resume", "has_uploaded_avatar", "avatar_url"] class UserPrivateSerializer(UserPublicSerializer): diff --git a/backend/siarnaq/api/user/views.py b/backend/siarnaq/api/user/views.py index 129cbdf7f..6c017743e 100644 --- a/backend/siarnaq/api/user/views.py +++ b/backend/siarnaq/api/user/views.py @@ -1,3 +1,4 @@ +import posixpath import uuid import google.cloud.storage as storage @@ -6,6 +7,7 @@ from django.db import transaction from django.http import Http404 from drf_spectacular.utils import extend_schema +from PIL import Image from rest_framework import filters, mixins, status, viewsets from rest_framework.decorators import action from rest_framework.permissions import AllowAny, IsAuthenticated @@ -135,11 +137,13 @@ def avatar(self, request, pk=None): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) avatar = serializer.validated_data["avatar"] + path = posixpath.join("user", str(self.pk), "avatar.png") with transaction.atomic(): - profile.has_avatar = True + profile.has_uploaded_avatar = True profile.avatar_uuid = uuid.uuid4() - profile.save(update_fields=["has_avatar", "avatar_uuid"]) - titan.upload_image(avatar, profile.get_avatar_path()) + profile.save(update_fields=["has_uploaded_avatar", "avatar_uuid"]) + img = Image.open(avatar) + titan.upload_image(img, path) return Response(None, status=status.HTTP_204_NO_CONTENT) diff --git a/backend/siarnaq/gcloud/titan.py b/backend/siarnaq/gcloud/titan.py index 5af3158ac..080230250 100644 --- a/backend/siarnaq/gcloud/titan.py +++ b/backend/siarnaq/gcloud/titan.py @@ -90,8 +90,8 @@ def get_object(bucket: str, name: str, check_safety: bool) -> dict[str, str | bo raise ValueError("Unexpected state.") -def upload_image(raw_image, image_path): - img = Image.open(raw_image) +def upload_image(img: Image, image_path): + img.thumbnail(settings.GCLOUD_MAX_AVATAR_SIZE) # Prepare image bytes for upload to Google Cloud diff --git a/frontend/src/components/avatar.js b/frontend/src/components/avatar.js index f5245c827..8c1c1da19 100644 --- a/frontend/src/components/avatar.js +++ b/frontend/src/components/avatar.js @@ -1,100 +1,13 @@ import React, { Component } from "react"; /* a component for displaying a user or teams avatar (used on team and user pages) - * props: data — either user or team, used to get avatar or seed for random generation. - * if data does not have either name or username defined, empty avatar will be returned */ + * props: data — either user or team, used to get avatar + * Assumes that the backend will always return a URL, which Jerry claims to trust */ class Avatar extends Component { - // seeded random numbers bc this isn't part of math.random in js - seededRNG(seed, min = 0, max = 1, depth = 0) { - //hashing seed to try to remove correlation - const hashSeed = (seed * 2654435761) % Math.pow(2, 32); - // see softwareengineering.stackexchange.com/questions/260969 - const modSeed = (hashSeed * 9301 + 49297) % 233280; - const rand = modSeed / 233280; - const randBound = min + rand * (max - min); - - // run this 3 times to remove correlation! - if (depth === 2) { - return min + rand * (max - min); - } else { - return this.seededRNG(randBound, min, max, depth + 1); - } - } - - // converts colors from HSV to RGB encoding - // adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB_alternative - HSVtoRGB(hsv) { - const func = (n, hsv) => { - let k = (n + hsv[0] / 60) % 6; - return Math.round( - (hsv[2] - hsv[2] * hsv[1] * Math.max(Math.min(k, 4 - k, 1), 0)) * 255 - ); - }; - return [func(5, hsv), func(3, hsv), func(1, hsv)]; - } - - // converts colors from RGB to hex string ('#xxxxxx') - RGBtoHex(rgb) { - const hex = (comp) => { - var str = comp.toString(16); - return str.length === 1 ? "0" + str : str; - }; - return `#${hex(rgb[0])}${hex(rgb[1])}${hex(rgb[2])}`; - } - render() { const data = this.props.data; - const has_avatar = data.profile.has_avatar; const avatar = data.profile.avatar_url; - if (has_avatar) { - // avatar is uploaded - return Avatar; - } else { - if (!data.name && !data.username && !data.id) { - // data not fully loaded, return placeholder - return ( -
- ); - } - - // no avatar, create a random one. which must always be same for this entity - // random number derived from hash of str defines HSV color (rand°, 100%, 100%) - // second random number is transparent "accent color" - - const seedStr = this.stringHash(data.name ? data.name : data.username); - const num = Math.floor(this.seededRNG(seedStr, 0, 361)); - const colorStr = this.RGBtoHex(this.HSVtoRGB([num, 1, 1])); - const num2 = Math.floor(this.seededRNG(data.id, 0, 361)); - const colorStr2 = this.RGBtoHex(this.HSVtoRGB([num2, 1, 1])) + "50"; - - const gradStr = `linear-gradient(45deg, ${colorStr}, ${colorStr2})`; - - return ( -
- ); - } - } - - // gives numerical hash for string (stackoverflow.com/questions/7616461/) - stringHash(str) { - let hash = 0, - chr; - - if (str.length === 0) return hash; - - for (let i = 0; i < str.length; i++) { - chr = str.charCodeAt(i); - hash = (hash << 5) - hash + chr; - hash |= 0; // Convert to 32bit integer - } - - return Math.abs(hash); + return Avatar; } }