diff --git a/composer.json b/composer.json index 78ea2d2..e882612 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "require": { "php": "^7.4 || ^8.0", "silverstripe/vendor-plugin": "^1", - "silverstripe/framework": "^4.10", + "silverstripe/framework": "^4.8", "silverstripe/cms": "^4.2", "silverstripe/reports": "^4.2", "silverstripe/siteconfig": "^4.2" diff --git a/docs/en/index.md b/docs/en/index.md index 383c077..95206a8 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -6,13 +6,21 @@ The module is set up in the `Settings` section of the CMS, see the [User guide](userguide/index.md). -### Reminder emails +### Email notifications -In order for the contentreview module to send emails, you need to *either*: +In order for the contentreview module to send overdue review and reminder email notifications, you need to *either*: * Setup the `ContentReviewEmails` script to run daily via a system cron job. + * Setup the `ContentReviewReminderEmails` script to run daily via a system cron job. * Install the [queuedjobs](https://github.com/symbiote/silverstripe-queuedjobs) module and follow the configuration steps to create a cron job for that module. Once installed, you can just run `dev/build` to have a job created, which will run at 9am every day by default. +## Reminders +Content Review module has two notification workflows. +This allows for authors to be reminded of upcoming reviews and then reminded of overdue review(s). + +1. A review date is assigned to a page that will send notifications to the author on the review date at a frequency specified by site admins. +2. Concurrent to the review date an interval configuration of `7`, `30` and `60` days will check if a piece of content is x days away from review and send a reminder that day to the author. + ## Using See the [user guide](userguide/index.md). diff --git a/docs/en/userguide/_images/content-review-siteconfig-settings-part-1.png b/docs/en/userguide/_images/content-review-siteconfig-settings-part-1.png new file mode 100644 index 0000000..6c9c5f3 Binary files /dev/null and b/docs/en/userguide/_images/content-review-siteconfig-settings-part-1.png differ diff --git a/docs/en/userguide/_images/content-review-siteconfig-settings-part-2.png b/docs/en/userguide/_images/content-review-siteconfig-settings-part-2.png new file mode 100644 index 0000000..3e13ed5 Binary files /dev/null and b/docs/en/userguide/_images/content-review-siteconfig-settings-part-2.png differ diff --git a/docs/en/userguide/_images/content-review-siteconfig-settings.png b/docs/en/userguide/_images/content-review-siteconfig-settings.png deleted file mode 100644 index d2113db..0000000 Binary files a/docs/en/userguide/_images/content-review-siteconfig-settings.png and /dev/null differ diff --git a/docs/en/userguide/index.md b/docs/en/userguide/index.md index 94626b2..047fcc0 100644 --- a/docs/en/userguide/index.md +++ b/docs/en/userguide/index.md @@ -8,7 +8,8 @@ summary: Mark pages in the CMS with a date and an owner for future reviews. Global settings can be configured via the global settings admin in the CMS under the "Content Review" tab. This includes global groups, users, as well as a template editor that supports a limited number of variables. - + + ## Schedules diff --git a/lang/en.yml b/lang/en.yml index 4f309e4..8ddc3a3 100644 --- a/lang/en.yml +++ b/lang/en.yml @@ -7,8 +7,10 @@ en: DEFAULTSETTINGSHELP: 'These settings will apply to all pages that do not have a specific Content Review schedule.' EMAILFROM: 'From email address' EMAILFROM_RIGHTTITLE: 'e.g: do-not-reply@site.com' - EMAILSUBJECT: 'Subject line' - EMAILTEMPLATE: 'Email template' + OVERDUEEMAILSUBJECT: 'Overdue subject line' + OVERDUEEMAILTEMPLATE: 'Overdue review email template' + REMINDEREMAILSUBJECT: 'Reminder subject line' + REMINDEREMAILTEMPLATE: 'Reminder email template' OWNERGROUPSDESCRIPTION: 'Page owners that are responsible for reviews' OWNERUSERSDESCRIPTION: 'Page owners that are responsible for reviews' PAGEOWNERGROUPS: Groups @@ -24,7 +26,7 @@ en: DISABLE: 'Disable content review' INHERIT: 'Inherit from parent page' NEXTREVIEWDATADESCRIPTION: 'Leave blank for no review' - NEXTREVIEWDATE: 'Next review date' + NEXTREVIEWDATE: 'Overdue review date' OPTIONS: Options OWNERGROUPSDESCRIPTION: 'Page owners that are responsible for reviews' OWNERUSERSDESCRIPTION: 'Page owners that are responsible for reviews' diff --git a/src/Extensions/ContentReviewDefaultSettings.php b/src/Extensions/ContentReviewDefaultSettings.php index bf636b8..2ab151e 100644 --- a/src/Extensions/ContentReviewDefaultSettings.php +++ b/src/Extensions/ContentReviewDefaultSettings.php @@ -6,9 +6,11 @@ use SilverStripe\Control\Email\Email; use SilverStripe\Forms\DropdownField; use SilverStripe\Forms\FieldList; +use SilverStripe\Forms\HTMLEditor\HTMLEditorField; +use SilverStripe\Forms\HTMLEditor\TinyMCEConfig; use SilverStripe\Forms\ListboxField; use SilverStripe\Forms\LiteralField; -use SilverStripe\Forms\TextareaField; +use SilverStripe\Forms\TextAreaField; use SilverStripe\Forms\TextField; use SilverStripe\ORM\DataExtension; use SilverStripe\Security\Group; @@ -33,6 +35,8 @@ class ContentReviewDefaultSettings extends DataExtension 'ReviewFrom' => 'Varchar(255)', 'ReviewSubject' => 'Varchar(255)', 'ReviewBody' => 'HTMLText', + 'ReminderSubject' => 'Varchar(255)', + 'ReminderBody' => 'HTMLText', ); /** @@ -44,6 +48,9 @@ class ContentReviewDefaultSettings extends DataExtension 'ReviewSubject' => 'Page(s) are due for content review', 'ReviewBody' => '
There are $PagesCount pages that are due for review today by you.
', + 'ReminderSubject' => 'Reminder: Page(s) are upcoming for content review', + 'ReminderBody' => 'There are $PagesCount pages that have reviews upcoming for you.
', ); /** @@ -67,6 +74,17 @@ class ContentReviewDefaultSettings extends DataExtension */ private static $content_review_template = 'SilverStripe\\ContentReview\\ContentReviewEmail'; + /** + * Template to use for Reminder content review emails. + * + * This should contain an $EmailBody variable as a placeholder for the user-defined copy + * + * @config + * + * @var string + */ + private static $content_review_reminder_template = 'SilverStripe\\ContentReview\\ContentReviewReminderEmail'; + /** * @return string */ @@ -163,14 +181,66 @@ public function updateCMSFields(FieldList $fields) [ TextField::create('ReviewFrom', _t(__CLASS__ . '.EMAILFROM', 'From email address')) ->setDescription(_t(__CLASS__ . '.EMAILFROM_RIGHTTITLE', 'e.g: do-not-reply@site.com')), - TextField::create('ReviewSubject', _t(__CLASS__ . '.EMAILSUBJECT', 'Subject line')), - TextAreaField::create('ReviewBody', _t(__CLASS__ . '.EMAILTEMPLATE', 'Email template')), + TextField::create( + 'ReviewSubject', + _t(__CLASS__ . '.OVERDUEEMAILSUBJECT', 'Overdue review subject line') + ), + $overdueReviewBody = HTMLEditorField::create( + 'ReviewBody', + _t(__CLASS__ . '.OVERDUEEMAILTEMPLATE', 'Overdue email template') + ), + TextField::create( + 'ReminderSubject', + _t(__CLASS__ . '.REMINDEREMAILSUBJECT', 'Reminder review subject line') + ), + $reminderReviewBody = HTMLEditorField::create( + 'ReminderBody', + _t(__CLASS__ . '.REMINDEREMAILTEMPLATE', 'Reminder Email template') + ), LiteralField::create( 'TemplateHelp', $this->owner->renderWith('SilverStripe\\ContentReview\\ContentReviewAdminHelp') ), ] ); + + // set up tinymce config for our body fields + $overdueReviewBody->setEditorConfig($this->getTinyMCEConfig($overdueReviewBody->getEditorConfig())); + $reminderReviewBody->setEditorConfig($this->getTinyMCEConfig($reminderReviewBody->getEditorConfig())); + } + + /** + * Get the TinyMCEConfig that should be used for the email template preview + * + * @return TinyMCEConfig + */ + private function getTinyMCEConfig( + TinyMCEConfig $config + ): TinyMCEConfig { + $editorButtonsGroupSeparator = '|'; + $allowedEditorButtons = [ + 'undo', + 'redo', + $editorButtonsGroupSeparator, + 'bold', + 'italic', + 'underline', + $editorButtonsGroupSeparator, + 'bullist', + 'numlist', + $editorButtonsGroupSeparator, + 'sslink', + $editorButtonsGroupSeparator, + 'formatselect', + $editorButtonsGroupSeparator, + 'code', + ]; + + $config->setButtonsForLine(1, $allowedEditorButtons); + $config->setButtonsForLine(2, []); + $config->setButtonsForLine(3, []); + + return $config; } /** diff --git a/src/Extensions/SiteTreeContentReview.php b/src/Extensions/SiteTreeContentReview.php index 8d02167..4281996 100644 --- a/src/Extensions/SiteTreeContentReview.php +++ b/src/Extensions/SiteTreeContentReview.php @@ -105,6 +105,20 @@ class SiteTreeContentReview extends DataExtension implements PermissionProvider 365 => "12 months", ]; + /** + * Array of interval timings used to remind content authors to do + * a review of their content before the overdue review date. + * + * @config + * + * @var string[] + */ + private static $reminder_intervals = [ + 7 => '7 days', + 30 => '30 days', + 60 => '60 days', + ]; + /** * @return array */ @@ -113,6 +127,14 @@ public static function get_schedule() return Config::inst()->get(static::class, 'schedule'); } + /** + * @return string[] + */ + public static function get_reminder_intervals() + { + return Config::inst()->get(static::class, 'reminder_intervals'); + } + /** * Takes a list of groups and members and return a list of unique member. * @@ -340,7 +362,7 @@ public function updateSettingsFields(FieldList $fields) ); $nextReviewAt = DateField::create( 'RONextReviewDate', - _t(__CLASS__ . ".NEXTREVIEWDATE", "Next review date"), + _t(__CLASS__ . ".NEXTREVIEWDATE", "Overdue review date"), $this->owner->NextReviewDate ); @@ -420,7 +442,7 @@ public function updateSettingsFields(FieldList $fields) ->setAttribute("data-placeholder", _t(__CLASS__ . ".ADDGROUP", "Add groups")) ->setDescription(_t(__CLASS__ . ".OWNERGROUPSDESCRIPTION", "Page owners that are responsible for reviews")); - $reviewDate = DateField::create("NextReviewDate", _t(__CLASS__ . ".NEXTREVIEWDATE", "Next review date")) + $reviewDate = DateField::create("NextReviewDate", _t(__CLASS__ . ".NEXTREVIEWDATE", "Overdue review date")) ->setDescription(_t(__CLASS__ . ".NEXTREVIEWDATADESCRIPTION", "Leave blank for no review")); $reviewFrequency = DropdownField::create( @@ -504,7 +526,7 @@ public function advanceReviewDate() } /** - * Check if a review is due by a member for this owner. + * A function to check whether the content review bell can be displayed * * @param Member $member * @@ -558,6 +580,24 @@ public function canBeReviewedBy(Member $member = null) return false; } + /** + * Check if a review is overdue and an email can be sent + * + * @param Member $member + * + * @return bool + */ + public function canSendEmail(Member $member = null) + { + $canSendEmail = $this->canBeReviewedBy($member); + + if ($this->owner->obj("NextReviewDate")->InFuture()) { + $canSendEmail = false; + } + + return $canSendEmail; + } + /** * Set the review data from the review period, if set. */ @@ -581,7 +621,7 @@ public function onBeforeWrite() } } - // Ensure that a inherited page always have a next review date + // Ensure that an inherited page always has an overdue review date if ($this->owner->ContentReviewType == "Inherit" && !$this->owner->NextReviewDate) { $this->setDefaultReviewDateForInherited(); } diff --git a/src/Jobs/ContentReviewReminderNotificationJob.php b/src/Jobs/ContentReviewReminderNotificationJob.php new file mode 100644 index 0000000..42262bd --- /dev/null +++ b/src/Jobs/ContentReviewReminderNotificationJob.php @@ -0,0 +1,124 @@ +totalSteps = 1; + + return QueuedJob::QUEUED; + } + + public function setUp() + { + parent::setup(); + + // Recommended for long running jobs that don't increment 'currentStep' + // https://github.com/silverstripe-australia/silverstripe-queuedjobs + $this->currentStep = -1; + } + + public function process() + { + $this->queueNextRun(); + + $task = new ContentReviewReminderEmails(); + $task->run(new HTTPRequest("GET", "/dev/tasks/ContentReminderReviewEmails")); + + $this->currentStep = 1; + $this->isComplete = true; + } + + /** + * Queue up the next job to run. + */ + protected function queueNextRun() + { + $nextRun = new self(); + + $nextRunTime = mktime( + Config::inst()->get(__CLASS__, 'next_run_hour'), + Config::inst()->get(__CLASS__, 'next_run_minute'), + 0, + date("m"), + date("d") + Config::inst()->get(__CLASS__, 'next_run_in_days'), + date("Y") + ); + + singleton(QueuedJobService::class)->queueJob( + $nextRun, + date("Y-m-d H:i:s", $nextRunTime) + ); + } +} diff --git a/src/Reports/PagesWithoutReviewScheduleReport.php b/src/Reports/PagesWithoutReviewScheduleReport.php index e54d0aa..7f81983 100644 --- a/src/Reports/PagesWithoutReviewScheduleReport.php +++ b/src/Reports/PagesWithoutReviewScheduleReport.php @@ -55,7 +55,7 @@ public function columns() "formatting" => "\$value", ], "NextReviewDate" => [ - "title" => "Review Date", + "title" => "Overdue Review Date", "casting" => "Date->Full", ], "OwnerNames" => [ diff --git a/src/Tasks/ContentReviewEmails.php b/src/Tasks/ContentReviewEmails.php index c59bc56..0cefb48 100644 --- a/src/Tasks/ContentReviewEmails.php +++ b/src/Tasks/ContentReviewEmails.php @@ -54,7 +54,7 @@ protected function getOverduePagesForOwners(SS_List $pages) $overduePages = []; foreach ($pages as $page) { - if (!$page->canBeReviewedBy()) { + if (!$page->canSendEmail()) { continue; } diff --git a/src/Tasks/ContentReviewReminderEmails.php b/src/Tasks/ContentReviewReminderEmails.php new file mode 100644 index 0000000..f7b29cb --- /dev/null +++ b/src/Tasks/ContentReviewReminderEmails.php @@ -0,0 +1,176 @@ +filter('NextReviewDate:GreaterThan', DBDatetime::now()->URLDate()); + + $upcomingPagesForReview = $this->getOverduePagesForOwners($pages); + + // Lets send one email to one owner with all the pages in there instead of no of pages + // of emails. + foreach ($upcomingPagesForReview as $memberID => $pages) { + $this->notifyOwner($memberID, $pages); + } + + ContentReviewCompatability::done($compatibility); + } + + /** + * @param SS_List $pages + * + * @return array + */ + protected function getOverduePagesForOwners(SS_List $pages) + { + $overduePages = []; + $reminderIntervals = SiteTreeContentReview::get_reminder_intervals(); + + foreach ($pages as $page) { + // get the owner NextReviewDate in 'days', so we can compare to our intervals + $upcomingForReviewDateInDays = $this->getUpcomingForReviewDateInDays($page->NextReviewDate); + $reminderIntervals = array_values($reminderIntervals); + + if (in_array($upcomingForReviewDateInDays, $reminderIntervals)) { + $options = $page->getOptions(); + + if ($options) { + foreach ($options->ContentReviewOwners() as $owner) { + if (!isset($overduePages[$owner->ID])) { + $overduePages[$owner->ID] = ArrayList::create(); + } + + // add our overdue NextReviewDate in days form + $page->UpcomingReviewdateInDays = $upcomingForReviewDateInDays; + $overduePages[$owner->ID]->push($page); + } + } + } + } + + return $overduePages; + } + + /** + * @param int $ownerID + * @param array|SS_List $pages + */ + protected function notifyOwner($ownerID, SS_List $pages) + { + // Prepare variables + $siteConfig = SiteConfig::current_site_config(); + $owner = Member::get()->byID($ownerID); + $templateVariables = $this->getTemplateVariables($owner, $siteConfig, $pages); + + // Build over due email + $email = Email::create(); + $email->setTo($owner->Email); + $email->setFrom($siteConfig->ReviewFrom); + $email->setSubject($siteConfig->ReminderSubject); + + // Get user-editable body + $body = $this->getEmailBody($siteConfig, $templateVariables); + + // Populate mail body with fixed template + $email->setHTMLTemplate($siteConfig->config()->get('content_review_reminder_template')); + $email->setData( + array_merge( + $templateVariables, + [ + 'EmailBody' => $body, + 'Recipient' => $owner, + 'Pages' => $pages, + ] + ) + ); + $email->send(); + } + + /** + * Get string value of HTML body with all variable evaluated. + * + * @param SiteConfig $config + * @param array List of safe template variables to expose to this template + * + * @return HTMLText + */ + protected function getEmailBody($config, $variables) + { + $template = SSViewer::fromString($config->ReminderBody); + $value = $template->process(ArrayData::create($variables)); + + // Cast to HTML + return DBField::create_field('HTMLText', (string) $value); + } + + /** + * Gets list of safe template variables and their values which can be used + * in both the static and editable templates. + * + * {@see ContentReviewAdminHelp.ss} + * + * @param Member $recipient + * @param SiteConfig $config + * @param SS_List $pages + * + * @return array + */ + protected function getTemplateVariables($recipient, $config, $pages) + { + return [ + 'ReminderSubject' => $config->Remindersubject, + 'PagesCount' => $pages->count(), + 'FromEmail' => $config->ReviewFrom, + 'ToFirstName' => $recipient->FirstName, + 'ToSurname' => $recipient->Surname, + 'ToEmail' => $recipient->Email, + ]; + } + + /** + * Helper method that compares a page owner `NextReviewDate` to {@see DBDatetime::now()} + * and returns a formatted in 'days' value. + * This return is used to validate to the configurable reminder interval values. + * + * {@see SiteTreeContentReview::$reminder_intervals} + * + * @param string $pageOwnerNextReviewDate + * @return string + */ + protected function getUpcomingForReviewDateInDays(string $pageOwnerNextReviewDate): string + { + $nextReviewDateInDays = DBDate::create()->setValue($pageOwnerNextReviewDate); + return $nextReviewDateInDays->TimeDiffIn('days'); + } +} diff --git a/src/Traits/ReviewPermission.php b/src/Traits/ReviewPermission.php new file mode 100644 index 0000000..82365ba --- /dev/null +++ b/src/Traits/ReviewPermission.php @@ -0,0 +1,27 @@ +canView($user) && + $record->hasMethod('canBeReviewedBy') && + $record->canBeReviewedBy($user); + // Whether or not the user is allowed to review the content of the page + // Fallback to canEdit as it the original implementation + $canEdit = $record->hasMethod('canReviewContent') ? $record->canReviewContent($user) : $record->canEdit(); + + return $canEdit || $isReviewer; + } +} diff --git a/templates/SilverStripe/ContentReview/ContentReviewAdminHelp.ss b/templates/SilverStripe/ContentReview/ContentReviewAdminHelp.ss index 243a541..d8d3c10 100644 --- a/templates/SilverStripe/ContentReview/ContentReviewAdminHelp.ss +++ b/templates/SilverStripe/ContentReview/ContentReviewAdminHelp.ss @@ -1,10 +1,12 @@This is a list of dynamic variables that you can use in the email templates.
| + $EmailBody + | +|
| $Title | +<%t SilverStripe\\ContentReview\\Tasks\\ContentReviewEmails.REVIEWPAGELINK 'Review the page in the CMS' %> + <%t SilverStripe\\ContentReview\\Tasks\\ContentReviewEmails.VIEWPUBLISHEDLINK 'View this page on the website' %> + Due for review in $UpcomingReviewdateInDays ($NextReviewDate.nice) + |
+
There are $PagesCount pages that are due for review today by you, $ToFirstName.
This email was sent to $ToEmail
' + ReminderSubject: 'You have upcoming reviews!' + ReminderBody: 'There are $PagesCount pages that have reviews upcoming for you.
' SilverStripe\Security\Permission: cmsmain1: diff --git a/tests/php/SiteTreeContentReviewTest.php b/tests/php/SiteTreeContentReviewTest.php index 63c6e93..4f386b3 100644 --- a/tests/php/SiteTreeContentReviewTest.php +++ b/tests/php/SiteTreeContentReviewTest.php @@ -180,7 +180,7 @@ public function testCanNotBeReviewBecauseNoReviewDate() DBDatetime::clear_mock_now(); } - public function testCanNotBeReviewedBecauseInFuture() + public function testCanBeReviewedInFuture() { DBDatetime::set_mock_now("2010-01-01 12:00:00"); @@ -190,7 +190,22 @@ public function testCanNotBeReviewedBecauseInFuture() /** @var Page|SiteTreeContentReview $page */ $page = $this->objFromFixture(Page::class, "staff"); - $this->assertFalse($page->canBeReviewedBy($author)); + $this->assertTrue($page->canBeReviewedBy($author)); + + DBDatetime::clear_mock_now(); + } + + public function testCanNotSendReviewEmail() + { + DBDatetime::set_mock_now("2010-01-01 12:00:00"); + + /** @var Member $author */ + $author = $this->objFromFixture(Member::class, "author"); + + /** @var Page|SiteTreeContentReview $page */ + $page = $this->objFromFixture(Page::class, "staff"); + + $this->assertFalse($page->canSendEmail($author)); DBDatetime::clear_mock_now(); }