[MERGE][IMP] website_slides: improve inclusion of gamification and ranks

Purpose of this merge is to improve inclusion of gamification and ranking
in elearning new main pages [1][2]. Ranking and profile information, links
and templates are added.

Some fixes are included in this merge, notably statistics computation, better
slide image fetch and demo data. See sub commits for more details.

Commit linked to task ID 1941250.

closes odoo/odoo#31133
This commit is contained in:
Robot Odoo
2019-02-15 19:52:20 +01:00
committed by GitHub
19 changed files with 131 additions and 77 deletions
@@ -13,9 +13,8 @@
<record id="rank_newbie" model="gamification.karma.rank">
<field name="name">Newbie</field>
<field name="description" type="html"><p>You just began the adventure! Welcome!</p></field>
<field name="description_reach_next" type="html"><p>Keep going !</p></field>
<field name="karma_min">1</field>
<field name="image" type="base64" file="gamification/static/img/rank_newbie_badge.svg"/>
</record>
</data>
</odoo>
</odoo>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data>
<odoo><data noupdate="1">
<!-- Set demo karma for test tour as demo user-->
<record id="base.user_demo" model="res.users">
<field name="karma">20</field>
@@ -12,7 +12,11 @@
<record id="rank_student" model="gamification.karma.rank">
<field name="name">Student</field>
<field name="description" type="html"><p>You begin to understand things ! Keep going!</p></field>
<field name="description_reach_next" type="html"><p>Keep going !</p></field>
<field name="description_motivational" type="html">
<div class="media align-items-center">
<div class="media-body">Reach the next rank and gain a very nice mug !</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_mug.jpg"/>
</div></field>
<field name="karma_min">100</field>
<field name="image" type="base64" file="gamification/static/img/rank_student_badge.svg"/>
</record>
@@ -20,7 +24,11 @@
<record id="rank_bachelor" model="gamification.karma.rank">
<field name="name">Bachelor</field>
<field name="description" type="html"><p>Hey ! You're not so dumb after all !</p></field>
<field name="description_reach_next" type="html"><p>Keep going !</p></field>
<field name="description_motivational" type="html">
<div class="media align-items-center">
<div class="media-body">Reach the next rank and gain a very magic wand !</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_wand.jpg"/>
</div></field>
<field name="karma_min">500</field>
<field name="image" type="base64" file="gamification/static/img/rank_bachelor_badge.svg"/>
</record>
@@ -28,7 +36,11 @@
<record id="rank_master" model="gamification.karma.rank">
<field name="name">Master</field>
<field name="description" type="html"><p>You're good ! Really Good !</p></field>
<field name="description_reach_next" type="html"><p>Keep going !</p></field>
<field name="description_motivational" type="html">
<div class="media align-items-center">
<div class="media-body">Reach the next rank and gain a very nice hat !</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_hat.jpg"/>
</div></field>
<field name="karma_min">2000</field>
<field name="image" type="base64" file="gamification/static/img/rank_master_badge.svg"/>
</record>
@@ -36,7 +48,11 @@
<record id="rank_doctor" model="gamification.karma.rank">
<field name="name">Doctor</field>
<field name="description" type="html"><p>Ok, RESPECT !</p></field>
<field name="description_reach_next" type="html"><p>Keep going !</p></field>
<field name="description_motivational" type="html">
<div class="media align-items-center">
<div class="media-body">Reach the next rank and gain a week-end with a unicorn !</div>
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_unicorn.jpg"/>
</div></field>
<field name="karma_min">10000</field>
<field name="image" type="base64" file="gamification/static/img/rank_doctor_badge.svg"/>
</record>
@@ -19,9 +19,9 @@ class KarmaRank(models.Model):
name = fields.Text(string='Rank Name', translate=True, required=True)
description = fields.Html(string='Description', translate=html_translate, sanitize_attributes=False,)
description_reach_next = fields.Html(
string='Motivation phrase', translate=html_translate, sanitize_attributes=False,
help="Motivation phrase to reach next rank")
description_motivational = fields.Html(
string='Motivational', translate=html_translate, sanitize_attributes=False,
help="Motivational phrase to reach this rank")
karma_min = fields.Integer(string='Required Karma', help='Minimum karma needed to reach this rank')
user_ids = fields.One2many('res.users', 'rank_id', string='Users', help="Users having this rank")
image = fields.Binary('Rank Icon')
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Ranks views -->
@@ -35,8 +36,6 @@
<tree string="Ranks List">
<field name="name"/>
<field name="karma_min"/>
<field name="description"/>
<field name="user_ids"/>
</tree>
</field>
</record>
@@ -58,6 +57,7 @@
<group>
<field name="karma_min"/>
<field name="description" placeholder="e.g. A Master Chief knows quite everything on the forum! You cannot beat him!"/>
<field name="description_motivational" placeholder="e.g. Reach this rank to gain a free mug !"/>
<field name="create_date" invisible="1"/>
</group>
<group string="Users to that rank" attrs="{'invisible': [('create_date','=',False)]}">
+23 -17
View File
@@ -10,7 +10,6 @@ import werkzeug.wrappers
from odoo import http, modules, tools
from odoo.http import request
from odoo.osv import expression
from odoo.tools import limited_image_resize
class WebsiteProfile(http.Controller):
@@ -30,6 +29,20 @@ class WebsiteProfile(http.Controller):
return user.website_published and user.karma > 0
return False
def _get_default_avatar(self, field, headers, width, height):
img_path = modules.get_module_resource('web', 'static/src/img', 'placeholder.png')
with open(img_path, 'rb') as f:
image = f.read()
content = base64.b64encode(image)
dictheaders = dict(headers) if headers else {}
dictheaders['Content-Type'] = 'image/png'
headers = list(dictheaders.items())
if not (width or height):
suffix = field.split('_')[-1] if '_' in field else 'large'
if suffix in ('small', 'medium', 'large', 'big'):
content = getattr(tools, 'image_resize_image_%s' % suffix)(content)
return content
def _check_user_profile_access(self, user_id):
user_sudo = request.env['res.users'].sudo().browse(user_id)
if user_sudo.karma == 0 or not user_sudo.website_published or \
@@ -60,16 +73,18 @@ class WebsiteProfile(http.Controller):
@http.route([
'/profile/avatar/<int:user_id>',
], type='http', auth="public", website=True, sitemap=False)
def get_user_profile_avatar(self, user_id=0, width=0, height=0, crop=False, avoid_if_small=False, upper_limit=False, **post):
def get_user_profile_avatar(self, user_id, field='image_large', width=0, height=0, crop=False, avoid_if_small=False, upper_limit=False, **post):
if field not in ('image_small', 'image_medium', 'image_large'):
return werkzeug.exceptions.Forbidden()
can_sudo = self._check_avatar_access(user_id, **post)
fname = post.get('field', 'image_medium')
if can_sudo:
status, headers, content = request.env['ir.http'].sudo().binary_content(
model='res.users', id=user_id, field=fname,
model='res.users', id=user_id, field=field,
default_mimetype='image/png')
else:
status, headers, content = request.env['ir.http'].binary_content(
model='res.users', id=user_id, field=fname,
model='res.users', id=user_id, field=field,
default_mimetype='image/png')
if status == 301:
return request.env['ir.http']._response_by_status(status, headers, content)
@@ -77,19 +92,9 @@ class WebsiteProfile(http.Controller):
return werkzeug.wrappers.Response(status=304)
if not content:
img_path = modules.get_module_resource('web', 'static/src/img', 'placeholder.png')
with open(img_path, 'rb') as f:
image = f.read()
content = base64.b64encode(image)
dictheaders = dict(headers) if headers else {}
dictheaders['Content-Type'] = 'image/png'
headers = list(dictheaders.items())
if not (width or height):
suffix = fname.split('_')[-1]
if suffix in ('small', 'medium', 'big'):
content = getattr(tools, 'image_resize_image_%s' % suffix)(content)
content = self._get_default_avatar(field, headers, width, height)
content = limited_image_resize(
content = tools.limited_image_resize(
content, width=width, height=height, crop=crop, upper_limit=upper_limit, avoid_if_small=avoid_if_small)
image_base64 = base64.b64decode(content)
@@ -159,6 +164,7 @@ class WebsiteProfile(http.Controller):
ranks = ranks.sorted(key=lambda b: b.karma_min)
values = {
'ranks': ranks,
'user': request.env.user,
}
return request.render("website_profile.rank_main", values)
@@ -127,7 +127,6 @@ $bronze: #eea91e;
height: 128px;
}
}
border-bottom: 1px solid lightgray;
.o_wprofile_next_rank_progress {
background-color: lightgray;
.o_wprofile_next_rank_progress_bar {
@@ -181,6 +181,7 @@
<th><span class="float-right" t-field="user.rank_id"/></th>
</tr></tbody></table>
<t t-call="website_profile.profile_next_rank_card"></t>
<hr/>
</div>
</div>
</t>
@@ -299,7 +300,7 @@
Ranks &amp; Badges
</h1>
<p>
Do not stop learning with Odoo. Collects points on the forum or the eLearning platform. Those points will make you reach new ranks.
Do not stop learning with <t t-esc="user.company_id.name"/>. Collects points on the forum or the eLearning platform. Those points will make you reach new ranks.
</p>
<p>
<b>How do I earn badges?</b> When you finish a course, reach milestones, you're awarded badges.
@@ -46,7 +46,7 @@
<field name="website_published" eval="True"/>
<field name="is_preview" eval="False"/>
<field name="public_views">10</field>
<field name="description" type="html"><div>Tools you will need to complete this course.</div></field>
<field name="description">Tools you will need to complete this course.</field>
</record>
<record id="slide_slide_demo_6_1" model="slide.slide">
<field name="name">How to find quality wood</field>
@@ -59,7 +59,7 @@
<field name="is_preview" eval="False"/>
<field name="public_views">5</field>
<field name="image" type="base64" file="website_sale_slides/static/img/5WMqwTnZ-qs.png"/>
<field name="description" type="html"><div>Learn of to identify quality wood in order to create solid furnitures.</div></field>
<field name="description">Learn of to identify quality wood in order to create solid furnitures.</field>
</record>
<record id="slide_slide_demo_6_2" model="slide.slide">
<field name="name">How to create your own piece of furniture</field>
@@ -72,7 +72,7 @@
<field name="is_preview" eval="False"/>
<field name="public_views">0</field>
<field name="image" type="base64" file="website_sale_slides/static/img/ptjeDDoURL8.png"/>
<field name="description" type="html"><div>From a piece of wood to a fully functional furniture, step by step.</div></field>
<field name="description">From a piece of wood to a fully functional furniture, step by step.</field>
</record>
</data>
</odoo>
+32 -3
View File
@@ -6,7 +6,7 @@ import itertools
import logging
import werkzeug
from odoo import http, _
from odoo import http, modules, tools, _
from odoo.exceptions import AccessError, UserError
from odoo.http import request
from odoo.osv import expression
@@ -14,7 +14,6 @@ from odoo.osv import expression
from odoo.addons.http_routing.models.ir_http import slug
from odoo.addons.website_profile.controllers.main import WebsiteProfile
from odoo.addons.website.models.ir_http import sitemap_qs2dom
from odoo.tools import html2plaintext
_logger = logging.getLogger(__name__)
@@ -267,7 +266,7 @@ class WebsiteSlides(WebsiteProfile):
'message_post_hash': channel._sign_token(request.env.user.partner_id.id),
'message_post_pid': request.env.user.partner_id.id,
'last_message_id': last_message_data.get('id'),
'last_message': html2plaintext(last_message_data.get('body', '')),
'last_message': tools.html2plaintext(last_message_data.get('body', '')),
'last_rating_value': last_message_data.get('rating_value'),
})
@@ -362,6 +361,36 @@ class WebsiteSlides(WebsiteProfile):
return request.redirect('/web/login?redirect=/slides/slide/%s' % (slide.id))
return request.render("website.403")
@http.route('/slides/slide/<int:slide_id>/get_image', type='http', auth="public", website=True, sitemap=False)
def slide_get_image(self, slide_id, field='image_medium', width=0, height=0, crop=False, avoid_if_small=False, upper_limit=False):
# Protect infographics by limiting access to 256px (large) images
if field not in ('image_small', 'image_medium', 'image_large'):
return werkzeug.exceptions.Forbidden()
slide = request.env['slide.slide'].sudo().browse(slide_id).exists()
if not slide:
raise werkzeug.exceptions.NotFound()
status, headers, content = request.env['ir.http'].sudo().binary_content(
model='slide.slide', id=slide.id, field=field,
default_mimetype='image/png')
if status == 301:
return request.env['ir.http']._response_by_status(status, headers, content)
if status == 304:
return werkzeug.wrappers.Response(status=304)
if not content:
content = self._get_default_avatar(field, headers, width, height)
content = tools.limited_image_resize(
content, width=width, height=height, crop=crop, upper_limit=upper_limit, avoid_if_small=avoid_if_small)
image_base64 = base64.b64decode(content)
headers.append(('Content-Length', len(image_base64)))
response = request.make_response(image_base64, headers)
response.status_code = status
return response
# JSONRPC
@http.route('/slides/slide/like', type='json', auth="user", website=True)
def slide_like(self, slide_id, upvote):
@@ -12,7 +12,7 @@
<field name="is_preview" eval="True"/>
<field name="public_views">40</field>
<field name="tag_ids" eval="[(4, ref('website_slides.slide_tag_demo_tools')), (4, ref('website_slides.slide_tag_demo_howto'))]"/>
<field name="description" type="html"><div>A summary of know-how: how and what.</div></field>
<field name="description">A summary of know-how: how and what.</field>
</record>
<record id="slide_slide_demo_1_0" model="slide.slide">
@@ -25,7 +25,7 @@
<field name="is_preview" eval="True"/>
<field name="public_views">5</field>
<field name="tag_ids" eval="[(4, ref('website_slides.slide_tag_demo_colorfull'))]"/>
<field name="description" type="html"><div>Just some basics Tree Infographic.</div></field>
<field name="description">Just some basics Tree Infographic.</field>
</record>
<record id="slide_slide_demo_1_1" model="slide.slide">
<field name="name">Interesting Tree Facts</field>
@@ -37,7 +37,7 @@
<field name="is_preview" eval="True"/>
<field name="public_views">10</field>
<field name="tag_ids" eval="[(4, ref('website_slides.slide_tag_demo_colorfull'))]"/>
<field name="description" type="html"><div>Just some basics Interesting Tree Facts.</div></field>
<field name="description">Just some basics Interesting Tree Facts.</field>
</record>
<record id="slide_slide_demo_1_2" model="slide.slide">
<field name="name">Energy Efficiency Facts</field>
@@ -49,7 +49,7 @@
<field name="is_preview" eval="False"/>
<field name="public_views">15</field>
<field name="tag_ids" eval="[(5, 0)]"/>
<field name="description" type="html"><div>Just some basics Energy Efficiency Facts.</div></field>
<field name="description">Just some basics Energy Efficiency Facts.</field>
</record>
<record id="slide_slide_demo_1_3" model="slide.slide">
<field name="name">How to plant a potted tree</field>
@@ -63,7 +63,7 @@
<field name="is_preview" eval="False"/>
<field name="public_views">0</field>
<field name="tag_ids" eval="[(4, ref('website_slides.slide_tag_demo_howto'))]"/>
<field name="description" type="html"><div>Jim and Todd plant a potted tree for a customer of Knecht's Nurseries and Landscaping. Narrated by Leif Knecht, owner.</div></field>
<field name="description">Jim and Todd plant a potted tree for a customer of Knecht's Nurseries and Landscaping. Narrated by Leif Knecht, owner.</field>
</record>
<record id="slide_slide_demo_1_4" model="slide.slide">
<field name="name">A little chat with Harry Potted</field>
@@ -110,7 +110,7 @@
<field name="is_preview" eval="False"/>
<field name="public_views">5</field>
<field name="tag_ids" eval="[(4, ref('website_slides.slide_tag_demo_howto'))]"/>
<field name="description" type="html"><div>We had a little chat with Harry Potted, sure he had interesting things to say !</div></field>
<field name="description">We had a little chat with Harry Potted, sure he had interesting things to say !</field>
</record>
<record id="slide_slide_demo_1_5" model="slide.slide">
<field name="name">3 Main Methodologies</field>
@@ -123,7 +123,7 @@
<field name="is_preview" eval="False"/>
<field name="public_views">20</field>
<field name="tag_ids" eval="[(4, ref('website_slides.slide_tag_demo_howto'))]"/>
<field name="description" type="html"><div>A summary of know-how: how and what.</div></field>
<field name="description">A summary of know-how: how and what.</field>
</record>
<record id="slide_slide_demo_5_0" model="slide.slide">
@@ -136,7 +136,7 @@ class Channel(models.Model):
@api.depends('channel_partner_ids.channel_id')
def _compute_members_count(self):
read_group_res = self.env['slide.channel.partner'].sudo().read_group([('channel_id', 'in', self.ids)], [], 'channel_id')
read_group_res = self.env['slide.channel.partner'].sudo().read_group([('channel_id', 'in', self.ids)], ['channel_id'], 'channel_id')
data = dict((res['channel_id'][0], res['channel_id_count']) for res in read_group_res)
for channel in self:
channel.members_count = data.get(channel.id, 0)
@@ -154,7 +154,7 @@ class Channel(models.Model):
channel.valid_channel_partner_ids = result.get(channel.id, False)
channel.is_member = self.env.user.partner_id.id in channel.valid_channel_partner_ids if channel.valid_channel_partner_ids else False
@api.depends('slide_ids.slide_type', 'slide_ids.is_published',
@api.depends('slide_ids.slide_type', 'slide_ids.is_published', 'slide_ids.completion_time',
'slide_ids.likes', 'slide_ids.dislikes', 'slide_ids.total_views')
def _compute_slides_statistics(self):
result = dict((cid, dict(total_slides=0, total_views=0, total_votes=0, total_time=0)) for cid in self.ids)
+1 -1
View File
@@ -288,7 +288,7 @@ class Slide(models.Model):
@api.multi
def write(self, values):
if values.get('website_published') and any(not slide.channel_id.can_publish for slide in self):
if 'website_published' in values and any(not slide.channel_id.can_publish for slide in self):
values.pop('website_published')
if values.get('url') and values['url'] != self.url:
@@ -149,6 +149,10 @@ div .o_wslides_home_main {
background: linear-gradient(150deg, #875A7B 20%, #62495B 80%) !important;
opacity: 0.95;
a {
color: #FFFFFF;
}
nav {
.breadcrumb-item::before {
font-weight: bold;
@@ -52,15 +52,11 @@
</li>
</ol>
</nav>
<div class="col-4 d-flex flex-row">
<!-- karma -->
<!-- <ul class="navbar-nav ml-auto float-right pt-2">
<li class="nav-item">
<a t-attf-href="/profile/user/#{user.id}?channel_id=#{channel.id}" t-attf-class="text-dark font-weight-bold nav-link}">
<i class="fa fa-diamond text-secondary" /> <t t-esc="user_id.karma"/>
</a>
</li>
</ul> -->
<div class="col-6 d-flex flex-row align-items-center">
<!-- karma / profile -->
<a t-if="not is_public_user" t-att-href="'/profile/user/%s?channel_id=%s' % (user.id, channel.id)" class="font-weight-bold mr-3">
<i class="fa fa-diamond" /> <t t-esc="user_id.karma"/>
</a>
<!-- search -->
<form t-attf-action="/slides/#{slug(channel)}" role="search" method="get">
<div class="input-group">
@@ -73,7 +69,7 @@
</div>
</form>
<!-- sort / filter -->
<small class="dropdown float-right ml-2">
<small class="dropdown float-right ml-3">
<h6 class="m-0">
<button class="btn btn-primary dropdown-toggle" type="button" id="slidesChannelDropdownSort"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
@@ -268,7 +264,9 @@
</div>
<div class="row" t-if="slide_promoted">
<div class="col-4">
<img class="img img-fluid" t-att-src="'/web/image/slide.slide/%s/image' % slide_promoted.id"/>
<img class="img img-fluid" style=""
t-att-src="'/slides/slide/%s/get_image?field=image_large' % slide_promoted.id"
t-att-alt="slide_promoted.name"/>
</div>
<div class="col-8">
<div class="row">
@@ -318,10 +316,9 @@
<div class="row o_wslides_row mt8">
<div class="col-lg-3 col-md-6 col-12" t-foreach="slides" t-as="slide">
<div class="card o_wslides_lesson_card">
<img class="card-img-top img-fuild"
style="max-height: 160px;"
t-att-src="'/web/image/slide.slide/%s/image_large' % slide.id if slide.image else '/website_slides/static/src/img/channel-default.jpg'"
t-att-alt="channel.name"/>
<img class="card-img-top img-fuild" style="max-height: 160px;"
t-att-src="'/slides/slide/%s/get_image?field=image_large' % slide.id"
t-att-alt="slide.name"/>
<div class="card-body">
<h5 class="card-title" t-att-title="slide.name">
<t t-if="slide.is_preview or channel.is_member or is_slides_publisher">
@@ -142,6 +142,11 @@
</li>
</t>
</ul>
<!-- karma / profile -->
<a t-if="not is_public_user" t-att-href="'/profile/user/%s' % user.id" class="font-weight-bold mr-3">
<i class="fa fa-diamond"/> <t t-esc="user_id.karma"/>
</a>
<!-- search box -->
<form method="GET" class="form-inline justify-content-center">
<div class="input-group">
<input type="search" name="search" class="form-control"
@@ -276,7 +281,7 @@
</div>
</t>
<t t-else="">
<small class="text-muted col-5"><span t-esc="len(channel.slide_ids)"/> steps</small>
<small class="text-muted col-5"><span t-esc="channel.total_slides"/> steps</small>
</t>
</div>
</div>
@@ -312,7 +317,6 @@
<div class="col-2">
<t t-call="website_slides.slides_misc_user_image">
<t t-set="user" t-value="achievement.user_id"/>
<t t-set="record" t-value="channels_popular[:1]"/>
</t>
</div>
<div class="col">
@@ -353,9 +357,7 @@
<span class="font-weight-bold text-muted o_wslides_home_circled" t-esc="counter"/>
</div>
<div class="col-2">
<t t-call="website_slides.slides_misc_user_image">
<t t-set="record" t-value="channels_popular[:1]"/>
</t>
<t t-call="website_slides.slides_misc_user_image"/>
</div>
<div class="col">
<span class="font-weight-bold" t-esc="user.name"/>
@@ -369,17 +371,18 @@
<template id='slides_home_user_profile_small' name="User Profile">
<div class="o_wslides_home_aside">
<div class="row mt16 o_wslides_home_aside_title align-items-center">
<div class="col">
<t t-call="website_slides.slides_misc_user_image">
<t t-set="record" t-value="channels_popular[:1]"/>
</t>
<a t-att-href="'/slides'" class="float-right">Edit</a>
<div class="col-12">
<t t-call="website_slides.slides_misc_user_image"/>
<a t-att-href="'/profile/user/%s' % user.id" class="float-right">Edit</a>
<h4 t-esc="user.name" class="font-weight-bold"/>
<hr/>
</div>
</div>
<div class="row mt8">
<p class="col">INSERT DBE TEMPLATE HERE</p>
<div class="col-12 mt8">
<span class="font-weight-bold text-muted">Current rank:</span>
<a href="/profile/ranks" class="float-right" t-field="user.rank_id"/>
<t t-call="website_profile.profile_next_rank_card"/>
<div t-field="user.next_rank_id.description_motivational"/>
</div>
</div>
</div>
</template>
@@ -407,10 +410,10 @@
</div>
</template>
<template id='slides_misc_user_image' name="Float Time">
<template id='slides_misc_user_image' name="User Avatar">
<img t-att-class="img_class if img_class else 'rounded-circle float-left'"
t-att-style="img_style if img_style else 'max-height: 32px;'"
t-att-src="'/profile/avatar/%s?field=image_medium%s' % (user.id, '&amp;res_model=%s&amp;res_id=%s' % (record._name, record.id) if record else '')"
t-att-src="'/profile/avatar/%s?field=image_medium' % user.id"
t-att-alt="user.name"/>
</template>