Files
odoo_source/addons/website_slides/tests/test_security.py
T
Noe Antoine 69277f846e [IMP] website_slides : add attendee status, improve invitations to course
RATIONALE

Improve and make easier the invitations on courses, whatever the enroll policy.
The goal is to increase the number of attendees easily

PREVIOUS BEHAVIOUR

Before this commit, the attendees of a course were always enrolled.
No distinction existed. Inviting partners meant enrolling them at once, even if
they did not want to join. Also, invitations to a 'members only'-visibility
course were a problem since the invited member could not reach the course page
if not logged in. Therefore the invitation link in invitation mail was often
not usable (403 error). The invitation link did not act as a user creation link
if the partner was not linked to any user

PURPOSE OF THIS COMMIT

After this commit, attendees to a course can either be enrolled (default behavior)
or have pending invitation, allowing to preview the course before joining it.
Also, the invitation links redirect the partner according to their (potential)
linked user, their log in status and the ACL's. All course completion and
invitation status are covered by the new member_status selection field for attendees:
- 'invited' : member with pending invitation
- 'joined' : enrolled member, not started
- 'ongoing' : enrolled member, started but never finished
- 'completed' : enrolled member who completed the course

A) INVITATIONS: ADD ATTENDEES and INVITE

The way to add and invite attendees to a course attendees in now centralized in
two options, the buttons for which being added on the course forms / kanban cards
dot menu. On the attendee list view coming from a course, only the [NEW] button
is shown (= ADD ATTENDEES). Using the buttons will open the invite wizard. (the
same one, the difference being the value of the new boolean enroll_mode)

In both cases, when sending a link to a given partner, it will contain both a hash
based on the couple (partner_id, channel_id) and the value of partner_id. This will
allow giving access to the invited partner even if not logged in and for all visibilities

    1) INVITE: copy the course link OR add partners as 'invited' + send them an email

    This new option is meant for promotional purposes. It will give a preview access
    to potential new members, but will not add them as enrolled. It will send an
    email to partners not already in the active attendees, to invite them to check
    the course

    Clicking the invitation link will lead them on the course page, where a [BANNER]
    will explicitely ask the invited partner to LOG IN or to SIGN UP depending on
    them having a user or not, and explain them that they must first be logged to
    access previews and join / buy the course. Before that, they can only see a
    [PREVIEW] of the course: browse the list of contents (i.e. categories are available),
    to allow them to see whether the course is of interest (forum / reviews are hidden
    too). If they are logged, or once they are logged, they will see the course as if
    it were public, no matter its visibility. (they can join - buy - browse previews)

        [LOG IN / SIGN UP] links (see /identify route): they will bypass the value of
        the 'Free Sign Up' setting and route will 1) check the invitation values
        (hash, partner_id), and that there exists a matching attendee for the current
        course. 2) prepare the signup / login. This prevents the invited partner from
        being locked outside of the course, not being able to join it (as not able to
        create user)

        ENROLL policies:
        -> 'on invite': inviting is considered as granting access. The partner will
            not have to ask for access again, but will be able to join in directly.
        -> 'on payment': users will NOT be able to join the course without buying it.
            The buy button on the course page will be replace by [LOG IN / SIGN UP].
            We do not want them to buy a course and potentially be locked out.
            (further work should deal with the generic case of this issue.)

    2) ADD ATTENDEES: add partners as 'joined' and send them an email

    This option is the same as what existed before: partners are added as enrolled
    attendees and invited by email. When they click on the link, they will be able
    to create an account if they do not have a user, or to log in if they have one,
    whatever the visibility, BEFORE being redirected to the course. They are enrolled,
    so they need to log in in order to use the course. 'invited' members will also be
    enrolled and upgraded to 'joined' and sent a joining email.

    3) About (archived) ATTENDEES and COMPLETION:

    (From task 2199207 on, attendees are now archived instead of deleted, in order to
    keep track of progression. Status and completion updates must be dealt with when
    using invitation / adding attendees)

    Now, [ONCE 'COMPLETED', ATTENDEES REMAIN SO], whatever slides they mark as
    uncompleted / completed or is created / archived on the course. Karma for
    finishing the course is won only once. test_attendee_course_completion_values
    is added. Even archived, we never recompute completion (remains 100) and status
    of 'completed' members. The same is true for 'invited' members, we do not make
    any update. This means that archived 'invited' members can have positive completion.

    In addition to the obvious (recompute status and completion when completing a slide
    as 'joined' or 'ongoing'), we only recompute member_status and completion when
    enrolling an attendee as 'joined', in the method _action_add_members with
    member_status = 'joined'. We explicitely recompute the values then. This happens
    on joining (possibly from an 'invited' state), on adding members as 'joined', or
    when unarchiving a record at least 'joined'.

        3.1) Archived with progress:

        When inviting an archived member with progress, we set their member_status
        to 'invited', and unarchive them. It means that completion could be > 0 for
        active (or inactive) 'invited' members. When enrolling them, we set them to
        'joined' but we need to recompute their completion to update the completion
        value (there may have been changes in the contents) and their member_status
        accordingly.

        3.2) Archived as 'invited' (no progress):

        When inviting them, we simply unarchive them. When enrolling them, they are
	unarchived and set to 'joined', status and completion being then recomputed.

	3.3) A complex and full example.

	Attendee A completed the course C, having 4 contents. member_status is
	'completed' and completion = 100. Then A leaves the course and is archived.
	Later, C gets 1 more content. The member_status and completion do not change.
        A is invited. Its member_status is now 'invited', and completion 100. 3 contents
	are archived. Again, values do not change. A clicks the link and enrolls. It
	is added as 'joined' and _recompute_completion is called. completion is set
	to 50% and status to 'ongoing'. As the completion was 100 but is not anymore,
        the karma for completing the course is lost. They see the new slide and are
	set to completion = 100 and 'completed', winning the course karma back.

