[MERGE] website_slides: allow to contact responsible and request access to course

PURPOSE

Before this merge, a user could not contact a course responsible to ask him
to join. With this merge he is be able to do it.

SPECIFICATIONS: ACTIVITY

Add activity_search method in mail.activity.mixin to ease searching on
automated activities (code refactoring mainly).

Add possibility to choose a source / request partner on an activity. It allows
to know if an activity is linked to a customer or a requesting partner.
Com
SPECIFICATIONS: SLIDES

Change the message 'Contact website administrator' to 'Contact Responsible'.

Add the name of the course responsible in the course description panel. When
clicking on its name it opens a modal to request access to the course. It
creates an activity linked to the customer and assigned to the responsible.

When hovering the 'You're enrolled' button the fa-check icon changes to a
fa-times icon to show that you can unsubscribe when clicking.

LINKS

Task ID : 2127364
Community PR odoo/odoo#40708
Upgrade PR odoo/upgrade#1101

Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2020-06-08 13:32:15 +02:00
committed by GitHub
16 changed files with 376 additions and 39 deletions
+37 -30
View File
@@ -8,6 +8,7 @@ import logging
import pytz
from odoo import api, exceptions, fields, models, _
from odoo.osv import expression
from odoo.tools.misc import clean_context
from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG
@@ -162,6 +163,7 @@ class MailActivity(models.Model):
'res.users', 'Assigned to',
default=lambda self: self.env.user,
index=True, required=True)
request_partner_id = fields.Many2one('res.partner', string='Requesting Partner')
state = fields.Selection([
('overdue', 'Overdue'),
('today', 'Today'),
@@ -781,6 +783,38 @@ class MailActivityMixin(models.AbstractModel):
)
return True
def activity_search(self, act_type_xmlids='', user_id=None, additional_domain=None):
""" Search automated activities on current record set, given a list of activity
types xml IDs. It is useful when dealing with specific types involved in automatic
activities management.
:param act_type_xmlids: list of activity types xml IDs
:param user_id: if set, restrict to activities of that user_id;
:param additional_domain: if set, filter on that domain;
"""
if self.env.context.get('mail_activity_automation_skip'):
return False
Data = self.env['ir.model.data'].sudo()
activity_types_ids = [type_id for type_id in (Data.xmlid_to_res_id(xmlid, raise_if_not_found=False) for xmlid in act_type_xmlids) if type_id]
if not any(activity_types_ids):
return False
domain = [
'&', '&', '&',
('res_model', '=', self._name),
('res_id', 'in', self.ids),
('automated', '=', True),
('activity_type_id', 'in', activity_types_ids)
]
if user_id:
domain = expression.AND([domain, [('user_id', '=', user_id)]])
if additional_domain:
domain = expression.AND([domain, additional_domain])
return self.env['mail.activity'].search(domain)
def activity_schedule(self, act_type_xmlid='', date_deadline=None, summary='', note='', **act_values):
""" Schedule an activity on each record of the current record set.
This method allow to provide as parameter act_type_xmlid. This is an
@@ -864,16 +898,7 @@ class MailActivityMixin(models.AbstractModel):
activity_types_ids = [act_type_id for act_type_id in activity_types_ids if act_type_id]
if not any(activity_types_ids):
return False
domain = [
'&', '&', '&',
('res_model', '=', self._name),
('res_id', 'in', self.ids),
('automated', '=', True),
('activity_type_id', 'in', activity_types_ids)
]
if user_id:
domain = ['&'] + domain + [('user_id', '=', user_id)]
activities = self.env['mail.activity'].search(domain)
activities = self.activity_search(act_type_xmlids, user_id=user_id)
if activities:
write_vals = {}
if date_deadline:
@@ -894,16 +919,7 @@ class MailActivityMixin(models.AbstractModel):
activity_types_ids = [act_type_id for act_type_id in activity_types_ids if act_type_id]
if not any(activity_types_ids):
return False
domain = [
'&', '&', '&',
('res_model', '=', self._name),
('res_id', 'in', self.ids),
('automated', '=', True),
('activity_type_id', 'in', activity_types_ids)
]
if user_id:
domain = ['&'] + domain + [('user_id', '=', user_id)]
activities = self.env['mail.activity'].search(domain)
activities = self.activity_search(act_type_xmlids, user_id=user_id)
if activities:
activities.action_feedback(feedback=feedback)
return True
@@ -919,14 +935,5 @@ class MailActivityMixin(models.AbstractModel):
activity_types_ids = [act_type_id for act_type_id in activity_types_ids if act_type_id]
if not any(activity_types_ids):
return False
domain = [
'&', '&', '&',
('res_model', '=', self._name),
('res_id', 'in', self.ids),
('automated', '=', True),
('activity_type_id', 'in', activity_types_ids)
]
if user_id:
domain = ['&'] + domain + [('user_id', '=', user_id)]
self.env['mail.activity'].search(domain).unlink()
self.activity_search(act_type_xmlids, user_id=user_id).unlink()
return True
@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-extend="slides.course.unsubscribe.modal.leave">
<t t-jquery="p" t-operation="after">
<t t-jquery="p:last" t-operation="after">
<t t-if="widget.enroll === 'payment'">
<p class="alert alert-warning">
<i class="fa fa-exclamation-triangle fa-3x float-left mr-3"></i>
+4
View File
@@ -48,6 +48,7 @@ Featuring
'wizard/slide_channel_invite_views.xml',
'data/gamification_data.xml',
'data/mail_data.xml',
'data/mail_activity_data.xml',
'data/slide_data.xml',
'data/website_data.xml',
],
@@ -58,6 +59,9 @@ Featuring
'data/slide_slide_demo.xml',
'data/slide_user_demo.xml',
],
'qweb': [
'static/src/xml/activity.xml',
],
'installable': True,
'application': True,
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="mail_activity_data_access_request" model="mail.activity.type">
<field name="name">Access Request</field>
<field name="icon">fa-check-circle</field>
<field name="sequence">50</field>
<field name="res_model_id" ref="website_slides.model_slide_channel"/>
</record>
</data>
</odoo>
+68 -2
View File
@@ -78,6 +78,7 @@ class Channel(models.Model):
_description = 'Course'
_inherit = [
'mail.thread', 'rating.mixin',
'mail.activity.mixin',
'image.mixin',
'website.seo.metadata', 'website.published.multi.mixin']
_order = 'sequence, id'
@@ -85,6 +86,9 @@ class Channel(models.Model):
def _default_access_token(self):
return str(uuid.uuid4())
def _get_default_enroll_msg(self):
return _('Contact Responsible')
# description
name = fields.Char('Name', translate=True, required=True)
active = fields.Boolean(default=True, tracking=100)
@@ -145,7 +149,7 @@ class Channel(models.Model):
help='Condition to enroll: everyone, on invite, on payment (sale bridge).')
enroll_msg = fields.Html(
'Enroll Message', help="Message explaining the enroll process",
default=False, translate=tools.html_translate, sanitize_attributes=False)
default=_get_default_enroll_msg, translate=tools.html_translate, sanitize_attributes=False)
enroll_group_ids = fields.Many2many('res.groups', string='Auto Enroll Groups', help="Members of those groups are automatically added as members of the channel.")
visibility = fields.Selection([
('public', 'Public'), ('members', 'Members Only')],
@@ -156,6 +160,7 @@ class Channel(models.Model):
string='Members', help="All members of the channel.", context={'active_test': False}, copy=False, depends=['channel_partner_ids'])
members_count = fields.Integer('Attendees count', compute='_compute_members_count')
members_done_count = fields.Integer('Attendees Done Count', compute='_compute_members_done_count')
has_requested_access = fields.Boolean(string='Access Requested', compute='_compute_has_requested_access', compute_sudo=False)
is_member = fields.Boolean(string='Is Member', compute='_compute_is_member', compute_sudo=False)
channel_partner_ids = fields.One2many('slide.channel.partner', 'channel_id', string='Members Information', groups='website_slides.group_website_slides_officer', depends=['partner_ids'])
upload_group_ids = fields.Many2many(
@@ -197,6 +202,17 @@ class Channel(models.Model):
for channel in self:
channel.members_done_count = data.get(channel.id, 0)
@api.depends('activity_ids.request_partner_id')
@api.depends_context('uid')
@api.model
def _compute_has_requested_access(self):
requested_cids = self.sudo().activity_search(
['website_slides.mail_activity_data_access_request'],
additional_domain=[('request_partner_id', '=', self.env.user.partner_id.id)]
).mapped('res_id')
for channel in self:
channel.has_requested_access = channel.id in requested_cids
@api.depends('channel_partner_ids.partner_id')
@api.depends_context('uid')
@api.model
@@ -391,6 +407,7 @@ class Channel(models.Model):
if vals.get('user_id'):
self._action_add_members(self.env['res.users'].sudo().browse(vals['user_id']).partner_id)
self.activity_reschedule(['website_slides.mail_activity_data_access_request'], new_user_id=vals.get('user_id'))
if 'enroll_group_ids' in vals:
self._add_groups_members()
@@ -587,8 +604,41 @@ class Channel(models.Model):
action['domain'] = [('res_id', 'in', self.ids)]
return action
def action_request_access(self):
""" Request access to the channel. Returns a dict with keys being either 'error'
(specific error raised) or 'done' (request done or not). """
if self.env.user.has_group('base.group_public'):
return {'error': _('You have to sign in before')}
if not self.is_published:
return {'error': _('Course not published yet')}
if self.is_member:
return {'error': _('Already member')}
if self.enroll == 'invite':
activities = self.sudo()._action_request_access(self.env.user.partner_id)
if activities:
return {'done': True}
return {'error': _('Already Requested')}
return {'done': False}
def action_grant_access(self, partner_id):
partner = self.env['res.partner'].browse(partner_id).exists()
if partner:
if self._action_add_members(partner):
self.activity_search(
['website_slides.mail_activity_data_access_request'],
user_id=self.user_id.id, additional_domain=[('request_partner_id', '=', partner.id)]
).action_feedback(feedback=_('Access Granted'))
def action_refuse_access(self, partner_id):
partner = self.env['res.partner'].browse(partner_id).exists()
if partner:
self.activity_search(
['website_slides.mail_activity_data_access_request'],
user_id=self.user_id.id, additional_domain=[('request_partner_id', '=', partner.id)]
).action_feedback(feedback=_('Access Refused'))
# ---------------------------------------------------------
# Rating Mixin API
# Mailing Mixin API
# ---------------------------------------------------------
def _rating_domain(self):
@@ -596,6 +646,22 @@ class Channel(models.Model):
domain = super(Channel, self)._rating_domain()
return expression.AND([domain, [('is_internal', '=', False)]])
def _action_request_access(self, partner):
activities = self.env['mail.activity']
requested_cids = self.sudo().activity_search(
['website_slides.mail_activity_data_access_request'],
additional_domain=[('request_partner_id', '=', partner.id)]
).mapped('res_id')
for channel in self:
if channel.id not in requested_cids:
activities += channel.activity_schedule(
'website_slides.mail_activity_data_access_request',
note=_('<b>%s</b> is requesting access to this course.') % partner.name,
user_id=channel.user_id.id,
request_partner_id=partner.id
)
return activities
# ---------------------------------------------------------
# Data / Misc
# ---------------------------------------------------------
@@ -0,0 +1,47 @@
odoo.define('website_slides.Activity', function (require) {
"use strict";
var field_registry = require('web.field_registry');
require('mail.Activity');
var KanbanActivity = field_registry.get('kanban_activity');
var MailActivity = field_registry.get('mail_activity');
function applyInclude(Activity) {
Activity.include({
events: _.extend({}, Activity.prototype.events, {
'click .o_activity_action_grant_access': '_onGrantAccess',
'click .o_activity_action_refuse_access': '_onRefuseAccess',
}),
_onGrantAccess: function (event) {
var self = this;
var partnerId = $(event.currentTarget).data('partner-id');
this._rpc({
model: 'slide.channel',
method: 'action_grant_access',
args: [this.res_id, partnerId],
}).then(function (result) {
self.trigger_up('reload');
});
},
_onRefuseAccess: function (event) {
var self = this;
var partnerId = $(event.currentTarget).data('partner-id');
this._rpc({
model: 'slide.channel',
method: 'action_refuse_access',
args: [this.res_id, partnerId],
}).then(function () {
self.trigger_up('reload');
});
},
});
}
applyInclude(KanbanActivity);
applyInclude(MailActivity);
});
@@ -0,0 +1,83 @@
odoo.define('website_slides.course.enroll', function (require) {
'use strict';
var core = require('web.core');
var Dialog = require('web.Dialog');
var publicWidget = require('web.public.widget');
var _t = core._t;
var SlideEnrollDialog = Dialog.extend({
template: 'slide.course.join.request',
init: function (parent, options, modalOptions) {
modalOptions = _.defaults(modalOptions || {}, {
title: _t('Request Access.'),
size: 'medium',
buttons: [{
text: _t('Yes'),
classes: 'btn-primary',
click: this._onSendRequest.bind(this)
}, {
text: _t('Cancel'),
close: true
}]
});
this.$element = options.$element;
this.channelId = options.channelId;
this._super(parent, modalOptions);
},
_onSendRequest: function () {
var self = this;
this._rpc({
model: 'slide.channel',
method: 'action_request_access',
args: [self.channelId]
}).then(function (result) {
if (result.error) {
self.$element.replaceWith('<div class="alert alert-danger" role="alert"><strong>' + result.error + '</strong></div>');
} else if (result.done) {
self.$element.replaceWith('<div class="alert alert-success" role="alert"><strong>' + _t('Request sent !') + '</strong></div>');
} else {
self.$element.replaceWith('<div class="alert alert-danger" role="alert"><strong>' + _t('Unknown error, try again.') + '</strong></div>');
}
self.close();
});
}
});
publicWidget.registry.websiteSlidesEnroll = publicWidget.Widget.extend({
selector: '.o_wslides_js_channel_enroll',
xmlDependencies: ['/website_slides/static/src/xml/slide_course_join.xml'],
events: {
'click': '_onSendRequestClick',
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
_openDialog: function (channelId) {
new SlideEnrollDialog(this, {
channelId: channelId,
$element: this.$el
}).open();
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
_onSendRequestClick: function (ev) {
ev.preventDefault();
this._openDialog($(ev.currentTarget).data('channelId'));
}
});
return {
slideEnrollDialog: SlideEnrollDialog,
websiteSlidesEnroll: publicWidget.registry.websiteSlidesEnroll
};
});
@@ -10,6 +10,8 @@ odoo.define('website_slides.quiz', function (require) {
var QuestionFormWidget = require('website_slides.quiz.question.form');
var SlideQuizFinishModal = require('website_slides.quiz.finish');
var SlideEnrollDialog = require('website_slides.course.enroll').slideEnrollDialog;
var QWeb = core.qweb;
var _t = core._t;
@@ -26,7 +28,10 @@ odoo.define('website_slides.quiz', function (require) {
*/
var Quiz = publicWidget.Widget.extend({
template: 'slide.slide.quiz',
xmlDependencies: ['/website_slides/static/src/xml/slide_quiz.xml'],
xmlDependencies: [
'/website_slides/static/src/xml/slide_quiz.xml',
'/website_slides/static/src/xml/slide_course_join.xml'
],
events: {
"click .o_wslides_quiz_answer": '_onAnswerClick',
"click .o_wslides_js_lesson_quiz_submit": '_submitQuiz',
@@ -36,6 +41,7 @@ odoo.define('website_slides.quiz', function (require) {
'click .o_wslides_js_quiz_add': '_onCreateQuizClick',
'click .o_wslides_js_quiz_edit_question': '_onEditQuestionClick',
'click .o_wslides_js_quiz_delete_question': '_onDeleteQuestionClick',
'click .o_wslides_js_channel_enroll': '_onSendRequestToResponsibleClick',
},
custom_events: {
@@ -531,6 +537,20 @@ odoo.define('website_slides.quiz', function (require) {
}).open();
},
/**
* Handler for the contact responsible link below a Quiz
* @param ev
* @private
*/
_onSendRequestToResponsibleClick: function(ev) {
ev.preventDefault();
var channelId = $(ev.currentTarget).data('channelId');
new SlideEnrollDialog(this, {
channelId: channelId,
$element: $(ev.currentTarget).closest('.alert.alert-info')
}).open();
},
/**
* Displays the created Question at the correct place (after the last question or
* at the first place if there is no questions yet) It also displays the 'Add Question'
@@ -718,6 +738,7 @@ odoo.define('website_slides.quiz', function (require) {
return {
channelId: slideData.channelId,
channelEnroll: slideData.channelEnroll,
channelRequestedAccess: slideData.channelRequestedAccess || false,
signupAllowed: slideData.signupAllowed
};
},
@@ -237,6 +237,37 @@ $o-wslides-fs-side-width: 300px;
@include media-breakpoint-up(md) {
border-top-width: 0;
}
.o_wslides_js_channel_unsubscribe {
> .fa-times {
display: none;
}
&:hover {
> .fa-check {
display: none;
}
> .fa-times {
display: inline-block;
}
}
}
.o_wslides_js_channel_enroll {
cursor: pointer;
&:hover, &:hover .o_wslides_enroll_msg small {
font-weight: bold;
}
}
.o_wslides_enroll_msg {
p {
display: inline-block;
margin-bottom: 0px;
}
}
}
@mixin o-wslides-tabs($tab-active-color: $o-wslides-color-bg) {
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-extend="mail.activity_items">
<t t-jquery=".o_thread_message .o_thread_message_core .o_thread_message_tools" t-operation="replace">
<t t-if="activity.activity_type_id[1] == 'Access Request'">
<t t-if="activity.user_id[0] === uid">
<a role="button" class="btn btn-link btn-success text-muted text-success o_activity_link o_activity_action_grant_access" t-att-data-partner-id="activity.request_partner_id[0]">
<i class="fa fa-check"/> Grant Access
</a>
<a role="button" class="btn btn-link btn-danger text-muted text-danger o_activity_link o_activity_action_refuse_access" t-att-data-partner-id="activity.request_partner_id[0]">
<i class="fa fa-times"/> Refuse Access
</a>
</t>
</t>
<t t-else="">
<div class="o_thread_message_tools btn-group">
<t t-call="mail.activity_thread_message_tools"/>
</div>
</t>
</t>
</t>
</templates>
@@ -15,4 +15,10 @@
</a>
</div>
</t>
<t t-name="slide.course.join.request">
<div>
<p>Do you want to request access to this course ?</p>
</div>
</t>
</templates>
@@ -47,8 +47,23 @@
<div t-if="!widget.isMember">
<div class="o_wslides_join_course alert alert-info d-flex align-items-center justify-content-between">
<div t-if="widget.channel.channelEnroll == 'invite'">
<b class="h5 mb-0">
<b>This course is private. <a href="/contactus" class="font-weight-bold">Contact the website administrator</a> to enroll.</b>
<b>This course is private.
<span t-if="widget.publicUser">
Please
<a t-att-href="'/web/login?redirect=' + widget.redirectURL" class="font-weight-bold">
sign in
</a>
to enroll.
</span>
<a t-else="" href="#" class="font-weight-bold o_wslides_js_channel_enroll"
t-att-data-channel-id="widget.channel.channelId">
<span t-if="widget.channel.channelRequestedAccess" class="text-success">
Responsible already contacted.
</span>
<span t-else="">
Contact the responsible to enroll.
</span>
</a>
</b>
<span class="my-0 h4">
<span title="Succeed and gain karma" aria-label="Succeed and gain karma" class="badge badge-pill badge-warning text-white font-weight-bold ml-3 px-2 py-1">
+2
View File
@@ -7,6 +7,7 @@
<link rel="stylesheet" type="text/scss" href="/website_slides/static/src/scss/slide_views.scss"/>
<script type="text/javascript" src="/website_slides/static/src/js/slide_category_one2many.js"/>
<script type="text/javascript" src="/website_slides/static/src/js/rating_field_backend.js"/>
<script type="text/javascript" src="/website_slides/static/src/js/activity.js"/>
</xpath>
</template>
@@ -27,6 +28,7 @@
<script type="text/javascript" src="/website_slides/static/src/js/slides_course_slides_list.js"/>
<script type="text/javascript" src="/website_slides/static/src/js/slides_course_fullscreen_player.js"/>
<script type="text/javascript" src="/website_slides/static/src/js/slides_course_join.js"/>
<script type="text/javascript" src="/website_slides/static/src/js/slides_course_enroll_email.js"/>
<script type="text/javascript" src="/website_slides/static/src/js/slides_course_quiz.js"/>
<script type="text/javascript" src="/website_slides/static/src/js/slides_course_quiz_question_form.js"/>
<script type="text/javascript" src="/website_slides/static/src/js/slides_course_quiz_finish.js"/>
@@ -148,6 +148,7 @@
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" widget="mail_followers"/>
<field name="activity_ids" widget="mail_activity"/>
<field name="message_ids" widget="mail_thread"/>
</div>
</form>
@@ -294,10 +294,25 @@
<t t-else="">Join Course</t>
</span>
</a>
<div t-if="not channel.is_member and channel.enroll == 'invite'" class="text-center">
<div class="alert my-0 bg-100 p-2">Private Course</div>
<div t-if="channel.enroll_msg" t-field="channel.enroll_msg"/>
<small t-if="not channel.enroll_msg">Contact website administrator.</small>
<div t-attf-class="alert my-0 bg-100 p-2 #{'o_wslides_js_channel_enroll' if not is_public_user else ''}"
t-att-data-channel-id="channel.id">
Private Course
<div t-if="is_public_user">
<small>
Please <a t-att-href="'/web/login?redirect=/slides/%s' % (slug(channel))">sign in</a> to contact responsible.
</small>
</div>
<div t-elif="channel.has_requested_access">
<small class="text-success">
Request already sent
</small>
</div>
<div t-else="" class="o_wslides_enroll_msg">
<small t-field="channel.enroll_msg"/>
</div>
</div>
</div>
<t t-if="channel.is_member">
<button class="d-flex align-items-center alert my-0 px-2 px-xl-3 bg-100 w-100 o_wslides_js_channel_unsubscribe"
@@ -310,6 +325,7 @@
</t>
<h6 class="d-flex flex-grow-1 my-0">You're enrolled</h6>
<i class="fa fa-check"/>
<i class="fa fa-times"/>
</button>
<div class="d-flex align-items-center pt-3">
<t t-if="channel.completed">
@@ -331,6 +347,10 @@
<div id="o_wslides_sidebar_collapse" class="collapse d-md-block">
<table class="table table-sm mt-3">
<tr>
<th class="border-top-0">Responsible</th>
<td class="border-top-0"><span t-field="channel.user_id"/></td>
</tr>
<tr>
<th class="border-top-0">Last Update</th>
<td class="border-top-0"><t t-esc="channel.slide_last_update" t-options="{'widget': 'date'}"/></td>
@@ -452,6 +452,7 @@
t-att-data-next-slide-url="'/slides/slide/%s' % (slug(next_slide)) if next_slide else None"
t-att-data-channel-id="slide.channel_id.id"
t-att-data-channel-enroll="slide.channel_id.enroll"
t-att-data-channel-requested-access="slide.channel_id.has_requested_access"
t-att-data-channel-can-upload="slide.channel_id.can_upload"
t-att-data-signup-allowed="signup_allowed"
t-att-data-session-answers="session_answers">