Add support for avatar
issue10085 review343561002
This commit is contained in:
1 parent
1184e154c1
commit
80ac9eb0be
17 files changed
+540
-23
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
~~~~~~~~~~~~
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
@@ -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>
|
||||
@@ -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
|
||||
@@ -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']
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user