From cda63445104da1f7c0bdb3f0daf970a82834539a Mon Sep 17 00:00:00 2001 From: Nate Wright Date: Wed, 8 Nov 2023 22:58:30 +0000 Subject: [PATCH] pkp/pkp-lib#9253 Add site-level announcements (main) (#9260) * pkp/pkp-lib#9253 Add site-level announcements * pkp/pkp-lib#9253 Fix type error in announcement helper function * pkp/pkp-lib#9253 Use shorter syntax to filter array * pkp/pkp-lib#9253 Use null to associate announcements with the site * pkp/pkp-lib#9253 Move shared announcements template into pkp-lib * pkp/pkp-lib#9253 Fix URL in announcement API responses * pkp/pkp-lib#9253 Add featured image to site announcements * pkp/pkp-lib#9253 Fix error when adding announcement * pkp/pkp-lib#9253 Fix unwanted file deletion when editing announcement * pkp/pkp-lib#9253 Add image to default announcement templates * pkp/pkp-lib#9253 Make context nullable in announcements install migration * pkp/pkp-lib#9253 Require User when handling announcement image upload * Fix exception name * Refactor to new Controllers * pkp/pkp-lib#9253 Fix duplicate method name after rebase * pkp/pkp-lib#9253 Fix announcements API handler after rebase * pkp/pkp-lib#9253 Check image validity when creating announcement --- .../PKPAnnouncementController.php | 172 ++++++++++------ classes/announcement/Announcement.php | 64 ++++++ classes/announcement/AnnouncementTypeDAO.php | 30 ++- classes/announcement/Collector.php | 51 ++++- classes/announcement/Repository.php | 189 +++++++++++++++++- classes/announcement/maps/Schema.php | 22 +- .../announcement/PKPAnnouncementForm.php | 51 +++-- .../context/PKPAnnouncementSettingsForm.php | 5 +- .../listPanels/PKPAnnouncementsListPanel.php | 11 - classes/file/PKPPublicFileManager.php | 2 +- .../install/AnnouncementsMigration.php | 4 +- .../v3_5_0/I9253_SiteAnnouncements.php | 46 +++++ classes/services/PKPNavigationMenuService.php | 5 +- .../AnnouncementTypeGridHandler.php | 29 ++- .../form/AnnouncementTypeForm.php | 4 +- locale/en/manager.po | 6 + pages/admin/AdminHandler.php | 46 +++++ pages/announcement/AnnouncementHandler.php | 62 +++--- pages/index/PKPIndexHandler.php | 22 +- pages/management/ManagementHandler.php | 9 +- schemas/announcement.json | 34 +++- schemas/site.json | 20 ++ templates/admin/settings.tpl | 31 +++ .../frontend/objects/announcement_full.tpl | 7 + .../frontend/objects/announcement_summary.tpl | 47 +++-- .../frontend/objects/announcements_list.tpl | 42 ++++ 26 files changed, 830 insertions(+), 181 deletions(-) create mode 100644 classes/migration/upgrade/v3_5_0/I9253_SiteAnnouncements.php create mode 100644 templates/frontend/objects/announcements_list.tpl diff --git a/api/v1/announcements/PKPAnnouncementController.php b/api/v1/announcements/PKPAnnouncementController.php index 159655dcd1a..158237e4990 100644 --- a/api/v1/announcements/PKPAnnouncementController.php +++ b/api/v1/announcements/PKPAnnouncementController.php @@ -27,6 +27,9 @@ use Illuminate\Support\Facades\Route; use PKP\core\PKPBaseController; use PKP\core\PKPRequest; +use PKP\announcement\Collector; +use PKP\context\Context; +use PKP\core\exceptions\StoreTemporaryFileException; use PKP\db\DAORegistry; use PKP\facades\Locale; use PKP\jobs\notifications\NewAnnouncementNotifyUsers; @@ -63,7 +66,6 @@ public function getRouteGroupMiddleware(): array { return [ 'has.user', - 'has.context', self::roleAuthorizer([ Role::ROLE_ID_SITE_ADMIN, Role::ROLE_ID_MANAGER, @@ -100,6 +102,10 @@ public function getGroupRoutes(): void */ public function authorize(PKPRequest $request, array &$args, array $roleAssignments): bool { + if (!$request->getContext()) { + $roleAssignments = $this->getSiteRoleAssignments($roleAssignments); + } + $this->addPolicy(new UserRolesRequiredPolicy($request), true); $rolePolicy = new PolicySet(PolicySet::COMBINING_PERMIT_OVERRIDES); @@ -127,7 +133,7 @@ public function get(Request $illuminateRequest): JsonResponse } // The assocId in announcements should always point to the contextId - if ($announcement->getData('assocId') !== $this->getRequest()->getContext()->getId()) { + if ($announcement->getData('assocId') !== $this->getRequest()->getContext()?->getId()) { return response()->json([ 'error' => __('api.announcements.400.contextsNotMatched') ], Response::HTTP_BAD_REQUEST); @@ -166,7 +172,12 @@ public function getMany(Request $illuminateRequest): JsonResponse } } - $collector->filterByContextIds([$this->getRequest()->getContext()->getId()]); + if ($this->getRequest()->getContext()) { + $collector->filterByContextIds([$this->getRequest()->getContext()->getId()]); + } else { + $collector->withSiteAnnouncements(Collector::SITE_ONLY); + } + Hook::run('API::announcements::params', [$collector, $illuminateRequest]); @@ -186,16 +197,12 @@ public function add(Request $illuminateRequest): JsonResponse $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.'); - } - $params = $this->convertStringsToSchema(PKPSchemaService::SCHEMA_ANNOUNCEMENT, $illuminateRequest->input()); $params['assocType'] = Application::get()->getContextAssocType(); - $params['assocId'] = $request->getContext()->getId(); + $params['assocId'] = $context?->getId(); - $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)) { @@ -203,55 +210,27 @@ public function add(Request $illuminateRequest): JsonResponse } $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); + try { + $announcementId = Repo::announcement()->add($announcement); + } catch (StoreTemporaryFileException $e) { + $announcementId = $e->dataObject->getId(); + if ($announcementId) { + $announcement = Repo::announcement()->get($announcementId); + Repo::announcement()->delete($announcement); + } + return response()->json([ + 'image' => [__('api.400.errorUploadingImage')] + ], Response::HTTP_BAD_REQUEST); } - $sender = $request->getUser(); - $jobs = []; - foreach ($userIdsToNotify->chunk(PKPNotification::NOTIFICATION_CHUNK_SIZE_LIMIT) as $notifyUserIds) { - $jobs[] = new NewAnnouncementNotifyUsers( - $notifyUserIds, - $contextId, - $announcementId, - Locale::getPrimaryLocale() - ); - } + $announcement = Repo::announcement()->get($announcementId); - if (isset($userIdsToNotifyAndMail)) { - foreach ($userIdsToNotifyAndMail->chunk(Mailer::BULK_EMAIL_SIZE_LIMIT) as $notifyAndMailUserIds) { - $jobs[] = new NewAnnouncementNotifyUsers( - $notifyAndMailUserIds, - $contextId, - $announcementId, - Locale::getPrimaryLocale(), - $sender - ); - } - } + $sendEmail = (bool) filter_var($params['sendEmail'], FILTER_VALIDATE_BOOLEAN); - Bus::batch($jobs)->dispatch(); + if ($context) { + $this->notifyUsers($request, $context, $announcementId, $sendEmail); + } return response()->json(Repo::announcement()->getSchemaMap()->map($announcement), Response::HTTP_OK); } @@ -262,6 +241,7 @@ public function add(Request $illuminateRequest): JsonResponse public function edit(Request $illuminateRequest): JsonResponse { $request = $this->getRequest(); + $context = $request->getContext(); $announcement = Repo::announcement()->get((int) $illuminateRequest->route('announcementId')); @@ -276,7 +256,7 @@ public function edit(Request $illuminateRequest): JsonResponse } // Don't allow to edit an announcement from one context from a different context's endpoint - if ($request->getContext()->getId() !== $announcement->getData('assocId')) { + if ($request->getContext()?->getId() !== $announcement->getData('assocId')) { return response()->json([ 'error' => __('api.announcements.400.contextsNotMatched') ], Response::HTTP_FORBIDDEN); @@ -286,16 +266,22 @@ public function edit(Request $illuminateRequest): JsonResponse $params['id'] = $announcement->getId(); $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)) { return response()->json($errors, Response::HTTP_BAD_REQUEST); } - Repo::announcement()->edit($announcement, $params); + try { + Repo::announcement()->edit($announcement, $params); + } catch (StoreTemporaryFileException $e) { + Repo::announcement()->delete($announcement); + return response()->json([ + 'image' => [__('api.400.errorUploadingImage')] + ], Response::HTTP_BAD_REQUEST); + } $announcement = Repo::announcement()->get($announcement->getId()); @@ -322,7 +308,7 @@ public function delete(Request $illuminateRequest): JsonResponse } // Don't allow to delete an announcement from one context from a different context's endpoint - if ($request->getContext()->getId() !== $announcement->getData('assocId')) { + if ($request->getContext()?->getId() !== $announcement->getData('assocId')) { return response()->json([ 'error' => __('api.announcements.400.contextsNotMatched') ], Response::HTTP_FORBIDDEN); @@ -334,4 +320,70 @@ public function delete(Request $illuminateRequest): JsonResponse return response()->json($announcementProps, Response::HTTP_OK); } + + /** + * Modify the role assignments so that only + * site admins have access + */ + protected function getSiteRoleAssignments(array $roleAssignments): array + { + return array_filter($roleAssignments, fn($key) => $key == Role::ROLE_ID_SITE_ADMIN, ARRAY_FILTER_USE_KEY); + } + + /** + * 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(PKPRequest $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/Announcement.php b/classes/announcement/Announcement.php index 5779adb0e03..48a82bedc79 100644 --- a/classes/announcement/Announcement.php +++ b/classes/announcement/Announcement.php @@ -23,6 +23,9 @@ namespace PKP\announcement; +use APP\core\Application; +use APP\facades\Repo; +use APP\file\PublicFileManager; use PKP\db\DAORegistry; class Announcement extends \PKP\core\DataObject @@ -275,6 +278,67 @@ public function setDatetimePosted($datetimePosted) { $this->setData('datePosted', $datetimePosted); } + + /** + * Get the featured image data + */ + public function getImage(): ?array + { + return $this->getData('image'); + } + + /** + * Set the featured image data + */ + public function setImage(array $image): void + { + $this->setData('image', $image); + } + + /** + * Get the full URL to the image + * + * @param bool $withTimestamp Pass true to include a query argument with a timestamp + * of the date the image was uploaded in order to workaround cache bugs in browsers + */ + public function getImageUrl(bool $withTimestamp = true): string + { + $image = $this->getImage(); + + if (!$image) { + return ''; + } + + $filename = $image['uploadName']; + if ($withTimestamp) { + $filename .= '?'. strtotime($image['dateUploaded']); + } + + $publicFileManager = new PublicFileManager(); + + return join('/', [ + Application::get()->getRequest()->getBaseUrl(), + $this->getAssocId() + ? $publicFileManager->getContextFilesPath((int) $this->getAssocId()) + : $publicFileManager->getSiteFilesPath(), + Repo::announcement()->getImageSubdirectory(), + $filename + ]); + } + + /** + * Get the alt text for the image + */ + public function getImageAltText(): string + { + $image = $this->getImage(); + + if (!$image || !$image['altText']) { + return ''; + } + + return $image['altText']; + } } if (!PKP_STRICT_MODE) { diff --git a/classes/announcement/AnnouncementTypeDAO.php b/classes/announcement/AnnouncementTypeDAO.php index cc2fbba8876..e26c95c6320 100644 --- a/classes/announcement/AnnouncementTypeDAO.php +++ b/classes/announcement/AnnouncementTypeDAO.php @@ -35,8 +35,8 @@ public function newDataObject() /** * Retrieve an announcement type by announcement type ID. * - * @param int $typeId Announcement type ID - * @param int $contextId Optional context ID + * @param ?int $typeId Announcement type ID + * @param ?int $contextId Optional context ID * * @return AnnouncementType */ @@ -110,7 +110,11 @@ public function insertObject($announcementType) (context_id) VALUES (?)'), - [(int) $announcementType->getContextId()] + [ + $announcementType->getContextId() + ? (int) $announcementType->getContextId() + : null + ] ); $announcementType->setId($this->getInsertId()); $this->updateLocaleFields($announcementType); @@ -131,7 +135,7 @@ public function updateObject($announcementType) SET context_id = ? WHERE type_id = ?', [ - (int) $announcementType->getContextId(), + $announcementType->getContextId(), (int) $announcementType->getId() ] ); @@ -181,16 +185,20 @@ public function deleteByContextId($contextId) /** * Retrieve an array of announcement types matching a particular context ID. * - * @param int $contextId - * * @return \Generator Matching AnnouncementTypes */ - public function getByContextId($contextId) + public function getByContextId(?int $contextId) { - $result = $this->retrieve( - 'SELECT * FROM announcement_types WHERE context_id = ? ORDER BY type_id', - [(int) $contextId] - ); + if ($contextId) { + $result = $this->retrieve( + 'SELECT * FROM announcement_types WHERE context_id = ? ORDER BY type_id', + [$contextId] + ); + } else { + $result = $this->retrieve( + 'SELECT * FROM announcement_types WHERE context_id IS NULL ORDER BY type_id' + ); + } foreach ($result as $row) { yield $row->type_id => $this->_fromRow((array) $row); } diff --git a/classes/announcement/Collector.php b/classes/announcement/Collector.php index 680959ed6a1..72b63d82bdd 100644 --- a/classes/announcement/Collector.php +++ b/classes/announcement/Collector.php @@ -27,13 +27,23 @@ */ 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 const SITE_ONLY = 'site'; + public const SITE_AND_CONTEXTS = 'all'; + public DAO $dao; public ?array $contextIds = null; public ?string $isActive = null; public ?string $searchPhrase = null; public ?array $typeIds = null; + public ?string $includeSite = 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) { @@ -98,6 +108,15 @@ public function filterByTypeIds(array $typeIds): self return $this; } + /** + * Include site-level announcements in the results + */ + public function withSiteAnnouncements(?string $includeMethod = self::SITE_AND_CONTEXTS): self + { + $this->includeSite = $includeMethod; + return $this; + } + /** * Filter announcements by those matching a search query */ @@ -126,6 +145,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() * @@ -136,9 +170,15 @@ public function getQueryBuilder(): Builder $qb = DB::table($this->dao->table . ' as a') ->select(['a.*']); - if (isset($this->contextIds)) { + if (isset($this->contextIds) && $this->includeSite !== self::SITE_ONLY) { + $qb->where('a.assoc_type', Application::get()->getContextAssocType()); $qb->whereIn('a.assoc_id', $this->contextIds); + if ($this->includeSite === self::SITE_AND_CONTEXTS) { + $qb->orWhereNull('a.assoc_id'); + } + } elseif ($this->includeSite === self::SITE_ONLY) { $qb->where('a.assoc_type', Application::get()->getContextAssocType()); + $qb->whereNull('a.assoc_id'); } if (isset($this->typeIds)) { @@ -186,6 +226,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/Repository.php b/classes/announcement/Repository.php index e560b69df61..c3b31a82a6c 100644 --- a/classes/announcement/Repository.php +++ b/classes/announcement/Repository.php @@ -13,8 +13,16 @@ namespace PKP\announcement; +use APP\core\Application; use APP\core\Request; +use APP\file\PublicFileManager; +use PKP\context\Context; use PKP\core\Core; +use PKP\core\exceptions\StoreTemporaryFileException; +use PKP\core\PKPString; +use PKP\file\FileManager; +use PKP\file\TemporaryFile; +use PKP\file\TemporaryFileManager; use PKP\plugins\Hook; use PKP\services\PKPSchemaService; use PKP\validation\ValidatorFactory; @@ -130,12 +138,23 @@ public function add(Announcement $announcement): int { $announcement->setData('datePosted', Core::getCurrentDate()); $id = $this->dao->insert($announcement); + $announcement = $this->get($id); + + if ($announcement->getImage()) { + $this->handleImageUpload($announcement); + } + Hook::call('Announcement::add', [$announcement]); return $id; } - /** @copydoc DAO::update() */ + /** + * Update an object in the database + * + * Deletes the old image if it has been removed, or a new image has + * been uploaded. + */ public function edit(Announcement $announcement, array $params) { $newAnnouncement = clone $announcement; @@ -144,13 +163,30 @@ public function edit(Announcement $announcement, array $params) Hook::call('Announcement::edit', [$newAnnouncement, $announcement, $params]); $this->dao->update($newAnnouncement); + + $image = $newAnnouncement->getImage(); + $hasNewImage = $image && $image['temporaryFileId']; + + if ((!$image || $hasNewImage) && $announcement->getImage()) { + $this->deleteImage($announcement); + } + + if ($hasNewImage) { + $this->handleImageUpload($newAnnouncement); + } } /** @copydoc DAO::delete() */ public function delete(Announcement $announcement) { Hook::call('Announcement::delete::before', [$announcement]); + + if ($announcement->getImage()) { + $this->deleteImage($announcement); + } + $this->dao->delete($announcement); + Hook::call('Announcement::delete', [$announcement]); } @@ -163,4 +199,155 @@ public function deleteMany(Collector $collector) $this->delete($announcement); } } + + /** + * The subdirectory where announcement images are stored + */ + public function getImageSubdirectory(): string + { + return 'announcements'; + } + + /** + * Get the base URL for announcement file uploads + */ + public function getFileUploadBaseUrl(?Context $context = null): string + { + return join('/', [ + Application::get()->getRequest()->getPublicFilesUrl($context), + $this->getImageSubdirectory(), + ]); + } + + /** + * Handle image uploads + * + * @throws StoreTemporaryFileException Unable to store temporary file upload + */ + protected function handleImageUpload(Announcement $announcement): void + { + $image = $announcement->getImage(); + if ($image && $image['temporaryFileId']) { + $user = Application::get()->getRequest()->getUser(); + $image = $announcement->getImage(); + $temporaryFileManager = new TemporaryFileManager(); + $temporaryFile = $temporaryFileManager->getFile((int) $image['temporaryFileId'], $user?->getId()); + $filePath = $this->getImageSubdirectory() . '/' . $this->getImageFilename($announcement, $temporaryFile); + if (!$this->isValidImage($temporaryFile, $filePath, $user, $announcement)) { + throw new StoreTemporaryFileException($temporaryFile, $filePath, $user, $announcement); + } + if ($this->storeTemporaryFile($temporaryFile, $filePath, $user->getId(), $announcement)) { + $announcement->setImage( + $this->getImageData($announcement, $temporaryFile) + ); + $this->dao->update($announcement); + } else { + $this->delete($announcement); + throw new StoreTemporaryFileException($temporaryFile, $filePath, $user, $announcement); + } + } + } + + /** + * Store a temporary file upload in the public files directory + * + * @param string $newPath The new filename with the path relative to the public files directoruy + * @return bool Whether or not the operation was successful + */ + protected function storeTemporaryFile(TemporaryFile $temporaryFile, string $newPath, int $userId, Announcement $announcement): bool + { + $publicFileManager = new PublicFileManager(); + $temporaryFileManager = new TemporaryFileManager(); + + if ($announcement->getAssocId()) { + $result = $publicFileManager->copyContextFile( + $announcement->getAssocId(), + $temporaryFile->getFilePath(), + $newPath + ); + } else { + $result = $publicFileManager->copySiteFile( + $temporaryFile->getFilePath(), + $newPath + ); + } + + if (!$result) { + return false; + } + + $temporaryFileManager->deleteById($temporaryFile->getId(), $userId); + + return $result; + } + + /** + * Get the data array for a temporary file that has just been stored + * + * @return array Data about the image, like the upload name, alt text, and date uploaded + */ + protected function getImageData(Announcement $announcement, TemporaryFile $temporaryFile): array + { + $image = $announcement->getImage(); + + return [ + 'name' => $temporaryFile->getOriginalFileName(), + 'uploadName' => $this->getImageFilename($announcement, $temporaryFile), + 'dateUploaded' => Core::getCurrentDate(), + 'altText' => !empty($image['altText']) ? $image['altText'] : '', + ]; + } + + /** + * Get the filename of the image upload + */ + protected function getImageFilename(Announcement $announcement, TemporaryFile $temporaryFile): string + { + $fileManager = new FileManager(); + + return $announcement->getId() + . $fileManager->getImageExtension($temporaryFile->getFileType()); + } + + /** + * Delete the image related to announcement + */ + protected function deleteImage(Announcement $announcement): void + { + $image = $announcement->getImage(); + if ($image && $image['uploadName']) { + $publicFileManager = new PublicFileManager(); + $filesPath = $announcement->getAssocId() + ? $publicFileManager->getContextFilesPath($announcement->getAssocId()) + : $publicFileManager->getSiteFilesPath(); + + $publicFileManager->deleteByPath( + join('/', [ + $filesPath, + $this->getImageSubdirectory(), + $image['uploadName'], + ]) + ); + } + } + + /** + * Check that temporary file is an image + */ + protected function isValidImage(TemporaryFile $temporaryFile): bool + { + if (getimagesize($temporaryFile->getFilePath()) === false) { + return false; + } + $extension = pathinfo($temporaryFile->getOriginalFileName(), PATHINFO_EXTENSION); + $fileManager = new FileManager(); + $extensionFromMimeType = $fileManager->getImageExtension( + PKPString::mime_content_type($temporaryFile->getFilePath()) + ); + if ($extensionFromMimeType !== '.' . $extension) { + return false; + } + + return true; + } } diff --git a/classes/announcement/maps/Schema.php b/classes/announcement/maps/Schema.php index 5a336969985..d28f31d6d44 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; @@ -85,7 +87,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 +99,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 b705fb92d11..113daff2042 100644 --- a/classes/components/forms/announcement/PKPAnnouncementForm.php +++ b/classes/components/forms/announcement/PKPAnnouncementForm.php @@ -15,11 +15,14 @@ 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\FieldUploadImage; use PKP\components\forms\FormComponent; +use PKP\context\Context; use PKP\db\DAORegistry; define('FORM_ANNOUNCEMENT', 'announcement'); @@ -32,17 +35,21 @@ class PKPAnnouncementForm extends FormComponent /** @copydoc FormComponent::$method */ public $method = 'POST'; + public ?Context $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, string $baseUrl, string $temporaryFileApiUrl, ?Context $context = null) { $this->action = $action; $this->locales = $locales; + $this->context = $context; + + $announcementTypeOptions = $this->getAnnouncementTypeOptions(); $this->addField(new FieldText('title', [ 'label' => __('common.title'), @@ -62,27 +69,23 @@ public function __construct($action, $locales, $announcementContext) 'toolbar' => 'bold italic superscript subscript | link | blockquote bullist numlist', 'plugins' => 'paste,link,lists', ])) + ->addField(new FieldUploadImage('image', [ + 'label' => __('manager.image'), + 'baseUrl' => $baseUrl, + 'options' => [ + 'url' => $temporaryFileApiUrl, + ], + ])) ->addField(new FieldText('dateExpire', [ 'label' => __('manager.announcements.form.dateExpire'), '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' => htmlspecialchars($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 +99,22 @@ public function __construct($action, $locales, $announcementContext) ] ])); } + + protected function getAnnouncementTypeOptions(): array + { + /** @var AnnouncementTypeDAO */ + $announcementTypeDao = DAORegistry::getDAO('AnnouncementTypeDAO'); + + $announcementTypes = $announcementTypeDao->getByContextId($this->context?->getId()); + + $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..ab164026e9d 100644 --- a/classes/components/listPanels/PKPAnnouncementsListPanel.php +++ b/classes/components/listPanels/PKPAnnouncementsListPanel.php @@ -15,8 +15,6 @@ namespace PKP\components\listPanels; -use APP\core\Application; - class PKPAnnouncementsListPanel extends ListPanel { /** @var string URL to the API endpoint where items can be retrieved */ @@ -39,7 +37,6 @@ class PKPAnnouncementsListPanel extends ListPanel */ public function getConfig() { - $request = Application::get()->getRequest(); return parent::getConfig() + [ 'addAnnouncementLabel' => __('grid.action.addAnnouncement'), 'apiUrl' => $this->apiUrl, @@ -49,14 +46,6 @@ 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__' - ) ]; } } diff --git a/classes/file/PKPPublicFileManager.php b/classes/file/PKPPublicFileManager.php index 48743b23513..4cd69a99612 100644 --- a/classes/file/PKPPublicFileManager.php +++ b/classes/file/PKPPublicFileManager.php @@ -81,7 +81,7 @@ public function uploadSiteFile($fileName, $destFileName) } /** - * Copy a file to a context's public directory. + * Copy a file to the site's public directory. * * @param string $sourceFile the source of the file to copy * @param string $destFileName the destination file name diff --git a/classes/migration/install/AnnouncementsMigration.php b/classes/migration/install/AnnouncementsMigration.php index 468a92ce2ce..d12af608688 100644 --- a/classes/migration/install/AnnouncementsMigration.php +++ b/classes/migration/install/AnnouncementsMigration.php @@ -29,7 +29,7 @@ public function up(): void $table->comment('Announcement types allow for announcements to optionally be categorized.'); $table->bigInteger('type_id')->autoIncrement(); - $table->bigInteger('context_id'); + $table->bigInteger('context_id')->nullable(); $contextDao = \APP\core\Application::getContextDAO(); $table->foreign('context_id')->references($contextDao->primaryKeyColumn)->on($contextDao->tableName)->onDelete('cascade'); $table->index(['context_id'], 'announcement_types_context_id'); @@ -57,7 +57,7 @@ public function up(): void $table->bigInteger('announcement_id')->autoIncrement(); // NOT NULL not included for upgrade purposes $table->smallInteger('assoc_type')->nullable(); - $table->bigInteger('assoc_id'); + $table->bigInteger('assoc_id')->nullable(); $table->bigInteger('type_id')->nullable(); $table->foreign('type_id')->references('type_id')->on('announcement_types')->onDelete('set null'); 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..f56322e78bb --- /dev/null +++ b/classes/migration/upgrade/v3_5_0/I9253_SiteAnnouncements.php @@ -0,0 +1,46 @@ +bigInteger('assoc_id')->nullable()->change(); + }); + Schema::table('announcement_types', function (Blueprint $table) { + $table->bigInteger('context_id')->nullable()->change(); + }); + } + + /** + * Reverse the migration. + */ + public function down(): void + { + Schema::table('announcements', function (Blueprint $table) { + $table->bigInteger('assoc_id')->nullable(false)->change(); + }); + Schema::table('announcement_types', function (Blueprint $table) { + $table->bigInteger('context_id')->nullable(false)->change(); + }); + } +} diff --git a/classes/services/PKPNavigationMenuService.php b/classes/services/PKPNavigationMenuService.php index 8b104e30e8b..98e31823313 100755 --- a/classes/services/PKPNavigationMenuService.php +++ b/classes/services/PKPNavigationMenuService.php @@ -172,7 +172,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..f13e034b4a2 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(); + if ($context) { + $this->addPolicy(new ContextAccessPolicy($request, $roleAssignments)); + } else { + $this->addPolicy(new PKPSiteAccessPolicy($request, null, $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() != $context?->getId()) { return false; } } @@ -87,8 +95,6 @@ public function initialize($request, $args = null) // Set the no items row text $this->setEmptyRowText('manager.announcementTypes.noneCreated'); - $context = $request->getContext(); - // Columns $announcementTypeCellProvider = new AnnouncementTypeGridCellProvider(); $this->addColumn( @@ -125,9 +131,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($request->getContext()?->getId()); } /** @@ -165,10 +170,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($request->getContext()?->getId(), $announcementTypeId); $announcementTypeForm->initData(); return new JSONMessage(true, $announcementTypeForm->fetch($request)); @@ -186,11 +188,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($request->getContext()?->getId(), $announcementTypeId); $announcementTypeForm->readInputData(); if ($announcementTypeForm->validate()) { @@ -227,10 +227,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, $request->getContext()?->getId()); if ($announcementType && $request->checkCSRF()) { $announcementTypeDao->deleteObject($announcementType); diff --git a/controllers/grid/announcements/form/AnnouncementTypeForm.php b/controllers/grid/announcements/form/AnnouncementTypeForm.php index a190dd49539..909d70a3dc6 100644 --- a/controllers/grid/announcements/form/AnnouncementTypeForm.php +++ b/controllers/grid/announcements/form/AnnouncementTypeForm.php @@ -25,7 +25,7 @@ class AnnouncementTypeForm extends Form { - /** @var int Context ID */ + /** @var ?int Context ID or null for site announcement */ public $contextId; /** @var int The ID of the announcement type being edited */ @@ -34,7 +34,7 @@ class AnnouncementTypeForm extends Form /** * Constructor * - * @param int $contextId Context ID + * @param ?int $contextId Context ID or null for site announcement * @param int $typeId leave as default for new announcement type */ public function __construct($contextId, $typeId = null) diff --git a/locale/en/manager.po b/locale/en/manager.po index 69b179b8f31..c462ae781fd 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." @@ -577,6 +580,9 @@ msgstr "No users were found." msgid "manager.groups.title" msgstr "Title" +msgid "manager.image" +msgstr "Image" + msgid "manager.importExport" msgstr "Import/Export Data" diff --git a/pages/admin/AdminHandler.php b/pages/admin/AdminHandler.php index 9163b3c9bb3..69266c272a4 100644 --- a/pages/admin/AdminHandler.php +++ b/pages/admin/AdminHandler.php @@ -25,9 +25,13 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use PDO; +use PKP\announcement\Collector; use PKP\cache\CacheManager; use PKP\components\forms\highlight\HighlightForm; use PKP\components\listPanels\HighlightsListPanel; +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; @@ -184,6 +188,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(); @@ -200,11 +205,20 @@ public function settings($args, $request) $themeForm = new \PKP\components\forms\context\PKPThemeForm($themeApiUrl, $locales); $siteStatisticsForm = new \PKP\components\forms\site\PKPSiteStatisticsForm($apiUrl, $locales, $site); $highlightsListPanel = $this->getHighlightsListPanel(); + $announcementSettingsForm = new PKPAnnouncementSettingsForm($apiUrl, $locales, $site); + $announcementsForm = new PKPAnnouncementForm($announcementsApiUrl, $locales, Repo::announcement()->getFileUploadBaseUrl(), $temporaryFileApiUrl); + $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(), @@ -212,6 +226,7 @@ public function settings($args, $request) FORM_THEME => $themeForm->getConfig(), FORM_SITE_STATISTICS => $siteStatisticsForm->getConfig(), $highlightsListPanel->id => $highlightsListPanel->getConfig(), + FORM_ANNOUNCEMENT_SETTINGS => $announcementSettingsForm->getConfig(), ], ]); @@ -258,6 +273,7 @@ private function siteSettingsAvailability($request) 'siteTheme', 'siteAppearanceSetup', 'statistics', + 'announcements', ]; $singleContextSite = (Services::get('context')->getCount() == 1); @@ -745,4 +761,34 @@ protected function getHighlightsListPanel(): HighlightsListPanel ] ); } + + /* + * Get the list panel for site-wide announcements + */ + protected function getAnnouncementsListPanel(string $apiUrl, PKPAnnouncementForm $form): PKPAnnouncementsListPanel + { + $collector = Repo::announcement() + ->getCollector() + ->withSiteAnnouncements(Collector::SITE_ONLY); + + $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..1ee978360d3 100644 --- a/pages/announcement/AnnouncementHandler.php +++ b/pages/announcement/AnnouncementHandler.php @@ -17,28 +17,15 @@ namespace PKP\pages\announcement; use APP\core\Application; +use APP\core\Request; use APP\facades\Repo; use APP\handler\Handler; use APP\template\TemplateManager; +use PKP\announcement\Collector; use PKP\core\PKPRequest; -use PKP\security\authorization\ContextRequiredPolicy; 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,24 +37,27 @@ 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() + $collector = Repo::announcement() ->getCollector() - ->filterByContextIds([$context->getId()]) - ->filterByActive() - ->getMany(); + ->filterByActive(); + + if ($request->getContext()) { + $collector->filterByContextIds([$request->getContext()->getId()]); + } else { + $collector->withSiteAnnouncements(Collector::SITE_ONLY); + } + + $announcements = $collector->getMany(); $templateMgr->assign('announcements', $announcements->toArray()); $templateMgr->display('frontend/pages/announcements.tpl'); @@ -81,16 +71,22 @@ 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(); $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() == $request->getContext()?->getId() + && ( + $announcement->getDateExpire() == null || strtotime($announcement->getDateExpire()) > time() + ) + ) { $templateMgr = TemplateManager::getManager($request); $templateMgr->assign('announcement', $announcement); $templateMgr->assign('announcementTitle', $announcement->getLocalizedTitleFull()); @@ -98,4 +94,16 @@ public function view($args, $request) } $request->redirect(null, 'announcement'); } + + 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 febe761edb1..fff01001e12 100644 --- a/pages/index/PKPIndexHandler.php +++ b/pages/index/PKPIndexHandler.php @@ -19,7 +19,9 @@ use APP\facades\Repo; use APP\handler\Handler; use Illuminate\Support\LazyCollection; +use PKP\announcement\Collector; use PKP\context\Context; +use PKP\site\Site; use PKP\template\PKPTemplateManager; class PKPIndexHandler extends Handler @@ -32,17 +34,23 @@ 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() + $collector = Repo::announcement() ->getCollector() - ->filterByContextIds([$context->getId()]) ->filterByActive() - ->limit((int) $numAnnouncementsHomepage) - ->getMany(); + ->limit((int) $numAnnouncementsHomepage); + + if (is_a($contextOrSite, Context::class)) { + $collector->filterByContextIds([$contextOrSite->getId()]); + } else { + $collector->withSiteAnnouncements(Collector::SITE_ONLY); + } + + $announcements = $collector->getMany(); $templateMgr->assign([ 'announcements' => $announcements->toArray(), diff --git a/pages/management/ManagementHandler.php b/pages/management/ManagementHandler.php index a8a6f336d85..a384bdbbbcb 100644 --- a/pages/management/ManagementHandler.php +++ b/pages/management/ManagementHandler.php @@ -23,6 +23,7 @@ use APP\file\PublicFileManager; use APP\handler\Handler; use APP\template\TemplateManager; +use PKP\components\forms\announcement\PKPAnnouncementForm; use PKP\components\forms\context\PKPDoiRegistrationSettingsForm; use PKP\components\forms\context\PKPEmailSetupForm; use PKP\components\forms\context\PKPInformationForm; @@ -354,7 +355,13 @@ public function announcements($args, $request) $locales = $this->getSupportedFormLocales($context); - $announcementForm = new \PKP\components\forms\announcement\PKPAnnouncementForm($apiUrl, $locales, $request->getContext()); + $announcementForm = new PKPAnnouncementForm( + $apiUrl, + $locales, + Repo::announcement()->getFileUploadBaseUrl($context), + $this->getTemporaryFileApiUrl($context), + $request->getContext() + ); $collector = Repo::announcement() ->getCollector() diff --git a/schemas/announcement.json b/schemas/announcement.json index b9119b5e40a..349b136d680 100644 --- a/schemas/announcement.json +++ b/schemas/announcement.json @@ -3,7 +3,6 @@ "description": "An announcement or news item.", "required": [ "assocType", - "assocId", "title" ], "properties": { @@ -16,8 +15,11 @@ }, "assocId": { "type": "integer", - "description": "The journal, press or preprint server ID.", - "apiSummary": true + "description": "The journal, press or preprint server ID. Null for site-level announcements.", + "apiSummary": true, + "validation": [ + "nullable" + ] }, "assocType": { "type": "integer", @@ -66,6 +68,32 @@ "readOnly": true, "apiSummary": true }, + "image": { + "type": "object", + "description": "The image to show with this announcement.", + "apiSummary": true, + "validation": [ + "nullable" + ], + "properties": { + "temporaryFileId": { + "type": "integer", + "writeOnly": true + }, + "name": { + "type": "string" + }, + "uploadName": { + "type": "string" + }, + "dateUploaded": { + "type": "string" + }, + "altText": { + "type": "string" + } + } + }, "title": { "type": "string", "multilingual": true, 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 d841df02e99..f088fe1ee14 100644 --- a/templates/admin/settings.tpl +++ b/templates/admin/settings.tpl @@ -109,6 +109,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} diff --git a/templates/frontend/objects/announcement_full.tpl b/templates/frontend/objects/announcement_full.tpl index b29d845722c..8d9de6fbca6 100644 --- a/templates/frontend/objects/announcement_full.tpl +++ b/templates/frontend/objects/announcement_full.tpl @@ -18,6 +18,13 @@
{$announcement->getDatePosted()|date_format:$dateFormatShort}
+ {if $announcement->getImage()} + {$announcement->getImageAltText()} + {/if}
{if $announcement->getLocalizedDescription()} {$announcement->getLocalizedDescription()|strip_unsafe_html} diff --git a/templates/frontend/objects/announcement_summary.tpl b/templates/frontend/objects/announcement_summary.tpl index 72d2231f8ee..236dd2f593d 100644 --- a/templates/frontend/objects/announcement_summary.tpl +++ b/templates/frontend/objects/announcement_summary.tpl @@ -14,24 +14,33 @@ {assign var="heading" value="h2"} {/if} -
- <{$heading}> - getId()}"> - {$announcement->getLocalizedTitle()|escape} - - -
- {$announcement->getDatePosted()|date_format:$dateFormatShort} -
-
- {$announcement->getLocalizedDescriptionShort()|strip_unsafe_html} - getId()}" class="read_more"> - - - {translate key="common.readMoreWithTitle" title=$announcement->getLocalizedTitle()|escape} - - + diff --git a/templates/frontend/objects/announcements_list.tpl b/templates/frontend/objects/announcements_list.tpl new file mode 100644 index 00000000000..d17bb583af3 --- /dev/null +++ b/templates/frontend/objects/announcements_list.tpl @@ -0,0 +1,42 @@ +{** + * templates/frontend/objects/announcements_list.tpl + * + * Copyright (c) 2014-2023 Simon Fraser University + * Copyright (c) 2003-2023 John Willinsky + * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING. + * + * @brief Display a list of announcements + * + * @uses $numAnnouncements int The number of announcements to display in the list + * @uses $announcements Announcement[] The list of announcements + *} + +{if $numAnnouncements && $announcements|@count} +
+ +

+ {translate key="announcement.announcements"} +

+ {foreach name=announcements from=$announcements item=announcement} + {if $smarty.foreach.announcements.iteration > $numAnnouncements} + {break} + {/if} + {if $smarty.foreach.announcements.iteration == 1} + {include file="frontend/objects/announcement_summary.tpl" heading="h3"} +
+ {else} + + {/if} + {/foreach} +
+
+{/if} \ No newline at end of file