B) ACCESS RIGHTS UPDATES

    1) PREVIEW:
    In order to give access to 'members only' courses even without logging in, we
    use the url parameters invite_hash and partner_id. They can access as sudo the
    course but _can_publish and _can_upload stay False. Categories can also be clicked.
    No slides are accessible, only the course page, checking the access values each time
    the channel route changes. See _get_channel_values_from_invite for all the checks on
    the direct invitation parameters invite_partner_id and invite_hash)

    The breadcrumbs and routes are updated to use channel_id instead of slug (since it
    would lead to a 403 error) The course_id routes should only be used in the context of
    an invitation. (generic or direct). Also, the main channel route now checks the access
    rights to the course and redirect to /slides if the access is not granted, useful for
    the generic invitation.

    2) ACLS
    - Slides: invited members to 'members only' courses now have the same access as
    anyone for public courses: previews and categories, once logged in.
    - Course: invited members have access to the course
    - Self-enroll: 'invited' member can self-enroll to 'on invite' courses, this is done
    with a sudo on the /join route.

    3) About ARCHIVED ATTENDEES. [FIX] (tests included)

    (*) In task 2199207, the ACL's were not updated to prevent archived members to have the
    same rights as if they were active. This is because the active value is not tested by
    default in rules. It is done by changing partner_ids into a computed field search method.

C) MODELS

    1) SLIDE.CHANNEL.PARTNER
    - new: invitation_link computed field. It generates invite_hash with course and
    partner_id and contains invite_partner_id as well, used for verifications.
    - recompute_completion will always recompute the completion %. However, member_status
    will only be updated if not currently 'invited' and currently active. One should write
    'joined' on attendees before in order to see the status of an 'invited' member updated.

    2) SLIDE.CHANNEL
    - channel_partner_ids / partner_ids keep the meaning of enrolled attendees/partners.
    partner_ids is now replaced with a compute field, and channel_partner_ids has a
    domain on member_status. search method is implemented (*)
    - new: channel_partner_all_ids / partner_all_ids also includes invited attendees /
    partners. partner_all_ids is also a computed field. search is implemented (*)
    - new: is_member / is_member_invited are computed fields to indicate the current
    user's membership status to the course

    - _action_add_member is removed and _action_add_members now centralizes the logic of
    adding an attendee. It will now return all NEW ACTIVE MEMBERS for the given status.
    The ones unarchived, the ones created, and the ones enrolling from 'invited' state
    for parameter = 'joined'. Therefore, reinvitation of 'invited' members in dealt with
    in action_invite in slide.channel.invite model as they will not be returned.

    3) RES.PARTNER
    As a rule of thumb, the fields and display are the same as before. They cover the
    courses partner is enrolled to. Changes are done to ease the search on partners:
    - slide_channel_ids keeps the same meaning: the courses the partner is enrolled to.
    It is changed to a compute field since we do not want to consider 'invited'
    members. search method is implemented
    - new slide_channel_all_ids contains all the courses: the ones the partner is
    invited to or enrolled in
    - Most compute methods are centralized in a single method and read_group is used.

