From 414b07cf36a92ee1650611f06add49f569a89e06 Mon Sep 17 00:00:00 2001 From: Vitalii Bezsheiko Date: Mon, 2 Sep 2024 23:46:20 +0300 Subject: [PATCH] pkp/pkp-lib#10328 Refactor Announcements --- .../PKPBackendSubmissionsController.php | 17 + classes/announcement/Announcement.php | 457 +++++++++--------- classes/announcement/Repository.php | 353 -------------- classes/announcement/maps/Schema.php | 6 +- classes/core/SettingsBuilder.php | 362 ++++++++++++++ .../StoreTemporaryFileException.php | 6 +- classes/core/maps/Schema.php | 13 + classes/core/traits/ModelWithSettings.php | 151 ++++++ classes/mail/mailables/AnnouncementNotify.php | 6 +- .../AnnouncementNotificationManager.php | 10 +- classes/services/PKPSchemaService.php | 55 ++- .../NewAnnouncementNotifyUsers.php | 6 +- pages/admin/AdminHandler.php | 3 +- pages/management/ManagementHandler.php | 3 +- schemas/announcement.json | 12 + 15 files changed, 862 insertions(+), 598 deletions(-) delete mode 100644 classes/announcement/Repository.php create mode 100644 classes/core/SettingsBuilder.php create mode 100644 classes/core/traits/ModelWithSettings.php diff --git a/api/v1/_submissions/PKPBackendSubmissionsController.php b/api/v1/_submissions/PKPBackendSubmissionsController.php index 56e2d4c70c4..30923a3a84f 100644 --- a/api/v1/_submissions/PKPBackendSubmissionsController.php +++ b/api/v1/_submissions/PKPBackendSubmissionsController.php @@ -24,6 +24,7 @@ use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Route; +use PKP\announcement\Announcement; use PKP\API\v1\submissions\AnonymizeData; use PKP\config\Config; use PKP\core\PKPBaseController; @@ -141,6 +142,15 @@ public function getGroupRoutes(): void Role::ROLE_ID_REVIEWER, ]) ]); + + // Endpoint for testing new User Model; TODO remove before merging + Route::get('testUserModel', $this->getTestUsers(...)) + ->name('_submission.getTestUsers') + ->middleware([ + self::roleAuthorizer([ + Role::ROLE_ID_MANAGER, + ]) + ]); } } @@ -508,4 +518,11 @@ protected function canAccessAllSubmissions(): bool $userRoles = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_USER_ROLES); return !empty(array_intersect([Role::ROLE_ID_SITE_ADMIN, Role::ROLE_ID_MANAGER], $userRoles)); } + + public function getTestUsers(Request $illuminateRequest): JsonResponse + { + $announcement = Announcement::find(1); + + return response()->json($announcement->datePosted, Response::HTTP_OK); + } } diff --git a/classes/announcement/Announcement.php b/classes/announcement/Announcement.php index f3760ad3381..502b6df0697 100644 --- a/classes/announcement/Announcement.php +++ b/classes/announcement/Announcement.php @@ -1,345 +1,350 @@ getData('assocId'); - } + use HasCamelCasing; + use ModelWithSettings; - /** - * Set assoc ID for this announcement. - * - * @param int $assocId - */ - public function setAssocId($assocId) - { - $this->setData('assocId', $assocId); - } + // The subdirectory where announcement images are stored + public const IMAGE_SUBDIRECTORY = 'announcements'; - /** - * Get assoc type for this announcement. - * - * @return int - */ - public function getAssocType() - { - return $this->getData('assocType'); - } + protected $table = 'announcements'; + protected $primaryKey = 'announcement_id'; + public const CREATED_AT = 'date_posted'; + public const UPDATED_AT = null; + protected string $settingsTable = 'announcement_settings'; /** - * Set assoc type for this announcement. - * - * @param int $assocType + * @inheritDoc */ - public function setAssocType($assocType) + public function getSchemaName(): string { - $this->setData('assocType', $assocType); + return PKPSchemaService::SCHEMA_ANNOUNCEMENT; } /** - * Get the announcement type of the announcement. - * - * @return int + * @inheritDoc */ - public function getTypeId() + public function getSettingsTable(): string { - return $this->getData('typeId'); + return $this->settingsTable; } /** - * Set the announcement type of the announcement. - * - * @param int $typeId + * Add Model-level defined multilingual properties */ - public function setTypeId($typeId) + public function getMultilingualProps(): array { - $this->setData('typeId', $typeId); + return array_merge( + $this->multilingualProps, + [ + 'fullTitle', + ] + ); } /** - * Get the announcement type name of the announcement. + * Delete announcement, also allows to delete multiple announcements by IDs at once with destroy() method * - * @return string|null - */ - public function getAnnouncementTypeName() - { - $announcementTypeDao = DAORegistry::getDAO('AnnouncementTypeDAO'); /** @var AnnouncementTypeDAO $announcementTypeDao */ - return $this->getData('typeId') ? $announcementTypeDao->getById($this->getData('typeId'))?->getLocalizedTypeName() : null; - } - - /** - * Get localized announcement title + * @return bool|null * - * @return string + * @hook Announcement::delete::before [[$this]] */ - public function getLocalizedTitle() + public function delete() { - return $this->getLocalizedData('title'); - } + Hook::call('Announcement::delete::before', [$this]); - /** - * Get full localized announcement title including type name - * - * @return string - */ - public function getLocalizedTitleFull() - { - $typeName = $this->getAnnouncementTypeName(); - if (!empty($typeName)) { - return $typeName . ': ' . $this->getLocalizedTitle(); - } else { - return $this->getLocalizedTitle(); + $deleted = parent::delete(); + if ($deleted) { + $this->deleteImage(); } - } - /** - * Get announcement title. - * - * @param string $locale - * - * @return string - */ - public function getTitle($locale) - { - return $this->getData('title', $locale); - } + Hook::call('Announcement::delete', [$this]); - /** - * Set announcement title. - * - * @param string $title - * @param string $locale - */ - public function setTitle($title, $locale) - { - $this->setData('title', $title, $locale); + return $deleted; } /** - * Get localized short description + * @return bool * - * @return string + * @hook Announcement::add [[$this]] */ - public function getLocalizedDescriptionShort() + public function save(array $options = []) { - return $this->getLocalizedData('descriptionShort'); - } + $newlyCreated = !$this->exists; + $saved = parent::save($options); - /** - * Get announcement brief description. - * - * @param string $locale - * - * @return string - */ - public function getDescriptionShort($locale) - { - return $this->getData('descriptionShort', $locale); - } + // If it's a new model with an image attribute, upload an image + if ($saved && $newlyCreated && $this->hasAttribute('image')) { + $this->handleImageUpload(); + } - /** - * Set announcement brief description. - * - * @param string $descriptionShort - * @param string $locale - */ - public function setDescriptionShort($descriptionShort, $locale) - { - $this->setData('descriptionShort', $descriptionShort, $locale); - } + // If it's updated model and a new image is uploaded, first, delete an old one + $hasNewImage = $this->hasAttribute('temporaryFileId'); + if ($saved && !$newlyCreated && $hasNewImage) { + $this->deleteImage(); + $this->handleImageUpload(); + } - /** - * Get localized full description - * - * @return string - */ - public function getLocalizedDescription() - { - return $this->getLocalizedData('description'); + Hook::call('Announcement::add', [$this]); + + return $saved; } /** - * Get announcement description. - * - * @param string $locale - * - * @return string + * Get the base URL for announcement file uploads */ - public function getDescription($locale) + public static function getFileUploadBaseUrl(?Context $context = null): string { - return $this->getData('description', $locale); + return join('/', [ + Application::get()->getRequest()->getPublicFilesUrl($context), + static::IMAGE_SUBDIRECTORY, + ]); } /** - * Set announcement description. - * - * @param string $description - * @param string $locale + * Get the full title + * TODO temporary measure while AnnouncementType isn't refactored as Eloquent Model */ - public function setDescription($description, $locale) + protected function fullTitle(): Attribute { - $this->setData('description', $description, $locale); + return Attribute::make( + get: function (mixed $value, array $attributes) { + $announcementTypeDao = DAORegistry::getDAO('AnnouncementTypeDAO'); /** @var AnnouncementTypeDAO $announcementTypeDao */ + $multilingualTitle = $attributes['title']; + if (!isset($attributes['typeId'])) { + return $multilingualTitle; + } + + $type = $announcementTypeDao->getById($attributes['typeId']); + $typeName = $type->getData('name'); + + $multilingualFullTitle = $multilingualTitle; + foreach ($multilingualTitle as $locale => $title) { + if (isset($typeName[$locale])) { + $multilingualFullTitle[$locale] = $typeName . ': ' . $title; + } + } + + return $multilingualFullTitle; + } + ); } /** - * Get announcement expiration date. - * - * @return string (YYYY-MM-DD) + * Get announcement's image URL */ - public function getDateExpire() + protected function imageUrl(bool $withTimestamp = true): Attribute { - return $this->getData('dateExpire'); + return Attribute::make( + get: function () use ($withTimestamp) { + if (!$this->hasAttribute('image')) { + return ''; + } + $image = $this->getAttribute('image'); + + $filename = $image->uploadName; + if ($withTimestamp) { + $filename .= '?' . strtotime($image->dateUploaded); + } + + $publicFileManager = new PublicFileManager(); + + return join('/', [ + Application::get()->getRequest()->getBaseUrl(), + $this->hasAttribute('assocId') + ? $publicFileManager->getContextFilesPath($this->getAttribute('assocId')) + : $publicFileManager->getSiteFilesPath(), + static::IMAGE_SUBDIRECTORY, + $filename + ]); + } + ); } /** - * Set announcement expiration date. - * - * @param string $dateExpire (YYYY-MM-DD) + * Get alternative text of the image */ - public function setDateExpire($dateExpire) + protected function imageAltText(): Attribute { - $this->setData('dateExpire', $dateExpire); + return Attribute::make( + get: fn () => $this->image->altText ?? '' + ); } /** - * Get announcement posted date. - * - * @return string (YYYY-MM-DD) + * Get the date announcement was posted */ - public function getDatePosted() + protected function datePosted(): Attribute { - return date('Y-m-d', strtotime($this->getData('datePosted'))); + return Attribute::make( + get: fn (string $value) => date('Y-m-d', strtotime($value)) + ); } /** - * Get announcement posted datetime. - * - * @return string (YYYY-MM-DD HH:MM:SS) + * Delete the image related to announcement */ - public function getDatetimePosted() + protected function deleteImage(): void { - return $this->getData('datePosted'); + $image = $this->getAttribute('image'); + if ($image?->uploadName) { + $publicFileManager = new PublicFileManager(); + $filesPath = $this->hasAttribute('assocId') + ? $publicFileManager->getContextFilesPath($this->getAttribute('assocId')) + : $publicFileManager->getSiteFilesPath(); + + $publicFileManager->deleteByPath( + join('/', [ + $filesPath, + static::IMAGE_SUBDIRECTORY, + $image->uploadName, + ]) + ); + } } /** - * Set announcement posted date. + * Handle image uploads * - * @param string $datePosted (YYYY-MM-DD) + * @throws StoreTemporaryFileException Unable to store temporary file upload */ - public function setDatePosted($datePosted) + protected function handleImageUpload(): void { - $this->setData('datePosted', $datePosted); + $image = $this->getAttribute('image'); + if (!$image?->temporaryFileId) { + return; + } + + $user = Application::get()->getRequest()->getUser(); + $temporaryFileManager = new TemporaryFileManager(); + $temporaryFile = $temporaryFileManager->getFile((int) $image->temporaryFileId, $user?->getId()); + $filePath = static::IMAGE_SUBDIRECTORY . '/' . $this->getImageFilename($temporaryFile); + if (!$this->isValidImage($temporaryFile)) { + throw new StoreTemporaryFileException($temporaryFile, $filePath, $user, $this); + } + + if ($this->storeTemporaryFile($temporaryFile, $filePath, $user->getId())) { + $this->setAttribute( + 'image', + $this->getImageData($temporaryFile) + ); + $this->save(); + } else { + $this->delete(); + throw new StoreTemporaryFileException($temporaryFile, $filePath, $user, $this); + } } /** - * Set announcement posted datetime. + * Get the data array for a temporary file that has just been stored * - * @param string $datetimePosted (YYYY-MM-DD HH:MM:SS) + * @return array Data about the image, like the upload name, alt text, and date uploaded */ - public function setDatetimePosted($datetimePosted) + protected function getImageData(TemporaryFile $temporaryFile): array { - $this->setData('datePosted', $datetimePosted); + $image = $this->image; + + return [ + 'name' => $temporaryFile->getOriginalFileName(), + 'uploadName' => $this->getImageFilename($temporaryFile), + 'dateUploaded' => Core::getCurrentDate(), + 'altText' => $image->altText ?? '', + ]; } /** - * Get the featured image data + * Get the filename of the image upload */ - public function getImage(): ?array + protected function getImageFilename(TemporaryFile $temporaryFile): string { - return $this->getData('image'); - } + $fileManager = new FileManager(); - /** - * Set the featured image data - */ - public function setImage(array $image): void - { - $this->setData('image', $image); + return $this->getAttribute('announcementId') + . $fileManager->getImageExtension($temporaryFile->getFileType()); } /** - * 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 + * Check that temporary file is an image */ - public function getImageUrl(bool $withTimestamp = true): string + protected function isValidImage(TemporaryFile $temporaryFile): bool { - $image = $this->getImage(); - - if (!$image) { - return ''; + if (getimagesize($temporaryFile->getFilePath()) === false) { + return false; } - - $filename = $image['uploadName']; - if ($withTimestamp) { - $filename .= '?'. strtotime($image['dateUploaded']); + $extension = pathinfo($temporaryFile->getOriginalFileName(), PATHINFO_EXTENSION); + $fileManager = new FileManager(); + $extensionFromMimeType = $fileManager->getImageExtension( + PKPString::mime_content_type($temporaryFile->getFilePath()) + ); + if ($extensionFromMimeType !== '.' . $extension) { + return false; } - $publicFileManager = new PublicFileManager(); - - return join('/', [ - Application::get()->getRequest()->getBaseUrl(), - $this->getAssocId() - ? $publicFileManager->getContextFilesPath((int) $this->getAssocId()) - : $publicFileManager->getSiteFilesPath(), - Repo::announcement()->getImageSubdirectory(), - $filename - ]); + return true; } /** - * Get the alt text for the image + * 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 */ - public function getImageAltText(): string + protected function storeTemporaryFile(TemporaryFile $temporaryFile, string $newPath, int $userId): bool { - $image = $this->getImage(); + $publicFileManager = new PublicFileManager(); + $temporaryFileManager = new TemporaryFileManager(); + + if ($assocId = $this->assocId) { + $result = $publicFileManager->copyContextFile( + $assocId, + $temporaryFile->getFilePath(), + $newPath + ); + } else { + $result = $publicFileManager->copySiteFile( + $temporaryFile->getFilePath(), + $newPath + ); + } - if (!$image || !$image['altText']) { - return ''; + if (!$result) { + return false; } - return $image['altText']; - } -} + $temporaryFileManager->deleteById($temporaryFile->getId(), $userId); -if (!PKP_STRICT_MODE) { - class_alias('\PKP\announcement\Announcement', '\Announcement'); + return $result; + } } diff --git a/classes/announcement/Repository.php b/classes/announcement/Repository.php deleted file mode 100644 index 021e0f58151..00000000000 --- a/classes/announcement/Repository.php +++ /dev/null @@ -1,353 +0,0 @@ - $schemaService */ - protected $schemaService; - - - public function __construct(DAO $dao, Request $request, PKPSchemaService $schemaService) - { - $this->dao = $dao; - $this->request = $request; - $this->schemaService = $schemaService; - } - - /** @copydoc DAO::newDataObject() */ - public function newDataObject(array $params = []): Announcement - { - $object = $this->dao->newDataObject(); - if (!empty($params)) { - $object->setAllData($params); - } - return $object; - } - - /** @copydoc DAO::get() */ - public function get(int $id): ?Announcement - { - return $this->dao->get($id); - } - - /** @copydoc DAO::exists() */ - public function exists(int $id): bool - { - return $this->dao->exists($id); - } - - /** @copydoc DAO::getCollector() */ - public function getCollector(): Collector - { - return app(Collector::class); - } - - /** - * Get an instance of the map class for mapping - * announcements to their schema - */ - public function getSchemaMap(): maps\Schema - { - return app('maps')->withExtensions($this->schemaMap); - } - - /** - * Validate properties for an announcement - * - * Perform validation checks on data used to add or edit an announcement. - * - * @param array $props A key/value array with the new data to validate - * @param array $allowedLocales The context's supported locales - * @param string $primaryLocale The context's primary locale - * - * @return array A key/value array with validation errors. Empty if no errors - * - * @hook Announcement::validate [[&$errors, $object, $props, $allowedLocales, $primaryLocale]] - */ - public function validate(?Announcement $object, array $props, array $allowedLocales, string $primaryLocale): array - { - $validator = ValidatorFactory::make( - $props, - $this->schemaService->getValidationRules($this->dao->schema, $allowedLocales), - [ - 'dateExpire.date_format' => __('stats.dateRange.invalidDate'), - ] - ); - - // Check required fields - ValidatorFactory::required( - $validator, - $object, - $this->schemaService->getRequiredProps($this->dao->schema), - $this->schemaService->getMultilingualProps($this->dao->schema), - $allowedLocales, - $primaryLocale - ); - - // Check for input from disallowed locales - ValidatorFactory::allowedLocales($validator, $this->schemaService->getMultilingualProps($this->dao->schema), $allowedLocales); - - $errors = []; - - if ($validator->fails()) { - $errors = $this->schemaService->formatValidationErrors($validator->errors()); - } - - Hook::call('Announcement::validate', [&$errors, $object, $props, $allowedLocales, $primaryLocale]); - - return $errors; - } - - /** @copydoc DAO::insert() */ - 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; - } - - /** - * 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; - $newAnnouncement->setAllData(array_merge($newAnnouncement->_data, $params)); - - Hook::call('Announcement::edit', [$newAnnouncement, $announcement, $params]); - - $this->dao->update($newAnnouncement); - - $image = $newAnnouncement->getImage(); - $hasNewImage = $image['temporaryFileId'] ?? null; - - 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]); - } - - /** - * Delete a collection of announcements - */ - public function deleteMany(Collector $collector) - { - foreach ($collector->getMany() as $announcement) { - $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['temporaryFileId'] ?? null) { - $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['uploadName'] ?? null) { - $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 412fe84c28f..304d28a6545 100644 --- a/classes/announcement/maps/Schema.php +++ b/classes/announcement/maps/Schema.php @@ -80,7 +80,7 @@ protected function mapByProperties(array $props, Announcement $item): array foreach ($props as $prop) { switch ($prop) { case '_href': - $output[$prop] = $this->getApiUrl('announcements/' . $item->getId()); + $output[$prop] = $this->getApiUrl('announcements/' . $item->getAttribute('announcementId')); break; case 'url': $output[$prop] = $this->request->getDispatcher()->url( @@ -89,11 +89,11 @@ protected function mapByProperties(array $props, Announcement $item): array $this->getUrlPath(), 'announcement', 'view', - [$item->getId()] + [$item->getAttribute('announcementId')] ); break; default: - $output[$prop] = $item->getData($prop); + $output[$prop] = $item->getAttribute($prop); break; } } diff --git a/classes/core/SettingsBuilder.php b/classes/core/SettingsBuilder.php new file mode 100644 index 00000000000..6a9f34562e7 --- /dev/null +++ b/classes/core/SettingsBuilder.php @@ -0,0 +1,362 @@ +getModelWithSettings($columns); + $returner = $this->model->hydrate( + $rows->all() + )->all(); + + return $returner; + } + + /** + * Update records in the database, including settings + * + * @return int + */ + public function update(array $values) + { + // Separate Model's primary values from settings + [$settingValues, $primaryValues] = collect($values)->partition( + fn (array|string $value, string $key) => array_key_exists(Str::camel($key), $this->model->getSettings()) + ); + + // Don't update settings if they aren't set + if ($settingValues->isEmpty()) { + return parent::update($primaryValues); + } + + // TODO Eloquent transforms attributes to snake case, find and override instead of transforming here + $settingValues = $settingValues->mapWithKeys( + fn (array|string $value, string $key) => [Str::camel($key) => $value] + ); + + $u = $this->model->getTable(); + $us = $this->model->getSettingsTable(); + $primaryKey = $this->model->getKeyName(); + $query = $this->toBase(); + + // Add table name to specify the right columns in the already existing WHERE statements + $query->wheres = collect($query->wheres)->map(function (array $item) use ($u) { + $item['column'] = $u . '.' . $item['column']; + return $item; + })->toArray(); + + $sql = $this->buildUpdateSql($settingValues, $us, $query); + + // Build a query for update + $count = $query->fromRaw($u . ', ' . $us) + ->whereColumn($u . '.' . $primaryKey, '=', $us . '.' . $primaryKey) + ->update(array_merge($primaryValues->toArray(), [ + $us . '.setting_value' => DB::raw($sql), + ])); + + return $count; + } + + /** + * Insert the given attributes and set the ID on the model. + * Overrides Builder's method to insert setting values for a models with + * + * @param string|null $sequence + * + * @return int + */ + public function insertGetId(array $values, $sequence = null) + { + // Separate Model's primary values from settings + [$settingValues, $primaryValues] = collect($values)->partition( + fn (array|string $value, string $key) => array_key_exists(Str::camel($key), $this->model->getSettings()) + ); + + $id = parent::insertGetId($primaryValues->toArray(), $sequence); + + if ($settingValues->isEmpty()) { + return $id; + } + + $rows = []; + $settingValues->each(function (string|array $settingValue, string $settingName) use ($id, &$rows) { + $settingName = Str::camel($settingName); + if ($this->isMultilingual($settingName)) { + foreach ($settingValue as $locale => $localizedValue) { + $rows[] = [ + 'user_id' => $id, 'locale' => $locale, 'setting_name' => $settingName, 'setting_value' => $localizedValue + ]; + } + } else { + $rows[] = [ + 'user_id' => $id, 'locale' => '', 'setting_name' => $settingName, 'setting_value' => $settingValue + ]; + } + }); + + DB::table($this->model->getSettingsTable())->insert($rows); + + return $id; + } + + /** + * Delete model with settings + */ + public function delete(): int + { + $id = parent::delete(); + if (!$id) { + return $id; + } + + DB::table($this->model->getSettingsTable())->where( + $this->model->getKeyName(), + $this->model->getRawOriginal($this->model->getKeyName()) ?? $this->model->getKey() + )->delete(); + + return $id; + } + + /** + * Add a basic where clause to the query. + * Overrides Eloquent Builder method to support settings table + * + * @param \Closure|string|array|\Illuminate\Contracts\Database\Query\Expression $column + * @param string $boolean + * @param null|mixed $operator + * @param null|mixed $value + * + * @return $this + */ + public function where($column, $operator = null, $value = null, $boolean = 'and') + { + if ($column instanceof ConditionExpression || $column instanceof Closure) { + return parent::where($column, $operator, $value, $boolean); + } + + $settings = []; + $primaryColumn = false; + + // See Illuminate\Database\Query\Builder::where() + [$value, $operator] = $this->query->prepareValueAndOperator( + $value, + $operator, + func_num_args() === 2 + ); + + if (is_string($column)) { + if (array_key_exists($column, $this->model->getSettings())) { + $settings[$column] = $value; + } else { + $primaryColumn = $column; + } + } + + if (is_array($column)) { + $modelSettingsList = array_keys($this->model->getSettings()); + $settings = array_intersect($column, $modelSettingsList); + $primaryColumn = array_diff($column, $modelSettingsList); + } + + if (empty($settings)) { + return parent::where($column, $operator, $value, $boolean); + } + + $where = []; + foreach ($settings as $settingName => $settingValue) { + $where = array_merge($where, [ + 'setting_name' => $settingName, + 'setting_value' => $settingValue, + ]); + } + + $this->query->whereIn( + $this->model->getKeyName(), + fn (QueryBuilder $query) => + $query->select($this->model->getKeyName())->from($this->model->getSettingsTable())->where($where, null, null, $boolean) + ); + + if (!empty($primaryColumn)) { + parent::where($primaryColumn, $operator, $value, $boolean); + } + + return $this; + } + + /** + * Add a "where in" clause to the query. + * Overrides Illuminate\Database\Query\Builder to support settings in select queries + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param string $boolean + * @param bool $not + * + * @return $this + */ + public function whereIn($column, $values, $boolean = 'and', $not = false) + { + if ($column instanceof Expression || !array_key_exists($column, $this->model->getSettings())) { + return parent::whereIn($column, $values, $boolean, $not); + } + + $this->query->whereIn( + $this->model->getKeyName(), + fn (QueryBuilder $query) => + $query + ->select($this->model->getKeyName()) + ->from($this->model->getSettingsTable()) + ->where('setting_name', $column) + ->whereIn('setting_value', $values, $boolean, $not) + ); + + return $this; + } + + /* + * Augment model with data from the settings table + */ + protected function getModelWithSettings(array|string $columns = ['*']): Collection + { + // First, get all Model columns from the main table + $rows = $this->query->get(); + if ($rows->isEmpty()) { + return $rows; + } + + // Retrieve records from the settings table associated with the primary Model IDs + $primaryKey = $this->model->getKeyName(); + $ids = $rows->pluck($primaryKey)->toArray(); + $settingsChunks = DB::table($this->model->getSettingsTable()) + ->whereIn($primaryKey, $ids) + // Order data by original primary Model's IDs + ->orderByRaw( + 'FIELD(' . + $primaryKey . + ',' . + implode(',', $ids) . + ')' + ) + ->get() + // Chunk records by Model IDs + ->chunkWhile( + fn (\stdClass $value, int $key, Collection $chunk) => + $value->{$primaryKey} === $chunk->last()->{$primaryKey} + ); + + // Associate settings with correspondent Model data + $rows = $rows->map(function (stdClass $row) use ($settingsChunks, $primaryKey, $columns) { + if ($settingsChunks->isNotEmpty()) { + // Don't iterate through all setting rows to avoid Big O(n^2) complexity, chunks are ordered by Model's IDs + // If Model's ID doesn't much it means it doesn't have any settings + if ($row->{$primaryKey} === $settingsChunks->first()->first()->{$primaryKey}) { + $settingsChunk = $settingsChunks->shift(); + $settingsChunk->each(function (\stdClass $settingsRow) use ($row) { + if ($settingsRow->locale) { + $row->{$settingsRow->setting_name}[$settingsRow->locale] = $settingsRow->setting_value; + } else { + $row->{$settingsRow->setting_name} = $settingsRow->setting_value; + } + }); + } + $row = $this->filterRow($row, $columns); + } + + return $row; + }); + + return $rows; + } + + /** + * If specific columns are selected to fill the Model with, iterate and filter all, which aren't specified + * TODO Instead of iterating through all row properties, we can force to pass primary key as a mandatory column? + */ + protected function filterRow(stdClass $row, string|array $columns = ['*']): stdClass + { + if ($columns == ['*']) { + return $row; + } + + $columns = Arr::wrap($columns); + foreach ($row as $property) { + if (!in_array($property, $columns)) { + unset($row->{$property}); + } + } + + return $row; + } + + /** + * @param Collection $settingValues list of setting names as keys and setting values to be updated + * @param string $us name of the settings table + * @param QueryBuilder $query original query associated with the Model + * + * @return string raw SQL statement + * + * Helper method to build a query to update settings with a conditional statement: + * SET settings_value = CASE WHEN setting_name='' AND locale=''... + */ + protected function buildUpdateSql(Collection $settingValues, string $us, QueryBuilder $query): string + { + $sql = 'CASE '; + $bindings = []; + $settingValues->each(function (array|string $settingValue, string $settingName) use (&$sql, &$bindings, $us) { + if ($this->isMultilingual($settingName)) { + foreach ($settingValue as $locale => $localizedValue) { + $sql .= 'WHEN ' . $us . '.setting_name=? AND ' . $us . '.locale=? THEN ? '; + $bindings = array_merge($bindings, [$settingName, $locale, $localizedValue]); + } + } else { + $sql .= 'WHEN ' . $us . '.setting_name=? THEN ? '; + $bindings = array_merge($bindings, [$settingName, $settingValue]); + } + }); + $sql .= 'ELSE setting_value END'; + + // Fix the order of bindings in Laravel, user ID in the where statement should be the last + $query->bindings['where'] = array_merge($bindings, $query->bindings['where']); + + return $sql; + } + + /** + * Checks if setting is multilingual + */ + protected function isMultilingual(string $settingName): bool + { + return in_array($settingName, $this->model->getMultilingualProps()); + } +}; diff --git a/classes/core/exceptions/StoreTemporaryFileException.php b/classes/core/exceptions/StoreTemporaryFileException.php index c313fc22474..e1604ce6f40 100644 --- a/classes/core/exceptions/StoreTemporaryFileException.php +++ b/classes/core/exceptions/StoreTemporaryFileException.php @@ -18,13 +18,14 @@ namespace PKP\core\exceptions; use Exception; +use Illuminate\Database\Eloquent\Model; use PKP\core\DataObject; use PKP\file\TemporaryFile; use PKP\user\User; class StoreTemporaryFileException extends Exception { - public function __construct(public TemporaryFile $temporaryFile, public string $targetPath, public ?User $user, public ?DataObject $dataObject) + public function __construct(public TemporaryFile $temporaryFile, public string $targetPath, public ?User $user, public DataObject|Model|null $dataObject) { $message = `Unable to store temporary file {$temporaryFile->getFilePath()} in {$targetPath}.`; if ($user) { @@ -32,7 +33,8 @@ public function __construct(public TemporaryFile $temporaryFile, public string $ } if ($dataObject) { $class = get_class($dataObject); - $message .= ` Handling {$class} id {$dataObject->getId()}.`; + $id = is_a($class, DataObject::class) ? $dataObject->getId() : $dataObject->id; + $message .= ` Handling {$class} id {$id}.`; } parent::__construct($message); } diff --git a/classes/core/maps/Schema.php b/classes/core/maps/Schema.php index 2d2d3514157..3e312d53b9d 100644 --- a/classes/core/maps/Schema.php +++ b/classes/core/maps/Schema.php @@ -21,6 +21,19 @@ abstract class Schema extends Base { + /** + * ATTRIBUTE_* constants refer to type of attributes according to the Eloquent Model + * + * @var string Primary attribute of the Model derived from the main table + */ + public const ATTRIBUTE_ORIGIN_MAIN = 'primary'; + + /** @var string Model's attribute derived from settings table */ + public const ATTRIBUTE_ORIGIN_SETTINGS = 'setting'; + + /** @var string The value for this attribute is composed with Eloquent's Mutators */ + public const ATTRIBUTE_ORIGIN_COMPOSED = 'composed'; + public PKPRequest $request; public ?Context $context; diff --git a/classes/core/traits/ModelWithSettings.php b/classes/core/traits/ModelWithSettings.php new file mode 100644 index 00000000000..38c65e6f582 --- /dev/null +++ b/classes/core/traits/ModelWithSettings.php @@ -0,0 +1,151 @@ +getSchemaName()) { + $this->setSchemaData(); + } + } + + /** + * Create a new Eloquent query builder for the model that supports settings table + * + * @param \Illuminate\Database\Query\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder|static + */ + public function newEloquentBuilder($query) + { + return new SettingsBuilder($query); + } + + /** + * Get a list of attributes from the settings table associated with the Model + */ + public function getSettings(): array + { + return $this->settings; + } + + /** + * Get multilingual attributes associated with the Model + */ + public function getMultilingualProps(): array + { + $modelProps = parent::getMultilingualProps(); + return array_merge($this->multilingualProps, $modelProps); + } + + /** + * @param string $data Model's localized attribute + * @param ?string $locale Locale to retrieve data for, default - current locale + * + * @throws Exception + * + * @return mixed Localized value + */ + public function getLocalizedData(string $data, ?string $locale = null): mixed + { + if (is_null($locale)) { + $locale = Locale::getLocale(); + } + + $multilingualProp = $this->getAttribute($data); + if (!$multilingualProp) { + throw new Exception('Attribute ' . $data . ' doesn\'t exist in the ' . static::class . ' model'); + } + + if (!in_array($data, $this->getMultilingualProps())) { + throw new Exception('Trying to retrieve localized data from a non-multilingual attribute ' . $data); + } + + // TODO What should the default behaviour be if localized value doesn't exist? + return $multilingualProp[$locale] ?? null; + } + + /** + * Sets the schema for current Model + */ + protected function setSchemaData(): void + { + $schemaService = app()->get('schema'); /** @var PKPSchemaService $schemaService */ + $schema = $schemaService->get($this->getSchemaName()); + $this->convertSchemaToCasts($schema); + $this->settings = array_merge($this->settings, $schemaService->groupPropsByOrigin($this->getSchemaName())[Schema::ATTRIBUTE_ORIGIN_SETTINGS]); + $this->multilingualProps = array_merge($this->settings, $schemaService->getMultilingualProps($this->getSchemaName())); + } + + /** + * Set casts by deriving proper types from schema + * TODO casts on multilingual properties. Keep in mind that overriding Model::attributesToArray() might conflict with HasCamelCasing trait + */ + protected function convertSchemaToCasts(stdClass $schema): void + { + $propCast = []; + foreach ($schema->properties as $propName => $propSchema) { + // Don't cast multilingual values as Eloquent tries to convert them from string to arrays with json_decode() + if (isset($propSchema->multilingual)) { + continue; + } + $propCast[$propName] = $propSchema->type; + } + + $this->mergeCasts($propCast); + } + +} diff --git a/classes/mail/mailables/AnnouncementNotify.php b/classes/mail/mailables/AnnouncementNotify.php index a171c1c004e..bd548f70860 100644 --- a/classes/mail/mailables/AnnouncementNotify.php +++ b/classes/mail/mailables/AnnouncementNotify.php @@ -81,15 +81,15 @@ public function setData(?string $locale = null): void $this->viewData = array_merge( $this->viewData, [ - static::$announcementTitle => $this->announcement->getData('title', $locale), - static::$announcementSummary => $this->announcement->getData('descriptionShort', $locale), + static::$announcementTitle => $this->announcement->getLocalizedData('title', $locale), + static::$announcementSummary => $this->announcement->getLocalizedData('descriptionShort', $locale), static::$announcementUrl => $dispatcher->url( $request, PKPApplication::ROUTE_PAGE, $this->context->getData('urlPath'), 'announcement', 'view', - $this->announcement->getId() + $this->announcement->getAttribute('announcementId') ), ] ); diff --git a/classes/notification/managerDelegate/AnnouncementNotificationManager.php b/classes/notification/managerDelegate/AnnouncementNotificationManager.php index 84e4386dd9c..c55ca7ad2df 100644 --- a/classes/notification/managerDelegate/AnnouncementNotificationManager.php +++ b/classes/notification/managerDelegate/AnnouncementNotificationManager.php @@ -26,8 +26,8 @@ class AnnouncementNotificationManager extends NotificationManagerDelegate { - /** @var Announcement The announcement to send a notification about */ - public $_announcement; + /** The announcement to send a notification about */ + public Announcement $_announcement; /** * Initializes the class. @@ -66,7 +66,7 @@ public function getNotificationUrl(PKPRequest $request, Notification $notificati $request->getContext()->getData('urlPath'), 'announcement', 'view', - $this->_announcement->getId() + $this->_announcement->getAttribute('announcementId') ); } @@ -99,11 +99,11 @@ public function notify(User $user): ?Notification Application::get()->getRequest(), $user->getId(), Notification::NOTIFICATION_TYPE_NEW_ANNOUNCEMENT, - $this->_announcement->getAssocId(), + $this->_announcement->getAttribute('assocId'), null, null, Notification::NOTIFICATION_LEVEL_NORMAL, - ['contents' => $this->_announcement->getLocalizedTitle()] + ['contents' => $this->_announcement->getLocalizedData('title')] ); } } diff --git a/classes/services/PKPSchemaService.php b/classes/services/PKPSchemaService.php index 327715bb284..0aabac27905 100644 --- a/classes/services/PKPSchemaService.php +++ b/classes/services/PKPSchemaService.php @@ -20,6 +20,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\MessageBag; use PKP\core\DataObject; +use PKP\core\maps\Schema; use PKP\plugins\Hook; /** @@ -67,11 +68,12 @@ class PKPSchemaService * * @hook Schema::get::(schemaName) [[schema]] * @hook Schema::get:: + * @hook Schema::get::before:: */ public function get($schemaName, $forceReload = false) { Hook::run('Schema::get::before::' . $schemaName, [&$forceReload]); - + if (!$forceReload && array_key_exists($schemaName, $this->_schemas)) { return $this->_schemas[$schemaName]; } @@ -231,6 +233,57 @@ public function getMultilingualProps($schemaName) return $multilingualProps; } + /** + * Retrieves properties of the schema of certain origin + * + * @param string $schemaName One of the SCHEMA_... constants + * @param string $attributeOrigin one of the Schema::ATTRIBUTE_ORIGIN_* constants + * + * @return array List of property names + */ + public function getPropsByAttributeOrigin(string $schemaName, string $attributeOrigin): array + { + $schema = $this->get($schemaName); + + $propsByOrigin = []; + foreach ($schema->properies as $propName => $propSchema) { + if (!empty($propSchema->origin) && $propSchema->origin == $attributeOrigin) { + $propsByOrigin[] = $propName; + } + } + + return $propsByOrigin; + } + + /** + * Groups properties by their origin, see Schema::ATTRIBUTE_ORIGIN_* constants + * + * @return array>, e.g. ['primary' => ['assocId', 'assocType']] + */ + public function groupPropsByOrigin(string $schemaName): array + { + $schema = $this->get($schemaName); + $propsByOrigin = []; + foreach ($schema->properties as $propName => $propSchema) { + if (!empty($propSchema->origin)) { + switch($propSchema->origin) { + case Schema::ATTRIBUTE_ORIGIN_SETTINGS: + $propsByOrigin[Schema::ATTRIBUTE_ORIGIN_SETTINGS][] = $propName; + break; + case Schema::ATTRIBUTE_ORIGIN_COMPOSED: + $propsByOrigin[Schema::ATTRIBUTE_ORIGIN_COMPOSED][] = $propName; + break; + case Schema::ATTRIBUTE_ORIGIN_MAIN: + default: + $propsByOrigin[Schema::ATTRIBUTE_ORIGIN_MAIN][] = $propName; + break; + } + } + } + + return $propsByOrigin; + } + /** * Sanitize properties according to a schema * diff --git a/jobs/notifications/NewAnnouncementNotifyUsers.php b/jobs/notifications/NewAnnouncementNotifyUsers.php index 586afe5f502..7f53509b5de 100644 --- a/jobs/notifications/NewAnnouncementNotifyUsers.php +++ b/jobs/notifications/NewAnnouncementNotifyUsers.php @@ -97,9 +97,9 @@ public function handle() * Creates new announcement notification email */ protected function createMailable( - Context $context, - User $recipient, - Announcement $announcement, + Context $context, + User $recipient, + Announcement $announcement, EmailTemplate $template ): AnnouncementNotify { $mailable = new AnnouncementNotify($context, $announcement); diff --git a/pages/admin/AdminHandler.php b/pages/admin/AdminHandler.php index 32113d5fefa..659a89efa63 100644 --- a/pages/admin/AdminHandler.php +++ b/pages/admin/AdminHandler.php @@ -25,6 +25,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use PDO; +use PKP\announcement\Announcement; use PKP\announcement\Collector; use PKP\components\forms\announcement\PKPAnnouncementForm; use PKP\components\forms\context\PKPAnnouncementSettingsForm; @@ -214,7 +215,7 @@ public function settings($args, $request) $siteStatisticsForm = new PKPSiteStatisticsForm($apiUrl, $locales, $site); $highlightsListPanel = $this->getHighlightsListPanel(); $announcementSettingsForm = new PKPAnnouncementSettingsForm($apiUrl, $locales, $site); - $announcementsForm = new PKPAnnouncementForm($announcementsApiUrl, $locales, Repo::announcement()->getFileUploadBaseUrl(), $temporaryFileApiUrl); + $announcementsForm = new PKPAnnouncementForm($announcementsApiUrl, $locales, Announcement::getFileUploadBaseUrl(), $temporaryFileApiUrl); $announcementsListPanel = $this->getAnnouncementsListPanel($announcementsApiUrl, $announcementsForm); $templateMgr = TemplateManager::getManager($request); diff --git a/pages/management/ManagementHandler.php b/pages/management/ManagementHandler.php index f04c5f36caf..49142a5386b 100644 --- a/pages/management/ManagementHandler.php +++ b/pages/management/ManagementHandler.php @@ -28,6 +28,7 @@ use APP\file\PublicFileManager; use APP\handler\Handler; use APP\template\TemplateManager; +use PKP\announcement\Announcement; use PKP\components\forms\announcement\PKPAnnouncementForm; use PKP\components\forms\context\PKPAnnouncementSettingsForm; use PKP\components\forms\context\PKPAppearanceMastheadForm; @@ -391,7 +392,7 @@ public function announcements($args, $request) $announcementForm = new PKPAnnouncementForm( $apiUrl, $locales, - Repo::announcement()->getFileUploadBaseUrl($context), + Announcement::getFileUploadBaseUrl($context), $this->getTemporaryFileApiUrl($context), $request->getContext() ); diff --git a/schemas/announcement.json b/schemas/announcement.json index 349b136d680..6edfa98bb41 100644 --- a/schemas/announcement.json +++ b/schemas/announcement.json @@ -8,6 +8,7 @@ "properties": { "_href": { "type": "string", + "origin": "composed", "description": "The URL to this announcement in the REST API.", "format": "uri", "readOnly": true, @@ -15,6 +16,7 @@ }, "assocId": { "type": "integer", + "origin": "primary", "description": "The journal, press or preprint server ID. Null for site-level announcements.", "apiSummary": true, "validation": [ @@ -23,11 +25,13 @@ }, "assocType": { "type": "integer", + "origin": "primary", "description": "The assoc object. This should always be `ASSOC_TYPE_JOURNAL` (OJS), `ASSOC_TYPE_PRESS` (OMP) or `ASSOC_TYPE_SERVER` (OPS).", "apiSummary": true }, "dateExpire": { "type": "string", + "origin": "primary", "description": "(Optional) The date that this announcement expires, if one is set. This is typically used to express closing dates for calls for papers.", "apiSummary": true, "validation": [ @@ -37,6 +41,7 @@ }, "datePosted": { "type": "string", + "origin": "primary", "description": "The date this announcement was posted.", "apiSummary": true, "writeDisabledInApi": true, @@ -47,6 +52,7 @@ }, "description": { "type": "string", + "origin": "setting", "description": "The full text of the announcement.", "multilingual": true, "apiSummary": true, @@ -56,6 +62,7 @@ }, "descriptionShort": { "type": "string", + "origin": "setting", "description": "A summary of this announcement.", "multilingual": true, "apiSummary": true, @@ -65,11 +72,13 @@ }, "id": { "type": "integer", + "origin": "primary", "readOnly": true, "apiSummary": true }, "image": { "type": "object", + "origin": "setting", "description": "The image to show with this announcement.", "apiSummary": true, "validation": [ @@ -96,6 +105,7 @@ }, "title": { "type": "string", + "origin": "setting", "multilingual": true, "apiSummary": true, "validation": [ @@ -104,6 +114,7 @@ }, "typeId": { "type": "integer", + "origin": "primary", "description": "(Optional) One of the announcement type ids.", "apiSummary": true, "validation": [ @@ -112,6 +123,7 @@ }, "url": { "type": "string", + "origin": "composed", "format": "uri", "readOnly": true, "apiSummary": true,