1<?php
2// This file is part of Moodle - http://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
17/**
18 * Capability manager for the forum.
19 *
20 * @package    mod_forum
21 * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
22 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23 */
24
25namespace mod_forum\local\managers;
26
27defined('MOODLE_INTERNAL') || die();
28
29use mod_forum\local\data_mappers\legacy\forum as legacy_forum_data_mapper;
30use mod_forum\local\data_mappers\legacy\discussion as legacy_discussion_data_mapper;
31use mod_forum\local\data_mappers\legacy\post as legacy_post_data_mapper;
32use mod_forum\local\entities\discussion as discussion_entity;
33use mod_forum\local\entities\forum as forum_entity;
34use mod_forum\local\entities\post as post_entity;
35use mod_forum\subscriptions;
36use context;
37use context_system;
38use stdClass;
39use moodle_exception;
40
41require_once($CFG->dirroot . '/mod/forum/lib.php');
42
43/**
44 * Capability manager for the forum.
45 *
46 * Defines all the business rules for what a user can and can't do in the forum.
47 *
48 * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
49 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
50 */
51class capability {
52    /** @var legacy_forum_data_mapper $forumdatamapper Legacy forum data mapper */
53    private $forumdatamapper;
54    /** @var legacy_discussion_data_mapper $discussiondatamapper Legacy discussion data mapper */
55    private $discussiondatamapper;
56    /** @var legacy_post_data_mapper $postdatamapper Legacy post data mapper */
57    private $postdatamapper;
58    /** @var forum_entity $forum Forum entity */
59    private $forum;
60    /** @var stdClass $forumrecord Legacy forum record */
61    private $forumrecord;
62    /** @var context $context Module context for the forum */
63    private $context;
64    /** @var array $canviewpostcache Cache of discussion posts that can be viewed by a user. */
65    protected $canviewpostcache = [];
66
67    /**
68     * Constructor.
69     *
70     * @param forum_entity $forum The forum entity to manage capabilities for.
71     * @param legacy_forum_data_mapper $forumdatamapper Legacy forum data mapper
72     * @param legacy_discussion_data_mapper $discussiondatamapper Legacy discussion data mapper
73     * @param legacy_post_data_mapper $postdatamapper Legacy post data mapper
74     */
75    public function __construct(
76        forum_entity $forum,
77        legacy_forum_data_mapper $forumdatamapper,
78        legacy_discussion_data_mapper $discussiondatamapper,
79        legacy_post_data_mapper $postdatamapper
80    ) {
81        $this->forumdatamapper = $forumdatamapper;
82        $this->discussiondatamapper = $discussiondatamapper;
83        $this->postdatamapper = $postdatamapper;
84        $this->forum = $forum;
85        $this->forumrecord = $forumdatamapper->to_legacy_object($forum);
86        $this->context = $forum->get_context();
87    }
88
89    /**
90     * Can the user subscribe to this forum?
91     *
92     * @param stdClass $user The user to check
93     * @return bool
94     */
95    public function can_subscribe_to_forum(stdClass $user) : bool {
96        if ($this->forum->get_type() == 'single') {
97            return false;
98        }
99
100        return !is_guest($this->get_context(), $user) &&
101            subscriptions::is_subscribable($this->get_forum_record());
102    }
103
104    /**
105     * Can the user create discussions in this forum?
106     *
107     * @param stdClass $user The user to check
108     * @param int|null $groupid The current activity group id
109     * @return bool
110     */
111    public function can_create_discussions(stdClass $user, int $groupid = null) : bool {
112        if (isguestuser($user) or !isloggedin()) {
113            return false;
114        }
115
116        if ($this->forum->is_cutoff_date_reached()) {
117            if (!has_capability('mod/forum:canoverridecutoff', $this->get_context())) {
118                return false;
119            }
120        }
121
122        switch ($this->forum->get_type()) {
123            case 'news':
124                $capability = 'mod/forum:addnews';
125                break;
126            case 'qanda':
127                $capability = 'mod/forum:addquestion';
128                break;
129            default:
130                $capability = 'mod/forum:startdiscussion';
131        }
132
133        if (!has_capability($capability, $this->forum->get_context(), $user)) {
134            return false;
135        }
136
137        if ($this->forum->get_type() == 'eachuser') {
138            if (forum_user_has_posted_discussion($this->forum->get_id(), $user->id, $groupid)) {
139                return false;
140            }
141        }
142
143        if ($this->forum->is_in_group_mode()) {
144            return $groupid ? $this->can_access_group($user, $groupid) : $this->can_access_all_groups($user);
145        } else {
146            return true;
147        }
148    }
149
150    /**
151     * Can the user access all groups?
152     *
153     * @param stdClass $user The user to check
154     * @return bool
155     */
156    public function can_access_all_groups(stdClass $user) : bool {
157        return has_capability('moodle/site:accessallgroups', $this->get_context(), $user);
158    }
159
160    /**
161     * Can the user access the given group?
162     *
163     * @param stdClass $user The user to check
164     * @param int $groupid The id of the group that the forum is set to
165     * @return bool
166     */
167    public function can_access_group(stdClass $user, int $groupid) : bool {
168        if ($this->can_access_all_groups($user)) {
169            // This user has access to all groups.
170            return true;
171        }
172
173        // This is a group discussion for a forum in separate groups mode.
174        // Check if the user is a member.
175        // This is the most expensive check.
176        return groups_is_member($groupid, $user->id);
177    }
178
179    /**
180     * Can the user post to their groups?
181     *
182     * @param stdClass $user The user to check
183     * @return bool
184     */
185    public function can_post_to_my_groups(stdClass $user) : bool {
186        return has_capability('mod/forum:canposttomygroups', $this->get_context(), $user);
187    }
188
189    /**
190     * Can the user view discussions in this forum?
191     *
192     * @param stdClass $user The user to check
193     * @return bool
194     */
195    public function can_view_discussions(stdClass $user) : bool {
196        return has_capability('mod/forum:viewdiscussion', $this->get_context(), $user);
197    }
198
199    /**
200     * Can the user move discussions in this forum?
201     *
202     * @param stdClass $user The user to check
203     * @return bool
204     */
205    public function can_move_discussions(stdClass $user) : bool {
206        $forum = $this->get_forum();
207        return $forum->get_type() !== 'single' &&
208                has_capability('mod/forum:movediscussions', $this->get_context(), $user);
209    }
210
211    /**
212     * Can the user pin discussions in this forum?
213     *
214     * @param stdClass $user The user to check
215     * @return bool
216     */
217    public function can_pin_discussions(stdClass $user) : bool {
218        return $this->forum->get_type() !== 'single' &&
219                has_capability('mod/forum:pindiscussions', $this->get_context(), $user);
220    }
221
222    /**
223     * Can the user split discussions in this forum?
224     *
225     * @param stdClass $user The user to check
226     * @return bool
227     */
228    public function can_split_discussions(stdClass $user) : bool {
229        $forum = $this->get_forum();
230        return $forum->get_type() !== 'single' && has_capability('mod/forum:splitdiscussions', $this->get_context(), $user);
231    }
232
233    /**
234     * Can the user export (see portfolios) discussions in this forum?
235     *
236     * @param stdClass $user The user to check
237     * @return bool
238     */
239    public function can_export_discussions(stdClass $user) : bool {
240        global $CFG;
241        return $CFG->enableportfolios && has_capability('mod/forum:exportdiscussion', $this->get_context(), $user);
242    }
243
244    /**
245     * Can the user manually mark posts as read/unread in this forum?
246     *
247     * @param stdClass $user The user to check
248     * @return bool
249     */
250    public function can_manually_control_post_read_status(stdClass $user) : bool {
251        global $CFG;
252        return $CFG->forum_usermarksread && isloggedin() && forum_tp_is_tracked($this->get_forum_record(), $user);
253    }
254
255    /**
256     * Is the user required to post in the discussion before they can view it?
257     *
258     * @param stdClass $user The user to check
259     * @param discussion_entity $discussion The discussion to check
260     * @return bool
261     */
262    public function must_post_before_viewing_discussion(stdClass $user, discussion_entity $discussion) : bool {
263        $forum = $this->get_forum();
264
265        if ($forum->get_type() === 'qanda') {
266            // If it's a Q and A forum then the user must either have the capability to view without
267            // posting or the user must have posted before they can view the discussion.
268            return !has_capability('mod/forum:viewqandawithoutposting', $this->get_context(), $user) &&
269                !forum_user_has_posted($forum->get_id(), $discussion->get_id(), $user->id);
270        } else {
271            // No other forum types require posting before viewing.
272            return false;
273        }
274    }
275
276    /**
277     * Can the user subscribe to the give discussion?
278     *
279     * @param stdClass $user The user to check
280     * @param discussion_entity $discussion The discussion to check
281     * @return bool
282     */
283    public function can_subscribe_to_discussion(stdClass $user, discussion_entity $discussion) : bool {
284        return $this->can_subscribe_to_forum($user);
285    }
286
287    /**
288     * Can the user move the discussion in this forum?
289     *
290     * @param stdClass $user The user to check
291     * @param discussion_entity $discussion The discussion to check
292     * @return bool
293     */
294    public function can_move_discussion(stdClass $user, discussion_entity $discussion) : bool {
295        return $this->can_move_discussions($user);
296    }
297
298    /**
299     * Is the user pin the discussion?
300     *
301     * @param stdClass $user The user to check
302     * @param discussion_entity $discussion The discussion to check
303     * @return bool
304     */
305    public function can_pin_discussion(stdClass $user, discussion_entity $discussion) : bool {
306        return $this->can_pin_discussions($user);
307    }
308
309    /**
310     * Can the user post in this discussion?
311     *
312     * @param stdClass $user The user to check
313     * @param discussion_entity $discussion The discussion to check
314     * @return bool
315     */
316    public function can_post_in_discussion(stdClass $user, discussion_entity $discussion) : bool {
317        $forum = $this->get_forum();
318        $forumrecord = $this->get_forum_record();
319        $discussionrecord = $this->get_discussion_record($discussion);
320        $context = $this->get_context();
321        $coursemodule = $forum->get_course_module_record();
322        $course = $forum->get_course_record();
323
324        return forum_user_can_post($forumrecord, $discussionrecord, $user, $coursemodule, $course, $context);
325    }
326
327    /**
328     * Can the user favourite the discussion
329     *
330     * @param stdClass $user The user to check
331     * @return bool
332     */
333    public function can_favourite_discussion(stdClass $user) : bool {
334        $context = $this->get_context();
335        return has_capability('mod/forum:cantogglefavourite', $context, $user);
336    }
337
338    /**
339     * Can the user view the content of a discussion?
340     *
341     * @param stdClass $user The user to check
342     * @param discussion_entity $discussion The discussion to check
343     * @return bool
344     */
345    public function can_view_discussion(stdClass $user, discussion_entity $discussion) : bool {
346        $forumrecord = $this->get_forum_record();
347        $discussionrecord = $this->get_discussion_record($discussion);
348        $context = $this->get_context();
349
350        return forum_user_can_see_discussion($forumrecord, $discussionrecord, $context, $user);
351    }
352
353    /**
354     * Can the user view the content of the post in this discussion?
355     *
356     * @param stdClass $user The user to check
357     * @param discussion_entity $discussion The discussion to check
358     * @param post_entity $post The post the user wants to view
359     * @return bool
360     */
361    public function can_view_post(stdClass $user, discussion_entity $discussion, post_entity $post) : bool {
362        if (!$this->can_view_post_shell($user, $post)) {
363            return false;
364        }
365
366        // Return cached can view if possible.
367        if (isset($this->canviewpostcache[$user->id][$post->get_id()])) {
368            return $this->canviewpostcache[$user->id][$post->get_id()];
369        }
370
371        // Otherwise, check if the user can see this post.
372        $forum = $this->get_forum();
373        $forumrecord = $this->get_forum_record();
374        $discussionrecord = $this->get_discussion_record($discussion);
375        $postrecord = $this->get_post_record($post);
376        $coursemodule = $forum->get_course_module_record();
377        $canviewpost = forum_user_can_see_post($forumrecord, $discussionrecord, $postrecord, $user, $coursemodule, false);
378
379        // Then cache the result before returning.
380        $this->canviewpostcache[$user->id][$post->get_id()] = $canviewpost;
381
382        return $canviewpost;
383    }
384
385    /**
386     * Can the user view the post at all?
387     * In some situations the user can view the shell of a post without being able to view its content.
388     *
389     * @param   stdClass $user The user to check
390     * @param   post_entity $post The post the user wants to view
391     * @return  bool
392     *
393     */
394    public function can_view_post_shell(stdClass $user, post_entity $post) : bool {
395        if (!$post->is_private_reply()) {
396            return true;
397        }
398
399        if ($post->is_private_reply_intended_for_user($user)) {
400            return true;
401        }
402
403        return $this->can_view_any_private_reply($user);
404    }
405
406    /**
407     * Whether the user can view any private reply in the forum.
408     *
409     * @param   stdClass $user The user to check
410     * @return  bool
411     */
412    public function can_view_any_private_reply(stdClass $user) : bool {
413        return has_capability('mod/forum:readprivatereplies', $this->get_context(), $user);
414    }
415
416    /**
417     * Can the user edit the post in this discussion?
418     *
419     * @param stdClass $user The user to check
420     * @param discussion_entity $discussion The discussion to check
421     * @param post_entity $post The post the user wants to edit
422     * @return bool
423     */
424    public function can_edit_post(stdClass $user, discussion_entity $discussion, post_entity $post) : bool {
425        global $CFG;
426
427        $context = $this->get_context();
428        $ownpost = $post->is_owned_by_user($user);
429        $ineditingtime = $post->get_age() < $CFG->maxeditingtime;
430
431        switch ($this->forum->get_type()) {
432            case 'news':
433                // Allow editing of news posts once the discussion has started.
434                $ineditingtime = !$post->has_parent() && $discussion->has_started();
435                break;
436            case 'single':
437                if ($discussion->is_first_post($post)) {
438                    return has_capability('moodle/course:manageactivities', $context, $user);
439                }
440                break;
441        }
442
443        return ($ownpost && $ineditingtime) || has_capability('mod/forum:editanypost', $context, $user);
444    }
445
446    /**
447     * Verifies is the given user can delete a post.
448     *
449     * @param stdClass $user The user to check
450     * @param discussion_entity $discussion The discussion to check
451     * @param post_entity $post The post the user wants to delete
452     * @param bool $hasreplies Whether the post has replies
453     * @return bool
454     * @throws moodle_exception
455     */
456    public function validate_delete_post(stdClass $user, discussion_entity $discussion, post_entity $post,
457            bool $hasreplies = false) : void {
458        global $CFG;
459
460        $forum = $this->get_forum();
461
462        if ($forum->get_type() == 'single' && $discussion->is_first_post($post)) {
463            // Do not allow deleting of first post in single simple type.
464            throw new moodle_exception('cannotdeletepost', 'forum');
465        }
466
467        $context = $this->get_context();
468        $ownpost = $post->is_owned_by_user($user);
469        $ineditingtime = $post->get_age() < $CFG->maxeditingtime;
470
471        if (!($ownpost && $ineditingtime && has_capability('mod/forum:deleteownpost', $context, $user) ||
472                has_capability('mod/forum:deleteanypost', $context, $user))) {
473
474            throw new moodle_exception('cannotdeletepost', 'forum');
475        }
476
477        if ($post->get_total_score()) {
478            throw new moodle_exception('couldnotdeleteratings', 'rating');
479        }
480
481        if ($hasreplies && !has_capability('mod/forum:deleteanypost', $context, $user)) {
482            throw new moodle_exception('couldnotdeletereplies', 'forum');
483        }
484    }
485
486
487    /**
488     * Can the user delete the post in this discussion?
489     *
490     * @param stdClass $user The user to check
491     * @param discussion_entity $discussion The discussion to check
492     * @param post_entity $post The post the user wants to delete
493     * @param bool $hasreplies Whether the post has replies
494     * @return bool
495     */
496    public function can_delete_post(stdClass $user, discussion_entity $discussion, post_entity $post,
497            bool $hasreplies = false) : bool {
498
499        try {
500            $this->validate_delete_post($user, $discussion, $post, $hasreplies);
501            return true;
502        } catch (moodle_exception $e) {
503            return false;
504        }
505    }
506
507    /**
508     * Can the user split the post in this discussion?
509     *
510     * @param stdClass $user The user to check
511     * @param discussion_entity $discussion The discussion to check
512     * @param post_entity $post The post the user wants to split
513     * @return bool
514     */
515    public function can_split_post(stdClass $user, discussion_entity $discussion, post_entity $post) : bool {
516        if ($post->is_private_reply()) {
517            // It is not possible to create a private discussion.
518            return false;
519        }
520
521        return $this->can_split_discussions($user) && $post->has_parent();
522    }
523
524    /**
525     * Can the user reply to the post in this discussion?
526     *
527     * @param stdClass $user The user to check
528     * @param discussion_entity $discussion The discussion to check
529     * @param post_entity $post The post the user wants to reply to
530     * @return bool
531     */
532    public function can_reply_to_post(stdClass $user, discussion_entity $discussion, post_entity $post) : bool {
533        if ($post->is_private_reply()) {
534            // It is not possible to reply to a private reply.
535            return false;
536        } else if (!$this->can_view_post($user, $discussion, $post)) {
537            // If the user cannot view the post in the first place, the user should not be able to reply to the post.
538            return false;
539        }
540
541        return $this->can_post_in_discussion($user, $discussion);
542    }
543
544    /**
545     * Can the user reply privately to the specified post?
546     *
547     * @param stdClass $user The user to check
548     * @param post_entity $post The post the user wants to reply to
549     * @return bool
550     */
551    public function can_reply_privately_to_post(stdClass $user, post_entity $post) : bool {
552        if ($post->is_private_reply()) {
553            // You cannot reply privately to a post which is, itself, a private reply.
554            return false;
555        }
556
557        return has_capability('mod/forum:postprivatereply', $this->get_context(), $user);
558    }
559
560    /**
561     * Can the user export (see portfolios) the post in this discussion?
562     *
563     * @param stdClass $user The user to check
564     * @param post_entity $post The post the user wants to export
565     * @return bool
566     */
567    public function can_export_post(stdClass $user, post_entity $post) : bool {
568        global $CFG;
569        $context = $this->get_context();
570        return $CFG->enableportfolios  && (has_capability('mod/forum:exportpost', $context, $user) ||
571            ($post->is_owned_by_user($user) && has_capability('mod/forum:exportownpost', $context, $user)));
572    }
573
574    /**
575     * Get the forum entity for this capability manager.
576     *
577     * @return forum_entity
578     */
579    protected function get_forum() : forum_entity {
580        return $this->forum;
581    }
582
583    /**
584     * Get the legacy forum record for this forum.
585     *
586     * @return stdClass
587     */
588    protected function get_forum_record() : stdClass {
589        return $this->forumrecord;
590    }
591
592    /**
593     * Get the context for this capability manager.
594     *
595     * @return context
596     */
597    protected function get_context() : context {
598        return $this->context;
599    }
600
601    /**
602     * Get the legacy discussion record for the given discussion entity.
603     *
604     * @param discussion_entity $discussion The discussion to convert
605     * @return stdClass
606     */
607    protected function get_discussion_record(discussion_entity $discussion) : stdClass {
608        return $this->discussiondatamapper->to_legacy_object($discussion);
609    }
610
611    /**
612     * Get the legacy post record for the given post entity.
613     *
614     * @param post_entity $post The post to convert
615     * @return stdClass
616     */
617    protected function get_post_record(post_entity $post) : stdClass {
618        return $this->postdatamapper->to_legacy_object($post);
619    }
620
621    /**
622     * Can the user view the participants of this discussion?
623     *
624     * @param stdClass $user The user to check
625     * @param discussion_entity $discussion The discussion to check
626     * @return bool
627     */
628    public function can_view_participants(stdClass $user, discussion_entity $discussion) : bool {
629        return course_can_view_participants($this->get_context()) &&
630            !$this->must_post_before_viewing_discussion($user, $discussion);
631    }
632
633    /**
634     * Can the user view hidden posts in this forum?
635     *
636     * @param stdClass $user The user to check
637     * @return bool
638     */
639    public function can_view_hidden_posts(stdClass $user) : bool {
640        return has_capability('mod/forum:viewhiddentimedposts', $this->get_context(), $user);
641    }
642
643    /**
644     * Can the user manage this forum?
645     *
646     * @param stdClass $user The user to check
647     * @return bool
648     */
649    public function can_manage_forum(stdClass $user) {
650        return has_capability('moodle/course:manageactivities', $this->get_context(), $user);
651    }
652
653    /**
654     * Can the user manage tags on the site?
655     *
656     * @param stdClass $user The user to check
657     * @return bool
658     */
659    public function can_manage_tags(stdClass $user) : bool {
660        return has_capability('moodle/tag:manage', context_system::instance(), $user);
661    }
662
663    /**
664     * Checks whether the user can self enrol into the course.
665     * Mimics the checks on the add button in deprecatedlib/forum_print_latest_discussions
666     *
667     * @param stdClass $user
668     * @return bool
669     */
670    public function can_self_enrol(stdClass $user) : bool {
671        $canstart = false;
672
673        if ($this->forum->get_type() != 'news') {
674            if (isguestuser($user) or !isloggedin()) {
675                $canstart = true;
676            }
677
678            if (!is_enrolled($this->context) and !is_viewing($this->context)) {
679                 // Allow guests and not-logged-in to see the button - they are prompted to log in after clicking the link,
680                 // Normal users with temporary guest access see this button too, they are asked to enrol instead,
681                 // Do not show the button to users with suspended enrolments here.
682                $canstart = enrol_selfenrol_available($this->forum->get_course_id());
683            }
684        }
685
686        return $canstart;
687    }
688
689    /**
690     * Checks whether the user can export the whole forum (discussions and posts).
691     *
692     * @param stdClass $user The user object.
693     * @return bool True if the user can export the forum or false otherwise.
694     */
695    public function can_export_forum(stdClass $user) : bool {
696        return has_capability('mod/forum:exportforum', $this->get_context(), $user);
697    }
698
699    /**
700     * Check whether the supplied grader can grade the gradee.
701     *
702     * @param stdClass $grader The user grading
703     * @param stdClass $gradee The user being graded
704     * @return bool
705     */
706    public function can_grade(stdClass $grader, stdClass $gradee = null): bool {
707        if (!has_capability('mod/forum:grade', $this->get_context(), $grader)) {
708            return false;
709        }
710
711        return true;
712    }
713}
714