D) VIEWS AND OTHER MAIN CHANGES

INVITE WIZARD
    - As a course can be only shared via its generic link, the invite wizard now has
    a [TOGGLE] 'send_email' that is visible for public courses and allow to either
    copy and share the generic link, or, if toggled, show and use the email composer.
    - if course is not published, a warning alert message is shown at the top. In
    order to have a clean UI, the form is restructured using a sheet
    - The course field is now hidden and is directly shown in the title of the wizard

ATTENDEE LIST VIEW
    - [NEW] button when coming from a course (i.e. not for reporting), acting as the
      [ADD ATTENDEES] button
    - new columns

OTHER VIEWS
    - Pivot and graph reporting views are added. A default member_status groupby too,
    on all reporting views. The % of completed slides is not used as measure on pivot
    view. However, it is on the graph view to compare completion of different members
    easily. Avg is used as an operator, as sum of percentages does not mean much here
    - Attendees kanban view update and new filters / group by's
    - Quicksearch 'Tags' and 'Responsible' on slide.channel model

MISC
    - Use 'course' instead of 'channel' in readable labels
    - Use 'attendee' instead of 'member' in readable labels
    - Error mgmt: clicking the invitation link may lead to an error, as well as
    accessing a course without the rights. The user will be redirected to the main
    /slides page with the appropriate error message
    - Add a new template similar to the one used to join a course, but for the
    invitation action
    - New template for the popup appearing when joining a course. (Login or Signup)
    - Use fstrings and t-attf when possible
    - Use native js instead of jquery
    - New placeholder if no contents on course page in the front-end
    - Markup is used when possible

E) Invitation Expiration

As the invitation could be used as a promotion tool, there may be a lot of records
created as 'invited'. In order to monitor that number, we use a garbage collector.
It will remove attendees as 'invited', active or not, with completion = 0 and invited
for the last time at least THREE MONTHS before (at least invited once). Also, an
invitation older than 3 months will become expired and will not grant access to
'invited' members.

In order to track the invitation dates, a new field last_invitation_date is added to
the slide.channel.partner model. Every time one invites an attendee, it is set to the
current date. One can reinvite attendees and send them an email more than once. This
may prevent the invitation to be collected by the GC. If not set, last_invitation_date
is considered as expired.

F) TESTS and TOURS

Extensive tests and tours are added for the different new flows coming from
this new distinction between joined and invited members, and invitation flows.
They test functionality, UI, security (access rights) and model correctness.

--- Links ---
Task-2508019
COM PR - odoo/odoo#70291
UPG PR - odoo/upgrade#2572

Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
2023-05-04 19:09:49 +02:00

531 lines
24 KiB
Python

