33 Commits

Author SHA1 Message Date
Oliver Falk
9423aa0f88 Carve out the AvatarCreator code into a separate module 2023-01-02 20:20:48 +01:00
Oliver Falk
65be9ac7cd Slightly rearrange the options to the right - still WIP 2022-12-31 20:41:05 +01:00
Oliver Falk
419f8fce90 Handle svg or svg+xml equally and it's anyway the default 2022-12-30 16:22:34 +01:00
Oliver Falk
575e3db1ed Just pass svg, handle it in the backend 2022-12-30 16:22:11 +01:00
Oliver Falk
5eff8a61b4 Add download buttons for SVG + PNG, using the new functionality from 08074e394e 2022-12-30 16:17:20 +01:00
Oliver Falk
08074e394e Default to SVG output, but allow PNG by format parameter 2022-12-30 16:15:21 +01:00
Oliver Falk
cc1191ee92 Replace variable names to be better reusable 2022-12-30 12:30:09 +01:00
Oliver Falk
72344c811b Missing break 2022-12-30 12:02:32 +01:00
Oliver Falk
f613b79ef9 Enhance UI/UX 2022-12-30 12:00:01 +01:00
Oliver Falk
0449e4f00a Wire up the rest of the options - size still is WIP 2022-12-29 22:21:44 +01:00
Oliver Falk
28ff2d16eb Wire up a lot of types and colors for our avatar creator 2022-12-29 17:05:52 +01:00
Oliver Falk
7f748cf8bf Merge branch 'devel' into avatar-creator 2022-12-29 15:16:18 +01:00
Oliver Falk
9e189b3fd2 Update black 2022-12-29 15:15:56 +01:00
Oliver Falk
37d70b09c8 Add local env 2022-12-29 15:13:10 +01:00
Oliver Falk
e6e712128a Merge branch 'devel' into avatar-creator 2022-12-29 15:12:12 +01:00
Oliver Falk
5730c2dabf Merge branch 'webp-support' into 'devel'
Webp support

See merge request oliver/ivatar!218
2022-12-06 17:50:48 +00:00
Oliver Falk
dddd24e57f Webp support 2022-12-06 17:50:48 +00:00
Oliver Falk
a6c5899f44 Merge branch 'webp-support' into 'devel'
Webp support

