Completed
Push — master ( 4a6357...521c8c )
by Franco
10s
created

code/extensions/SiteTreeContentReview.php (1 issue)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
3
/**
4
 * Set dates at which content needs to be reviewed and provide a report and emails to alert
5
 * to content needing review.
6
 *
7
 * @property string $ContentReviewType
8
 * @property int    $ReviewPeriodDays
9
 * @property Date   $NextReviewDate
10
 * @property string $LastEditedByName
11
 * @property string $OwnerNames
12
 *
13
 * @method DataList ReviewLogs()
14
 * @method DataList ContentReviewGroups()
15
 * @method DataList ContentReviewUsers()
16
 */
17
class SiteTreeContentReview extends DataExtension implements PermissionProvider
18
{
19
    /**
20
     * @var array
21
     */
22
    private static $db = array(
23
        "ContentReviewType" => "Enum('Inherit, Disabled, Custom', 'Inherit')",
24
        "ReviewPeriodDays"  => "Int",
25
        "NextReviewDate"    => "Date",
26
        "LastEditedByName"  => "Varchar(255)",
27
        "OwnerNames"        => "Varchar(255)",
28
    );
29
30
    /**
31
     * @var array
32
     */
33
    private static $defaults = array(
34
        "ContentReviewType" => "Inherit",
35
    );
36
37
    /**
38
     * @var array
39
     */
40
    private static $has_many = array(
41
        "ReviewLogs" => "ContentReviewLog",
42
    );
43
44
    /**
45
     * @var array
46
     */
47
    private static $belongs_many_many = array(
48
        "ContentReviewGroups" => "Group",
49
        "ContentReviewUsers"  => "Member",
50
    );
51
52
    /**
53
     * @var array
54
     */
55
    private static $schedule = array(
56
        0   => "No automatic review date",
57
        1   => "1 day",
58
        7   => "1 week",
59
        30  => "1 month",
60
        60  => "2 months",
61
        91  => "3 months",
62
        121 => "4 months",
63
        152 => "5 months",
64
        183 => "6 months",
65
        365 => "12 months",
66
    );
67
68
    /**
69
     * @return array
70
     */
71
    public static function get_schedule()
72
    {
73
        return self::$schedule;
74
    }
75
76
    /**
77
     * Takes a list of groups and members and return a list of unique member.
78
     *
79
     * @param SS_List $groups
80
     * @param SS_List $members
81
     *
82
     * @return ArrayList
83
     */
84
    public static function merge_owners(SS_List $groups, SS_List $members)
85
    {
86
        $contentReviewOwners = new ArrayList();
87
88
        if ($groups->count()) {
89
            $groupIDs = array();
90
91
            foreach ($groups as $group) {
92
                $familyIDs = $group->collateFamilyIDs();
93
94
                if (is_array($familyIDs)) {
95
                    $groupIDs = array_merge($groupIDs, array_values($familyIDs));
96
                }
97
            }
98
99
            array_unique($groupIDs);
100
101
            if (count($groupIDs)) {
102
                $groupMembers = DataObject::get("Member")->where("\"Group\".\"ID\" IN (" . implode(",", $groupIDs) . ")")
103
                    ->leftJoin("Group_Members", "\"Member\".\"ID\" = \"Group_Members\".\"MemberID\"")
104
                    ->leftJoin("Group", "\"Group_Members\".\"GroupID\" = \"Group\".\"ID\"");
105
106
                $contentReviewOwners->merge($groupMembers);
107
            }
108
        }
109
110
        $contentReviewOwners->merge($members);
111
        $contentReviewOwners->removeDuplicates();
112
113
        return $contentReviewOwners;
114
    }
115
116
    /**
117
     * @param FieldList $actions
118
     */
119
    public function updateCMSActions(FieldList $actions)
120
    {
121
        if ($this->canBeReviewedBy(Member::currentUser())) {
122
            Requirements::css("contentreview/css/contentreview.css");
123
124
            $reviewTitle = LiteralField::create(
125
                "ReviewContentNotesLabel",
126
                "<label class=\"left\" for=\"Form_EditForm_ReviewNotes\">" . _t("ContentReview.CONTENTREVIEW", "Content due for review") . "</label>"
127
            );
128
129
            $ReviewNotes = LiteralField::create("ReviewNotes", "<textarea class=\"no-change-track\" id=\"Form_EditForm_ReviewNotes\" name=\"ReviewNotes\" placeholder=\"" . _t("ContentReview.COMMENTS", "(optional) Add comments...") . "\" class=\"text\"></textarea>");
130
131
            $quickReviewAction = FormAction::create("savereview", _t("ContentReview.MARKREVIEWED", "Mark as reviewed"))
132
                ->setAttribute("data-icon", "pencil")
133
                ->setAttribute("data-text-alternate", _t("ContentReview.MARKREVIEWED", "Mark as reviewed"));
134
135
            $allFields = CompositeField::create($reviewTitle, $ReviewNotes, $quickReviewAction)
136
                ->addExtraClass('review-notes field');
137
138
            $reviewTab = Tab::create('ReviewContent', $allFields);
139
            $reviewTab->addExtraClass('contentreview-tab');
140
141
            $actions->fieldByName('ActionMenus')->insertBefore($reviewTab, 'MoreOptions');
142
        }
143
    }
144
145
    /**
146
     * Returns false if the content review have disabled.
147
     *
148
     * @param SiteTree $page
149
     *
150
     * @return bool|Date
151
     */
152
    public function getReviewDate(SiteTree $page = null)
153
    {
154
        if ($page === null) {
155
            $page = $this->owner;
156
        }
157
158
        if ($page->obj("NextReviewDate")->exists()) {
159
            return $page->obj("NextReviewDate");
160
        }
161
162
        $options = $this->owner->getOptions();
163
164
        if (!$options) {
165
            return false;
166
        }
167
168
        if (!$options->ReviewPeriodDays) {
169
            return false;
170
        }
171
172
        // Failover to check on ReviewPeriodDays + LastEdited
173
        $nextReviewUnixSec = strtotime(" + " . $options->ReviewPeriodDays . " days", SS_Datetime::now()->format("U"));
174
        $date = Date::create("NextReviewDate");
175
        $date->setValue(date("Y-m-d H:i:s", $nextReviewUnixSec));
176
177
        return $date;
178
    }
179
180
    /**
181
     * Get the object that have the information about the content review settings. Either:
182
     *
183
     *  - a SiteTreeContentReview decorated object
184
     *  - the default SiteTree config
185
     *  - false if this page have it's content review disabled
186
     *
187
     * Will go through parents and root pages will use the site config if their setting is Inherit.
188
     *
189
     * @return bool|DataObject
190
     *
191
     * @throws Exception
192
     */
193
    public function getOptions()
194
    {
195
        if ($this->owner->ContentReviewType == "Custom") {
196
            return $this->owner;
197
        }
198
199
        if ($this->owner->ContentReviewType == "Disabled") {
200
            return false;
201
        }
202
203
        $page = $this->owner;
204
205
        // $page is inheriting it's settings from it's parent, find
206
        // the first valid parent with a valid setting
207
        while ($parent = $page->Parent()) {
208
209
            // Root page, use site config
210
            if (!$parent->exists()) {
211
                return SiteConfig::current_site_config();
212
            }
213
214
            if ($parent->ContentReviewType == "Custom") {
215
                return $parent;
216
            }
217
218
            if ($parent->ContentReviewType == "Disabled") {
219
                return false;
220
            }
221
222
            $page = $parent;
223
        }
224
225
        throw new Exception("This shouldn't really happen, as per usual developer logic.");
226
    }
227
228
    /**
229
     * @return string
230
     */
231
    public function getOwnerNames()
232
    {
233
        $options = $this->getOptions();
234
235
        $names = array();
236
237
        if (!$options) {
238
            return "";
239
        }
240
241
        foreach ($options->OwnerGroups() as $group) {
242
            $names[] = $group->getBreadcrumbs(" > ");
243
        }
244
245
        foreach ($options->OwnerUsers() as $group) {
246
            $names[] = $group->getName();
247
        }
248
249
        return implode(", ", $names);
250
    }
251
252
    /**
253
     * @return null|string
254
     */
255
    public function getEditorName()
256
    {
257
        $member = Member::currentUser();
258
259
        if ($member) {
260
            return $member->getTitle();
261
        }
262
263
        return null;
264
    }
265
266
    /**
267
     * Get all Members that are Content Owners to this page. This includes checking group
268
     * hierarchy and adding any direct users.
269
     *
270
     * @return ArrayList
271
     */
272
    public function ContentReviewOwners()
273
    {
274
        return SiteTreeContentReview::merge_owners(
275
            $this->OwnerGroups(),
276
            $this->OwnerUsers()
277
        );
278
    }
279
280
    /**
281
     * @return ManyManyList
282
     */
283
    public function OwnerGroups()
284
    {
285
        return $this->owner->getManyManyComponents("ContentReviewGroups");
286
    }
287
288
    /**
289
     * @return ManyManyList
290
     */
291
    public function OwnerUsers()
292
    {
293
        return $this->owner->getManyManyComponents("ContentReviewUsers");
294
    }
295
296
    /**
297
     * @param FieldList $fields
298
     */
299
    public function updateSettingsFields(FieldList $fields)
300
    {
301
        Requirements::javascript("contentreview/javascript/contentreview.js");
302
303
        // Display read-only version only
304
        if (!Permission::check("EDIT_CONTENT_REVIEW_FIELDS")) {
305
            $schedule = self::get_schedule();
306
            $contentOwners = ReadonlyField::create("ROContentOwners", _t("ContentReview.CONTENTOWNERS", "Content Owners"), $this->getOwnerNames());
307
            $nextReviewAt = DateField::create('RONextReviewDate', _t("ContentReview.NEXTREVIEWDATE", "Next review date"), $this->owner->NextReviewDate);
308
309
            if (!isset($schedule[$this->owner->ReviewPeriodDays])) {
310
                $reviewFreq = ReadonlyField::create("ROReviewPeriodDays", _t("ContentReview.REVIEWFREQUENCY", "Review frequency"), $schedule[0]);
311
            } else {
312
                $reviewFreq = ReadonlyField::create("ROReviewPeriodDays", _t("ContentReview.REVIEWFREQUENCY", "Review frequency"), $schedule[$this->owner->ReviewPeriodDays]);
313
            }
314
315
            $logConfig = GridFieldConfig::create()
316
                ->addComponent(new GridFieldSortableHeader())
317
                ->addComponent($logColumns = new GridFieldDataColumns());
318
319
            // Cast the value to the users preferred date format
320
            $logColumns->setFieldCasting(array(
321
                "Created" => "DateTimeField->value",
322
            ));
323
324
            $logs = GridField::create("ROReviewNotes", "Review Notes", $this->owner->ReviewLogs(), $logConfig);
325
326
327
            $optionsFrom = ReadonlyField::create("ROType", _t("ContentReview.SETTINGSFROM", "Options are"), $this->owner->ContentReviewType);
328
329
            $fields->addFieldsToTab("Root.ContentReview", array(
330
                $contentOwners,
331
                $nextReviewAt->performReadonlyTransformation(),
332
                $reviewFreq,
333
                $optionsFrom,
334
                $logs,
335
            ));
336
337
            return;
338
        }
339
340
        $options = array();
341
        $options["Disabled"] = _t("ContentReview.DISABLE", "Disable content review");
342
        $options["Inherit"] = _t("ContentReview.INHERIT", "Inherit from parent page");
343
        $options["Custom"] = _t("ContentReview.CUSTOM", "Custom settings");
344
345
        $viewersOptionsField = OptionsetField::create("ContentReviewType", _t("ContentReview.OPTIONS", "Options"), $options);
346
347
        $users = Permission::get_members_by_permission(array("CMS_ACCESS_CMSMain", "ADMIN"));
348
349
        $usersMap = $users->map("ID", "Title")->toArray();
0 ignored issues
show
The method toArray cannot be called on $users->map('ID', 'Title') (of type array).

Methods can only be called on objects. This check looks for methods being called on variables that have been inferred to never be objects.

Loading history...
350
351
        asort($usersMap);
352
353
        $userField = ListboxField::create("OwnerUsers", _t("ContentReview.PAGEOWNERUSERS", "Users"), $usersMap)
354
            ->setMultiple(true)
355
            ->addExtraClass('custom-setting')
356
            ->setAttribute("data-placeholder", _t("ContentReview.ADDUSERS", "Add users"))
357
            ->setDescription(_t('ContentReview.OWNERUSERSDESCRIPTION', 'Page owners that are responsible for reviews'));
358
359
        $groupsMap = array();
360
361
        foreach (Group::get() as $group) {
362
            $groupsMap[$group->ID] = $group->getBreadcrumbs(" > ");
363
        }
364
        asort($groupsMap);
365
366
        $groupField = ListboxField::create("OwnerGroups", _t("ContentReview.PAGEOWNERGROUPS", "Groups"), $groupsMap)
367
            ->setMultiple(true)
368
            ->addExtraClass('custom-setting')
369
            ->setAttribute("data-placeholder", _t("ContentReview.ADDGROUP", "Add groups"))
370
            ->setDescription(_t("ContentReview.OWNERGROUPSDESCRIPTION", "Page owners that are responsible for reviews"));
371
372
        $reviewDate = DateField::create("NextReviewDate", _t("ContentReview.NEXTREVIEWDATE", "Next review date"))
373
            ->setConfig("showcalendar", true)
374
            ->setConfig("dateformat", "yyyy-MM-dd")
375
            ->setConfig("datavalueformat", "yyyy-MM-dd")
376
            ->setDescription(_t("ContentReview.NEXTREVIEWDATADESCRIPTION", "Leave blank for no review"));
377
378
        $reviewFrequency = DropdownField::create(
379
            "ReviewPeriodDays",
380
            _t("ContentReview.REVIEWFREQUENCY", "Review frequency"),
381
            self::get_schedule()
382
        )
383
            ->addExtraClass('custom-setting')
384
            ->setDescription(_t("ContentReview.REVIEWFREQUENCYDESCRIPTION", "The review date will be set to this far in the future whenever the page is published"));
385
386
        $notesField = GridField::create("ReviewNotes", "Review Notes", $this->owner->ReviewLogs(), GridFieldConfig_RecordEditor::create());
387
388
        $fields->addFieldsToTab("Root.ContentReview", array(
389
            new HeaderField(_t("ContentReview.REVIEWHEADER", "Content review"), 2),
390
            $viewersOptionsField,
391
            CompositeField::create(
392
                $userField,
393
                $groupField,
394
                $reviewDate,
395
                $reviewFrequency
396
            )->addExtraClass("review-settings"),
397
            ReadonlyField::create("ROContentOwners", _t("ContentReview.CONTENTOWNERS", "Content Owners"), $this->getOwnerNames()),
398
            $notesField,
399
        ));
400
    }
401
402
    /**
403
     * Creates a ContentReviewLog and connects it to this Page.
404
     *
405
     * @param Member $reviewer
406
     * @param string $message
407
     */
408
    public function addReviewNote(Member $reviewer, $message)
409
    {
410
        $reviewLog = ContentReviewLog::create();
411
        $reviewLog->Note = $message;
412
        $reviewLog->ReviewerID = $reviewer->ID;
413
        $this->owner->ReviewLogs()->add($reviewLog);
414
    }
415
416
    /**
417
     * Advance review date to the next date based on review period or set it to null
418
     * if there is no schedule. Returns true if date was required and false is content
419
     * review is 'off'.
420
     *
421
     * @return bool
422
     */
423
    public function advanceReviewDate()
424
    {
425
        $nextDate = false;
426
        $options = $this->getOptions();
427
428
        if ($options && $options->ReviewPeriodDays) {
429
            $nextDate = date('Y-m-d', strtotime('+ ' . $options->ReviewPeriodDays . ' days', SS_Datetime::now()->format('U')));
430
431
            $this->owner->NextReviewDate = $nextDate;
432
            $this->owner->write();
433
        }
434
435
        return (bool) $nextDate;
436
    }
437
438
    /**
439
     * Check if a review is due by a member for this owner.
440
     *
441
     * @param Member $member
442
     *
443
     * @return bool
444
     */
445
    public function canBeReviewedBy(Member $member = null)
446
    {
447
        if (!$this->owner->obj("NextReviewDate")->exists()) {
448
            return false;
449
        }
450
451
        if ($this->owner->obj("NextReviewDate")->InFuture()) {
452
            return false;
453
        }
454
455
        $options = $this->getOptions();
456
        
457
        if (!$options) {
458
            return false;
459
        }
460
461
        if (!$options || !$options->hasExtension($this->class)) {
462
            return false;
463
        }
464
465
        if ($options->OwnerGroups()->count() == 0 && $options->OwnerUsers()->count() == 0) {
466
            return false;
467
        }
468
469
        if (!$member) {
470
            return true;
471
        }
472
473
        if ($member->inGroups($options->OwnerGroups())) {
474
            return true;
475
        }
476
477
        if ($options->OwnerUsers()->find("ID", $member->ID)) {
478
            return true;
479
        }
480
481
        return false;
482
    }
483
484
    /**
485
     * Set the review data from the review period, if set.
486
     */
487
    public function onBeforeWrite()
488
    {
489
        // Only update if DB fields have been changed
490
        $changedFields = $this->owner->getChangedFields(true, 2);
491
        if($changedFields) {
492
            $this->owner->LastEditedByName = $this->owner->getEditorName();
493
            $this->owner->OwnerNames = $this->owner->getOwnerNames();
494
        }
495
496
        // If the user changed the type, we need to recalculate the review date.
497
        if ($this->owner->isChanged("ContentReviewType", 2)) {
498
            if ($this->owner->ContentReviewType == "Disabled") {
499
                $this->setDefaultReviewDateForDisabled();
500
            } elseif ($this->owner->ContentReviewType == "Custom") {
501
                $this->setDefaultReviewDateForCustom();
502
            } else {
503
                $this->setDefaultReviewDateForInherited();
504
            }
505
        }
506
507
        // Ensure that a inherited page always have a next review date
508
        if ($this->owner->ContentReviewType == "Inherit" && !$this->owner->NextReviewDate) {
509
            $this->setDefaultReviewDateForInherited();
510
        }
511
512
        // We need to update all the child pages that inherit this setting. We can only
513
        // change children after this record has been created, otherwise the stageChildren
514
        // method will grab all pages in the DB (this messes up unit testing)
515
        if (!$this->owner->exists()) {
516
            return;
517
        }
518
519
        // parent page change its review period
520
        // && !$this->owner->isChanged('ContentReviewType', 2)
521
        if ($this->owner->isChanged("ReviewPeriodDays", 2)) {
522
            $nextReviewUnixSec = strtotime(" + " . $this->owner->ReviewPeriodDays . " days", SS_Datetime::now()->format("U"));
523
            $this->owner->NextReviewDate = date("Y-m-d", $nextReviewUnixSec);
524
        }
525
    }
526
527
    private function setDefaultReviewDateForDisabled()
528
    {
529
        $this->owner->NextReviewDate = null;
530
    }
531
532
    protected function setDefaultReviewDateForCustom()
533
    {
534
        // Don't overwrite existing value
535
        if ($this->owner->NextReviewDate) {
536
            return;
537
        }
538
539
        $this->owner->NextReviewDate = null;
540
        $nextDate = $this->getReviewDate();
541
542 View Code Duplication
        if (is_object($nextDate)) {
543
            $this->owner->NextReviewDate = $nextDate->getValue();
544
        } else {
545
            $this->owner->NextReviewDate = $nextDate;
546
        }
547
    }
548
549
    protected function setDefaultReviewDateForInherited()
550
    {
551
        // Don't overwrite existing value
552
        if ($this->owner->NextReviewDate) {
553
            return;
554
        }
555
556
        $options = $this->getOptions();
557
        $nextDate = null;
558
559
        if ($options instanceof SiteTree) {
560
            $nextDate = $this->getReviewDate($options);
561
        } elseif ($options instanceof SiteConfig) {
562
            $nextDate = $this->getReviewDate();
563
        }
564
565 View Code Duplication
        if (is_object($nextDate)) {
566
            $this->owner->NextReviewDate = $nextDate->getValue();
567
        } else {
568
            $this->owner->NextReviewDate = $nextDate;
569
        }
570
    }
571
572
    /**
573
     * Provide permissions to the CMS.
574
     *
575
     * @return array
576
     */
577
    public function providePermissions()
578
    {
579
        return array(
580
            "EDIT_CONTENT_REVIEW_FIELDS" => array(
581
                "name"     => "Set content owners and review dates",
582
                "category" => _t("Permissions.CONTENT_CATEGORY", "Content permissions"),
583
                "sort"     => 50,
584
            ),
585
        );
586
    }
587
588
    /**
589
     * If the queued jobs module is installed, queue up the first job for 9am tomorrow morning
590
     * (by default).
591
     */
592
    public function requireDefaultRecords()
593
    {
594
        if (class_exists("ContentReviewNotificationJob")) {
595
            // Ensure there is not already a job queued
596
            if (QueuedJobDescriptor::get()->filter("Implementation", "ContentReviewNotificationJob")->first()) {
597
                return;
598
            }
599
600
            $nextRun = new ContentReviewNotificationJob();
601
            $runHour = Config::inst()->get("ContentReviewNotificationJob", "first_run_hour");
602
            $firstRunTime = date("Y-m-d H:i:s", mktime($runHour, 0, 0, date("m"), date("d") + 1, date("y")));
603
604
            singleton("QueuedJobService")->queueJob(
605
                $nextRun,
606
                $firstRunTime
607
            );
608
609
            DB::alteration_message(sprintf("Added ContentReviewNotificationJob to run at %s", $firstRunTime));
610
        }
611
    }
612
}
613