# -*- 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
from odoo.tools import mute_logger
@tagged('security')
class TestAccess(common.SlidesCase):
@mute_logger('odoo.models', 'odoo.addons.base.models.ir_rule')
def test_access_channel_invite(self):
""" Invite channels don't give enroll if not member """
self.channel.write({'enroll': 'invite'})
self.channel.with_user(self.user_officer).read(['name'])
self.channel.with_user(self.user_manager).read(['name'])
self.channel.with_user(self.user_emp).read(['name'])
self.channel.with_user(self.user_portal).read(['name'])
self.channel.with_user(self.user_public).read(['name'])
self.slide.with_user(self.user_officer).read(['name'])
self.slide.with_user(self.user_manager).read(['name'])
with self.assertRaises(AccessError):
self.slide.with_user(self.user_emp).read(['name'])
with self.assertRaises(AccessError):
self.slide.with_user(self.user_portal).read(['name'])
with self.assertRaises(AccessError):
self.slide.with_user(self.user_portal).read(['name'])
# if member -> can read
membership = self.env['slide.channel.partner'].create({
'channel_id': self.channel.id,
'partner_id': self.user_emp.partner_id.id,
})
self.channel.with_user(self.user_emp).read(['name'])
self.slide.with_user(self.user_emp).read(['name'])
# not member anymore -> cannot read
membership.action_archive()
self.channel.with_user(self.user_emp).read(['name'])
with self.assertRaises(AccessError):
self.slide.with_user(self.user_emp).read(['name'])
# re-activate member -> can read again
membership.action_unarchive()
self.channel.with_user(self.user_emp).read(['name'])
self.slide.with_user(self.user_emp).read(['name'])
# unlink membership -> cannot read
membership.unlink()
self.channel.with_user(self.user_emp).read(['name'])
with self.assertRaises(AccessError):
self.slide.with_user(self.user_emp).read(['name'])
@mute_logger('odoo.models', 'odoo.addons.base.models.ir_rule')
def test_access_channel_public(self):
""" Public channels don't give enroll if not member """
self.channel.write({'enroll': 'public'})
self.channel.with_user(self.user_officer).read(['name'])
self.channel.with_user(self.user_manager).read(['name'])
self.channel.with_user(self.user_emp).read(['name'])
self.channel.with_user(self.user_portal).read(['name'])
self.channel.with_user(self.user_public).read(['name'])
self.slide.with_user(self.user_officer).read(['name'])
self.slide.with_user(self.user_manager).read(['name'])
with self.assertRaises(AccessError):
self.slide.with_user(self.user_emp).read(['name'])
with self.assertRaises(AccessError):
self.slide.with_user(self.user_portal).read(['name'])
with self.assertRaises(AccessError):
self.slide.with_user(self.user_public).read(['name'])
@mute_logger('odoo.models', 'odoo.addons.base.models.ir_rule')
def test_access_channel_publish(self):
""" Unpublished channels and their content are visible only to eLearning people """
self.channel.write({'is_published': False, 'enroll': 'public'})
self.channel.flush_model()
# channel available only to eLearning
self.channel.invalidate_model(['name'])
self.channel.with_user(self.user_officer).read(['name'])
self.channel.invalidate_model(['name'])
self.channel.with_user(self.user_manager).read(['name'])
with self.assertRaises(AccessError):
self.channel.invalidate_model(['name'])
self.channel.with_user(self.user_emp).read(['name'])
with self.assertRaises(AccessError):
self.channel.invalidate_model(['name'])
self.channel.with_user(self.user_portal).read(['name'])
with self.assertRaises(AccessError):
self.channel.invalidate_model(['name'])
self.channel.with_user(self.user_public).read(['name'])
# slide available only to eLearning
self.channel.invalidate_model(['name'])
self.slide.with_user(self.user_officer).read(['name'])
self.channel.invalidate_model(['name'])
self.slide.with_user(self.user_manager).read(['name'])
with self.assertRaises(AccessError):
self.slide.invalidate_model(['name'])
self.slide.with_user(self.user_emp).read(['name'])
with self.assertRaises(AccessError):
self.slide.invalidate_model(['name'])
self.slide.with_user(self.user_portal).read(['name'])
with self.assertRaises(AccessError):
self.slide.invalidate_model(['name'])
self.slide.with_user(self.user_public).read(['name'])
# even members cannot see unpublished content
self.env['slide.channel.partner'].create({
'channel_id': self.channel.id,
'partner_id': self.user_emp.partner_id.id,
})
with self.assertRaises(AccessError):
self.channel.invalidate_model(['name'])
self.channel.with_user(self.user_emp).read(['name'])
with self.assertRaises(AccessError):
self.slide.invalidate_model(['name'])
self.slide.with_user(self.user_emp).read(['name'])
# publish channel but content unpublished (even if can be previewed) still unavailable
self.channel.write({'is_published': True})
self.slide.write({
'is_preview': True,
'is_published': False,
})
self.channel.flush_model()
self.slide.flush_model()
self.slide.invalidate_model(['name'])
self.slide.with_user(self.user_officer).read(['name'])
self.slide.invalidate_model(['name'])
self.slide.with_user(self.user_manager).read(['name'])
with self.assertRaises(AccessError):
self.slide.invalidate_model(['name'])
self.slide.with_user(self.user_emp).read(['name'])
with self.assertRaises(AccessError):
self.slide.invalidate_model(['name'])
self.slide.with_user(self.user_portal).read(['name'])
with self.assertRaises(AccessError):
self.slide.invalidate_model(['name'])
self.slide.with_user(self.user_public).read(['name'])
@mute_logger('odoo.models', 'odoo.addons.base.models.ir_rule')
def test_access_slide_preview(self):
""" Slides with preview flag are always visible even to non members if published """
self.channel.write({'enroll': 'invite'})
self.slide.write({'is_preview': True})
self.slide.flush_model()
self.slide.with_user(self.user_officer).read(['name'])
self.slide.with_user(self.user_manager).read(['name'])
self.slide.with_user(self.user_emp).read(['name'])
self.slide.with_user(self.user_portal).read(['name'])
self.slide.with_user(self.user_public).read(['name'])
@mute_logger('odoo.models', 'odoo.addons.base.models.ir_rule')
def test_access_channel_visibility_public(self):
self.channel.write({'visibility': 'public'})
self.slide.write({'is_preview': True})
self.slide.flush_model()
self.channel.with_user(self.user_officer).read(['name'])
self.channel.with_user(self.user_manager).read(['name'])
self.channel.with_user(self.user_emp).read(['name'])
self.channel.with_user(self.user_portal).read(['name'])
self.channel.with_user(self.user_public).read(['name'])
self.slide.with_user(self.user_officer).read(['name'])
self.slide.with_user(self.user_manager).read(['name'])
self.slide.with_user(self.user_emp).read(['name'])
self.slide.with_user(self.user_portal).read(['name'])
self.slide.with_user(self.user_public).read(['name'])
@mute_logger('odoo.models', 'odoo.addons.base.models.ir_rule')
def test_access_channel_public_with_website_published(self):
self.channel.write({'visibility': 'public', 'website_published': False})
self.channel.with_user(self.user_officer).read(['name'])
self.channel.with_user(self.user_manager).read(['name'])
with self.assertRaises(AccessError):
self.channel.with_user(self.user_emp).read(['name'])
with self.assertRaises(AccessError):
self.channel.with_user(self.user_portal).read(['name'])
with self.assertRaises(AccessError):
self.channel.with_user(self.user_public).read(['name'])
self.slide.with_user(self.user_officer).read(['name'])
self.slide.with_user(self.user_manager).read(['name'])
with self.assertRaises(AccessError):
self.slide.with_user(self.user_emp).read(['name'])
with self.assertRaises(AccessError):
self.slide.with_user(self.user_portal).read(['name'])
with self.assertRaises(AccessError):
self.slide.with_user(self.user_public).read(['name'])
@mute_logger('odoo.models', 'odoo.addons.base.models.ir_rule')
def test_access_channel_visibility_members(self):
self.channel.write({'visibility': 'members'})
self.channel.flush_model()
user_emp_membership = self.env['slide.channel.partner'].create({
'channel_id': self.channel.id,
'partner_id': self.user_emp.partner_id.id,
})
self.channel.with_user(self.user_emp).read(['name'])
with self.assertRaises(AccessError):
self.channel.with_user(self.user_portal).read(['name'])
user_emp_membership.action_archive()
with self.assertRaises(AccessError):
self.channel.with_user(self.user_emp).read(['name'])
with self.assertRaises(AccessError):
self.slide.with_user(self.user_emp).read(['name'])
user_emp_membership.unlink()
with self.assertRaises(AccessError):
self.channel.with_user(self.user_emp).read(['name'])
@mute_logger('odoo.models', 'odoo.addons.base.models.ir_rule')
def test_access_channel_visiblilty_members_as_invited(self):
self.channel.visibility = 'members'
self.channel.flush_recordset()
with self.assertRaises(AccessError):
self.channel.with_user(self.user_portal).read(['name'])
user_portal_membership = self.env['slide.channel.partner'].create({
'channel_id': self.channel.id,
'partner_id': self.user_portal.partner_id.id,
'member_status': 'invited'
})
self.channel.with_user(self.user_portal).read(['name'])
user_portal_membership.action_archive()
with self.assertRaises(AccessError):
self.channel.with_user(self.user_portal).read(['name'])
@mute_logger('odoo.models', 'odoo.addons.base.models.ir_rule')
def test_access_channel_members_with_website_published(self):
self.channel.write({'visibility': 'members', 'website_published': False})
self.channel.flush_model()
with self.assertRaises(AccessError):
self.channel.with_user(self.user_emp).read(['name'])
with self.assertRaises(AccessError):
self.channel.with_user(self.user_portal).read(['name'])
@mute_logger('odoo.models', 'odoo.addons.base.models.ir_rule')
def test_access_channel_visibility_connected(self):
self.channel.write({'visibility': 'connected'})
self.channel.with_user(self.user_officer).read(['name'])
self.channel.with_user(self.user_manager).read(['name'])
self.channel.with_user(self.user_emp).read(['name'])
self.channel.with_user(self.user_portal).read(['name'])
with self.assertRaises(AccessError):
self.channel.with_user(self.user_public).read(['name'])
self.slide.with_user(self.user_officer).read(['name'])
self.slide.with_user(self.user_manager).read(['name'])
with self.assertRaises(AccessError):
self.slide.with_user(self.user_emp).read(['name'])
with self.assertRaises(AccessError):
self.slide.with_user(self.user_portal).read(['name'])
with self.assertRaises(AccessError):
self.slide.with_user(self.user_public).read(['name'])
@mute_logger('odoo.models', 'odoo.addons.base.models.ir_rule')
def test_access_channel_visiblilty_connected_as_invited(self):
self.channel.visibility = 'connected'
self.channel.flush_recordset()
self.env['slide.channel.partner'].create({
'channel_id': self.channel.id,
'partner_id': self.user_emp.partner_id.id,
'member_status': 'invited'
})
self.channel.with_user(self.user_emp).read(['name'])
@mute_logger('odoo.models', 'odoo.addons.base.models.ir_rule')
def test_access_slide_slide_as_invited(self):
""" Check that preview slides are visible to logged invited attendees, but not others, nor non published ones."""
self.env['slide.channel.partner'].create({
'channel_id': self.channel.id,
'partner_id': self.user_portal.partner_id.id,
'member_status': 'invited'
})
with self.assertRaises(AccessError):
self.slide.with_user(self.user_portal).read(['name'])
self.slide.is_preview = True
self.slide.with_user(self.user_portal).read(['name'])
self.channel.visibility = 'connected'
self.channel.flush_recordset()
self.slide.with_user(self.user_portal).read(['name'])
self.channel.visibility = 'members'
self.channel.flush_recordset()
self.slide.with_user(self.user_portal).read(['name'])
self.slide.is_published = False
self.slide.flush_recordset(['is_published'])
with self.assertRaises(AccessError):
self.slide.with_user(self.user_portal).read(['name'])
@tagged('functional', 'security')
class TestRemoveMembership(common.SlidesCase):
def setUp(self):
super(TestRemoveMembership, self).setUp()
self.channel_partner = self.env['slide.channel.partner'].create({
'channel_id': self.channel.id,
'partner_id': self.customer.id,
})
self.slide_partner = self.env['slide.slide.partner'].create({
'slide_id': self.slide.id,
'channel_id': self.channel.id,
'partner_id': self.customer.id
})
def test_security_unlink(self):
# Only the publisher can unlink channel_partner (and slide_partner by extension)
with self.assertRaises(AccessError):
self.channel_partner.with_user(self.user_public).unlink()
with self.assertRaises(AccessError):
self.channel_partner.with_user(self.user_portal).unlink()
with self.assertRaises(AccessError):
self.channel_partner.with_user(self.user_emp).unlink()
def test_slide_partner_remove(self):
id_slide_partner = self.slide_partner.id
id_channel_partner = self.channel_partner.id
self.channel_partner.with_user(self.user_officer).unlink()
self.assertFalse(self.env['slide.channel.partner'].search([('id', '=', '%d' % id_channel_partner)]))
# Slide(s) related to the channel and the partner is unlink too.
self.assertFalse(self.env['slide.slide.partner'].search([('id', '=', '%d' % id_slide_partner)]))
@tagged('functional')
class TestAccessFeatures(common.SlidesCase):
@mute_logger('odoo.models', 'odoo.addons.base.models.ir_rule')
def test_channel_auto_subscription(self):
user_employees = self.env['res.users'].search([('groups_id', 'in', self.ref('base.group_user'))])
channel = self.env['slide.channel'].with_user(self.user_officer).create({
'name': 'Test',
'enroll': 'invite',
'is_published': True,
'enroll_group_ids': [(4, self.ref('base.group_user'))]
})
channel.invalidate_model(['partner_ids'])
self.assertEqual(channel.partner_ids, user_employees.mapped('partner_id'))
new_user = self.env['res.users'].create({
'name': 'NewUser',
'login': 'NewUser',
'groups_id': [(6, 0, [self.ref('base.group_user')])]
})
channel.invalidate_model()
self.assertEqual(channel.partner_ids, user_employees.mapped('partner_id') | new_user.partner_id)
new_user_2 = self.env['res.users'].create({
'name': 'NewUser2',
'login': 'NewUser2',
'groups_id': [(5, 0)]
})
channel.invalidate_model()
self.assertEqual(channel.partner_ids, user_employees.mapped('partner_id') | new_user.partner_id)
new_user_2.write({'groups_id': [(4, self.ref('base.group_user'))]})
channel.invalidate_model()
self.assertEqual(channel.partner_ids, user_employees.mapped('partner_id') | new_user.partner_id | new_user_2.partner_id)
new_user_3 = self.env['res.users'].create({
'name': 'NewUser3',
'login': 'NewUser3',
'groups_id': [(5, 0)]
})
channel.invalidate_model()
self.assertEqual(channel.partner_ids, user_employees.mapped('partner_id') | new_user.partner_id | new_user_2.partner_id)
self.env.ref('base.group_user').write({'users': [(4, new_user_3.id)]})
channel.invalidate_model()
self.assertEqual(channel.partner_ids, user_employees.mapped('partner_id') | new_user.partner_id | new_user_2.partner_id | new_user_3.partner_id)
@mute_logger('odoo.models', 'odoo.addons.base.models.ir_rule')
def test_channel_access_fields_employee(self):
channel_manager = self.channel.with_user(self.user_manager)
channel_emp = self.channel.with_user(self.user_emp)
channel_portal = self.channel.with_user(self.user_portal)
self.assertFalse(channel_emp.can_upload)
self.assertFalse(channel_emp.can_publish)
self.assertFalse(channel_portal.can_upload)
self.assertFalse(channel_portal.can_publish)
# allow employees to upload
channel_manager.write({'upload_group_ids': [(4, self.ref('base.group_user'))]})
self.assertTrue(channel_emp.can_upload)
self.assertFalse(channel_emp.can_publish)
self.assertFalse(channel_portal.can_upload)
self.assertFalse(channel_portal.can_publish)
@mute_logger('odoo.models', 'odoo.addons.base.models.ir_rule')
def test_channel_access_fields_officer(self):
self.assertEqual(self.channel.user_id, self.user_officer)
channel_officer = self.channel.with_user(self.user_officer)
self.assertTrue(channel_officer.can_upload)
self.assertTrue(channel_officer.can_publish)
channel_officer.write({'upload_group_ids': [(4, self.ref('base.group_system'))]})
self.assertTrue(channel_officer.can_upload)
self.assertTrue(channel_officer.can_publish)
channel_manager = self.channel.with_user(self.user_manager)
channel_manager.write({
'upload_group_ids': [(5, 0)],
'user_id': self.user_manager.id
})
self.assertFalse(channel_officer.can_upload)
self.assertFalse(channel_officer.can_publish)
self.assertTrue(channel_manager.can_upload)
self.assertTrue(channel_manager.can_publish)
@mute_logger('odoo.models', 'odoo.addons.base.models.ir_rule')
def test_channel_access_fields_manager(self):
channel_manager = self.channel.with_user(self.user_manager)
self.assertTrue(channel_manager.can_upload)
self.assertTrue(channel_manager.can_publish)
# test upload group limitation: member of group_system OR responsible OR manager
channel_manager.write({'upload_group_ids': [(4, self.ref('base.group_system'))]})
self.assertFalse(channel_manager.can_upload)
self.assertFalse(channel_manager.can_publish)
channel_manager.write({'user_id': self.user_manager.id})
self.assertTrue(channel_manager.can_upload)
self.assertTrue(channel_manager.can_publish)
# Needs the manager to write on channel as user_officer is not the responsible anymore
channel_manager.write({'upload_group_ids': [(5, 0)]})
self.assertTrue(channel_manager.can_upload)
self.assertTrue(channel_manager.can_publish)
channel_manager.write({'user_id': self.user_officer.id})
self.assertTrue(channel_manager.can_upload)
self.assertTrue(channel_manager.can_publish)
# superuser should always be able to publish even if they are not the responsible
channel_superuser = self.channel.sudo()
channel_superuser.invalidate_recordset(['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)