See merge request oliver/ivatar!217
2022-12-05 15:56:13 +00:00
Oliver Falk
ba6f46c6eb Webp support 2022-12-05 15:56:12 +00:00
Oliver Falk
ddfc1e7824 Experimental support for Animated GIFs 2022-12-05 16:16:40 +01:00
Oliver Falk
2761e801df Add util function to resize an animated GIF 2022-12-05 16:15:30 +01:00
Oliver Falk
555a8b0523 Update pre-commit 2022-12-05 16:15:18 +01:00
Oliver Falk
6d984a486a Missing webp test file 2022-11-30 23:15:41 +01:00
Oliver Falk
9dceb7a696 Some jpgs are recognized as MPO (basically jpg with additional data 2022-11-30 11:50:29 +01:00
Oliver Falk
64575a9b99 No absolute URI required any more, actually leads to broken redir 2022-11-30 11:49:37 +01:00
Oliver Falk
a94954d58c Merge branch 'django-4.1' into 'devel'
Changes required for Django > 4

See merge request oliver/ivatar!216
2022-11-22 20:20:51 +00:00
Oliver Falk
34b005643f Sort reqs 2022-02-22 13:55:38 +01:00
Oliver Falk
f43d8e5642 Merge with master 2022-02-22 13:55:26 +01:00
Oliver Falk
74c43baca6 For the moment only - coverage with 69 is ok 2022-02-18 14:16:55 +01:00
Oliver Falk
cddb6d4fbe Merge branch 'devel' into avatar-creator 2021-09-10 13:01:45 +02:00
Oliver Falk
0a877a76e3 Merge branch 'master' into avatar-creator 2021-09-10 12:47:20 +02:00
Oliver Falk
dacbd21a7f Merge branch 'devel' into avatar-creator 2021-04-13 11:08:43 +02:00
Oliver Falk
8e5d730c25 (WIP) Very early progress of avatar creator 2021-03-14 17:32:25 +01:00
16 changed files with 550 additions and 18 deletions

4
.env
View File

@@ -7,3 +7,7 @@ if [ -f .virtualenv/bin/activate ]; then
source .virtualenv/bin/activate source .virtualenv/bin/activate
AUTOENV_ENABLE_LEAVE=True AUTOENV_ENABLE_LEAVE=True
fi fi
if [ -f .env.local ]; then
source .env.local
fi

1
.gitignore vendored
View File

@@ -20,3 +20,4 @@ falko_gravatar.jpg
*.egg-info *.egg-info
dump_all*.sql dump_all*.sql
dist/ dist/
.env.local

View File

@@ -24,7 +24,7 @@ test_and_coverage:
- echo "CACHES['default'] = CACHES['filesystem']" >> config_local.py - echo "CACHES['default'] = CACHES['filesystem']" >> config_local.py
- python manage.py collectstatic --noinput - python manage.py collectstatic --noinput
- coverage run --source . manage.py test -v3 - coverage run --source . manage.py test -v3
- coverage report --fail-under=70 - coverage report --fail-under=69
- coverage html - coverage html
artifacts: artifacts:
paths: paths:

View File

@@ -9,11 +9,11 @@ repos:
- id: prettier - id: prettier
files: \.(css|js|md|markdown|json) files: \.(css|js|md|markdown|json)
- repo: https://github.com/python/black - repo: https://github.com/python/black
rev: 22.10.0 rev: 22.12.0
hooks: hooks:
- id: black - id: black
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0 rev: v4.4.0
hooks: hooks:
- id: check-added-large-files - id: check-added-large-files
- id: check-ast - id: check-ast
@@ -38,7 +38,7 @@ repos:
- id: sort-simple-yaml - id: sort-simple-yaml
- id: trailing-whitespace - id: trailing-whitespace
- repo: https://github.com/PyCQA/flake8 - repo: https://github.com/PyCQA/flake8
rev: 5.0.4 rev: 6.0.0
hooks: hooks:
- id: flake8 - id: flake8
- repo: local - repo: local

View File

@@ -0,0 +1,196 @@
# -*- coding: utf-8 -*-
"""
View classes for the avatar creator
"""
from django.utils.decorators import method_decorator
from django.contrib.auth.decorators import login_required
from django.views.generic.base import View, TemplateView
from django.http import HttpResponseRedirect, HttpResponse
from django.urls import reverse_lazy
from jinja2 import Environment, PackageLoader
import py_avataaars as pa
@method_decorator(login_required, name="dispatch")
class AvatarCreatorView(TemplateView):
"""
View class responsible for handling avatar creation
"""
template_name = "avatar_creator.html"
def get(self, request, *args, **kwargs):
"""
Handle get for create view
"""
if request.user:
if not request.user.is_authenticated:
return HttpResponseRedirect(reverse_lazy("profile"))
return super().get(self, request, args, kwargs)
def get_context_data(self, **kwargs):
"""
Provide additional context data
"""
context = super().get_context_data(**kwargs)
context["SkinColor"] = list(pa.SkinColor)
context["HairColor"] = list(pa.HairColor)
context["FacialHairType"] = list(pa.FacialHairType)
context["TopType"] = list(pa.TopType)
context["HatColor"] = list(pa.Color)
context["MouthType"] = list(pa.MouthType)
context["EyesType"] = list(pa.EyesType)
context["EyebrowType"] = list(pa.EyebrowType)
context["NoseType"] = list(pa.NoseType)
context["AccessoriesType"] = list(pa.AccessoriesType)
context["ClotheType"] = list(pa.ClotheType)
context["ClotheColor"] = list(pa.Color)
context["ClotheGraphicType"] = list(pa.ClotheGraphicType)
return context
@method_decorator(login_required, name="dispatch")
class AvatarView(View):
"""
View class responsible for handling avatar view
"""
def get(
self, request, *args, **kwargs
): # pylint: disable=too-many-locals,too-many-branches,too-many-statements,unused-argument
"""
Handle get for create view
"""
output_format = "svg+xml"
avatar_style = list(pa.AvatarStyle)[0]
skin_color = list(pa.SkinColor)[0]
hair_color = list(pa.HairColor)[0]
facial_hair_type = list(pa.FacialHairType)[0]
top_type = pa.TopType.SHORT_HAIR_SHORT_FLAT
hat_color = list(pa.Color)[0]
mouth_type = list(pa.MouthType)[0]
eyes_type = list(pa.EyesType)[0]
eyebrow_type = list(pa.EyebrowType)[0]
nose_type = list(pa.NoseType)[0]
accessories_type = list(pa.AccessoriesType)[0]
clothe_type = list(pa.ClotheType)[0]
clothe_color = list(pa.Color)[0]
clothe_graphic_type = list(pa.ClotheGraphicType)[0]
if "avatar_style" in request.GET:
avatar_style = list(pa.AvatarStyle)[int(request.GET["avatar_style"])]
if "skin_color" in request.GET:
skin_color = list(pa.SkinColor)[int(request.GET["skin_color"])]
if "hair_color" in request.GET:
hair_color = list(pa.HairColor)[int(request.GET["hair_color"])]
if "facial_hair_type" in request.GET:
facial_hair_type = list(pa.FacialHairType)[
int(request.GET["facial_hair_type"])
]
if "facial_hair_color" in request.GET:
facial_hair_color = list(pa.HairColor)[
int(request.GET["facial_hair_color"])
]
if "top_type" in request.GET:
top_type = list(pa.TopType)[int(request.GET["top_type"])]
if "hat_color" in request.GET:
hat_color = list(pa.Color)[int(request.GET["hat_color"])]
if "mouth_type" in request.GET:
mouth_type = list(pa.MouthType)[int(request.GET["mouth_type"])]
if "eyes_type" in request.GET:
eyes_type = list(pa.EyesType)[int(request.GET["eyes_type"])]
if "eyebrow_type" in request.GET:
eyebrow_type = list(pa.EyebrowType)[int(request.GET["eyebrow_type"])]
if "nose_type" in request.GET:
nose_type = list(pa.NoseType)[int(request.GET["nose_type"])]
if "accessories_type" in request.GET:
accessories_type = list(pa.AccessoriesType)[
int(request.GET["accessories_type"])
]
if "clothe_type" in request.GET:
clothe_type = list(pa.ClotheType)[int(request.GET["clothe_type"])]
if "clothe_color" in request.GET:
clothe_color = list(pa.Color)[int(request.GET["clothe_color"])]
if "clothe_graphic_type" in request.GET:
clothe_graphic_type = list(pa.ClotheGraphicType)[
int(request.GET["clothe_graphic_type"])
]
if "format" in request.GET:
if request.GET["format"] == "png":
output_format = request.GET["format"]
elif request.GET["format"] in ("svg", "svg+xml"):
output_format = "svg+xml"
else:
print(
"Format: '%s' isn't supported" % request.GET["format"]
) # pylint: disable=consider-using-f-string
avatar = pa.PyAvataaar(
style=avatar_style,
skin_color=skin_color,
hair_color=hair_color,
facial_hair_type=facial_hair_type,
facial_hair_color=facial_hair_color,
top_type=top_type,
hat_color=hat_color,
mouth_type=mouth_type,
eye_type=eyes_type,
eyebrow_type=eyebrow_type,
nose_type=nose_type,
accessories_type=accessories_type,
clothe_type=clothe_type,
clothe_color=clothe_color,
clothe_graphic_type=clothe_graphic_type,
)
if output_format == "png":
return HttpResponse(avatar.render_png(), content_type="image/png")
return HttpResponse(avatar.render_svg(), content_type="image/svg+xml")
@method_decorator(login_required, name="dispatch")
class AvatarItemView(View):
"""
View class responsible for providing access to single avatar items
"""
def get(self, request, *args, **kwargs): # pylint: disable=unused-argument
"""
Handle get for create view
"""
item = request.GET["item"]
env = Environment(
loader=PackageLoader("py_avataaars", "templates"),
trim_blocks=True,
lstrip_blocks=True,
autoescape=True,
keep_trailing_newline=False,
extensions=[],
)
template = env.get_template(item)
def uni(attr): # pylint: disable=unused-argument
return None
rendered_template = template.render(
unique_id=uni,
template_path=pa.PyAvataaar._PyAvataaar__template_path, # pylint: disable=protected-access
template_name=pa.PyAvataaar._PyAvataaar__template_name, # pylint: disable=protected-access
facial_hair_type=pa.FacialHairType.DEFAULT,
hat_color=pa.Color.BLACK,
clothe_color=pa.Color.HEATHER,
hair_color=pa.HairColor.BROWN,
facial_hair_color=pa.HairColor.BROWN,
clothe_graphic_type=pa.ClotheGraphicType.BAT,
accessories_type=pa.AccessoriesType.DEFAULT,
).replace("\n", "")
rendered_template = (
'<?xml version="1.0" encoding="utf-8" ?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="280px" height="280px" viewBox="-6 0 274 280" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">'
+ rendered_template
+ "</svg>"
) # pylint: disable=line-too-long
return HttpResponse(rendered_template, content_type="image/svg+xml")

View File

@@ -41,12 +41,14 @@ def file_format(image_type):
""" """
Helper method returning a short image type Helper method returning a short image type
""" """
if image_type == "JPEG": if image_type in ("JPEG", "MPO"):
return "jpg" return "jpg"
elif image_type == "PNG": elif image_type == "PNG":
return "png" return "png"
elif image_type == "GIF": elif image_type == "GIF":
return "gif" return "gif"
elif image_type == "WEBP":
return "webp"
return None return None
@@ -54,12 +56,14 @@ def pil_format(image_type):
""" """
Helper method returning the 'encoder name' for PIL Helper method returning the 'encoder name' for PIL
""" """
if image_type == "jpg" or image_type == "jpeg": if image_type in ("jpg", "jpeg", "mpo"):
return "JPEG" return "JPEG"
elif image_type == "png": elif image_type == "png":
return "PNG" return "PNG"
elif image_type == "gif": elif image_type == "gif":
return "GIF" return "GIF"
elif image_type == "webp":
return "WEBP"
logger.info("Unsupported file format: %s", image_type) logger.info("Unsupported file format: %s", image_type)
return None return None

View File

@@ -0,0 +1,243 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% load bootstrap4 %}
{% block title %}{% trans 'Avatar Creator' %}{% endblock title %}
{% block content %}
<style>
.achoose {
float:left;
margin-right:2px;
width:20px;
height:20px;
}
</style>
<script>
var skin_color = 0;
var hair_color = 0;
var facial_hair_type = 0;
var facial_hair_color = 0;
var top_type = -1;
var hat_color = 0;
var mouth_type = 0;
var eyes_type = 0;
var eyebrow_type = 0;
var nose_type = 0;
var accessories_type = 0;
var clothe_type = 0;
var clothe_color = 0;
var clothe_graphic_type = 0;
var avatar_style = 0;
var size = 172;
function update_image() {
var url = "{% url 'avataaar' %}?avatar_style=" + avatar_style + "&skin_color=" + skin_color + "&hair_color=" + hair_color +
"&facial_hair_type=" + facial_hair_type + "&facial_hair_color=" + facial_hair_color +
"&top_type=" + top_type + "&hat_color=" + hat_color + "&mouth_type=" + mouth_type +
"&eyes_type=" + eyes_type + "&eyebrow_type=" + eyebrow_type + "&nose_type=" + nose_type +
"&accessories_type=" + accessories_type + "&clothe_type=" + clothe_type +
"&clothe_color=" + clothe_color + "&clothe_graphic_type=" + clothe_graphic_type;
$("#avatar_image").attr('src', url);
$("#avatar_image").attr('width', size + "px");
}
</script>
<div>
<div id="preview_and_download" style="float:left;width:50%;" data-spy="affix" data-offset-top="0" data-offset-bottom="200">
<h3>{% trans 'Adjust your avatar' %}</h3>
<div id="avatar_image_div">
<img id="avatar_image" width="172px">
<script>update_image();</script>
</div>
<div class="form-group" role="group" style="margin-top:5px;align:center;">
<button type="button" class="btn btn-info" onclick='url=$("#avatar_image").attr("src")+"&format=svg";window.open(url);'>Download SVG</button>
&nbsp;
<button type="button" class="btn btn-info" onclick='url=$("#avatar_image").attr("src")+"&format=png";window.open(url);'>Download PNG</button>
</div>
</div>
<div id="options" style="float:right;">
<!--
<div class="form-group" role="group">
<label for="preview_size" class="form-label">{% trans 'Preview size'%}</label>
<input type="range" class="form-range" id="preview_size" min="16" max="500" step="1" value="172">
<script>
var preview_size = document.getElementById("preview_size");
preview_size.addEventListener("input", function() {
size = this.value;
update_image();
});
</script>
</div>
-->
<div class="form-group" role="group">
<label for="skincolor" class="form-label">{% trans 'Skin color'%}</label>
<div id="skincolor">
{% for color in SkinColor %}
<a class="button achoose" style="background:{{ color.main_value }};" onclick='skin_color="{{ color.value }}"; update_image();'>&nbsp;</a>
{% endfor %}
</div>
</div>
<br/>
<div class="form-group" role="group">
<label for="haircolor" class="form-label">{% trans 'Hair color'%}</label>
<div id="haircolor">
{% for color in HairColor %}
<a class="button achoose" style="background:{{ color.main_value }};" onclick='hair_color="{{ color.value }}"; update_image();'>&nbsp;</a>
{% endfor %}
</div>
</div>
<br/>
<div class="form-group" role="group" style="float:none;">
<label for="facialhairtype" class="form-label">{% trans 'Facial hair type' %}</label>
<input type="range" class="form-range" id="facialhairtype" min="0" max="5" step="1" value="0">
<script>
var elem = document.getElementById("facialhairtype");
facialhairtype.addEventListener("input", function() {
facial_hair_type = this.value;
var thediv = document.getElementById("facialhaircolor").parentNode;
if(this.value != 0) {
thediv.display = "block";
thediv.removeAttribute("hidden");
} else {
thediv.setAttribute("hidden", "hidden");
}
update_image();
});
</script>
</div>
<div hidden="hidden" class="form-group" style="float:none;">
<label for="facialhaircolor" class="form-label">{% trans 'Facial hair color'%}</label>
<div id="facialhaircolor">
{% for color in HairColor %}
<a class="button achoose" style="background:{{ color.main_value }};" onclick='facial_hair_color="{{ color.value }}"; update_image();'>&nbsp;</a>
{% endfor %}
</div>
<br/>
</div>
<div class="form-group" role="group" style="float:none;">
<label for="toptype" class="form-label">{% trans 'Top type' %}</label>
<input type="range" class="form-range" id="toptype" min="0" max="34" step="1" value="0">
<script>
var elem = document.getElementById("toptype");
elem.addEventListener("input", function() {
top_type = this.value;
update_image();
});
</script>
</div>
<div class="form-group" role="group" style="float:none;">
<label for="hatcolor" class="form-label">{% trans 'Hat color'%}</label>
<div id="hatcolor">
{% for color in HatColor %}
<a class="button achoose" style="background:{{ color.main_value }};" onclick='hat_color="{{ color.value }}"; update_image();'>&nbsp;</a>
{% endfor %}
</div>
</div>
<br/>
<div class="form-group" role="group" style="float:none;">
<label for="mouthtype" class="form-label">{% trans 'Mouth type' %}</label>
<input type="range" class="form-range" id="mouthtype" min="0" max="11" step="1" value="0">
<script>
var elem = document.getElementById("mouthtype");
elem.addEventListener("input", function() {
mouth_type = this.value;
update_image();
});
</script>
</div>
<div class="form-group" role="group" style="float:none;">
<label for="eyestype" class="form-label">{% trans 'Eyes type' %}</label>
<input type="range" class="form-range" id="eyestype" min="0" max="11" step="1" value="0">
<script>
var elem = document.getElementById("eyestype");
elem.addEventListener("input", function() {
eyes_type = this.value;
update_image();
});
</script>
</div>
<div class="form-group" role="group" style="float:none;">
<label for="eyebrowtype" class="form-label">{% trans 'Eyebrow type' %}</label>
<input type="range" class="form-range" id="eyebrowtype" min="0" max="12" step="1" value="0">
<script>
var elem = document.getElementById("eyebrowtype");
elem.addEventListener("input", function() {
eyebrow_type = this.value;
update_image();
});
</script>
</div>
<div class="form-group" role="group" style="float:none;">
<label for="accessoriestype" class="form-label">{% trans 'Accessories type' %}</label>
<input type="range" class="form-range" id="accessoriestype" min="0" max="6" step="1" value="0">
<script>
var elem = document.getElementById("accessoriestype");
elem.addEventListener("input", function() {
accessories_type = this.value;
update_image();
});
</script>
</div>
<div class="form-group" role="group" style="float:none;">
<label for="clothetype" class="form-label">{% trans 'Clothe type' %}</label>
<input type="range" class="form-range" id="clothetype" min="0" max="8" step="1" value="0">
<script>
var elem = document.getElementById("clothetype");
elem.addEventListener("input", function() {
clothe_type = this.value;
var thediv = document.getElementById("clothegraphictype").parentNode;
if(this.value == 3) {
thediv.display = "block";
thediv.removeAttribute("hidden");
} else {
thediv.setAttribute("hidden", "hidden");
}
update_image();
});
</script>
</div>
<div class="form-group" role="group" style="float:none;">
<label for="clothegraphictype" class="form-label">{% trans 'Clothe color' %}</label>
<div id"=clothegraphictype">
{% for color in ClotheColor %}
<a class="button achoose" style="background:{{ color.main_value }};" onclick='clothe_color="{{ color.value }}"; update_image();'>&nbsp;</a>
{% endfor %}
</div>
</div>
<br/>
<div hidden="hidden" class="form-group" role="group" style="float:none;">
<label for="clothegraphictype" class="form-label">{% trans 'Clothe graphic type' %}</label>
<input type="range" class="form-range" id="clothegraphictype" min="0" max="10" step="1" value="0">
<script>
var elem = document.getElementById("clothegraphictype");
elem.addEventListener("input", function() {
clothe_graphic_type = this.value;
update_image();
});
</script>
</div>
</div>
</div>
<div>
</div>
<div style="height:40px"></div>
{% endblock content %}

