changeset c2494167ee23 in trytond:default
details: https://hg.tryton.org/trytond?cmd=changeset&node=c2494167ee23
description:
        Add support for avatar

        issue10085
        review343561002
diffstat:

 CHANGELOG                                  |    1 +
 doc/ref/models/models.rst                  |   35 +++++
 doc/topics/configuration.rst               |   32 ++++
 setup.py                                   |    5 +-
 trytond/ir/__init__.py                     |    3 +
 trytond/ir/avatar.py                       |  193 +++++++++++++++++++++++++++++
 trytond/ir/fonts/LICENSE                   |   93 +++++++++++++
 trytond/ir/fonts/karla.ttf                 |  Bin 
 trytond/ir/message.xml                     |   15 ++
 trytond/ir/routes.py                       |   25 +++
 trytond/model/__init__.py                  |    4 +-
 trytond/model/avatar.py                    |   80 ++++++++++++
 trytond/res/user.py                        |   13 +-
 trytond/res/view/user_form.xml             |   27 ++-
 trytond/res/view/user_form_preferences.xml |   17 +-
 trytond/res/view/user_list.xml             |    4 +-
 trytond/tests/test_res.py                  |   16 ++-
 17 files changed, 540 insertions(+), 23 deletions(-)

diffs (777 lines):

diff -r 4d7ab7a969d3 -r c2494167ee23 CHANGELOG
--- a/CHANGELOG Sat Mar 27 21:00:44 2021 +0100
+++ b/CHANGELOG Sun Mar 28 19:20:37 2021 +0200
@@ -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
diff -r 4d7ab7a969d3 -r c2494167ee23 doc/ref/models/models.rst
--- a/doc/ref/models/models.rst Sat Mar 27 21:00:44 2021 +0100
+++ b/doc/ref/models/models.rst Sun Mar 28 19:20:37 2021 +0200
@@ -914,3 +914,38 @@
 
     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.
diff -r 4d7ab7a969d3 -r c2494167ee23 doc/topics/configuration.rst
--- a/doc/topics/configuration.rst      Sat Mar 27 21:00:44 2021 +0100
+++ b/doc/topics/configuration.rst      Sun Mar 28 19:20:37 2021 +0200
@@ -78,6 +78,23 @@
 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 @@
 
 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
 ~~~~~~~~~~~~
 
diff -r 4d7ab7a969d3 -r c2494167ee23 setup.py
--- a/setup.py  Sat Mar 27 21:00:44 2021 +0100
+++ b/setup.py  Sun Mar 28 19:20:37 2021 +0200
@@ -76,6 +76,7 @@
     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 @@
     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 @@
         '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,
         },
diff -r 4d7ab7a969d3 -r c2494167ee23 trytond/ir/__init__.py
--- a/trytond/ir/__init__.py    Sat Mar 27 21:00:44 2021 +0100
+++ b/trytond/ir/__init__.py    Sun Mar 28 19:20:37 2021 +0200
@@ -9,6 +9,7 @@
 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 @@
         attachment.Attachment,
         note.Note,
         note.NoteRead,
+        avatar.Avatar,
+        avatar.AvatarCache,
         cron.Cron,
         lang.Lang,
         lang.LangConfigStart,
diff -r 4d7ab7a969d3 -r c2494167ee23 trytond/ir/avatar.py
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/ir/avatar.py      Sun Mar 28 19:20:37 2021 +0200
@@ -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()
diff -r 4d7ab7a969d3 -r c2494167ee23 trytond/ir/fonts/LICENSE
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/ir/fonts/LICENSE  Sun Mar 28 19:20:37 2021 +0200
@@ -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.
diff -r 4d7ab7a969d3 -r c2494167ee23 trytond/ir/fonts/karla.ttf
Binary file trytond/ir/fonts/karla.ttf has changed
diff -r 4d7ab7a969d3 -r c2494167ee23 trytond/ir/message.xml
--- a/trytond/ir/message.xml    Sat Mar 27 21:00:44 2021 +0100
+++ b/trytond/ir/message.xml    Sun Mar 28 19:20:37 2021 +0200
@@ -351,5 +351,20 @@
         <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>
diff -r 4d7ab7a969d3 -r c2494167ee23 trytond/ir/routes.py
--- a/trytond/ir/routes.py      Sat Mar 27 21:00:44 2021 +0100
+++ b/trytond/ir/routes.py      Sun Mar 28 19:20:37 2021 +0200
@@ -24,6 +24,8 @@
 
 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 @@
             'Content-Disposition', 'attachment', filename=filename)
         response.headers.add('Content-Length', len(data))
         return response
+
+
[email protected]('/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
diff -r 4d7ab7a969d3 -r c2494167ee23 trytond/model/__init__.py
--- a/trytond/model/__init__.py Sat Mar 27 21:00:44 2021 +0100
+++ b/trytond/model/__init__.py Sun Mar 28 19:20:37 2021 +0200
@@ -15,9 +15,11 @@
 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']
diff -r 4d7ab7a969d3 -r c2494167ee23 trytond/model/avatar.py
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/model/avatar.py   Sun Mar 28 19:20:37 2021 +0200
@@ -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
diff -r 4d7ab7a969d3 -r c2494167ee23 trytond/res/user.py
--- a/trytond/res/user.py       Sat Mar 27 21:00:44 2021 +0100
+++ b/trytond/res/user.py       Sun Mar 28 19:20:37 2021 +0200
@@ -43,7 +43,8 @@
 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 @@
     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 @@
             '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 @@
             'pyson_menu',
             'actions',
             'status_bar',
+            'avatar',
+            'avatar_url',
+            'avatar_badge_url',
             'warnings',
             'applications',
         ]
@@ -242,6 +248,9 @@
     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
 
diff -r 4d7ab7a969d3 -r c2494167ee23 trytond/res/view/user_form.xml
--- a/trytond/res/view/user_form.xml    Sat Mar 27 21:00:44 2021 +0100
+++ b/trytond/res/view/user_form.xml    Sun Mar 28 19:20:37 2021 +0200
@@ -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>
diff -r 4d7ab7a969d3 -r c2494167ee23 trytond/res/view/user_form_preferences.xml
--- a/trytond/res/view/user_form_preferences.xml        Sat Mar 27 21:00:44 
2021 +0100
+++ b/trytond/res/view/user_form_preferences.xml        Sun Mar 28 19:20:37 
2021 +0200
@@ -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>
diff -r 4d7ab7a969d3 -r c2494167ee23 trytond/res/view/user_list.xml
--- a/trytond/res/view/user_list.xml    Sat Mar 27 21:00:44 2021 +0100
+++ b/trytond/res/view/user_list.xml    Sun Mar 28 19:20:37 2021 +0200
@@ -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>
diff -r 4d7ab7a969d3 -r c2494167ee23 trytond/tests/test_res.py
--- a/trytond/tests/test_res.py Sat Mar 27 21:00:44 2021 +0100
+++ b/trytond/tests/test_res.py Sun Mar 28 19:20:37 2021 +0200
@@ -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)

Reply via email to