diff --git a/Gulpfile.js b/Gulpfile.js index 8835e1aa1a..9e0611dcc7 100644 --- a/Gulpfile.js +++ b/Gulpfile.js @@ -153,6 +153,7 @@ function jsPackages() { require.resolve('chartjs-adapter-moment/dist/chartjs-adapter-moment.min.js'), require.resolve('chart.js/dist/chart.min.js'), require.resolve('easymde/dist/easymde.min.js'), + require.resolve('jdenticon/standalone'), path.resolve('node_modules/mathjax/unpacked/**') ], { sourcemaps: true }) .pipe(gulp.dest('dist/js/', { sourcemaps: '.' })) diff --git a/assets/scss/components/_topic-message.scss b/assets/scss/components/_topic-message.scss index 8e7b3c169f..933f0ea66b 100644 --- a/assets/scss/components/_topic-message.scss +++ b/assets/scss/components/_topic-message.scss @@ -165,7 +165,7 @@ div.msg-are-hidden { overflow: hidden; } - img { + .avatar { height: $length-48; width: $length-48; diff --git a/package.json b/package.json index 955289a191..8cb192a20f 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "gulp-postcss": "10.0.0", "gulp-terser-js": "5.2.2", "gulp.spritesmith": "6.13.0", + "jdenticon": "3.3.0", "jquery": "3.7.1", "mathjax": "2.7.1", "moment": "2.30.1", diff --git a/templates/base.html b/templates/base.html index a7f19bf9b6..21e57c686f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -241,6 +241,10 @@

{{ headlin {# Javascript stuff start #} + + {% block extra_js %} {% endblock %} diff --git a/templates/forum/base.html b/templates/forum/base.html index dbc9149bb6..b3f13cbe6f 100644 --- a/templates/forum/base.html +++ b/templates/forum/base.html @@ -140,7 +140,7 @@

{{ period|humane_delta }}

{% with answer=topic.get_last_answer %} {% if answer %} {% with profile=answer.author|profile %} - + {% avatar profile %} {% endwith %} {% trans "Dernière réponse" %} diff --git a/templates/header.html b/templates/header.html index 493f6b8815..f81103d9bc 100644 --- a/templates/header.html +++ b/templates/header.html @@ -236,7 +236,7 @@

{% trans "Messagerie privée" %}

  • - + {% avatar notification.author.profile %} {{ notification.author.username }} {{ notification.pubdate|format_date:True|capfirst }}
    @@ -274,7 +274,7 @@

    {% trans "Notifications" %}

  • - + {% avatar first_unread.author.profile %} {{ first_unread.author.username }} {{ first_unread.pubdate|format_date:True|capfirst }}
    @@ -344,7 +344,7 @@

    {% trans "Alertes de modération" %}

    data-active="open-my-account" {% endif %} > - Mon compte + {% avatar profile %} {{ user.username }}
    {% endwith %} diff --git a/templates/member/profile.html b/templates/member/profile.html index 4f3b0b6443..f88ba71e98 100644 --- a/templates/member/profile.html +++ b/templates/member/profile.html @@ -78,7 +78,7 @@
    - {% trans + {% avatar profile size=200 %}
    diff --git a/templates/misc/avatar.part.html b/templates/misc/avatar.part.html new file mode 100644 index 0000000000..b653d14fb3 --- /dev/null +++ b/templates/misc/avatar.part.html @@ -0,0 +1,11 @@ +{% load captureas %} + +{# Template used by the templatetag "avatar" defined in zds/utils/templatetags/profile.py #} + +{% captureas alt_text %}Avatar de {{ username }}{% endcaptureas %} + +{% if avatar_url %} + +{% else %} + {{ alt_text }} +{% endif %} diff --git a/templates/misc/member_item.part.html b/templates/misc/member_item.part.html index 4c3ca23da7..e6e3fcc12e 100644 --- a/templates/misc/member_item.part.html +++ b/templates/misc/member_item.part.html @@ -16,7 +16,7 @@ {% endif %}> {% if avatar %} - + {% avatar profile %} {% endif %} {{ username }} diff --git a/templates/misc/message_user.html b/templates/misc/message_user.html index adf8ac626d..37401bfe50 100644 --- a/templates/misc/message_user.html +++ b/templates/misc/message_user.html @@ -5,7 +5,7 @@
    {% with profile=member|profile %} - + {% avatar profile %} {% include 'misc/badge.part.html' %} diff --git a/yarn.lock b/yarn.lock index d90bcf6070..ef1df2fa67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1049,6 +1049,13 @@ caniuse-lite@^1.0.30001587, caniuse-lite@^1.0.30001591: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001596.tgz#da06b79c3d9c3d9958eb307aa832ac68ead79bee" integrity sha512-zpkZ+kEr6We7w63ORkoJ2pOfBwBkY/bJrG/UZ90qNb45Isblu8wzDgevEOrRL1r9dWayHjYiiyCMEXPn4DweGQ== +canvas-renderer@~2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/canvas-renderer/-/canvas-renderer-2.2.1.tgz#c1d131f78a9799aca8af9679ad0a005052b65550" + integrity sha512-RrBgVL5qCEDIXpJ6NrzyRNoTnXxYarqm/cS/W6ERhUJts5UQtt/XPEosGN3rqUkZ4fjBArlnCbsISJ+KCFnIAg== + dependencies: + "@types/node" "*" + capture-stack-trace@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.2.tgz#1c43f6b059d4249e7f3f8724f15f048b927d3a8a" @@ -4809,6 +4816,13 @@ isurl@^1.0.0-alpha5: has-to-string-tag-x "^1.2.0" is-object "^1.0.1" +jdenticon@3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/jdenticon/-/jdenticon-3.3.0.tgz#64bae9f9b3cf5c2a210e183648117afe3a89b367" + integrity sha512-DhuBRNRIybGPeAjMjdHbkIfiwZCCmf8ggu7C49jhp6aJ7DYsZfudnvnTY5/1vgUhrGA7JaDAx1WevnpjCPvaGg== + dependencies: + canvas-renderer "~2.2.0" + jpeg-js@0.0.4: version "0.0.4" resolved "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.0.4.tgz" diff --git a/zds/member/api/serializers.py b/zds/member/api/serializers.py index 0f8b614e35..8fa9d7d9cf 100644 --- a/zds/member/api/serializers.py +++ b/zds/member/api/serializers.py @@ -16,7 +16,7 @@ class UserListSerializer(serializers.ModelSerializer): serializers. """ - avatar_url = serializers.CharField(source="profile.get_avatar_url") + avatar_url = serializers.CharField(source="profile.get_absolute_avatar_url") html_url = serializers.CharField(source="get_absolute_url") class Meta: @@ -34,7 +34,7 @@ class ProfileListSerializer(serializers.ModelSerializer): html_url = serializers.CharField(source="user.get_absolute_url") is_active = serializers.BooleanField(source="user.is_active") date_joined = serializers.DateTimeField(source="user.date_joined") - avatar_url = serializers.CharField(source="get_avatar_url") + avatar_url = serializers.CharField(source="get_absolute_avatar_url") permissions = DRYPermissionsField(additional_actions=["ban"]) class Meta: @@ -78,7 +78,7 @@ class ProfileDetailSerializer(serializers.ModelSerializer): email = serializers.EmailField(source="user.email") is_active = serializers.BooleanField(source="user.is_active") date_joined = serializers.DateTimeField(source="user.date_joined") - avatar_url = serializers.CharField(source="get_avatar_url") + avatar_url = serializers.CharField(source="get_absolute_avatar_url") permissions = DRYPermissionsField(additional_actions=["ban"]) class Meta: diff --git a/zds/member/api/tests.py b/zds/member/api/tests.py index 9160869e7b..20103d34bc 100644 --- a/zds/member/api/tests.py +++ b/zds/member/api/tests.py @@ -400,7 +400,7 @@ def test_detail_of_the_member(self): self.assertEqual(profile.user.is_active, response.data.get("is_active")) self.assertIsNotNone(response.data.get("date_joined")) self.assertEqual(profile.site, response.data.get("site")) - self.assertEqual(profile.get_avatar_url(), response.data.get("avatar_url")) + self.assertEqual(profile.get_absolute_avatar_url(), response.data.get("avatar_url")) self.assertEqual(profile.biography, response.data.get("biography")) self.assertEqual(profile.sign, response.data.get("sign")) self.assertFalse(response.data.get("show_email")) @@ -442,7 +442,7 @@ def test_detail_of_a_member(self): self.assertEqual(self.profile.user.is_active, response.data.get("is_active")) self.assertIsNotNone(response.data.get("date_joined")) self.assertEqual(self.profile.site, response.data.get("site")) - self.assertEqual(self.profile.get_avatar_url(), response.data.get("avatar_url")) + self.assertEqual(self.profile.get_absolute_avatar_url(), response.data.get("avatar_url")) self.assertEqual(self.profile.biography, response.data.get("biography")) self.assertEqual(self.profile.sign, response.data.get("sign")) self.assertFalse(response.data.get("show_email")) diff --git a/zds/member/forms.py b/zds/member/forms.py index 1002d5fd81..b7b2a62899 100644 --- a/zds/member/forms.py +++ b/zds/member/forms.py @@ -194,9 +194,7 @@ class MiniProfileForm(forms.Form): label="Avatar", required=False, max_length=Profile._meta.get_field("avatar_url").max_length, - widget=forms.TextInput( - attrs={"placeholder": _("Lien vers un avatar externe (laissez vide pour utiliser Gravatar).")} - ), + widget=forms.TextInput(attrs={"placeholder": _("Lien vers un avatar externe.")}), ) sign = forms.CharField( diff --git a/zds/member/management/commands/migrate_from_gravatar.py b/zds/member/management/commands/migrate_from_gravatar.py new file mode 100644 index 0000000000..2c4890fa1d --- /dev/null +++ b/zds/member/management/commands/migrate_from_gravatar.py @@ -0,0 +1,29 @@ +from hashlib import md5 +from time import sleep + +import requests +from django.core.management.base import BaseCommand +from django.db.models import Q + +from zds.member.models import Profile + + +class Command(BaseCommand): + help = "Migrate from Gravatar" + + def handle(self, *args, **options): + # We have profiles with either NULL or empty avatar_url field + profiles_without_avatar_url = Profile.objects.filter(Q(avatar_url__isnull=True) | Q(avatar_url="")) + total = profiles_without_avatar_url.count() + i = 1 + for profile in profiles_without_avatar_url.iterator(): + hash = md5(profile.user.email.lower().encode("utf-8")).hexdigest() + gravatar_url = f"https://secure.gravatar.com/avatar/{hash}" + r = requests.get(f"{gravatar_url}?d=404") + if r.status_code == 200: + profile.avatar_url = f"{gravatar_url}?s=200" + profile.save() + self.stdout.write(f"\rProgress: {i}/{total}", ending="") + i += 1 + sleep(1) + self.stdout.write(self.style.SUCCESS("\nSuccessfully migrated from Gravatar!")) diff --git a/zds/member/models.py b/zds/member/models.py index 856e844404..53fefa63d9 100644 --- a/zds/member/models.py +++ b/zds/member/models.py @@ -105,22 +105,14 @@ def get_city(self): return geo_location - def get_avatar_url(self, size=80): - """Get the avatar URL for this profile. - If the user has defined a custom URL, use it. - If not, use Gravatar. - :return: The avatar URL for this profile - :rtype: str - """ - if self.avatar_url: - if self.avatar_url.startswith(settings.MEDIA_URL): - return "{}{}".format(settings.ZDS_APP["site"]["url"], self.avatar_url) - else: - return self.avatar_url - else: - return "https://secure.gravatar.com/avatar/{}?d=identicon&s={}".format( - md5(self.user.email.lower().encode("utf-8")).hexdigest(), size - ) + def get_absolute_avatar_url(self): + """Gets the avatar URL of this profile. + :return: The absolute URL of this profile's avatar + :rtype: str or None + """ + if self.avatar_url and self.avatar_url.startswith(settings.MEDIA_URL): + return settings.ZDS_APP["site"]["url"] + self.avatar_url + return self.avatar_url def get_post_count(self): """ diff --git a/zds/member/tests/tests_models.py b/zds/member/tests/tests_models.py index c21c30b114..2a74b4e608 100644 --- a/zds/member/tests/tests_models.py +++ b/zds/member/tests/tests_models.py @@ -29,22 +29,23 @@ def setUp(self): def test_get_absolute_url_for_details_of_member(self): self.assertEqual(self.user1.get_absolute_url(), f"/@{self.user1.user.username}") - def test_get_avatar_url(self): - # if no url was specified -> gravatar ! - self.assertIn("gravatar.com", self.user1.get_avatar_url()) + def test_get_absolute_avatar_url(self): + # if no url was specified -> nothing ! + self.assertEqual(self.user1.get_absolute_avatar_url(), None) # if an url is specified -> take it ! user2 = ProfileFactory() testurl = "http://test.com/avatar.jpg" user2.avatar_url = testurl - self.assertEqual(user2.get_avatar_url(), testurl) + self.assertEqual(user2.get_absolute_avatar_url(), testurl) # if url is relative, send absolute url - gallerie_avtar = GalleryFactory() - image_avatar = ImageFactory(gallery=gallerie_avtar) + gallerie_avatar = GalleryFactory() + image_avatar = ImageFactory(gallery=gallerie_avatar) user2.avatar_url = image_avatar.physical.url - self.assertNotEqual(user2.get_avatar_url(), image_avatar.physical.url) - self.assertIn("http", user2.get_avatar_url()) + self.assertNotEqual(user2.get_absolute_avatar_url(), image_avatar.physical.url) + self.assertIn(image_avatar.physical.url, user2.get_absolute_avatar_url()) + self.assertIn("http", user2.get_absolute_avatar_url()) def test_get_post_count(self): # Start with 0 diff --git a/zds/mp/api/tests.py b/zds/mp/api/tests.py index 25b065d0b3..7a0290dcf1 100644 --- a/zds/mp/api/tests.py +++ b/zds/mp/api/tests.py @@ -211,7 +211,7 @@ def test_expand_list_of_private_topics_for_author(self): self.assertEqual(response.status_code, status.HTTP_200_OK) author = response.data.get("results")[0].get("author") self.assertEqual(author.get("username"), self.profile.user.username) - self.assertEqual(author.get("avatar_url"), self.profile.get_avatar_url()) + self.assertEqual(author.get("avatar_url"), self.profile.get_absolute_avatar_url()) def test_create_private_topics_with_client_unauthenticated(self): """ diff --git a/zds/utils/templatetags/profile.py b/zds/utils/templatetags/profile.py index bf234cc1b6..87bd6f7283 100644 --- a/zds/utils/templatetags/profile.py +++ b/zds/utils/templatetags/profile.py @@ -5,7 +5,6 @@ from django.core.cache import cache from zds.member.models import Profile -from zds.utils.templatetags.remove_url_scheme import remove_url_scheme register = template.Library() @@ -73,13 +72,11 @@ def state(current_user): return user_state -@register.simple_tag -def avatar_url(profile: Profile, size=80) -> str: - """ - Return the URL of the avatar of a profile. - If the profile is None, return an empty string. - """ +@register.inclusion_tag("misc/avatar.part.html") +def avatar(profile: Profile, size=80) -> dict: if profile is not None: - url = profile.get_avatar_url(size) - return remove_url_scheme(url) - return "" + return { + "avatar_url": profile.avatar_url, + "avatar_size": size, + "username": profile.user.username, + }