View File

@@ -242,7 +242,8 @@
{% if not max_photos %} {% if not max_photos %}
<p> <p>
<a href="{% url 'upload_photo' %}" class="button">{% trans 'Upload a new photo' %}</a>&nbsp; <a href="{% url 'upload_photo' %}" class="button">{% trans 'Upload a new photo' %}</a>&nbsp;
<a href="{% url 'import_photo' %}" class="button">{% trans 'Import photo from other services' %}</a> <a href="{% url 'import_photo' %}" class="button">{% trans 'Import photo from other services' %}</a>&nbsp;
<a href="{% url 'avatar_creator' %}" class="button">{% trans 'Create an avatar' %}</a>
</p> </p>
{% else %} {% else %}
{% trans "You've reached the maximum number of allowed images!" %}<br/> {% trans "You've reached the maximum number of allowed images!" %}<br/>

View File

@@ -748,6 +748,47 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
response = self.client.get(url, follow=True) response = self.client.get(url, follow=True)
self.assertEqual(response.status_code, 200, "unable to fetch avatar?") self.assertEqual(response.status_code, 200, "unable to fetch avatar?")
def test_upload_webp_image(self):
"""
Test if webp is correctly detected and can be viewed
"""
self.login()
url = reverse("upload_photo")
# rb => Read binary
# Broken is _not_ broken - it's just an 'x' :-)
with open(
os.path.join(settings.STATIC_ROOT, "img", "broken.webp"), "rb"
) as photo:
response = self.client.post(
url,
{
"photo": photo,
"not_porn": True,
"can_distribute": True,
},
follow=True,
)
self.assertEqual(
str(list(response.context[0]["messages"])[0]),
"Successfully uploaded",
"WEBP upload failed?!",
)
self.assertEqual(
self.user.photo_set.first().format,
"webp",
"Format must be webp, since we uploaded a webp!",
)
self.test_confirm_email()
self.user.confirmedemail_set.first().photo = self.user.photo_set.first()
urlobj = urlsplit(
libravatar_url(
email=self.user.confirmedemail_set.first().email,
)
)
url = "%s?%s" % (urlobj.path, urlobj.query)
response = self.client.get(url, follow=True)
self.assertEqual(response.status_code, 200, "unable to fetch avatar?")
def test_upload_unsupported_tif_image(self): # pylint: disable=invalid-name def test_upload_unsupported_tif_image(self): # pylint: disable=invalid-name
""" """
Test if unsupported format is correctly detected Test if unsupported format is correctly detected

