From 3acc9474df001e9234cdaa7fbf3644d1609e247c Mon Sep 17 00:00:00 2001 From: Nate Wright Date: Thu, 24 Aug 2023 11:29:12 +0100 Subject: [PATCH] pkp/pkp-lib#9253 Add site-level announcements --- .../announcements/PKPAnnouncementHandler.php | 157 +++++++++++------- classes/announcement/Collector.php | 31 ++++ classes/announcement/maps/Schema.php | 28 +++- .../announcement/PKPAnnouncementForm.php | 48 ++++-- .../context/PKPAnnouncementSettingsForm.php | 5 +- .../listPanels/PKPAnnouncementsListPanel.php | 25 ++- .../v3_5_0/I9253_SiteAnnouncements.php | 56 +++++++ classes/services/PKPNavigationMenuService.php | 5 +- .../AnnouncementTypeGridHandler.php | 42 +++-- locale/en/manager.po | 3 + pages/admin/AdminHandler.php | 45 +++++ pages/announcement/AnnouncementHandler.php | 57 ++++--- pages/index/PKPIndexHandler.php | 14 +- schemas/site.json | 20 +++ templates/admin/settings.tpl | 31 ++++ 15 files changed, 441 insertions(+), 126 deletions(-) create mode 100644 classes/migration/upgrade/v3_5_0/I9253_SiteAnnouncements.php diff --git a/api/v1/announcements/PKPAnnouncementHandler.php b/api/v1/announcements/PKPAnnouncementHandler.php index f1730a2d54c..d0f2a48a3e1 100644 --- a/api/v1/announcements/PKPAnnouncementHandler.php +++ b/api/v1/announcements/PKPAnnouncementHandler.php @@ -18,9 +18,11 @@ namespace PKP\API\v1\announcements; use APP\core\Application; +use APP\core\Request; use APP\facades\Repo; use Exception; use Illuminate\Support\Facades\Bus; +use PKP\context\Context; use PKP\db\DAORegistry; use PKP\facades\Locale; use PKP\handler\APIHandler; @@ -92,6 +94,10 @@ public function __construct() */ public function authorize($request, &$args, $roleAssignments) { + if (!$request->getContext()) { + $roleAssignments = $this->getSiteRoleAssignments($roleAssignments); + } + $this->addPolicy(new UserRolesRequiredPolicy($request), true); $rolePolicy = new PolicySet(PolicySet::COMBINING_PERMIT_OVERRIDES); @@ -163,7 +169,7 @@ public function getMany($slimRequest, $response, $args) } } - $collector->filterByContextIds([$this->getRequest()->getContext()->getId()]); + $collector->filterByContextIds([$this->getContextId()]); Hook::call('API::submissions::params', [$collector, $slimRequest]); @@ -188,17 +194,14 @@ public function add($slimRequest, $response, $args) { $request = $this->getRequest(); $context = $request->getContext(); - - if (!$context) { - throw new Exception('You can not add an announcement without sending a request to the API endpoint of a particular context.'); - } + $contextId = $this->getContextId(); $params = $this->convertStringsToSchema(PKPSchemaService::SCHEMA_ANNOUNCEMENT, $slimRequest->getParsedBody()); $params['assocType'] = Application::get()->getContextAssocType(); - $params['assocId'] = $request->getContext()->getId(); + $params['assocId'] = $contextId; - $primaryLocale = $context->getPrimaryLocale(); - $allowedLocales = $context->getSupportedFormLocales(); + $primaryLocale = $context ? $context->getPrimaryLocale() : $request->getSite()->getPrimaryLocale(); + $allowedLocales = $context ? $context->getSupportedFormLocales() : $request->getSite()->getSupportedLocales(); $errors = Repo::announcement()->validate(null, $params, $allowedLocales, $primaryLocale); if (!empty($errors)) { @@ -208,54 +211,11 @@ public function add($slimRequest, $response, $args) $announcement = Repo::announcement()->newDataObject($params); $announcementId = Repo::announcement()->add($announcement); $sendEmail = (bool) filter_var($params['sendEmail'], FILTER_VALIDATE_BOOLEAN); - $contextId = $context->getId(); - - /** @var NotificationSubscriptionSettingsDAO $notificationSubscriptionSettingsDao */ - $notificationSubscriptionSettingsDao = DAORegistry::getDAO('NotificationSubscriptionSettingsDAO'); - - // Notify users - $userIdsToNotify = $notificationSubscriptionSettingsDao->getSubscribedUserIds( - [NotificationSubscriptionSettingsDAO::BLOCKED_NOTIFICATION_KEY], - [PKPNotification::NOTIFICATION_TYPE_NEW_ANNOUNCEMENT], - [$contextId] - ); - - if ($sendEmail) { - $userIdsToMail = $notificationSubscriptionSettingsDao->getSubscribedUserIds( - [NotificationSubscriptionSettingsDAO::BLOCKED_NOTIFICATION_KEY, NotificationSubscriptionSettingsDAO::BLOCKED_EMAIL_NOTIFICATION_KEY], - [PKPNotification::NOTIFICATION_TYPE_NEW_ANNOUNCEMENT], - [$contextId] - ); - - $userIdsToNotifyAndMail = $userIdsToNotify->intersect($userIdsToMail); - $userIdsToNotify = $userIdsToNotify->diff($userIdsToMail); - } - - $sender = $request->getUser(); - $jobs = []; - foreach ($userIdsToNotify->chunk(PKPNotification::NOTIFICATION_CHUNK_SIZE_LIMIT) as $notifyUserIds) { - $jobs[] = new NewAnnouncementNotifyUsers( - $notifyUserIds, - $contextId, - $announcementId, - Locale::getPrimaryLocale() - ); - } - if (isset($userIdsToNotifyAndMail)) { - foreach ($userIdsToNotifyAndMail->chunk(Mailer::BULK_EMAIL_SIZE_LIMIT) as $notifyAndMailUserIds) { - $jobs[] = new NewAnnouncementNotifyUsers( - $notifyAndMailUserIds, - $contextId, - $announcementId, - Locale::getPrimaryLocale(), - $sender - ); - } + if ($context) { + $this->notifyUsers($request, $context, $announcementId, $sendEmail); } - Bus::batch($jobs)->dispatch(); - return $response->withJson(Repo::announcement()->getSchemaMap()->map($announcement), 200); } @@ -283,7 +243,7 @@ public function edit($slimRequest, $response, $args) } // Don't allow to edit an announcement from one context from a different context's endpoint - if ($request->getContext()->getId() !== $announcement->getData('assocId')) { + if ($this->getContextId() !== $announcement->getData('assocId')) { return $response->withStatus(403)->withJsonError('api.announcements.400.contextsNotMatched'); } @@ -292,8 +252,8 @@ public function edit($slimRequest, $response, $args) $params['typeId'] ??= null; $context = $request->getContext(); - $primaryLocale = $context->getPrimaryLocale(); - $allowedLocales = $context->getSupportedFormLocales(); + $primaryLocale = $context ? $context->getPrimaryLocale() : $request->getSite()->getPrimaryLocale(); + $allowedLocales = $context ? $context->getSupportedFormLocales() : $request->getSite()->getSupportedLocales(); $errors = Repo::announcement()->validate($announcement, $params, $allowedLocales, $primaryLocale); if (!empty($errors)) { @@ -331,7 +291,7 @@ public function delete($slimRequest, $response, $args) } // Don't allow to delete an announcement from one context from a different context's endpoint - if ($request->getContext()->getId() !== $announcement->getData('assocId')) { + if ($this->getContextId() !== $announcement->getData('assocId')) { return $response->withStatus(403)->withJsonError('api.announcements.400.contextsNotMatched'); } @@ -341,4 +301,87 @@ public function delete($slimRequest, $response, $args) return $response->withJson($announcementProps, 200); } + + /** + * Get the context id or site-wide context id of the current request + */ + protected function getContextId(): int + { + $context = $this->getRequest()->getContext(); + return $context + ? $context->getId() + : Application::CONTEXT_ID_NONE; + } + + /** + * Modify the role assignments so that only + * site admins have access + */ + protected function getSiteRoleAssignments(array $roleAssignments): array + { + $roleIds = array_keys($roleAssignments); + foreach ($roleIds as $roleId) { + if ($roleId !== Role::ROLE_ID_SITE_ADMIN) { + unset($roleAssignments[$roleId]); + } + } + return $roleAssignments; + } + + /** + * Notify subscribed users + * + * This only works for context-level announcements. There is no way to + * determine users who have subscribed to site-level announcements. + * + * @param bool $sendEmail Whether or not the editor chose to notify users by email + */ + protected function notifyUsers(Request $request, Context $context, int $announcementId, bool $sendEmail): void + { + /** @var NotificationSubscriptionSettingsDAO $notificationSubscriptionSettingsDao */ + $notificationSubscriptionSettingsDao = DAORegistry::getDAO('NotificationSubscriptionSettingsDAO'); + + // Notify users + $userIdsToNotify = $notificationSubscriptionSettingsDao->getSubscribedUserIds( + [NotificationSubscriptionSettingsDAO::BLOCKED_NOTIFICATION_KEY], + [PKPNotification::NOTIFICATION_TYPE_NEW_ANNOUNCEMENT], + [$context->getId()] + ); + + if ($sendEmail) { + $userIdsToMail = $notificationSubscriptionSettingsDao->getSubscribedUserIds( + [NotificationSubscriptionSettingsDAO::BLOCKED_NOTIFICATION_KEY, NotificationSubscriptionSettingsDAO::BLOCKED_EMAIL_NOTIFICATION_KEY], + [PKPNotification::NOTIFICATION_TYPE_NEW_ANNOUNCEMENT], + [$context->getId()] + ); + + $userIdsToNotifyAndMail = $userIdsToNotify->intersect($userIdsToMail); + $userIdsToNotify = $userIdsToNotify->diff($userIdsToMail); + } + + $sender = $request->getUser(); + $jobs = []; + foreach ($userIdsToNotify->chunk(PKPNotification::NOTIFICATION_CHUNK_SIZE_LIMIT) as $notifyUserIds) { + $jobs[] = new NewAnnouncementNotifyUsers( + $notifyUserIds, + $context->getId(), + $announcementId, + Locale::getPrimaryLocale() + ); + } + + if (isset($userIdsToNotifyAndMail)) { + foreach ($userIdsToNotifyAndMail->chunk(Mailer::BULK_EMAIL_SIZE_LIMIT) as $notifyAndMailUserIds) { + $jobs[] = new NewAnnouncementNotifyUsers( + $notifyAndMailUserIds, + $context->getId(), + $announcementId, + Locale::getPrimaryLocale(), + $sender + ); + } + } + + Bus::batch($jobs)->dispatch(); + } } diff --git a/classes/announcement/Collector.php b/classes/announcement/Collector.php index 4c64170f3de..c3a90e73068 100644 --- a/classes/announcement/Collector.php +++ b/classes/announcement/Collector.php @@ -27,6 +27,11 @@ */ class Collector implements CollectorInterface { + public const ORDERBY_DATE_POSTED = 'date_posted'; + public const ORDERBY_DATE_EXPIRE = 'date_expire'; + public const ORDER_DIR_ASC = 'ASC'; + public const ORDER_DIR_DESC = 'DESC'; + public DAO $dao; public ?array $contextIds = null; public ?string $isActive = null; @@ -34,6 +39,8 @@ class Collector implements CollectorInterface public ?array $typeIds = null; public ?int $count = null; public ?int $offset = null; + public string $orderBy = self::ORDERBY_DATE_POSTED; + public string $orderDirection = self::ORDER_DIR_DESC; public function __construct(DAO $dao) { @@ -124,6 +131,21 @@ public function offset(?int $offset): self return $this; } + /** + * Order the results + * + * Results are ordered by the date posted by default. + * + * @param string $sorter One of the self::ORDERBY_ constants + * @param string $direction One of the self::ORDER_DIR_ constants + */ + public function orderBy(?string $sorter, string $direction = self::ORDER_DIR_DESC): self + { + $this->orderBy = $sorter; + $this->orderDirection = $direction; + return $this; + } + /** * @copydoc CollectorInterface::getQueryBuilder() */ @@ -180,6 +202,15 @@ public function getQueryBuilder(): Builder $qb->offset($this->offset); } + if (isset($this->orderBy)) { + $qb->orderBy('a.' . $this->orderBy, $this->orderDirection); + // Add a secondary sort by id to catch cases where two + // announcements share the same date + if (in_array($this->orderBy, [SELF::ORDERBY_DATE_EXPIRE, SELF::ORDERBY_DATE_POSTED])) { + $qb->orderBy('a.announcement_id', $this->orderDirection); + } + } + Hook::call('Announcement::Collector', [&$qb, $this]); return $qb; diff --git a/classes/announcement/maps/Schema.php b/classes/announcement/maps/Schema.php index 5a336969985..e3d82f97be0 100644 --- a/classes/announcement/maps/Schema.php +++ b/classes/announcement/maps/Schema.php @@ -13,6 +13,8 @@ namespace PKP\announcement\maps; +use APP\core\Application; +use APP\core\Request; use Illuminate\Support\Enumerable; use PKP\announcement\Announcement; use PKP\core\PKPApplication; @@ -24,6 +26,12 @@ class Schema extends \PKP\core\maps\Schema public string $schema = PKPSchemaService::SCHEMA_ANNOUNCEMENT; + public function __construct(Request $request, PKPSchemaService $schemaService) + { + $this->request = $request; + $this->schemaService = $schemaService; + } + /** * Map an announcement * @@ -85,7 +93,7 @@ protected function mapByProperties(array $props, Announcement $item): array $output[$prop] = $this->request->getDispatcher()->url( $this->request, PKPApplication::ROUTE_PAGE, - $this->context->getData('urlPath'), + $this->getUrlPath(), 'announcement', 'view', $item->getId() @@ -97,10 +105,26 @@ protected function mapByProperties(array $props, Announcement $item): array } } - $output = $this->schemaService->addMissingMultilingualValues($this->schema, $output, $this->context->getSupportedFormLocales()); + $output = $this->schemaService->addMissingMultilingualValues($this->schema, $output, $this->getSupportedLocales()); ksort($output); return $this->withExtensions($output, $item); } + + protected function getUrlPath(): string + { + if (isset($this->context)) { + return $this->context->getData('urlPath'); + } + return 'index'; + } + + protected function getSupportedLocales(): array + { + if (isset($this->context)) { + return $this->context->getSupportedFormLocales(); + } + return Application::get()->getRequest()->getSite()->getSupportedLocales(); + } } diff --git a/classes/components/forms/announcement/PKPAnnouncementForm.php b/classes/components/forms/announcement/PKPAnnouncementForm.php index b81bff5f3f7..3cf8fba24d6 100644 --- a/classes/components/forms/announcement/PKPAnnouncementForm.php +++ b/classes/components/forms/announcement/PKPAnnouncementForm.php @@ -15,12 +15,15 @@ namespace PKP\components\forms\announcement; +use APP\core\Application; use PKP\announcement\AnnouncementTypeDAO; use PKP\components\forms\FieldOptions; use PKP\components\forms\FieldRichTextarea; use PKP\components\forms\FieldText; use PKP\components\forms\FormComponent; +use PKP\context\Context; use PKP\db\DAORegistry; +use PKP\site\Site; define('FORM_ANNOUNCEMENT', 'announcement'); @@ -32,17 +35,21 @@ class PKPAnnouncementForm extends FormComponent /** @copydoc FormComponent::$method */ public $method = 'POST'; + public Context|Site $context; + /** * Constructor * * @param string $action URL to submit the form to * @param array $locales Supported locales - * @param \PKP\context\Context $announcementContext The context to get supported announcement types */ - public function __construct($action, $locales, $announcementContext) + public function __construct($action, $locales, Context|Site $context) { $this->action = $action; $this->locales = $locales; + $this->context = $context; + + $announcementTypeOptions = $this->getAnnouncementTypeOptions(); $this->addField(new FieldText('title', [ 'label' => __('common.title'), @@ -67,22 +74,11 @@ public function __construct($action, $locales, $announcementContext) 'description' => __('manager.announcements.form.dateExpireInstructions'), 'size' => 'small', ])); - - /** @var AnnouncementTypeDAO */ - $announcementTypeDao = DAORegistry::getDAO('AnnouncementTypeDAO'); - $announcementTypes = $announcementTypeDao->getByContextId($announcementContext->getId()); - $announcementOptions = []; - foreach ($announcementTypes as $announcementType) { - $announcementOptions[] = [ - 'value' => (int) $announcementType->getId(), - 'label' => $announcementType->getLocalizedTypeName(), - ]; - } - if (!empty($announcementOptions)) { + if (!empty($announcementTypeOptions)) { $this->addField(new FieldOptions('typeId', [ 'label' => __('manager.announcementTypes.typeName'), 'type' => 'radio', - 'options' => $announcementOptions, + 'options' => $announcementTypeOptions, ])); } @@ -96,4 +92,26 @@ public function __construct($action, $locales, $announcementContext) ] ])); } + + protected function getAnnouncementTypeOptions(): array + { + /** @var AnnouncementTypeDAO */ + $announcementTypeDao = DAORegistry::getDAO('AnnouncementTypeDAO'); + + $announcementTypes = $announcementTypeDao->getByContextId( + is_a($this->context, Context::class) + ? $this->context->getId() + : Application::CONTEXT_ID_NONE + ); + + $announcementTypeOptions = []; + foreach ($announcementTypes as $announcementType) { + $announcementTypeOptions[] = [ + 'value' => (int) $announcementType->getId(), + 'label' => $announcementType->getLocalizedTypeName(), + ]; + } + + return $announcementTypeOptions; + } } diff --git a/classes/components/forms/context/PKPAnnouncementSettingsForm.php b/classes/components/forms/context/PKPAnnouncementSettingsForm.php index e2d2d853b1b..a6aa6aa376c 100644 --- a/classes/components/forms/context/PKPAnnouncementSettingsForm.php +++ b/classes/components/forms/context/PKPAnnouncementSettingsForm.php @@ -19,6 +19,8 @@ use PKP\components\forms\FieldRichTextarea; use PKP\components\forms\FieldText; use PKP\components\forms\FormComponent; +use PKP\context\Context; +use PKP\site\Site; define('FORM_ANNOUNCEMENT_SETTINGS', 'announcementSettings'); @@ -35,9 +37,8 @@ class PKPAnnouncementSettingsForm extends FormComponent * * @param string $action URL to submit the form to * @param array $locales Supported locales - * @param \PKP\context\Context $context Journal or Press to change settings for */ - public function __construct($action, $locales, $context) + public function __construct($action, $locales, Context|Site $context) { $this->action = $action; $this->locales = $locales; diff --git a/classes/components/listPanels/PKPAnnouncementsListPanel.php b/classes/components/listPanels/PKPAnnouncementsListPanel.php index 81bc9b40a3c..c0c93646de1 100644 --- a/classes/components/listPanels/PKPAnnouncementsListPanel.php +++ b/classes/components/listPanels/PKPAnnouncementsListPanel.php @@ -49,14 +49,23 @@ public function getConfig() 'editAnnouncementLabel' => __('manager.announcements.edit'), 'form' => $this->form->getConfig(), 'itemsMax' => $this->itemsMax, - 'urlBase' => $request->getDispatcher()->url( - $request, - Application::ROUTE_PAGE, - $request->getContext()->getPath(), - 'announcement', - 'view', - '__id__' - ) + 'urlBase' => $this->getUrlBase() ]; } + + protected function getUrlBase(): string + { + $request = Application::get()->getRequest(); + + return $request->getDispatcher()->url( + $request, + Application::ROUTE_PAGE, + is_a($this->form->context, Context::class) + ? $request->getContext()->getPath() + : 'index', + 'announcement', + 'view', + '__id__' + ); + } } diff --git a/classes/migration/upgrade/v3_5_0/I9253_SiteAnnouncements.php b/classes/migration/upgrade/v3_5_0/I9253_SiteAnnouncements.php new file mode 100644 index 00000000000..b243db7bd26 --- /dev/null +++ b/classes/migration/upgrade/v3_5_0/I9253_SiteAnnouncements.php @@ -0,0 +1,56 @@ +dropForeign(['context_id']); + }); + } + + /** + * Reverse the migration. + */ + public function down(): void + { + $app = Application::getName(); + + $contextIdColumn = 'journal_id'; + $contextTable = 'journals'; + if ($app === 'omp') { + $contextIdColumn = 'press_id'; + $contextTable = 'presses'; + } elseif ($app === 'ops') { + $contextIdColumn = 'server_id'; + $contextTable = 'servers'; + } + + Schema::table('announcement_types', function (Blueprint $table) use ($contextIdColumn, $contextTable) { + $table + ->foreign('context_id') + ->references($contextIdColumn) + ->on($contextTable); + }); + } +} diff --git a/classes/services/PKPNavigationMenuService.php b/classes/services/PKPNavigationMenuService.php index 276b839a83d..8785a243931 100755 --- a/classes/services/PKPNavigationMenuService.php +++ b/classes/services/PKPNavigationMenuService.php @@ -166,7 +166,10 @@ public function getDisplayStatus(&$navigationMenuItem, &$navigationMenu) // Conditionally hide some items switch ($menuItemType) { case NavigationMenuItem::NMI_TYPE_ANNOUNCEMENTS: - $navigationMenuItem->setIsDisplayed($context && $context->getData('enableAnnouncements')); + $navigationMenuItem->setIsDisplayed( + ($context && $context->getData('enableAnnouncements')) + || (!$context && $request->getSite()->getData('enableAnnouncements')) + ); break; case NavigationMenuItem::NMI_TYPE_EDITORIAL_TEAM: $navigationMenuItem->setIsDisplayed($context && $context->getLocalizedData('editorialTeam')); diff --git a/controllers/grid/announcements/AnnouncementTypeGridHandler.php b/controllers/grid/announcements/AnnouncementTypeGridHandler.php index 4f18fca6bac..edfea5dabdd 100644 --- a/controllers/grid/announcements/AnnouncementTypeGridHandler.php +++ b/controllers/grid/announcements/AnnouncementTypeGridHandler.php @@ -16,6 +16,7 @@ namespace PKP\controllers\grid\announcements; +use APP\core\Application; use APP\notification\NotificationManager; use PKP\announcement\AnnouncementTypeDAO; use PKP\controllers\grid\announcements\form\AnnouncementTypeForm; @@ -28,6 +29,8 @@ use PKP\linkAction\request\AjaxModal; use PKP\notification\PKPNotification; use PKP\security\authorization\ContextAccessPolicy; +use PKP\security\authorization\PKPSiteAccessPolicy; +use PKP\security\authorization\UserRolesRequiredPolicy; use PKP\security\Role; class AnnouncementTypeGridHandler extends GridHandler @@ -57,15 +60,20 @@ public function __construct() */ public function authorize($request, &$args, $roleAssignments) { - $this->addPolicy(new ContextAccessPolicy($request, $roleAssignments)); - $context = $request->getContext(); + $contextId = $this->getContextId(); + + if ($contextId === Application::CONTEXT_ID_NONE) { + $this->addPolicy(new PKPSiteAccessPolicy($request, null, $roleAssignments)); + } else { + $this->addPolicy(new ContextAccessPolicy($request, $roleAssignments)); + } $announcementTypeId = $request->getUserVar('announcementTypeId'); if ($announcementTypeId) { // Ensure announcement type is valid and for this context $announcementTypeDao = DAORegistry::getDAO('AnnouncementTypeDAO'); /** @var AnnouncementTypeDAO $announcementTypeDao */ $announcementType = $announcementTypeDao->getById($announcementTypeId); - if (!$announcementType || $announcementType->getContextId() != $context->getId()) { + if (!$announcementType || $announcementType->getContextId() != $contextId) { return false; } } @@ -125,9 +133,8 @@ public function initialize($request, $args = null) */ protected function loadData($request, $filter) { - $context = $request->getContext(); $announcementTypeDao = DAORegistry::getDAO('AnnouncementTypeDAO'); /** @var AnnouncementTypeDAO $announcementTypeDao */ - return $announcementTypeDao->getByContextId($context->getId()); + return $announcementTypeDao->getByContextId($this->getContextId()); } /** @@ -165,10 +172,7 @@ public function addAnnouncementType($args, $request) public function editAnnouncementType($args, $request) { $announcementTypeId = (int)$request->getUserVar('announcementTypeId'); - $context = $request->getContext(); - $contextId = $context->getId(); - - $announcementTypeForm = new AnnouncementTypeForm($contextId, $announcementTypeId); + $announcementTypeForm = new AnnouncementTypeForm($this->getContextId(), $announcementTypeId); $announcementTypeForm->initData(); return new JSONMessage(true, $announcementTypeForm->fetch($request)); @@ -186,11 +190,9 @@ public function updateAnnouncementType($args, $request) { // Identify the announcement type id. $announcementTypeId = $request->getUserVar('announcementTypeId'); - $context = $request->getContext(); - $contextId = $context->getId(); // Form handling. - $announcementTypeForm = new AnnouncementTypeForm($contextId, $announcementTypeId); + $announcementTypeForm = new AnnouncementTypeForm($this->getContextId(), $announcementTypeId); $announcementTypeForm->readInputData(); if ($announcementTypeForm->validate()) { @@ -227,10 +229,9 @@ public function updateAnnouncementType($args, $request) public function deleteAnnouncementType($args, $request) { $announcementTypeId = (int) $request->getUserVar('announcementTypeId'); - $context = $request->getContext(); $announcementTypeDao = DAORegistry::getDAO('AnnouncementTypeDAO'); /** @var AnnouncementTypeDAO $announcementTypeDao */ - $announcementType = $announcementTypeDao->getById($announcementTypeId, $context->getId()); + $announcementType = $announcementTypeDao->getById($announcementTypeId, $this->getContextId()); if ($announcementType && $request->checkCSRF()) { $announcementTypeDao->deleteObject($announcementType); @@ -244,4 +245,17 @@ public function deleteAnnouncementType($args, $request) return new JSONMessage(false); } + + /** + * Get the id of the request context + * + * Returns CONTEXT_ID_NONE for site-level requests + */ + protected function getContextId(): int + { + $request = Application::get()->getRequest(); + return $request->getContext() + ? $request->getContext()->getId() + : Application::CONTEXT_ID_NONE; + } } diff --git a/locale/en/manager.po b/locale/en/manager.po index 26dfa117244..6ead5ed78c4 100644 --- a/locale/en/manager.po +++ b/locale/en/manager.po @@ -107,6 +107,9 @@ msgstr "Type" msgid "manager.announcements.form.typeIdValid" msgstr "Please select a valid announcement type." +msgid "manager.announcements.notEnabled" +msgstr "You must enable announcements." + msgid "manager.announcements.noneCreated" msgstr "No announcements have been created." diff --git a/pages/admin/AdminHandler.php b/pages/admin/AdminHandler.php index 2f8ca3d5c67..49e44852e11 100644 --- a/pages/admin/AdminHandler.php +++ b/pages/admin/AdminHandler.php @@ -26,6 +26,9 @@ use Illuminate\Support\Str; use PDO; use PKP\cache\CacheManager; +use PKP\components\forms\announcement\PKPAnnouncementForm; +use PKP\components\forms\context\PKPAnnouncementSettingsForm; +use PKP\components\listPanels\PKPAnnouncementsListPanel; use PKP\config\Config; use PKP\core\JSONMessage; use PKP\core\PKPContainer; @@ -181,6 +184,7 @@ public function settings($args, $request) $apiUrl = $dispatcher->url($request, Application::ROUTE_API, Application::CONTEXT_ID_ALL, 'site'); $themeApiUrl = $dispatcher->url($request, Application::ROUTE_API, Application::CONTEXT_ID_ALL, 'site/theme'); $temporaryFileApiUrl = $dispatcher->url($request, Application::ROUTE_API, Application::CONTEXT_ID_ALL, 'temporaryFiles'); + $announcementsApiUrl = $dispatcher->url($request, Application::ROUTE_API, Application::CONTEXT_ID_ALL, 'announcements'); $publicFileManager = new PublicFileManager(); $baseUrl = $request->getBaseUrl() . '/' . $publicFileManager->getSiteFilesPath(); @@ -196,17 +200,27 @@ public function settings($args, $request) $siteBulkEmailsForm = new \PKP\components\forms\site\PKPSiteBulkEmailsForm($apiUrl, $site, $contexts); $themeForm = new \PKP\components\forms\context\PKPThemeForm($themeApiUrl, $locales); $siteStatisticsForm = new \PKP\components\forms\site\PKPSiteStatisticsForm($apiUrl, $locales, $site); + $announcementSettingsForm = new PKPAnnouncementSettingsForm($apiUrl, $locales, $site); + $announcementsForm = new PKPAnnouncementForm($announcementsApiUrl, $locales, $site); + $announcementsListPanel = $this->getAnnouncementsListPanel($announcementsApiUrl, $announcementsForm); $templateMgr = TemplateManager::getManager($request); + $templateMgr->setConstants([ + 'FORM_ANNOUNCEMENT_SETTINGS' => FORM_ANNOUNCEMENT_SETTINGS, + ]); + $templateMgr->setState([ + 'announcementsEnabled' => (bool) $site->getData('enableAnnouncements'), 'components' => [ + $announcementsListPanel->id => $announcementsListPanel->getConfig(), FORM_SITE_APPEARANCE => $siteAppearanceForm->getConfig(), FORM_SITE_CONFIG => $siteConfigForm->getConfig(), FORM_SITE_INFO => $siteInformationForm->getConfig(), FORM_SITE_BULK_EMAILS => $siteBulkEmailsForm->getConfig(), FORM_THEME => $themeForm->getConfig(), FORM_SITE_STATISTICS => $siteStatisticsForm->getConfig(), + FORM_ANNOUNCEMENT_SETTINGS => $announcementSettingsForm->getConfig(), ], ]); @@ -252,6 +266,7 @@ private function siteSettingsAvailability($request) 'siteTheme', 'siteAppearanceSetup', 'statistics', + 'announcements', ]; $singleContextSite = (Services::get('context')->getCount() == 1); @@ -694,4 +709,34 @@ public function failedJobDetails($args, $request) $templateMgr->display('admin/failedJobDetails.tpl'); } + + /** + * Get the list panel for site-wide announcements + */ + protected function getAnnouncementsListPanel(string $apiUrl, PKPAnnouncementForm $form): PKPAnnouncementsListPanel + { + $collector = Repo::announcement() + ->getCollector() + ->filterByContextIds([Application::CONTEXT_ID_NONE]); + + $itemsMax = $collector->getCount(); + $items = Repo::announcement()->getSchemaMap()->summarizeMany( + $collector->limit(30)->getMany() + ); + + return new PKPAnnouncementsListPanel( + 'announcements', + __('manager.setup.announcements'), + [ + 'apiUrl' => $apiUrl, + 'form' => $form, + 'getParams' => [ + 'contextIds' => [Application::CONTEXT_ID_NONE], + 'count' => 30, + ], + 'items' => $items->values(), + 'itemsMax' => $itemsMax, + ] + ); + } } diff --git a/pages/announcement/AnnouncementHandler.php b/pages/announcement/AnnouncementHandler.php index aa45debe9ea..564bd3e7cbb 100644 --- a/pages/announcement/AnnouncementHandler.php +++ b/pages/announcement/AnnouncementHandler.php @@ -17,6 +17,7 @@ namespace PKP\pages\announcement; use APP\core\Application; +use APP\core\Request; use APP\facades\Repo; use APP\handler\Handler; use APP\template\TemplateManager; @@ -25,20 +26,6 @@ class AnnouncementHandler extends Handler { - // - // Implement methods from Handler. - // - /** - * @copydoc Handler::authorize() - */ - public function authorize($request, &$args, $roleAssignments) - { - $this->addPolicy(new ContextRequiredPolicy($request)); - - return parent::authorize($request, $args, $roleAssignments); - } - - // // Public handler methods. // @@ -50,22 +37,19 @@ public function authorize($request, &$args, $roleAssignments) */ public function index($args, $request) { - if (!$request->getContext()->getData('enableAnnouncements')) { + if (!$this->isAnnouncementsEnabled($request)) { $request->getDispatcher()->handle404(); } $this->setupTemplate($request); - $context = $request->getContext(); - $announcementsIntro = $context->getLocalizedData('announcementsIntroduction'); - $templateMgr = TemplateManager::getManager($request); - $templateMgr->assign('announcementsIntroduction', $announcementsIntro); + $templateMgr->assign('announcementsIntroduction', $this->getAnnouncementsIntro($request)); // TODO the announcements list should support pagination $announcements = Repo::announcement() ->getCollector() - ->filterByContextIds([$context->getId()]) + ->filterByContextIds([$this->getContextId($request)]) ->filterByActive() ->getMany(); @@ -81,16 +65,23 @@ public function index($args, $request) */ public function view($args, $request) { - if (!$request->getContext()->getData('enableAnnouncements')) { + if (!$this->isAnnouncementsEnabled($request)) { $request->getDispatcher()->handle404(); } $this->validate(); $this->setupTemplate($request); - $context = $request->getContext(); + $contextId = $this->getContextId($request); $announcementId = (int) array_shift($args); $announcement = Repo::announcement()->get($announcementId); - if ($announcement && $announcement->getAssocType() == Application::getContextAssocType() && $announcement->getAssocId() == $context->getId() && ($announcement->getDateExpire() == null || strtotime($announcement->getDateExpire()) > time())) { + if ( + $announcement + && $announcement->getAssocType() == Application::getContextAssocType() + && $announcement->getAssocId() == $contextId + && ( + $announcement->getDateExpire() == null || strtotime($announcement->getDateExpire()) > time() + ) + ) { $templateMgr = TemplateManager::getManager($request); $templateMgr->assign('announcement', $announcement); $templateMgr->assign('announcementTitle', $announcement->getLocalizedTitleFull()); @@ -98,4 +89,24 @@ public function view($args, $request) } $request->redirect(null, 'announcement'); } + + protected function getContextId(Request $request): int + { + $context = $request->getContext(); + return $context + ? $context->getId() + : Application::CONTEXT_ID_NONE; + } + + protected function isAnnouncementsEnabled(Request $request): bool + { + $contextOrSite = $request->getContext() ?? $request->getSite(); + return $contextOrSite->getData('enableAnnouncements'); + } + + protected function getAnnouncementsIntro(Request $request): string + { + $contextOrSite = $request->getContext() ?? $request->getSite(); + return $contextOrSite->getLocalizedData('announcementsIntroduction'); + } } diff --git a/pages/index/PKPIndexHandler.php b/pages/index/PKPIndexHandler.php index 3f1c83a1f60..c491bc2e467 100644 --- a/pages/index/PKPIndexHandler.php +++ b/pages/index/PKPIndexHandler.php @@ -16,9 +16,11 @@ namespace PKP\pages\index; +use APP\core\Application; use APP\facades\Repo; use APP\handler\Handler; use PKP\context\Context; +use PKP\site\Site; use PKP\template\PKPTemplateManager; class PKPIndexHandler extends Handler @@ -31,14 +33,18 @@ class PKPIndexHandler extends Handler * @param Context $context * @param PKPTemplateManager $templateMgr */ - protected function _setupAnnouncements($context, $templateMgr) + protected function _setupAnnouncements(Context|Site $contextOrSite, $templateMgr) { - $enableAnnouncements = $context->getData('enableAnnouncements'); - $numAnnouncementsHomepage = $context->getData('numAnnouncementsHomepage'); + $enableAnnouncements = $contextOrSite->getData('enableAnnouncements'); + $numAnnouncementsHomepage = $contextOrSite->getData('numAnnouncementsHomepage'); if ($enableAnnouncements && $numAnnouncementsHomepage) { $announcements = Repo::announcement() ->getCollector() - ->filterByContextIds([$context->getId()]) + ->filterByContextIds([ + is_a($contextOrSite, Context::class) + ? $contextOrSite->getId() + : Application::CONTEXT_ID_NONE + ]) ->filterByActive() ->limit((int) $numAnnouncementsHomepage) ->getMany(); diff --git a/schemas/site.json b/schemas/site.json index d14ff6e2b0a..c1fcf6a5da5 100644 --- a/schemas/site.json +++ b/schemas/site.json @@ -14,6 +14,13 @@ "nullable" ] }, + "announcementsIntroduction": { + "type": "string", + "multilingual": true, + "validation": [ + "nullable" + ] + }, "contactEmail": { "type": "string", "multilingual": true, @@ -36,6 +43,12 @@ "nullable" ] }, + "enableAnnouncements": { + "type": "boolean", + "validation": [ + "nullable" + ] + }, "enableBulkEmails": { "type": "array", "description": "Which hosted journals, presses or preprint servers are allowed to send bulk emails.", @@ -99,6 +112,13 @@ "min:4" ] }, + "numAnnouncementsHomepage": { + "type": "integer", + "validation": [ + "nullable", + "min:0" + ] + }, "pageFooter": { "type": "string", "multilingual": true, diff --git a/templates/admin/settings.tpl b/templates/admin/settings.tpl index f2c2dd5bbbf..13d116f72cc 100644 --- a/templates/admin/settings.tpl +++ b/templates/admin/settings.tpl @@ -97,6 +97,37 @@ {/if} + {if $componentAvailability['announcements']} + + + + + + + +

+ {translate key="manager.announcements.notEnabled"} +

+
+ + +

+ {translate key="manager.announcements.notEnabled"} +

+
+
+
+ {/if} {if $componentAvailability['sitePlugins']} {capture assign=pluginGridUrl}{url router=\PKP\core\PKPApplication::ROUTE_COMPONENT component="grid.admin.plugins.AdminPluginGridHandler" op="fetchGrid" escape=false}{/capture}