Add support for avatar

issue10085
review343561002
This commit is contained in:
Cédric Krier committed 2021-03-28 19:20:37 +02:00
1 parent 1184e154c1
commit 80ac9eb0be
17 files changed
+540 -23

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
* Add support for avatars
* Add status command
* Add document widget on form view
* Add full text and similarity search and FullText field
+35
View File
@@ -914,3 +914,38 @@ adapted to search across the tree.
Helper method that checks if there is no recursion in the tree defined by
:meth:`tree`.
============
avatar_mixin
============
.. method:: avatar_mixin([size[, default]])
Returns a mixin_ :class:`AvatarMixin`. ``size`` defines the size of the avatar
image and its default value is ``64``. ``default`` indicates the name of the
field to use for generating a default avatar, if it's not set then no default
avatar is generated.
.. class:: AvatarMixin
.. attribute:: AvatarMixin.avatars
The :class:`trytond.model.fields.One2Many` field used to store the
``ir.avatar`` records.
.. attribute:: AvatarMixin.avatar
The :class:`trytond.model.fields.Binary` field that contains the avatar.
.. attribute:: AvatarMixin.avatar_url
The :class:`trytond.model.fields.Char` field that containts the URL for the
avatar.
.. attribute:: AvatarMixin.has_avatar
Indicates whether the record has an avatar.
.. classmethod:: AvatarMixin.generate_avatar(records, field)
Generate a default avatar for each record using the field.
+32
View File
@@ -78,6 +78,23 @@ cors
The list (one per line) of origins allowed for `Cross-Origin Resource sharing
<https://en.wikipedia.org/wiki/Cross-origin_resource_sharing>`_.
avatar_base
~~~~~~~~~~~
The base URL without a path for avatar URL.
Default: ``''``
.. note:: It can be used to setup a CDN.
avatar_timeout
~~~~~~~~~~~~~~
The time in seconds that the avatar can be stored in cache.
Default: 7 days
database
--------
@@ -139,6 +156,21 @@ table for translations.
Default: ``en``
avatar_filestore
~~~~~~~~~~~~~~~~
This configuration value indicates whether the avatars should be stored in the
:py:mod:`trytond.filestore` (``True``) or the database (``False``).
Default: ``False``
avatar_prefix
~~~~~~~~~~~~~
The prefix to use with the :ref:`FileStore <ref-filestore>` to store avatars.
Default: ``None``
default_name
~~~~~~~~~~~~
+4 -1
View File
@@ -76,6 +76,7 @@ if platform.python_implementation() == 'PyPy':
pg_require = ['psycopg2cffi >= 2.5.4']
else:
pg_require = ['psycopg2 >= 2.5.4']
tests_require = ['pillow']
setup(name=name,
version=version,
@@ -95,7 +96,7 @@ setup(name=name,
packages=find_packages(exclude=['*.modules.*', 'modules.*', 'modules',
'*.proteus.*', 'proteus.*', 'proteus']),
package_data={
'trytond': ['ir/ui/icons/*.svg', '*.rnc', '*.rng'],
'trytond': ['ir/ui/icons/*.svg', '*.rnc', '*.rng', 'ir/fonts/*.ttf'],
'trytond.backend.postgresql': ['init.sql'],
'trytond.backend.sqlite': ['init.sql'],
'trytond.ir': ['tryton.cfg', '*.xml', 'view/*.xml', 'locale/*.po'],
@@ -172,11 +173,13 @@ setup(name=name,
'html2text': ['html2text'],
'weasyprint': ['weasyprint'],
'coroutine': ['gevent>=1.1'],
'image': ['pillow'],
},
dependency_links=dependency_links,
zip_safe=False,
test_suite='trytond.tests',
test_loader='trytond.test_loader:Loader',
tests_require=tests_require,
cmdclass={
'update_rng': rnc2rng,
},
+3
View File
@@ -9,6 +9,7 @@ from . import action
from . import model
from . import attachment
from . import note
from . import avatar
from . import cron
from . import lang
from . import export
@@ -70,6 +71,8 @@ def register():
attachment.Attachment,
note.Note,
note.NoteRead,
avatar.Avatar,
avatar.AvatarCache,
cron.Cron,
lang.Lang,
lang.LangConfigStart,
+193
View File
@@ -0,0 +1,193 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import io
import math
import os
import uuid
from random import Random
from urllib.parse import urljoin, quote
try:
import PIL
from PIL import Image, ImageDraw, ImageFont
except ImportError:
PIL = None
from trytond.config import config
from trytond.model import ModelSQL, fields, Unique
from trytond.pool import Pool
from trytond.transaction import Transaction
from trytond.wsgi import Base64Converter
from .resource import ResourceMixin
if config.getboolean('database', 'avatar_filestore', default=False):
file_id = 'image_id'
store_prefix = config.get('database', 'avatar_prefix', default=None)
else:
file_id = None
store_prefix = None
URL_BASE = config.get('web', 'avatar_base', default='')
FONT = os.path.join(os.path.dirname(__file__), 'fonts', 'karla.ttf')
class ImageMixin:
image = fields.Binary(
"Image", file_id=file_id, store_prefix=store_prefix)
image_id = fields.Char("Image ID", readonly=True)
class Avatar(ImageMixin, ResourceMixin, ModelSQL):
"Avatar"
__name__ = 'ir.avatar'
uuid = fields.Char("UUID", required=True)
cache = fields.One2Many('ir.avatar.cache', 'avatar', "Cache")
@classmethod
def __setup__(cls):
super().__setup__()
t = cls.__table__()
cls._sql_constraints = [
('resource_unique', Unique(t, t.resource),
'ir.msg_avatar_resource_unique'),
]
@classmethod
def default_uuid(cls):
return uuid.uuid4().hex
@classmethod
def create(cls, vlist):
vlist = [v.copy() for v in vlist]
for values in vlist:
values.setdefault('uuid', cls.default_uuid())
return super().create(vlist)
@classmethod
def write(cls, *args):
avatars = sum(args[0:None:2], [])
super().write(*args)
cls.clear_cache(avatars)
@property
def url(self):
if self.image_id or self.image:
return urljoin(
URL_BASE, quote('/avatar/%(database)s/%(uuid)s' % {
'database': Base64Converter(None).to_url(
Transaction().database.name),
'uuid': self.uuid,
}))
def get(self, size=64):
size = min((
2 ** math.ceil(math.log2(size)),
10 * math.ceil(size / 10) if size <= 100
else 50 * math.ceil(size / 50)))
if not (0 < size < 2048):
raise ValueError("Invalid size")
for avatar in self.cache:
if avatar.size == size:
return avatar.image
if not self.image:
return None
if PIL:
with Transaction().new_transaction():
cache = self._store_cache(size, self._resize(size))
cache.save()
return cache.image
else:
return self.image
@classmethod
def convert(cls, image, **_params):
if not PIL or not image:
return image
data = io.BytesIO()
img = Image.open(io.BytesIO(image))
width, height = img.size
size = min(width, height)
img.crop((
(width - size) // 2,
(height - size) // 2,
(width + size) // 2,
(height + size) // 2))
if size > 2048:
img.resize((2048, 2048))
img.save(data, format='jpeg', optimize=True, **_params)
return data.getvalue()
def _resize(self, size=64, **_params):
if not PIL:
return self.image
data = io.BytesIO()
img = Image.open(io.BytesIO(self.image))
img = img.resize((size, size))
img.save(data, format='jpeg', optimize=True, **_params)
return data.getvalue()
def _store_cache(self, size, image):
pool = Pool()
Cache = pool.get('ir.avatar.cache')
return Cache(
avatar=self,
image=image,
size=size)
@classmethod
def clear_cache(cls, avatars):
pool = Pool()
Cache = pool.get('ir.avatar.cache')
caches = [c for a in avatars for c in a.cache]
Cache.delete(caches)
class AvatarCache(ImageMixin, ModelSQL):
"Avatar Cache"
__name__ = 'ir.avatar.cache'
avatar = fields.Many2One(
'ir.avatar', "Avatar", required=True, ondelete='CASCADE')
size = fields.Integer(
"Size", required=True,
domain=[
('size', '>', 0),
('size', '<', 2048),
])
@classmethod
def __setup__(cls):
super().__setup__()
t = cls.__table__()
cls._sql_constraints = [
('size_unique', Unique(t, t.avatar, t.size),
'ir.msg_avatar_size_unique'),
]
cls._order.append(('size', 'ASC'))
def generate(size, string):
if not PIL:
return
def background_color(string):
random = Random(string)
r = v = b = 255
# Skip too bright color
while r + v + b > 255 * 2:
r = random.randint(0, 255)
v = random.randint(0, 255)
b = random.randint(0, 255)
return r, v, b
white = (255, 255, 255)
image = Image.new('RGB', (size, size), background_color(string))
draw = ImageDraw.Draw(image)
font = ImageFont.truetype(FONT, size=int(0.65 * size))
letter = string[0].upper() if string else ''
draw.text(
(size / 2, size / 2), letter, fill=white, font=font, anchor='mm')
data = io.BytesIO()
image.save(data, format='jpeg', optimize=True)
return data.getvalue()
+93
View File
@@ -0,0 +1,93 @@
Copyright 2019 The Karla Project Authors (https://github.com/googlefonts/karla)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+15
View File
@@ -351,5 +351,20 @@ this repository contains the full copyright notices and license terms. -->
<record model="ir.message" id="msg_email_template_invalid_field_pyson_type">
<field name="text">The PYSON %(field)s in e-mail template "%(template)s" must generate a list.</field>
</record>
<record model="ir.message" id="msg_avatar_resource_unique">
<field name="text">Only one avatar is allowed per resource.</field>
</record>
<record model="ir.message" id="msg_avatar_size_unique">
<field name="text">The size of an avatar must be unique.</field>
</record>
<record model="ir.message" id="msg_avatar">
<field name="text">Avatar</field>
</record>
<record model="ir.message" id="msg_avatars">
<field name="text">Avatars</field>
</record>
<record model="ir.message" id="msg_avatar_url">
<field name="text">Avatar URL</field>
</record>
</data>
</tryton>
+25
View File
@@ -24,6 +24,8 @@ from trytond.transaction import Transaction
SOURCE = config.get(
'html', 'src', default='https://cloud.tinymce.com/stable/tinymce.min.js')
AVATAR_TIMEOUT = config.getint(
'web', 'avatar_timeout', default=7 * 24 * 60 * 60)
def get_token(record):
@@ -278,3 +280,26 @@ def data(request, pool, model):
'Content-Disposition', 'attachment', filename=filename)
response.headers.add('Content-Length', len(data))
return response
@app.route('/avatar/<base64:database_name>/<uuid>', methods={'GET'})
@with_pool
@with_transaction()
def avatar(request, pool, uuid):
Avatar = pool.get('ir.avatar')
try:
avatar, = Avatar.search([
('uuid', '=', uuid),
])
except ValueError:
abort(HTTPStatus.NOT_FOUND)
try:
size = int(request.args.get('s', 64))
except ValueError:
abort(HTTPStatus.BAD_REQUEST)
response = Response(avatar.get(size), mimetype='image/jpeg')
response.headers['Cache-Control'] = (
'max-age=%s, public' % AVATAR_TIMEOUT)
response.add_etag()
return response
+3 -1
View File
@@ -15,9 +15,11 @@ from .descriptors import dualmethod
from .order import sequence_ordered
from .active import DeactivableMixin
from .tree import tree
from .avatar import avatar_mixin
__all__ = ['Model', 'ModelView', 'ModelStorage', 'ModelSingleton', 'ModelSQL',
'Check', 'Unique', 'Exclude',
'Workflow', 'DictSchemaMixin', 'MatchMixin', 'UnionMixin', 'dualmethod',
'MultiValueMixin', 'ValueMixin', 'SymbolMixin',
'EvalEnvironment', 'sequence_ordered', 'DeactivableMixin', 'tree']
'EvalEnvironment', 'sequence_ordered', 'DeactivableMixin', 'tree',
'avatar_mixin']
+80
View File
@@ -0,0 +1,80 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
from trytond.i18n import lazy_gettext
from trytond.model import fields
from trytond.pool import Pool
def avatar_mixin(size=64, default=None):
class AvatarMixin:
avatars = fields.One2Many(
'ir.avatar', 'resource', lazy_gettext('ir.msg_avatars'), size=1)
avatar = fields.Function(
fields.Binary(lazy_gettext('ir.msg_avatar')),
'_get_avatar', setter='_set_avatar')
avatar_url = fields.Function(
fields.Char(lazy_gettext('ir.msg_avatar_url')), '_get_avatar_url')
@property
def has_avatar(self):
if self.avatars:
avatar, = self.avatars
return bool(avatar.image_id or avatar.image)
return False
def _get_avatar(self, name):
if self.avatars:
avatar, = self.avatars
return avatar.get(size=size)
return None
@classmethod
def _set_avatar(cls, records, name, value):
pool = Pool()
Avatar = pool.get('ir.avatar')
avatars = []
image = Avatar.convert(value)
for record in records:
if record.avatars:
avatar, = record.avatars
else:
avatar = Avatar(resource=record)
avatars.append(avatar)
Avatar.save(avatars)
# Use write the image to store only once in filestore
Avatar.write(avatars, {
'image': image,
})
def _get_avatar_url(self, name):
if self.avatars:
avatar, = self.avatars
return avatar.url
@classmethod
def generate_avatar(cls, records, field='rec_name'):
from trytond.ir.avatar import generate
records = [r for r in records if not r.has_avatar]
if not records:
return
for record in records:
record.avatar = generate(size, getattr(record, field))
cls.save(records)
if default:
@classmethod
def create(cls, vlist):
records = super().create(vlist)
cls.generate_avatar(records, field=default)
return records
@classmethod
def write(cls, *args):
records = sum(args[0:None:2], [])
super().write(*args)
cls.generate_avatar(records, field=default)
return AvatarMixin
+11 -2
View File
@@ -43,7 +43,8 @@ from trytond.exceptions import LoginException, RateLimitException
from trytond.exceptions import UserError
from trytond.i18n import gettext
from trytond.model import (
ModelView, ModelSQL, Workflow, DeactivableMixin, fields, Unique)
ModelView, ModelSQL, Workflow, DeactivableMixin, fields, Unique,
avatar_mixin)
from trytond.pool import Pool
from trytond.pyson import PYSONEncoder, Eval, Bool
from trytond.report import Report, get_email
@@ -99,7 +100,7 @@ class DeleteError(UserError):
pass
class User(DeactivableMixin, ModelSQL, ModelView):
class User(avatar_mixin(100, 'login'), DeactivableMixin, ModelSQL, ModelView):
"User"
__name__ = "res.user"
name = fields.Char('Name', select=True)
@@ -141,6 +142,8 @@ class User(DeactivableMixin, ModelSQL, ModelView):
'get_language_direction')
email = fields.Char('Email')
status_bar = fields.Function(fields.Char('Status Bar'), 'get_status_bar')
avatar_badge_url = fields.Function(
fields.Char("Avatar Badge URL"), 'get_avatar_badge_url')
warnings = fields.One2Many('res.user.warning', 'user', 'Warnings')
sessions = fields.Function(fields.Integer('Sessions'),
'get_sessions')
@@ -176,6 +179,9 @@ class User(DeactivableMixin, ModelSQL, ModelView):
'pyson_menu',
'actions',
'status_bar',
'avatar',
'avatar_url',
'avatar_badge_url',
'warnings',
'applications',
]
@@ -242,6 +248,9 @@ class User(DeactivableMixin, ModelSQL, ModelView):
def get_status_bar(self, name):
return self.name
def get_avatar_badge_url(self, name):
pass
def get_password(self, name):
return 'x' * 10
+16 -11
View File
@@ -2,19 +2,24 @@
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<form>
<label name="name"/>
<field name="name"/>
<label name="active"/>
<field name="active"/>
<group col="4" colspan="3" yalign="0" id="header">
<label name="name"/>
<field name="name"/>
<label name="active"/>
<field name="active"/>
<label name="login"/>
<field name="login"/>
<label name="email"/>
<field name="email" widget="email"/>
<label name="password"/>
<field name="password" widget="password"/>
<button name="reset_password" colspan="2" />
</group>
<field name="avatar" widget="image" height="100" width="100" xexpand="0"/>
<notebook colspan="4">
<page string="User" id="user">
<label name="login"/>
<field name="login"/>
<label name="email"/>
<field name="email" widget="email"/>
<label name="password"/>
<field name="password" widget="password"/>
<button name="reset_password" colspan="2" />
<separator name="signature" colspan="4"/>
<field name="signature" colspan="4" widget="richtext"/>
</page>
+11 -6
View File
@@ -2,14 +2,19 @@
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<form>
<label name="name"/>
<field name="name"/>
<group col="4" colspan="3" yalign="0" id="header">
<label name="name"/>
<field name="name"/>
<newline/>
<label name="email"/>
<field name="email" widget="email"/>
<label name="password"/>
<field name="password" widget="password"/>
</group>
<field name="avatar" widget="image" height="100" width="100" xexpand="0"/>
<notebook colspan="4">
<page string="User" id="user">
<label name="email"/>
<field name="email" widget="email"/>
<label name="password"/>
<field name="password" widget="password"/>
<separator name="signature" colspan="4"/>
<field name="signature" colspan="4" widget="richtext"/>
</page>
+3 -1
View File
@@ -2,7 +2,9 @@
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<tree>
<field name="name" expand="2"/>
<field name="name" expand="2">
<prefix id="avatar" icon="avatar_url" icon_type="url" url_size="s"/>
</field>
<field name="login" expand="1"/>
<field name="sessions"/>
</tree>
+15 -1
View File
@@ -2,13 +2,27 @@
# this repository contains the full copyright notices and license terms.
import unittest
from .test_tryton import ModuleTestCase
from trytond.pool import Pool
from .test_tryton import ModuleTestCase, with_transaction
class ResTestCase(ModuleTestCase):
'Test res module'
module = 'res'
@with_transaction()
def test_user_avatar(self):
pool = Pool()
User = pool.get('res.user')
user = User(login="avatar")
user.save()
self.assertEqual(len(user.avatars), 1)
self.assertIsNotNone(user.avatar)
self.assertRegex(user.avatar_url, r'/avatar/.*/([0-9a-fA-F]{12})')
def suite():
return unittest.TestLoader().loadTestsFromTestCase(ResTestCase)