View File

@@ -26,6 +26,9 @@ from .views import ResendConfirmationMailView
from .views import IvatarLoginView from .views import IvatarLoginView
from .views import DeleteAccountView from .views import DeleteAccountView
from .views import ExportView from .views import ExportView
from .avatar_creator_views import AvatarCreatorView, AvatarView
# from .avatar_creator_views import AvatarItemView
# Define URL patterns, self documenting # Define URL patterns, self documenting
# To see the fancy, colorful evaluation of these use: # To see the fancy, colorful evaluation of these use:
@@ -80,6 +83,10 @@ urlpatterns = [ # pylint: disable=invalid-name
path("add_openid/", AddOpenIDView.as_view(), name="add_openid"), path("add_openid/", AddOpenIDView.as_view(), name="add_openid"),
path("upload_photo/", UploadPhotoView.as_view(), name="upload_photo"), path("upload_photo/", UploadPhotoView.as_view(), name="upload_photo"),
path("password_set/", PasswordSetView.as_view(), name="password_set"), path("password_set/", PasswordSetView.as_view(), name="password_set"),
path("avatar_creator/", AvatarCreatorView.as_view(), name="avatar_creator"),
path("avatar_view/", AvatarView.as_view(), name="avataaar"),
# This is for testing purpose only and shall not be used in production at all
# path("avatar_item_view/", AvatarItemView.as_view(), name="avataaar_item"),
re_path( re_path(
r"remove_unconfirmed_openid/(?P<openid_id>\d+)", r"remove_unconfirmed_openid/(?P<openid_id>\d+)",
RemoveUnconfirmedOpenIDView.as_view(), RemoveUnconfirmedOpenIDView.as_view(),

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -37,6 +37,7 @@ class Tester(TestCase):
self.assertEqual(pil_format("jpeg"), "JPEG") self.assertEqual(pil_format("jpeg"), "JPEG")
self.assertEqual(pil_format("png"), "PNG") self.assertEqual(pil_format("png"), "PNG")
self.assertEqual(pil_format("gif"), "GIF") self.assertEqual(pil_format("gif"), "GIF")
self.assertEqual(pil_format("webp"), "WEBP")
self.assertEqual(pil_format("abc"), None) self.assertEqual(pil_format("abc"), None)
def test_userprefs_str(self): def test_userprefs_str(self):

View File

@@ -4,7 +4,8 @@ Simple module providing reusable random_string function
""" """
import random import random
import string import string
from PIL import Image, ImageDraw from io import BytesIO
from PIL import Image, ImageDraw, ImageSequence
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -158,3 +159,25 @@ def is_trusted_url(url, url_filters):
return True return True
return False return False
def resize_animated_gif(input_pil: Image, size: list) -> BytesIO:
def _thumbnail_frames(image):
for frame in ImageSequence.Iterator(image):
new_frame = frame.copy()
new_frame.thumbnail(size)
yield new_frame
frames = list(_thumbnail_frames(input_pil))
output = BytesIO()
output_image = frames[0]
output_image.save(
output,
format="gif",
save_all=True,
optimize=False,
append_images=frames[1:],
disposal=input_pil.disposal_method,
**input_pil.info,
)
return output

View File

@@ -34,7 +34,7 @@ from .ivataraccount.models import ConfirmedEmail, ConfirmedOpenId
from .ivataraccount.models import UnconfirmedEmail, UnconfirmedOpenId from .ivataraccount.models import UnconfirmedEmail, UnconfirmedOpenId
from .ivataraccount.models import Photo from .ivataraccount.models import Photo
from .ivataraccount.models import pil_format, file_format from .ivataraccount.models import pil_format, file_format
from .utils import is_trusted_url, mm_ng from .utils import is_trusted_url, mm_ng, resize_animated_gif
URL_TIMEOUT = 5 # in seconds URL_TIMEOUT = 5 # in seconds
@@ -151,7 +151,8 @@ class AvatarImageView(TemplateView):
if not trusted_url: if not trusted_url:
print( print(
"Default URL is not in trusted URLs: '%s' ; Kicking it!" % default "Default URL is not in trusted URLs: '%s' ; Kicking it!"
% default
) )
default = None default = None
@@ -318,14 +319,23 @@ class AvatarImageView(TemplateView):
imgformat = obj.photo.format imgformat = obj.photo.format
photodata = Image.open(BytesIO(obj.photo.data)) photodata = Image.open(BytesIO(obj.photo.data))
data = BytesIO()
# Animated GIFs need additional handling
if imgformat == "gif" and photodata.is_animated:
# Debug only
# print("Object is animated and has %i frames" % photodata.n_frames)
data = resize_animated_gif(photodata, (size, size))
else:
# If the image is smaller than what was requested, we need # If the image is smaller than what was requested, we need
# to use the function resize # to use the function resize
if photodata.size[0] < size or photodata.size[1] < size: if photodata.size[0] < size or photodata.size[1] < size:
photodata = photodata.resize((size, size), Image.ANTIALIAS) photodata = photodata.resize((size, size), Image.ANTIALIAS)
else: else:
photodata.thumbnail((size, size), Image.ANTIALIAS) photodata.thumbnail((size, size), Image.ANTIALIAS)
data = BytesIO()
photodata.save(data, pil_format(imgformat), quality=JPEG_QUALITY) photodata.save(data, pil_format(imgformat), quality=JPEG_QUALITY)
data.seek(0) data.seek(0)
obj.photo.access_count += 1 obj.photo.access_count += 1
obj.photo.save() obj.photo.save()

View File

@@ -23,6 +23,7 @@ notsetuptools
Pillow Pillow
pip pip
psycopg2-binary psycopg2-binary
py-avataaars
py3dns py3dns
pydocstyle pydocstyle
pyLibravatar pyLibravatar

View File

@@ -29,7 +29,7 @@
<p> <p>
<button type="submit" class="button">{% trans 'Login' %}</button> <button type="submit" class="button">{% trans 'Login' %}</button>
<input type="hidden" name="next" value="{{ request.build_absolute_uri }}{% url 'profile' %}" /> <input type="hidden" name="next" value="{% url 'profile' %}" />
&nbsp; &nbsp;
<button type="reset" class="button" onclick="window.history.back();">{% trans 'Cancel' %}</button> <button type="reset" class="button" onclick="window.history.back();">{% trans 'Cancel' %}</button>