[IMP] calendar,calendar_sms,crm,google_calendar: Improve calendars
This commit improves multiple small details in the calendar application. UI, UX mostly: * some labels were confusing * the invisible attribute were not set in popover view * the sample data were missing * ... taskid: 2342252
This commit is contained in:
@@ -28,11 +28,12 @@ class Attendee(models.Model):
|
||||
|
||||
event_id = fields.Many2one(
|
||||
'calendar.event', 'Meeting linked', required=True, ondelete='cascade')
|
||||
partner_id = fields.Many2one('res.partner', 'Contact', required=True, readonly=True)
|
||||
partner_id = fields.Many2one('res.partner', 'Attendee', required=True, readonly=True)
|
||||
state = fields.Selection(STATE_SELECTION, string='Status', readonly=True, default='needsAction',
|
||||
help="Status of the attendee's participation")
|
||||
common_name = fields.Char('Common name', compute='_compute_common_name', store=True)
|
||||
email = fields.Char('Email', related='partner_id.email', help="Email of Invited Person")
|
||||
phone = fields.Char('Phone', related='partner_id.phone', help="Phone number of Invited Person")
|
||||
availability = fields.Selection(
|
||||
[('free', 'Available'), ('busy', 'Busy')], 'Available/Busy', readonly=True)
|
||||
access_token = fields.Char('Invitation Token', default=_default_access_token)
|
||||
|
||||
@@ -127,7 +127,7 @@ class Meeting(models.Model):
|
||||
return self._get_recurrent_fields() | self._get_time_fields() | self._get_custom_fields() | {
|
||||
'id', 'active', 'allday',
|
||||
'duration', 'user_id', 'interval',
|
||||
'count', 'rrule', 'recurrence_id', 'show_as'}
|
||||
'count', 'rrule', 'recurrence_id', 'show_as', 'privacy'}
|
||||
|
||||
@api.model
|
||||
def _get_display_time(self, start, stop, zduration, zallday):
|
||||
@@ -217,15 +217,21 @@ class Meeting(models.Model):
|
||||
duration = fields.Float('Duration', compute='_compute_duration', store=True, readonly=False)
|
||||
description = fields.Text('Description')
|
||||
privacy = fields.Selection(
|
||||
[('public', 'Everyone'),
|
||||
('private', 'Only me'),
|
||||
[('public', 'Public'),
|
||||
('private', 'Private'),
|
||||
('confidential', 'Only internal users')],
|
||||
'Privacy', default='public', required=True)
|
||||
'Privacy', default='public', required=True,
|
||||
help="People to whom this event will be visible.")
|
||||
location = fields.Char('Location', tracking=True, help="Location of Event")
|
||||
videocall_location = fields.Char('Join Video Call', default=_default_videocall_location)
|
||||
show_as = fields.Selection(
|
||||
[('free', 'Available'),
|
||||
('busy', 'Busy')], 'Show Time as', default='busy', required=True)
|
||||
('busy', 'Busy')], 'Show as', default='busy', required=True,
|
||||
help="If the time is shown as 'busy', this event will be visible to other people with either the full \
|
||||
information or simply 'busy' written depending on its privacy. Use this option to let other people know \
|
||||
that you are unavailable during that period of time. \n If the time is shown as 'free', this event won't \
|
||||
be visible to other people at all. Use this option to let other people know that you are available during \
|
||||
that period of time.")
|
||||
|
||||
# linked document
|
||||
# LUL TODO use fields.Reference ?
|
||||
@@ -253,12 +259,13 @@ class Meeting(models.Model):
|
||||
string='Attendees', default=_default_partners)
|
||||
alarm_ids = fields.Many2many(
|
||||
'calendar.alarm', 'calendar_alarm_calendar_event_rel',
|
||||
string='Reminders', ondelete="restrict")
|
||||
string='Reminders', ondelete="restrict",
|
||||
help="Notifications sent to all attendees to remind of the meeting.")
|
||||
is_highlighted = fields.Boolean(
|
||||
compute='_compute_is_highlighted', string='Is the Event Highlighted')
|
||||
|
||||
# RECURRENCE FIELD
|
||||
recurrency = fields.Boolean('Recurrent', help="Recurrent Event")
|
||||
recurrency = fields.Boolean('Recurrent')
|
||||
recurrence_id = fields.Many2one(
|
||||
'calendar.recurrence', string="Recurrence Rule", index=True)
|
||||
follow_recurrence = fields.Boolean(default=False) # Indicates if an event follows the recurrence, i.e. is not an exception
|
||||
@@ -556,6 +563,29 @@ class Meeting(models.Model):
|
||||
meeting.attendee_ids._send_mail_to_attendees('calendar.calendar_template_meeting_invitation')
|
||||
return True
|
||||
|
||||
def action_mass_mailing(self):
|
||||
partners_ids = self.mapped('partner_ids')
|
||||
if not partners_ids:
|
||||
raise UserError(_("There are no attendees on these events"))
|
||||
compose_form = self.env.ref('mail.email_compose_message_wizard_form', False)
|
||||
default_partners = partners_ids and partners_ids.ids
|
||||
compose_ctx = dict(
|
||||
default_use_template=False,
|
||||
default_composition_mode='mass_mail',
|
||||
default_partner_ids=default_partners,
|
||||
default_subject=_("Event update")
|
||||
)
|
||||
return {
|
||||
'name': _('Contact Attendees'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_mode': 'form',
|
||||
'res_model': 'mail.compose.message',
|
||||
'views': [(compose_form.id, 'form')],
|
||||
'view_id': compose_form.id,
|
||||
'target': 'new',
|
||||
'context': compose_ctx,
|
||||
}
|
||||
|
||||
def _apply_recurrence_values(self, values, future=True):
|
||||
"""Apply the new recurrence rules in `values`. Create a recurrence if it does not exist
|
||||
and create all missing events according to the rrule.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from random import randint
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
@@ -9,7 +11,11 @@ class MeetingType(models.Model):
|
||||
_name = 'calendar.event.type'
|
||||
_description = 'Event Meeting Type'
|
||||
|
||||
def _default_color(self):
|
||||
return randint(1, 11)
|
||||
|
||||
name = fields.Char('Name', required=True)
|
||||
color = fields.Integer('Color', default=_default_color)
|
||||
|
||||
_sql_constraints = [
|
||||
('name_uniq', 'unique (name)', "Tag name already exists !"),
|
||||
|
||||
@@ -4,9 +4,22 @@ odoo.define('calendar.CalendarController', function (require) {
|
||||
const Controller = require('web.CalendarController');
|
||||
const Dialog = require('web.Dialog');
|
||||
const { qweb, _t } = require('web.core');
|
||||
const core = require('web.core');
|
||||
const QWeb = core.qweb;
|
||||
|
||||
const CalendarController = Controller.extend({
|
||||
|
||||
renderButtons: function ($node) {
|
||||
this._super.apply(this, arguments);
|
||||
const $addButton = $(QWeb.render('Calendar.calendar_add_buttons'));
|
||||
this.$buttons.prepend($addButton)
|
||||
const self = this;
|
||||
// When clicking on "Add", create a new record in form view
|
||||
this.$buttons.on('click', 'button.o-calendar-button-new', () => {
|
||||
return self.do_action('calendar.action_calendar_event_notify');
|
||||
});
|
||||
},
|
||||
|
||||
_askRecurrenceUpdatePolicy() {
|
||||
return new Promise((resolve, reject) => {
|
||||
new Dialog(this, {
|
||||
@@ -25,7 +38,6 @@ odoo.define('calendar.CalendarController', function (require) {
|
||||
});
|
||||
},
|
||||
|
||||
// TODO factorize duplicated code
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
@@ -33,12 +45,7 @@ odoo.define('calendar.CalendarController', function (require) {
|
||||
*/
|
||||
async _onDropRecord(event) {
|
||||
const _super = this._super; // reference to this._super is lost after async call
|
||||
if (event.data.record.recurrency) {
|
||||
const recurrenceUpdate = await this._askRecurrenceUpdatePolicy();
|
||||
event.data = _.extend({}, event.data, {
|
||||
'recurrenceUpdate': recurrenceUpdate,
|
||||
});
|
||||
}
|
||||
await this._dropdUpdateRecord(event);
|
||||
_super.apply(this, arguments);
|
||||
},
|
||||
|
||||
@@ -49,14 +56,18 @@ odoo.define('calendar.CalendarController', function (require) {
|
||||
*/
|
||||
async _onUpdateRecord(event) {
|
||||
const _super = this._super; // reference to this._super is lost after async call
|
||||
await this._dropdUpdateRecord(event);
|
||||
_super.apply(this, arguments);
|
||||
},
|
||||
|
||||
async _dropdUpdateRecord(event) {
|
||||
if (event.data.record.recurrency) {
|
||||
const recurrenceUpdate = await this._askRecurrenceUpdatePolicy();
|
||||
event.data = _.extend({}, event.data, {
|
||||
'recurrenceUpdate': recurrenceUpdate,
|
||||
});
|
||||
}
|
||||
_super.apply(this, arguments);
|
||||
},
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -77,3 +77,7 @@
|
||||
.o_cw_body .o_clipboard_button {
|
||||
padding-top: 0px !important;
|
||||
}
|
||||
|
||||
.o_calendar_attendees {
|
||||
max-width:80% !important;
|
||||
}
|
||||
|
||||
@@ -56,4 +56,10 @@
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="Calendar.calendar_add_buttons">
|
||||
<span class="o_calendar_create_buttons">
|
||||
<button class="btn btn-primary o-calendar-button-new" aria-label="Add" title="Add">Add</button>
|
||||
</span>
|
||||
</t>
|
||||
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<field name="name">calendar.event.type</field>
|
||||
<field name="model">calendar.event.type</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Meeting Types" editable="bottom">
|
||||
<tree string="Meeting Types" sample="1" editable="bottom">
|
||||
<field name="name"/>
|
||||
</tree>
|
||||
</field>
|
||||
@@ -23,7 +23,7 @@
|
||||
<field name="name">calendar.alarm.tree</field>
|
||||
<field name="model">calendar.alarm</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Calendar Alarm" editable="bottom">
|
||||
<tree string="Calendar Alarm" sample="1" editable="bottom">
|
||||
<field name="name" invisible="1"/>
|
||||
<field name="alarm_type"/>
|
||||
<field name="duration"/>
|
||||
@@ -65,14 +65,25 @@
|
||||
<field name="name">calendar.event.tree</field>
|
||||
<field name="model">calendar.event</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Meetings" multi_edit="1">
|
||||
<field name="name" string="Subject" readonly="1"/>
|
||||
<field name="allday" invisible="True"/>
|
||||
<field name="start" string="Start Date"/>
|
||||
<field name="stop" string="End Date"/>
|
||||
<field name="partner_ids" readonly="1"/>
|
||||
<field name="location"/>
|
||||
<tree string="Meetings" sample="1" multi_edit="1">
|
||||
<header>
|
||||
<button name="action_mass_mailing" type="object"
|
||||
string="Send Mail"/>
|
||||
</header>
|
||||
<field name="name" string="Subject" decoration-bf="1" attrs="{'readonly':[('recurrency','=',True)]}"/>
|
||||
<field name="start" string="Start Date" readonly="1"/>
|
||||
<field name="stop" string="End Date" readonly="1"/>
|
||||
<field name="user_id" widget="many2one_avatar_user" attrs="{'readonly':[('recurrency','=',True)]}" optional="hide"/>
|
||||
<field name="partner_ids" widget="many2many_tags" attrs="{'readonly':[('recurrency','=',True)]}" optional="show"/>
|
||||
<field name="alarm_ids" widget="many2many_tags" optional="hide" attrs="{'readonly':[('recurrency','=',True)]}"/>
|
||||
<field name="categ_ids" widget="many2many_tags" optional="hide" attrs="{'readonly':[('recurrency','=',True)]}"/>
|
||||
<field name="recurrency" optional="hide" readonly="1"/>
|
||||
<field name="privacy" optional="hide" attrs="{'readonly':[('recurrency','=',True)]}"/>
|
||||
<field name="show_as" optional="hide" attrs="{'readonly':[('recurrency','=',True)]}"/>
|
||||
<field name="location" optional="show" attrs="{'readonly':[('recurrency','=',True)]}"/>
|
||||
<field name="duration" widget="float_time" readonly="1"/>
|
||||
<field name="description" optional="hide" attrs="{'readonly':[('recurrency','=',True)]}"/>
|
||||
<field name="allday" invisible="1"/>
|
||||
<field name="message_needaction" invisible="1"/>
|
||||
</tree>
|
||||
</field>
|
||||
@@ -104,12 +115,16 @@
|
||||
<h1>
|
||||
<field name="name" placeholder="e.g. Business Lunch"/>
|
||||
</h1>
|
||||
<h2>
|
||||
<div class="d-flex">
|
||||
<field name="partner_ids" widget="many2manyattendee"
|
||||
placeholder="Select attendees..."
|
||||
context="{'force_email':True}"
|
||||
class="oe_inline"/>
|
||||
</h2>
|
||||
class="oe_inline o_calendar_attendees"
|
||||
/>
|
||||
<div name="send_buttons">
|
||||
<button name="action_mass_mailing" help="Send Email to attendees" type="object" string=" EMAIl" icon="fa-envelope"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
@@ -125,20 +140,19 @@
|
||||
<field name="stop_date" string="Ending at" attrs="{'required': [('allday','=',True)],'invisible': [('allday','=',False)]}" force_save="1"/>
|
||||
|
||||
<field name="start" string="Starting at" attrs="{'required': [('allday','=',False)], 'invisible': [('allday','=',True)]}"/>
|
||||
<field name="stop" invisible="1"/>
|
||||
<field name="stop" string="Ending At" attrs="{'invisible': [('allday','=',True)]}"/>
|
||||
<label for="duration" attrs="{'invisible': [('allday','=',True)]}"/>
|
||||
<div attrs="{'invisible': [('allday','=',True)]}">
|
||||
<field name="duration" widget="float_time" string="Duration" class="oe_inline" attrs="{'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/>
|
||||
<span> hours</span>
|
||||
</div>
|
||||
<field name="event_tz" attrs="{'invisible': [('recurrency', '=', False)]}"/>
|
||||
<field name="allday" attrs="{'readonly': [('id', '!=', False), ('recurrency','=',True)]}" force_save="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="categ_ids" widget="many2many_tags" options="
|
||||
{'no_create_edit': True}"/>
|
||||
<field name="alarm_ids" widget="many2many_tags" />
|
||||
<field name="alarm_ids" widget="many2many_tags" options="{'no_quick_create': True}"/>
|
||||
<field name="location" />
|
||||
<field name="event_tz" attrs="{'invisible': [('recurrency', '=', False)]}"/>
|
||||
<field name="categ_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/>
|
||||
</group>
|
||||
|
||||
</group>
|
||||
@@ -200,12 +214,13 @@
|
||||
</page>
|
||||
|
||||
<page name="page_invitations" string="Invitations" groups="base.group_no_one">
|
||||
<button name="action_sendmail" type="object" string="Send mail" icon="fa-envelope" class="oe_link"/>
|
||||
<button name="action_sendmail" type="object" string="Send Invitations" icon="fa-envelope" class="oe_link"/>
|
||||
<field name="attendee_ids" widget="one2many" mode="tree,kanban">
|
||||
<tree string="Invitation details" editable="top" create="false" delete="false">
|
||||
<field name="partner_id" />
|
||||
<field name="state" />
|
||||
<field name="email" widget="email"/>
|
||||
<field name="phone" widget="phone"/>
|
||||
<field name="state" />
|
||||
|
||||
<button name="do_tentative" states="needsAction,declined,accepted" string="Uncertain" type="object" icon="fa-asterisk" />
|
||||
<button name="do_accept" string="Accept" states="needsAction,tentative,declined" type="object" icon="fa-check text-success"/>
|
||||
@@ -259,13 +274,17 @@
|
||||
event_open_popup="true"
|
||||
event_limit="5"
|
||||
color="user_id">
|
||||
<field name="location" attrs="{'invisible': [('location', '=', False)]}"/>
|
||||
<field name="videocall_location" string="Join Video Call" widget="CopyClipboardURL" />
|
||||
<field name="attendee_status"/>
|
||||
<field name="attendee_status" invisible="1"/>
|
||||
<field name="user_id" string="Responsible" filters="1" widget="many2one_avatar_user"/>
|
||||
<field name="partner_ids" widget="many2manyattendee" write_model="calendar.contacts" write_field="partner_id" avatar_field="image_128"/>
|
||||
<field name="is_highlighted" invisible="1"/>
|
||||
<field name="description"/>
|
||||
<field name="description" attrs="{'invisible': [('description', '=', False)]}"/>
|
||||
<field name="privacy"/>
|
||||
<field name="show_as"/>
|
||||
<field name="alarm_ids" attrs="{'invisible': [('alarm_ids', '=', [])]}"/>
|
||||
<field name="categ_ids" attrs="{'invisible': [('categ_ids', '=', [])]}"/>
|
||||
<!-- For recurrence update Dialog -->
|
||||
<field name="recurrency" invisible="1"/>
|
||||
<field name="recurrence_update" invisible="1"/>
|
||||
@@ -280,13 +299,24 @@
|
||||
<search string="Search Meetings">
|
||||
<field name="name" string="Meeting" filter_domain="[('name', 'ilike', self)]"/>
|
||||
<field name="partner_ids"/>
|
||||
<field name="categ_ids"/>
|
||||
<field name="user_id"/>
|
||||
<field name="location"/>
|
||||
<field name="show_as"/>
|
||||
<field name="categ_ids"/>
|
||||
<field name="description"/>
|
||||
<filter string="My Meetings" help="My Meetings" name="mymeetings" domain="[('partner_ids.user_ids', 'in', [uid])]"/>
|
||||
<separator/>
|
||||
<filter string="Date" name="filter_start_date" date="start"/>
|
||||
<separator/>
|
||||
<filter string="Busy" name="busy" domain="[('show_as', '=', 'busy')]"/>
|
||||
<filter string="Free" name="free" domain="[('show_as', '=', 'free')]"/>
|
||||
<separator/>
|
||||
<filter string="Public" name="public" domain="[('privacy', '=', 'public')]"/>
|
||||
<filter string="Private" name="private" domain="[('privacy', '=', 'private')]"/>
|
||||
<filter string="Only Internal Users" name="confidential" domain="[('privacy', '=', 'confidential')]"/>
|
||||
<separator/>
|
||||
<filter string="Recurrent" name="recurrent" domain="[('recurrency', '=', True)]"/>
|
||||
<separator/>
|
||||
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Responsible" name="responsible" domain="[]" context="{'group_by': 'user_id'}"/>
|
||||
@@ -304,7 +334,7 @@
|
||||
<field name="search_view_id" ref="view_calendar_event_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Schedule a new meeting
|
||||
No meetings found. Let's schedule one!
|
||||
</p><p>
|
||||
The calendar is shared between employees and fully integrated with
|
||||
other applications such as the employee leaves or the business
|
||||
@@ -357,6 +387,21 @@
|
||||
web_icon="calendar,static/description/icon.png"
|
||||
groups="base.group_user"/>
|
||||
|
||||
<menuitem
|
||||
id="calendar_menu_config"
|
||||
parent="calendar.mail_menu_calendar"
|
||||
name="Configuration"
|
||||
sequence="40"
|
||||
action="calendar.action_calendar_event"
|
||||
groups="base.group_no_one"/>
|
||||
|
||||
<menuitem
|
||||
id="calendar_submenu_reminders"
|
||||
parent="calendar_menu_config"
|
||||
name="Reminders"
|
||||
action="action_calendar_alarm"
|
||||
groups="base.group_no_one"/>
|
||||
|
||||
<menuitem
|
||||
id="menu_calendar_configuration"
|
||||
name="Calendar"
|
||||
|
||||
@@ -27,6 +27,19 @@ class CalendarEvent(models.Model):
|
||||
put_in_queue=False
|
||||
)
|
||||
|
||||
def action_send_sms(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sms.composer',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_composition_mode': 'mass',
|
||||
'default_res_model': 'res.partner',
|
||||
'default_res_ids': self.partner_ids.ids,
|
||||
},
|
||||
}
|
||||
|
||||
def _get_trigger_alarm_types(self):
|
||||
return super()._get_trigger_alarm_types() + ['sms']
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
|
||||
<!-- Add action entry in the Action Menu for Events -->
|
||||
<record id="calendar_event_act_window_sms_composer_single" model="ir.actions.act_window">
|
||||
<field name="name">Send SMS to attendees</field>
|
||||
<field name="name">Send SMS</field>
|
||||
<field name="res_model">sms.composer</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="context">{
|
||||
'sms_composition_mode': 'guess',
|
||||
'default_composition_mode': 'guess',
|
||||
'default_res_id': active_id,
|
||||
'default_res_ids': active_ids,
|
||||
}</field>
|
||||
@@ -19,11 +19,20 @@
|
||||
<field name="model">calendar.event</field>
|
||||
<field name="inherit_id" ref="calendar.view_calendar_event_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//tree" position="inside">
|
||||
<header>
|
||||
<button name="%(calendar_event_act_window_sms_composer_single)d" type="action"
|
||||
string="Send SMS to attendees"/>
|
||||
</header>
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="%(calendar_event_act_window_sms_composer_single)d" type="action"
|
||||
string="Send SMS"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_calendar_event_form_inherited" model="ir.ui.view">
|
||||
<field name="name">calendar.event.form.calendar_sms</field>
|
||||
<field name="model">calendar.event</field>
|
||||
<field name="inherit_id" ref="calendar.view_calendar_event_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='send_buttons']" position="inside">
|
||||
<button name="action_send_sms" help="Send SMS to attendees" type="object" string="SMS" icon="fa-mobile"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<field name="model">calendar.event</field>
|
||||
<field name="inherit_id" ref="calendar.view_calendar_event_search"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='user_id']" position="after">
|
||||
<xpath expr="//field[@name='description']" position="after">
|
||||
<field name="opportunity_id"/>
|
||||
</xpath>
|
||||
</field>
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
<label for="cal_client_secret" string="Client Secret" class="col-3 col-lg-3 o_light_label"/>
|
||||
<field name="cal_client_secret" password="True" nolabel="1"/>
|
||||
</div>
|
||||
<a href="https://www.odoo.com/documentation/user/14.0/crm/optimize/google_calendar_credentials.html" class="oe-link" target="_blank"><i class="fa fa-fw fa-arrow-right"/>Tutorial</a>
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
|
||||
Reference in New Issue
Block a user