[FW][MERGE][FIX] website_{,sale_}slides: fix join/buy links and resource access

This commit fixes
* One link that was not updated to use the post_link class
* The join course link for "payment" courses after completing the quiz once
  logged in.
* The link to resources that became accessible for non-authorized users for
  "payment" courses

See underlying commits for details.

Task-2818136
Part of Task-2663320

closes odoo/odoo#92104

Forward-port-of: odoo/odoo#92055
Forward-port-of: odoo/odoo#88454
Signed-off-by: Warnon Aurélien (awa) <awa@odoo.com>
Signed-off-by: Florian Charlier (flch) <flch@odoo.com>
This commit is contained in:
Odoo's Mergebot
2022-05-24 14:51:15 +02:00
committed by GitHub
12 changed files with 194 additions and 78 deletions
@@ -6,8 +6,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Odoo Server saas~15.3\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-04-19 13:58+0000\n"
"PO-Revision-Date: 2022-04-19 13:58+0000\n"
"POT-Creation-Date: 2022-05-20 12:45+0000\n"
"PO-Revision-Date: 2022-05-20 12:45+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
@@ -272,5 +272,5 @@ msgstr ""
#. module: website_sale_slides
#: model_terms:ir.ui.view,arch_db:website_sale_slides.course_buy_course_link
msgid "to download resources"
msgid "to access resources"
msgstr ""
@@ -29,9 +29,10 @@ CourseJoinWidget.include({
ev.preventDefault();
if (this.channel.channelEnroll === 'payment' && !this.publicUser) {
const self = this;
this.beforeJoin().then(function () {
wUtils.sendRequest('/shop/cart/update', {
product_id: this.productId,
product_id: self.productId,
express: 1,
});
});
@@ -14,7 +14,7 @@
</template>
<template name="Buy Course To Download Resource" id="slide_aside_training_category_buy_course" inherit_id="website_slides.slide_aside_training_category">
<xpath expr="//div[hasclass('o_wslides_js_course_join')]" position="inside">
<xpath expr="//div[hasclass('o_wslides_js_course_join') and hasclass('o_wslides_no_access')]" position="inside">
<li t-elif="aside_slide.channel_id.enroll == 'payment'" class="text-decoration-none small">
<i class="fa fa-download mr-1"/>
<t t-call="website_sale_slides.course_buy_course_link">
@@ -25,7 +25,7 @@
</template>
<template name="Buy Course To Download Resource Slide Detail" id="slide_content_detailed_buy_course" inherit_id="website_slides.slide_content_detailed">
<xpath expr="//div[hasclass('o_wslides_js_course_join')]" position="inside">
<xpath expr="//div[hasclass('o_wslides_js_course_join') and hasclass('o_wslides_no_access')]" position="inside">
<span t-elif="slide.channel_id.enroll == 'payment'" class="text-muted mr-auto border-left pl-3">
<t t-call="website_sale_slides.course_buy_course_link"/>
</span>
@@ -59,7 +59,7 @@
</template>
<template name="Buy Course To Download Resource Fullscreen" id="slide_fullscreen_sidebar_category_buy_course" inherit_id="website_slides.slide_fullscreen_sidebar_category">
<xpath expr="//div[hasclass('o_wslides_js_course_join')]" position="inside">
<xpath expr="//div[hasclass('o_wslides_js_course_join') and hasclass('o_wslides_no_access')]" position="inside">
<li t-elif="slide.channel_id.enroll == 'payment'" class="o_wslides_fs_slide_link mb-1">
<i class="fa fa-download mr-1"/>
<t t-call="website_sale_slides.course_buy_course_link"/>
@@ -79,10 +79,8 @@
<!-- TOOLS -->
<template name="Buy Course Link" id="course_buy_course_link">
<a t-att-href="'/shop/cart/update?product_id=%s' % slide.channel_id.product_id.id">
Buy Course
</a>
to download resources
<a class="post_link" t-att-href="'/shop/cart/update?product_id=%s' % slide.channel_id.product_id.id">
Buy Course</a> to access resources
</template>
<template name="Buy Course Button" id="course_buy_course_button">
+11 -3
View File
@@ -6,8 +6,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Odoo Server saas~15.3\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-04-19 13:57+0000\n"
"PO-Revision-Date: 2022-04-19 13:57+0000\n"
"POT-Creation-Date: 2022-05-20 12:38+0000\n"
"PO-Revision-Date: 2022-05-20 12:38+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
@@ -628,6 +628,14 @@ msgstr ""
msgid "<span class=\"text-500 mx-2\">•</span>"
msgstr ""
#. module: website_slides
#: model_terms:ir.ui.view,arch_db:website_slides.slide_content_detailed
msgid ""
"<span class=\"text-muted font-weight-bold mr-3\">\n"
" Additional Resources\n"
" </span>"
msgstr ""
#. module: website_slides
#: model_terms:ir.ui.view,arch_db:website_slides.slide_content_detailed
msgid "<span class=\"text-muted font-weight-bold mr-3\">External sources</span>"
@@ -5650,7 +5658,7 @@ msgstr ""
#. module: website_slides
#: model_terms:ir.ui.view,arch_db:website_slides.join_course_link
msgid "to download resources"
msgid "to access resources"
msgstr ""
#. module: website_slides
@@ -300,6 +300,13 @@ class Slide(models.Model):
for slide in self:
slide.questions_count = len(slide.question_ids)
def _has_additional_resources(self, resource_type=None):
"""Sudo required for public user to know if the course has additional
resources that they will be able to access once a member."""
self.ensure_one()
domain = [('resource_type', '=', resource_type)] if resource_type else []
return bool(self.sudo().slide_resource_ids.filtered_domain(domain))
@api.depends('website_message_ids.res_id', 'website_message_ids.model', 'website_message_ids.message_type')
def _compute_comments_count(self):
for slide in self:
@@ -21,7 +21,9 @@ access_slide_channel_partners_all,slide.channel.users.all,model_slide_channel_pa
access_slide_channel_partners_system,slide.channel.users.system,model_slide_channel_partner,website_slides.group_website_slides_officer,1,1,1,1
access_slide_embed_all,slide.embed.all,model_slide_embed,,1,0,0,0
access_slide_embed_user,slide.embed.user,model_slide_embed,base.group_user,1,1,1,1
access_slide_slide_resource_all,slide.slide.resource.all,model_slide_slide_resource,,1,0,0,0
access_slide_slide_resource_all,slide.slide.resource.all,model_slide_slide_resource,,0,0,0,0
access_slide_slide_resource_public,slide.slide.resource.public,model_slide_slide_resource,base.group_public,0,0,0,0
access_slide_slide_resource_portal,slide.slide.resource.portal,model_slide_slide_resource,base.group_portal,1,0,0,0
access_slide_slide_resource_internal,slide.slide.resource.internal,model_slide_slide_resource,base.group_user,1,0,0,0
access_slide_slide_resource_publisher,slide.slide.resource.publisher,model_slide_slide_resource,website_slides.group_website_slides_officer,1,1,1,1
access_slide_channel_invite,access.slide.channel.invite,model_slide_channel_invite,base.group_user,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
21 access_slide_channel_partners_system slide.channel.users.system model_slide_channel_partner website_slides.group_website_slides_officer 1 1 1 1
22 access_slide_embed_all slide.embed.all model_slide_embed 1 0 0 0
23 access_slide_embed_user slide.embed.user model_slide_embed base.group_user 1 1 1 1
24 access_slide_slide_resource_all slide.slide.resource.all model_slide_slide_resource 1 0 0 0 0
25 access_slide_slide_resource_public slide.slide.resource.public model_slide_slide_resource base.group_public 0 0 0 0
26 access_slide_slide_resource_portal slide.slide.resource.portal model_slide_slide_resource base.group_portal 1 0 0 0
27 access_slide_slide_resource_internal slide.slide.resource.internal model_slide_slide_resource base.group_user 1 0 0 0
28 access_slide_slide_resource_publisher slide.slide.resource.publisher model_slide_slide_resource website_slides.group_website_slides_officer 1 1 1 1
29 access_slide_channel_invite access.slide.channel.invite model_slide_channel_invite base.group_user 1 1 1 0
@@ -189,7 +189,7 @@
<!--SLIDE RESOURCE-->
<record id="rule_slide_slide_resource_downloadable" model="ir.rule">
<field name="name">Resource: restricted to channel members and channel responsible</field>
<field name="name">Resource: read restricted to channel members and channel responsible</field>
<field name="model_id" ref="model_slide_slide_resource"/>
<field name="domain_force">[('slide_id.channel_id.partner_ids', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal')), (4, ref('base.group_user'))]"/>
@@ -199,6 +199,28 @@
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_slide_slide_resource_officer_read" model="ir.rule">
<field name="name">Resource: officer: read all</field>
<field name="model_id" ref="model_slide_slide_resource"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_website_slides_officer'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_slide_slide_resource_officer_crud" model="ir.rule">
<field name="name">Resource: officer: crud own only</field>
<field name="model_id" ref="model_slide_slide_resource"/>
<field name="domain_force">[('slide_id.channel_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_website_slides_officer'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="rule_slide_slide_resource_downloadable_manager" model="ir.rule">
<field name="name">Resource: manager: crud all</field>
<field name="model_id" ref="model_slide_slide_resource"/>
@@ -33,8 +33,8 @@ var CourseJoinWidget = publicWidget.Widget.extend({
this.channel = options.channel;
this.isMember = options.isMember;
this.publicUser = options.publicUser;
this.joinMessage = options.joinMessage || _t('Join Course'),
this.beforeJoin = options.beforeJoin || Promise.resolve();
this.joinMessage = options.joinMessage || _t('Join Course');
this.beforeJoin = options.beforeJoin || function () {return Promise.resolve();};
this.afterJoin = options.afterJoin || function () {document.location.reload();};
},
@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.website_slides.tests import common
from odoo.exceptions import AccessError
from odoo.tests import tagged
@@ -297,3 +299,69 @@ class TestAccessFeatures(common.SlidesCase):
channel_superuser.invalidate_cache(['can_upload', 'can_publish'])
self.assertTrue(channel_superuser.can_upload)
self.assertTrue(channel_superuser.can_publish)
@mute_logger('odoo.models.unlink', 'odoo.addons.base.models.ir_rule', 'odoo.addons.base.models.ir_model')
def test_resource_access(self):
resource_values = {
'name': 'Image',
'slide_id': self.slide_3.id,
'resource_type': 'file',
'data': base64.b64encode(b'Some content')
}
resource1, resource2 = self.env['slide.slide.resource'].with_user(self.user_officer).create(
[resource_values for _ in range(2)])
resource3 = self.env['slide.slide.resource'].with_user(self.user_officer).create([
{'name': 'Link',
'slide_id': self.slide_3.id,
'resource_type': 'url',
'link': 'https://www.odoo.com'}
])
# No public access to resources
with self.assertRaises(AccessError):
resource1.with_user(self.user_public).read(['name'])
resource3.with_user(self.user_public).read(['name'])
with self.assertRaises(AccessError):
resource1.with_user(self.user_public).write({'name': 'other name'})
resource3.with_user(self.user_public).write({'name': 'other name'})
# public access to knowing if there are resources, also by type
self.assertTrue(self.slide_3.with_user(self.user_public)._has_additional_resources())
self.assertTrue(self.slide_3.with_user(self.user_public)._has_additional_resources('file'))
self.assertTrue(self.slide_3.with_user(self.user_public)._has_additional_resources('url'))
# No random portal access
with self.assertRaises(AccessError):
resource1.with_user(self.user_portal).read(['name'])
# Members can only read
self.env['slide.channel.partner'].create({
'channel_id': self.channel.id,
'partner_id': self.user_portal.partner_id.id,
})
resource1.with_user(self.user_portal).read(['name'])
with self.assertRaises(AccessError):
resource1.with_user(self.user_portal).write({'name': 'other name'})
# Other officers can only read
user_officer_other = mail_new_test_user(
self.env, name='Ornella Officer', login='user_officer_2', email='officer2@example.com',
groups='base.group_user,website_slides.group_website_slides_officer'
)
resource1.with_user(user_officer_other).read(['name'])
with self.assertRaises(AccessError):
resource1.with_user(user_officer_other).write({'name': 'Another name'})
with self.assertRaises(AccessError):
self.env['slide.slide.resource'].with_user(user_officer_other).create(resource_values)
with self.assertRaises(AccessError):
resource1.with_user(user_officer_other).unlink()
# Responsible officer can do anything on their own channels
resource1.with_user(self.user_officer).write({'name': 'other name'})
resource1.with_user(self.user_officer).unlink()
# Managers can do anything on all channels
resource2.with_user(self.user_manager).write({'name': 'Another name'})
resource2.with_user(self.user_manager).unlink()
self.env['slide.slide.resource'].with_user(self.user_manager).create(resource_values)
@@ -145,10 +145,11 @@
</t>
</a>
<ul class="collapse show p-0 m-0 list-unstyled" t-att-id="('collapse-%s') % (category.id if category else 0)" >
<t t-set="is_member" t-value="slide.channel_id.is_member"/>
<t t-set="can_access_channel" t-value="is_member or slide.channel_id.can_publish"/>
<t t-foreach="category_slide_ids" t-as="aside_slide">
<t t-set="slide_completed" t-value="channel_progress[aside_slide.id].get('completed')"/>
<t t-set="is_member" t-value="slide.channel_id.is_member"/>
<t t-set="can_access" t-value="aside_slide.is_preview or is_member or slide.channel_id.can_publish"/>
<t t-set="can_access" t-value="aside_slide.is_preview or can_access_channel"/>
<li class="p-0 pb-1">
<div t-att-class="'o_wslides_lesson_aside_list_link d-flex align-items-center p-1 %s%s' % (('bg-100 active' if aside_slide == slide else ''), 'text-muted' if not can_access else '')"
t-att-data-id="slide.id"
@@ -172,27 +173,25 @@
</div>
</a>
</div>
<ul t-if="aside_slide.slide_resource_ids or aside_slide.question_ids" class="list-group px-2 mb-1 list-unstyled">
<t t-foreach="aside_slide.slide_resource_ids" t-as="resource" t-if="resource.resource_type == 'url'">
<li class="pl-4">
<a t-if="can_access" t-att-href="resource.link" target="new" class="text-decoration-none small">
<i class="fa fa-link mr-1"/><span t-field="resource.name"/>
</a>
<span t-else="" class="text-decoration-none text-muted small">
<i class="fa fa-link mr-1"/><span t-field="resource.name"/>
</span>
</li>
</t>
<t t-set="resource_file_ids" t-value="aside_slide.slide_resource_ids.filtered(lambda res: res.resource_type == 'file')"/>
<div class="o_wslides_js_course_join pl-4" t-if="resource_file_ids">
<t t-if="is_member or aside_slide.channel_id.can_publish">
<li t-foreach="resource_file_ids" t-as="resource">
<ul t-if="aside_slide._has_additional_resources() or aside_slide.question_ids" class="list-group px-2 mb-1 list-unstyled">
<t t-if="can_access_channel">
<t t-foreach="aside_slide.slide_resource_ids" t-as="resource" t-if="resource.resource_type == 'url'">
<li class="pl-4">
<a t-if="can_access" t-att-href="resource.link" target="new" class="text-decoration-none small">
<i class="fa fa-link mr-1"/><span t-field="resource.name"/>
</a>
</li>
</t>
<div class="o_wslides_js_course_join pl-4" t-if="aside_slide._has_additional_resources()">
<li t-foreach="aside_slide.slide_resource_ids" t-as="resource" t-if="resource.resource_type == 'file'">
<a t-attf-href="/web/content/slide.slide.resource/#{resource.id}/data?download=true" class="text-decoration-none small">
<i class="fa fa-download mr-1"/><span t-field="resource.name"/>
</a>
</li>
</t>
<li t-elif="aside_slide.channel_id.enroll == 'public'" class="text-decoration-none small">
</div>
</t>
<div t-else="" class="o_wslides_js_course_join o_wslides_no_access">
<li t-if="aside_slide.channel_id.enroll == 'public'" class="text-decoration-none small">
<i class="fa fa-download mr-1"/>
<t t-call="website_slides.join_course_link"/>
</li>
@@ -430,31 +429,43 @@
</div>
</div>
<div class="row mt-3 mb-3">
<t t-set="links" t-value="slide.slide_resource_ids.filtered(lambda res: res.resource_type == 'url')"/>
<t t-set="files" t-value="slide.slide_resource_ids.filtered(lambda res: res.resource_type == 'file' and res.data)"/>
<div class="col-12 col-md d-flex align-items-start mb-4 mb-md-0" t-if="links">
<span class="text-muted font-weight-bold mr-3">External sources</span>
<div class="text-muted mr-auto border-left pl-3">
<t t-foreach="links" t-as="link">
<a t-att-href="link.link" t-esc="link.name"/><br />
</t>
</div>
</div>
<group>
<div class="col-12 col-md d-flex align-items-start mb-4 mb-md-0 o_wslides_js_course_join" t-if="files">
<span t-if="slide.channel_id.is_member or slide.channel_id.can_publish or slide.is_preview or slide.channel_id.enroll in ['private', 'payment']" class="text-muted font-weight-bold mr-3">
<t t-if="slide._has_additional_resources()">
<t t-set="can_access_channel" t-value="slide.channel_id.is_member or slide.channel_id.can_publish"/>
<t t-if="can_access_channel">
<t t-set="links" t-value="slide.slide_resource_ids.filtered(lambda res: res.resource_type == 'url')"/>
<div class="col-12 col-md d-flex align-items-start mb-4 mb-md-0" t-if="links">
<span class="text-muted font-weight-bold mr-3">External sources</span>
<div class="text-muted mr-auto border-left pl-3">
<t t-foreach="links" t-as="link">
<a t-att-href="link.link" t-esc="link.name"/><br />
</t>
</div>
</div>
<group>
<t t-set="files" t-value="slide.slide_resource_ids.filtered(lambda res: res.resource_type == 'file' and res.data)"/>
<div class="col-12 col-md d-flex align-items-start mb-4 mb-md-0 o_wslides_js_course_join" t-if="files">
<span class="text-muted font-weight-bold mr-3">
Additional Resources
</span>
<div class="text-muted mr-auto border-left pl-3">
<t t-foreach="slide.slide_resource_ids" t-as="resource" t-if="resource.resource_type == 'file' and resource.data">
<a t-attf-href="/web/content/slide.slide.resource/#{resource.id}/data?download=true" t-esc="resource.name"/><br />
</t>
</div>
</div>
</group>
</t>
<t t-else="">
<span t-if="slide.is_preview or slide.channel_id.enroll in ['private', 'payment']" class="text-muted font-weight-bold mr-3">
Additional Resources
</span>
<div t-if="slide.channel_id.is_member or slide.channel_id.can_publish" class="text-muted mr-auto border-left pl-3">
<t t-foreach="files" t-as="resource">
<a t-attf-href="/web/content/slide.slide.resource/#{resource.id}/data?download=true" t-esc="resource.name"/><br />
</t>
<div class="o_wslides_js_course_join o_wslides_no_access">
<div t-if="slide.channel_id.enroll == 'public'" class="text-muted mr-auto border-left pl-3">
<t t-call="website_slides.join_course_link"/>
</div>
</div>
<div t-elif="slide.channel_id.enroll == 'public'" class="text-muted mr-auto border-left pl-3">
<t t-call="website_slides.join_course_link"/>
</div>
</div>
</group>
</t>
</t>
</div>
</template>
@@ -101,10 +101,11 @@
<b t-field="category.name"/>
</a>
<ul class="o_wslides_fs_sidebar_section_slides collapse show position-relative px-0 pb-1 my-0 mx-n3" t-att-id="('collapse-%s') % (category.id if category else 0)">
<t t-set="is_member" t-value="current_slide.channel_id.is_member"/>
<t t-set="can_access_channel" t-value="is_member or current_slide.channel_id.can_publish"/>
<t t-foreach="slides" t-as="slide">
<t t-set="slide_completed" t-value="channel_progress[slide.id].get('completed')"/>
<t t-set="is_member" t-value="current_slide.channel_id.is_member"/>
<t t-set="can_access" t-value="slide.is_preview or is_member or current_slide.channel_id.can_publish"/>
<t t-set="can_access" t-value="can_access_channel or slide.is_preview"/>
<li t-attf-class="o_wslides_fs_sidebar_list_item d-flex align-items-center py-1 #{'active' if slide.id == current_slide.id else ''}"
t-att-data-id="slide.id"
t-att-data-can-access="can_access"
@@ -137,26 +138,25 @@
<div class="o_wslides_fs_slide_name text-600" t-esc="slide.name"/>
</div>
</span>
<ul class="list-unstyled w-100 pt-2 small" t-if="slide.slide_resource_ids or (slide.question_ids and not slide.slide_category =='quiz')" >
<t t-set="links" t-value="slide.slide_resource_ids.filtered(lambda res: res.resource_type == 'url')"/>
<li t-if="links" t-foreach="links" t-as="link" class="pl-0 mb-1">
<a t-if="can_access" class="o_wslides_fs_slide_link" t-att-href="link.link" target="_blank">
<i class="fa fa-link mr-2"/><span t-esc="link.name"/>
</a>
<span t-else="" class="o_wslides_fs_slide_link text-600">
<i class="fa fa-link mr-2"/><span t-esc="link.name"/>
</span>
</li>
<t t-set="resources" t-value="slide.slide_resource_ids.filtered(lambda res: res.resource_type == 'file')"/>
<div class="o_wslides_js_course_join pl-0" t-if="resources">
<t t-if="is_member or slide.channel_id.can_publish">
<ul class="list-unstyled w-100 pt-2 small" t-if="slide._has_additional_resources() or (slide.question_ids and not slide.slide_category =='quiz')" >
<t t-if="can_access_channel">
<t t-set="links" t-value="slide.slide_resource_ids.filtered(lambda res: res.resource_type == 'url')"/>
<li t-foreach="links" t-as="link" class="pl-0 mb-1">
<a t-if="can_access" class="o_wslides_fs_slide_link" t-att-href="link.link" target="_blank">
<i class="fa fa-link mr-2"/><span t-esc="link.name"/>
</a>
</li>
<div class="o_wslides_js_course_join pl-0" t-if="slide._has_additional_resources('file')">
<t t-set="resources" t-value="slide.slide_resource_ids.filtered(lambda res: res.resource_type == 'file')"/>
<li t-foreach="resources" t-as="resource" class="mb-1">
<a class="o_wslides_fs_slide_link" t-attf-href="/web/content/slide.slide.resource/#{resource.id}/data?download=true">
<i class="fa fa-download mr-2"/><span t-esc="resource.name"/>
</a>
</li>
</t>
<li t-elif="slide.channel_id.enroll == 'public'" class="o_wslides_fs_slide_link mb-1">
</div>
</t>
<div t-else="" class="o_wslides_js_course_join o_wslides_no_access pl-0">
<li t-if="slide.channel_id.enroll == 'public'" class="o_wslides_fs_slide_link mb-1">
<i class="fa fa-download mr-1"/>
<t t-call="website_slides.join_course_link"/>
</li>
@@ -92,8 +92,7 @@
<template id="join_course_link" name="Join Course Link">
<a class="o_wslides_js_course_join_link" href="#" t-att-data-channel-enroll="slide.channel_id.enroll"
t-att-data-channel-id="slide.channel_id.id">
Join Course
</a> to download resources
Join Course</a> to access resources
</template>
<!-- Python equivalent of the JS template "website.slides.sidebar.done.button" -->