diff --git a/api/v1/funders/PKPFunderController.php b/api/v1/funders/PKPFunderController.php new file mode 100644 index 00000000000..22bb49e48c9 --- /dev/null +++ b/api/v1/funders/PKPFunderController.php @@ -0,0 +1,341 @@ +group(function () { + + Route::get('', $this->getMany(...)) + ->name('funders.getMany'); + + Route::get('{funderId}', $this->get(...)) + ->name('funders.getFunder') + ->whereNumber('funderId'); + + Route::post('', $this->add(...)) + ->name('funders.add'); + + Route::put('{funderId}', $this->edit(...)) + ->name('funders.edit') + ->whereNumber('funderId'); + + Route::delete('{funderId}', $this->delete(...)) + ->name('funders.delete') + ->whereNumber('funderId'); + + Route::put('order', $this->saveOrder(...)) + ->name('funders.order'); + + })->whereNumber(['submissionId', 'publicationId']); + } + + /** + * @copydoc \PKP\core\PKPBaseController::authorize() + */ + public function authorize(PKPRequest $request, array &$args, array $roleAssignments): bool + { + + $illuminateRequest = $args[0]; /** @var \Illuminate\Http\Request $illuminateRequest */ + $actionName = static::getRouteActionName($illuminateRequest); + + $this->addPolicy(new UserRolesRequiredPolicy($request), true); + + $this->addPolicy(new ContextAccessPolicy($request, $roleAssignments)); + $this->addPolicy(new SubmissionAccessPolicy($request, $args, $roleAssignments)); + + if (in_array($actionName, ['get', 'getMany'], true)) { + $this->addPolicy(new PublicationAccessPolicy($request, $args, $roleAssignments)); + } else { + $this->addPolicy(new PublicationWritePolicy($request, $args, $roleAssignments)); + } + + return parent::authorize($request, $args, $roleAssignments); + } + + /** + * Get a single funder. + */ + public function get(Request $illuminateRequest): JsonResponse + { + $funder = Funder::find((int) $illuminateRequest->route('funderId')); + + + if (!$funder) { + return response()->json([ + 'error' => __('api.funders.404.funderNotFound') + ], Response::HTTP_NOT_FOUND); + } + + $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION); + + if ($submission->getId() !== $funder->submissionId) { + return response()->json([ + 'error' => __('api.funders.400.submissionsNotMatched'), + ], Response::HTTP_FORBIDDEN); + } + + return response()->json(Repo::funder()->getSchemaMap()->summarize($funder), Response::HTTP_OK); + } + + /** + * Get a collection of funders. + * + * @hook API::funders::params [[$collector, $illuminateRequest]] + */ + public function getMany(Request $illuminateRequest): JsonResponse + { + $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION); + $funders = Funder::withSubmissionId($submission->getId())->orderBySeq(); + + Hook::run('API::funders::params', [$funders, $illuminateRequest]); + + return response()->json([ + 'itemsMax' => $funders->count(), + 'items' => Repo::funder()->getSchemaMap()->summarizeMany($funders->get())->values(), + ], Response::HTTP_OK); + } + + /** + * Add a funder. + */ + public function add(Request $illuminateRequest): JsonResponse + { + $input = $illuminateRequest->input(); + + $ror = $input['funder']['ror'] ?? null; + $params = [ + 'ror' => $ror, + 'name' => $ror ? null : ($input['funder']['name'] ?? []), + 'grants' => $input['grants'] ?? [], + 'seq' => 0, + ]; + + $params = $this->convertStringsToSchema(PKPSchemaService::SCHEMA_FUNDER, $params); + $readOnlyErrors = $this->getWriteDisabledErrors(PKPSchemaService::SCHEMA_FUNDER, $params); + if ($readOnlyErrors) { + return response()->json($readOnlyErrors, Response::HTTP_BAD_REQUEST); + } + + $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION); + $params['submissionId'] = (int) $submission->getId(); + + $errors = Repo::funder()->validate(null, $params); + if (!empty($errors)) { + return response()->json($errors, Response::HTTP_BAD_REQUEST); + } + + $funder = Funder::create($params); + + return response()->json(Repo::funder()->getSchemaMap()->map($funder), Response::HTTP_OK); + } + + /** + * Edit a funder. + */ + public function edit(Request $illuminateRequest): JsonResponse + { + $funder = Funder::find((int)$illuminateRequest->route('funderId')); + + if (!$funder) { + return response()->json([ + 'error' => __('api.funders.404.funderNotFound'), + ], Response::HTTP_NOT_FOUND); + } + + $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION); + + if ($submission->getId() !== $funder->submissionId) { + return response()->json([ + 'error' => __('api.funders.400.submissionsNotMatched'), + ], Response::HTTP_FORBIDDEN); + } + + $input = $illuminateRequest->input(); + + $ror = $input['funder']['ror'] ?? null; + $params = [ + 'ror' => $ror, + 'name' => $ror ? null : ($input['funder']['name'] ?? []), + 'grants' => $input['grants'] ?? [], + 'seq' => 0, + ]; + + $params = $this->convertStringsToSchema(PKPSchemaService::SCHEMA_FUNDER, $params); + + $readOnlyErrors = $this->getWriteDisabledErrors(PKPSchemaService::SCHEMA_FUNDER, $params); + if (!empty($readOnlyErrors)) { + return response()->json($readOnlyErrors, Response::HTTP_BAD_REQUEST); + } + + $params['id'] = $funder->id; + + $errors = Repo::funder()->validate($funder, $params); + if (!empty($errors)) { + return response()->json($errors, Response::HTTP_BAD_REQUEST); + } + + $funder->update($params); + + $funder = Funder::find($funder->id); + + return response()->json( + Repo::funder()->getSchemaMap()->map($funder), Response::HTTP_OK + ); + } + + /** + * Delete a funder. + */ + public function delete(Request $illuminateRequest): JsonResponse + { + $funder = Funder::find((int) $illuminateRequest->route('funderId')); + + if (!$funder) { + return response()->json([ + 'error' => __('api.funders.404.funderNotFound') + ], Response::HTTP_NOT_FOUND); + } + + $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION); + + if ($submission->getId() !== $funder->submissionId) { + return response()->json([ + 'error' => __('api.funders.400.submissionsNotMatched'), + ], Response::HTTP_FORBIDDEN); + } + + $funder->delete(); + + return response()->json( + Repo::funder()->getSchemaMap()->map($funder), Response::HTTP_OK + ); + } + + /** + * Save the order of funders for a publication. + */ + public function saveOrder(Request $illuminateRequest): JsonResponse + { + $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION); + + $submissionId = (int) $submission->getId(); + $sequence = $illuminateRequest->json()->all(); + + if (!is_array($sequence)) { + return response()->json( + ['error' => __('api.funders.404.invalidOrderFormat')], + Response::HTTP_BAD_REQUEST + ); + } + + foreach ($sequence as $index => $funderId) { + Funder::where('funder_id', (int) $funderId) + ->where('submission_id', $submissionId) + ->update(['seq' => $index + 1]); + } + + return response()->json(['status' => true], Response::HTTP_OK); + } + + /** + * This method returns errors for any params that match + * properties in the schema with writeDisabledInApi set to true. + * + * This is used for properties that can not be edited through + * the API, but which otherwise can be edited by the entity's + * repository. + */ + protected function getWriteDisabledErrors(string $schemaName, array $params): array + { + $schema = app()->get('schema')->get($schemaName); + + $writeDisabledProps = []; + foreach ($schema->properties as $propName => $propSchema) { + if (!empty($propSchema->writeDisabledInApi)) { + $writeDisabledProps[] = $propName; + } + } + + $errors = []; + + $notAllowedProps = array_intersect( + $writeDisabledProps, + array_keys($params) + ); + + if (!empty($notAllowedProps)) { + foreach ($notAllowedProps as $propName) { + $errors[$propName] = [__('api.400.propReadOnly', ['prop' => $propName])]; + } + } + + return $errors; + } +} diff --git a/classes/components/forms/FieldFunder.php b/classes/components/forms/FieldFunder.php new file mode 100644 index 00000000000..9305a7bae0a --- /dev/null +++ b/classes/components/forms/FieldFunder.php @@ -0,0 +1,54 @@ +submissionId; + $config['value'] = $this->value ?? $this->default ?? null; + + return $config; + } +} diff --git a/classes/components/forms/FieldFunderGrants.php b/classes/components/forms/FieldFunderGrants.php new file mode 100644 index 00000000000..7858abf2b54 --- /dev/null +++ b/classes/components/forms/FieldFunderGrants.php @@ -0,0 +1,52 @@ +value ?? $this->default ?? null; + $config['addButtonLabel'] = $this->addButtonLabel; + return $config; + } +} diff --git a/classes/components/forms/context/PKPMetadataSettingsForm.php b/classes/components/forms/context/PKPMetadataSettingsForm.php index 0e9a51162c4..469ff769d95 100644 --- a/classes/components/forms/context/PKPMetadataSettingsForm.php +++ b/classes/components/forms/context/PKPMetadataSettingsForm.php @@ -215,6 +215,32 @@ public function __construct($action, $context) ], 'value' => $context->getData('fundingStatement') ? $context->getData('fundingStatement') : Context::METADATA_DISABLE, ])) + ->addField(new FieldMetadataSetting('funders', [ + 'label' => __('manager.setup.metadata.funders'), + + 'description' => __('manager.setup.metadata.funders.description'), + 'options' => [ + ['value' => Context::METADATA_ENABLE, 'label' => __('manager.setup.metadata.funders.enable')] + ], + 'submissionOptions' => [ + ['value' => Context::METADATA_ENABLE, 'label' => __('manager.setup.metadata.funders.noRequest')], + ['value' => Context::METADATA_REQUEST, 'label' => __('manager.setup.metadata.funders.request')], + ['value' => Context::METADATA_REQUIRE, 'label' => __('manager.setup.metadata.funders.require')], + ], + 'value' => $context->getData('funders') ? $context->getData('funders') : Context::METADATA_DISABLE, + ])) + ->addField(new FieldOptions('funderGrantValidation', [ + 'label' => __('manager.setup.metadata.funders.funderGrantValidation'), + 'description' => __('manager.setup.metadata.funders.funderGrantValidation.description'), + 'options' => [ + [ + 'value' => 'true', + 'label' => __('manager.setup.metadata.funders.funderGrantValidation.enable') + ] + ], + 'value' => (bool)$context->getData('funderGrantValidation'), + 'showWhen' => 'funders' + ])) ->addField(new FieldMetadataSetting('dataAvailability', [ 'label' => __('submission.dataAvailability'), 'description' => __('manager.setup.metadata.dataAvailability.description'), diff --git a/classes/components/forms/funder/FunderEditForm.php b/classes/components/forms/funder/FunderEditForm.php new file mode 100644 index 00000000000..005707f67e4 --- /dev/null +++ b/classes/components/forms/funder/FunderEditForm.php @@ -0,0 +1,49 @@ +action = $action; + + $this->addField(new FieldFunder('funder', [ + 'label' => __('submission.funders.funder.label.name'), + 'isMultilingual' => false, + ])); + $this->addField(new FieldFunderGrants('grants', [ + 'label' => __('submission.funders.funder.grants.label.name'), + 'description' => __('submission.funders.funder.grants.label.description'), + 'value' => null, + ])); + } +} diff --git a/classes/context/Context.php b/classes/context/Context.php index d53b16925c1..b6e94c5a077 100644 --- a/classes/context/Context.php +++ b/classes/context/Context.php @@ -610,6 +610,7 @@ public function getRequiredMetadata(): array 'dataCitations', 'disciplines', 'fundingStatement', + 'funders', 'keywords', 'rights', 'source', diff --git a/classes/core/PKPApplication.php b/classes/core/PKPApplication.php index 605cc8c352a..adc545af6b6 100644 --- a/classes/core/PKPApplication.php +++ b/classes/core/PKPApplication.php @@ -666,6 +666,7 @@ public static function getMetadataFields(): array 'dataAvailability', 'dataCitations', 'fundingStatement', + 'funders', ]; } diff --git a/classes/facades/Repo.php b/classes/facades/Repo.php index c25a8aafd1e..68002bcb3b5 100644 --- a/classes/facades/Repo.php +++ b/classes/facades/Repo.php @@ -37,6 +37,7 @@ use PKP\decision\Repository as DecisionRepository; use PKP\editorialTask\Repository as EditorialTaskRepository; use PKP\emailTemplate\Repository as EmailTemplateRepository; +use PKP\funder\Repository as FunderRepository; use PKP\highlight\Repository as HighlightRepository; use PKP\institution\Repository as InstitutionRepository; use PKP\invitation\repositories\Repository as InvitationRepository; @@ -108,6 +109,11 @@ public static function emailTemplate(): EmailTemplateRepository return app(EmailTemplateRepository::class); } + public static function funder(): FunderRepository + { + return app(FunderRepository::class); + } + public static function category(): CategoryRepository { return app(CategoryRepository::class); diff --git a/classes/funder/Funder.php b/classes/funder/Funder.php new file mode 100644 index 00000000000..736b73e303b --- /dev/null +++ b/classes/funder/Funder.php @@ -0,0 +1,109 @@ +settingsTable; + } + + /** + * Filter by submission ID + */ + protected function scopeWithSubmissionId(EloquentBuilder $builder, int $submissionId): EloquentBuilder + { + return $builder->where('submission_id', $submissionId); + } + + /** + * Order by seq + */ + protected function scopeOrderBySeq(EloquentBuilder $builder): EloquentBuilder + { + return $builder->orderBy('seq'); + } + + /** + * Get the funder name, falling back to the ROR name if not set + */ + protected function name(): Attribute + { + return Attribute::make( + get: function (mixed $value, array $attributes) { + if (!empty($value) || empty($attributes['ror'])) { + return $value; + } + + $ror = Repo::ror() + ->getCollector() + ->filterByRor($attributes['ror']) + ->getMany() + ->first(); + + if (!$ror) { + return $value; + } + + $names = []; + foreach (Application::get()->getRequest()->getContext()->getSupportedLocales() as $allowedLocale) { + $rorLocale = LocaleConversion::getIso1FromLocale($allowedLocale); + $names[$allowedLocale] = $ror->getName($rorLocale) ?? $ror->getName($ror->getDisplayLocale()); + } + + return empty($names) ? $value : $names; + } + ); + } +} \ No newline at end of file diff --git a/classes/funder/Repository.php b/classes/funder/Repository.php new file mode 100644 index 00000000000..73b3042a2cb --- /dev/null +++ b/classes/funder/Repository.php @@ -0,0 +1,171 @@ + $schemaService */ + protected PKPSchemaService $schemaService; + + // Funders with awards that can be validated via the Zenodo API, and their corresponding ROR IDs. + const AWARD_FUNDERS = [ + '05k73zm37', // Research Council of Finland + '00rbzpz17', // French National Research Agency + '05mmh0f86', // Australian Research Council + '03zj4c476', // Aligning Science Across Parkinson's + '01gavpb45', // Canadian Institutes of Health Research + '00k4n6c32', // European Commission + '02k4b9v70', // European Environment Agency + '00snfqn58', // Portuguese Science and Technology Foundation + '013tf3c58', // Austrian Science Fund + '03m8vkq32', // The French National Cancer Institute + '03n51vw80', // Croatian Science Foundation + '02ar66p97', // Latvian Council of Science + '01znas443', // Ministry of Education, Science and Technological Development of the Republic of Serbia + '011kf5r70', // National Health and Medical Research Council + '01cwqze88', // National Institutes of Health + '01h531d29', // Natural Sciences and Engineering Research Council of Canada + '021nxhr62', // National Science Foundation + '04jsz6e67', // Dutch Research Council + '00dq2kk65', // Research Councils UK + '0271asj38', // Science Foundation Ireland + '00yjd3n13', // Swiss National Science Foundation + '006cvnv84', // Social Science Research Council + '04w9kkr77', // Scientific and Technological Research Council of Turkey + '00x0z1472', // Templeton World Charity Foundation + '001aqnf71', // UK Research and Innovation + '029chgv08', // Wellcome Trust + ]; + + public function __construct(Request $request, PKPSchemaService $schemaService) + { + $this->request = $request; + $this->schemaService = $schemaService; + } + + /** + * Validate properties for a funder + * + * Perform validation checks on data used to add or edit a funder + * + * @param Funder|null $funder Funder being edited. Pass `null` if creating a new funder + * @param array $props A key/value array with the new data to validate + * + * @return array A key/value array with validation errors. Empty if no errors + * + * @hook Funder::validate [[&$errors, $funder, $props]] + */ + public function validate(?Funder $funder, array $props): array + { + $schema = Funder::getSchemaName(); + + $validator = ValidatorFactory::make( + $props, + $this->schemaService->getValidationRules($schema, []) + ); + + // Check required fields + ValidatorFactory::required( + $validator, + $funder, + $this->schemaService->getRequiredProps($schema), + $this->schemaService->getMultilingualProps($schema), + [], + '' + ); + + // Validate grant numbers if funder grant validation is enabled and a ROR ID is provided + $context = $this->request->getContext(); + $funderGrantValidationSetting = (bool) $context->getData('funderGrantValidation'); + + if ($funderGrantValidationSetting) { + $ror = $props['ror'] ?? null; + + if ($ror) { + $ror = preg_replace('#^https?://ror\.org/#i', '', trim($ror)); + + if (in_array($ror, self::AWARD_FUNDERS, true)) { + + $validator->after(function ($validator) use ($props, $ror) { + + $grants = $props['grants'] ?? []; + $httpClient = Application::get()->getHttpClient(); + + foreach ($grants as $key => $grant) { + $grantNumber = $grant['grantNumber'] ?? null; + + if (!$grantNumber) { + continue; + } + + $awardResponse = $httpClient->request( + 'GET', + "https://zenodo.org/api/awards?funders={$ror}&q=" . urlencode($grantNumber) + ); + + $body = json_decode($awardResponse->getBody(), true); + + $success = array_reduce( + $body['hits']['hits'] ?? [], + fn($carry, $item) => $carry || $item['number'] == $grantNumber, + false + ); + + if (!$success) { + $validator->errors()->add("grants.{$key}.grantNumber", __('submission.funders.grantNumberInvalid')); + } + } + }); + } + } + } + + $validator->after(function ($validator) use ($props) { + if (empty($props['ror']) && empty(array_filter($props['name'] ?? []))) { + $validator->errors()->add('funder', __('submission.funders.funderNameOrRorRequired')); + } + }); + + $errors = []; + + if ($validator->fails()) { + $errors = $this->schemaService->formatValidationErrors($validator->errors()); + } + + Hook::call('Funder::validate', [&$errors, $funder, $props]); + + return $errors; + } + + /** + * Get an instance of the map class for mapping + * data citations to their schema + */ + public function getSchemaMap(): maps\Schema + { + return app('maps')->withExtensions($this->schemaMap); + } +} diff --git a/classes/funder/maps/Schema.php b/classes/funder/maps/Schema.php new file mode 100644 index 00000000000..bb55725f2aa --- /dev/null +++ b/classes/funder/maps/Schema.php @@ -0,0 +1,130 @@ +mapByProperties($this->getProps(), $item); + } + + /** + * Summarize a Funder + * + * Includes properties with the apiSummary flag in the Funder schema. + */ + public function summarize(Funder $item): array + { + return $this->mapByProperties($this->getSummaryProps(), $item); + } + + /** + * Map a collection of Funder + * + * @see self::map + */ + public function mapMany(Enumerable $collection): Enumerable + { + $this->collection = $collection; + return $collection->map(function ($item) { + return $this->map($item); + }); + } + + /** + * Summarize a collection of Funder + * + * @see self::summarize + */ + public function summarizeMany(Enumerable $collection): Enumerable + { + $this->collection = $collection; + return $collection->map(function ($item) { + return $this->summarize($item); + }); + } + + /** + * Map schema properties of a Funder to an assoc array + */ + protected function mapByProperties(array $props, Funder $item): array + { + $grantModel = $this->getGrantDataModel(); + $output = []; + + foreach ($props as $prop) { + switch ($prop) { + case 'grants': + $grants = []; + foreach (is_array($item->getAttribute($prop)) ? $item->getAttribute($prop) : [] as $grant) { + $grants[] = array_merge($grantModel, $grant); + } + $output[$prop] = $grants; + break; + + case 'rorObject': + $rorObject = null; + if ($item->ror) { + $rorObject = Repo::ror() + ->getCollector() + ->filterByRor($item->ror) + ->getMany() + ->first(); + } + $output[$prop] = $rorObject + ? Repo::ror()->getSchemaMap()->summarize($rorObject) + : null; + break; + + default: + $output[$prop] = $item->getAttribute($prop); + break; + } + } + + ksort($output); + return $this->withExtensions($output, $item); + } + + /** + * Get a funder grant data model as defined in schemas/funder.json. + */ + public function getGrantDataModel(): array + { + $schemaService = new PKPSchemaService(); + $schema = $schemaService->get($this->schema); + $grantModel = []; + foreach (array_keys((array)$schema->properties->grants->items->properties) as $property) { + $grantModel[$property] = ''; + } + return $grantModel; + } +} \ No newline at end of file diff --git a/classes/migration/install/AffiliationsMigration.php b/classes/migration/install/AffiliationsMigration.php index 93971361d95..dc20cea1e2e 100644 --- a/classes/migration/install/AffiliationsMigration.php +++ b/classes/migration/install/AffiliationsMigration.php @@ -27,6 +27,7 @@ public function up(): void { Schema::create('author_affiliations', function (Blueprint $table) { $table->comment('Author affiliations'); + $table->bigInteger('author_affiliation_id')->autoIncrement(); $table->bigInteger('author_id'); $table->string('ror')->nullable(); diff --git a/classes/migration/install/MetadataMigration.php b/classes/migration/install/MetadataMigration.php index e5a59896570..7036542ce37 100644 --- a/classes/migration/install/MetadataMigration.php +++ b/classes/migration/install/MetadataMigration.php @@ -83,6 +83,31 @@ public function up(): void $table->unique(['data_citation_id', 'locale', 'setting_name'], 'data_citation_settings_unique'); }); + // Funders + Schema::create('funders', function (Blueprint $table) { + $table->comment('A funder associated with a publication.'); + $table->bigInteger('funder_id')->autoIncrement(); + $table->bigInteger('submission_id'); + $table->string('ror')->nullable(); + $table->bigInteger('seq')->default(0); + $table->index(['ror'], 'funders_ror'); + $table->index(['submission_id'], 'funders_submission'); + $table->foreign('submission_id', 'funders_submission')->references('submission_id')->on('submissions')->onDelete('cascade'); + }); + + // Funder settings + Schema::create('funder_settings', function (Blueprint $table) { + $table->comment('Additional data about funders.'); + $table->bigIncrements('funder_setting_id'); + $table->bigInteger('funder_id'); + $table->string('locale', 28)->default(''); + $table->string('setting_name', 255); + $table->mediumText('setting_value')->nullable(); + $table->foreign('funder_id', 'funder_settings_funder_id')->references('funder_id')->on('funders')->onDelete('cascade'); + $table->index(['funder_id'], 'funder_settings_funder_id'); + $table->unique(['funder_id', 'locale', 'setting_name'], 'funder_settings_unique'); + }); + // Filter groups Schema::create('filter_groups', function (Blueprint $table) { $table->comment('Filter groups are used to organized filters into named sets, which can be retrieved by the application for invocation.'); diff --git a/classes/migration/upgrade/v3_6_0/I12392_Funders.php b/classes/migration/upgrade/v3_6_0/I12392_Funders.php new file mode 100644 index 00000000000..ed056b3a469 --- /dev/null +++ b/classes/migration/upgrade/v3_6_0/I12392_Funders.php @@ -0,0 +1,134 @@ +comment('A funder associated with a publication.'); + $table->bigInteger('funder_id')->autoIncrement(); + $table->bigInteger('submission_id'); + $table->string('ror')->nullable(); + $table->bigInteger('seq')->default(0); + $table->index(['ror'], 'funders_ror'); + $table->index(['submission_id'], 'funders_submission'); + $table->foreign('submission_id', 'funders_submission')->references('submission_id')->on('submissions')->onDelete('cascade'); + }); + + // Funder settings + Schema::create('funder_settings', function (Blueprint $table) { + $table->comment('Additional data about funders.'); + $table->bigIncrements('funder_setting_id'); + $table->bigInteger('funder_id'); + $table->string('locale', 28)->default(''); + $table->string('setting_name', 255); + $table->mediumText('setting_value')->nullable(); + $table->foreign('funder_id', 'funder_settings_funder_id')->references('funder_id')->on('funders')->onDelete('cascade'); + $table->index(['funder_id'], 'funder_settings_funder_id'); + $table->unique(['funder_id', 'locale', 'setting_name'], 'funder_settings_unique'); + }); + + // Migrate legacy funder data if it exists + if (Schema::hasTable('funders_legacy')) { + $legacyFunders = DB::table('funders_legacy')->get(); + + foreach ($legacyFunders as $legacyFunder) { + + // If we want to try mapping old Funder Registry values to RORs, this is where that would happen. + // We could use a csv file for mapping containing ROR's and matching Funder Registry DOI suffixes + + $newFunderId = DB::table('funders')->insertGetId([ + 'submission_id' => $legacyFunder->submission_id, + 'ror' => $legacyFunder->funder_identification, + 'seq' => 0, + ]); + + $settings = DB::table('funder_settings_legacy') + ->where('funder_id', $legacyFunder->funder_id) + ->get(); + + foreach ($settings as $setting) { + DB::table('funder_settings')->insert([ + 'funder_id' => $newFunderId, + 'locale' => $setting->locale, + 'setting_name' => $setting->setting_name, + 'setting_value' => $setting->setting_value, + ]); + } + + $awardNumbers = DB::table('funder_awards_legacy') + ->where('funder_id', $legacyFunder->funder_id) + ->pluck('funder_award_number') + ->toArray(); + + $awardNumbers = array_values(array_unique($awardNumbers)); + + if (!empty($awardNumbers)) { + $grants = array_map(fn($n) => [ + 'grantNumber' => $n, + 'grantName' => null, + ], $awardNumbers); + + DB::table('funder_settings')->insert([ + 'funder_id' => $newFunderId, + 'locale' => '', + 'setting_name' => 'grants', + 'setting_value' => json_encode($grants), + ]); + } + } + } + + // DECISION: Do we want to drop the legacy tables after migration? + Schema::dropIfExists('funder_award_settings_legacy'); + Schema::dropIfExists('funder_awards_legacy'); + Schema::dropIfExists('funder_settings_legacy'); + Schema::dropIfExists('funders_legacy'); + } + + /** + * Reverse the migration. + */ + public function down(): void + { + throw new \PKP\install\DowngradeNotSupportedException(); + } +} diff --git a/classes/publication/DAO.php b/classes/publication/DAO.php index acf1a61481c..6d90e83f8d1 100644 --- a/classes/publication/DAO.php +++ b/classes/publication/DAO.php @@ -27,6 +27,7 @@ use PKP\core\EntityDAO; use PKP\core\traits\EntityWithParent; use PKP\dataCitation\DataCitation; +use PKP\funder\Funder; use PKP\services\PKPSchemaService; /** @@ -183,6 +184,7 @@ public function __toString() $this->setCategories($publication); $this->setControlledVocab($publication); $this->setDataCitations($publication); + $this->setFunders($publication); return $publication; } @@ -247,6 +249,7 @@ public function deleteById(int $publicationId): int $this->deleteControlledVocab($publicationId); $this->deleteDataCitations($publicationId); Repo::citation()->deleteByPublicationId($publicationId); + // Deleting Funders takes place in Submission DAO because Funders are linked to Submissions return $affectedRows; } @@ -495,6 +498,19 @@ protected function deleteDataCitations(int $publicationId): void DataCitation::where('publication_id', $publicationId)->delete(); } + /** + * Set a publication's Funders + */ + protected function setFunders(Publication $publication): void + { + $funders = Funder::withSubmissionId($publication->getData('submissionId')) + ->orderBySeq() + ->get() + ->values() + ->all(); + $publication->setData('funders', $funders); + } + /** * Set the DOI object * diff --git a/classes/publication/maps/Schema.php b/classes/publication/maps/Schema.php index 49514bcdd2a..a84d6b9e32d 100644 --- a/classes/publication/maps/Schema.php +++ b/classes/publication/maps/Schema.php @@ -21,6 +21,7 @@ use Illuminate\Support\Enumerable; use PKP\context\Context; use PKP\dataCitation\DataCitation; +use PKP\funder\Funder; use PKP\services\PKPSchemaService; use PKP\submission\Genre; @@ -178,6 +179,13 @@ protected function mapByProperties(array $props, Publication $publication, bool } $output[$prop] = $retVal; break; + case 'funders': + $data = []; + foreach ($publication->getData('funders') as $funder) { + $data[] = Repo::funder()->getSchemaMap()->map($funder); + } + $output[$prop] = $data; + break; case 'reviewDoiItems': $reviewDoiItemsByPub = $this->getReviewDoiItemsCache($publication); $entries = $reviewDoiItemsByPub[$publication->getId()] ?? []; diff --git a/classes/services/PKPSchemaService.php b/classes/services/PKPSchemaService.php index 11b56c8e0c0..cde76e29917 100644 --- a/classes/services/PKPSchemaService.php +++ b/classes/services/PKPSchemaService.php @@ -40,6 +40,7 @@ class PKPSchemaService public const SCHEMA_DOI = 'doi'; public const SCHEMA_DECISION = 'decision'; public const SCHEMA_EMAIL_TEMPLATE = 'emailTemplate'; + public const SCHEMA_FUNDER = 'funder'; public const SCHEMA_GALLEY = 'galley'; public const SCHEMA_HIGHLIGHT = 'highlight'; public const SCHEMA_INSTITUTION = 'institution'; diff --git a/classes/submission/DAO.php b/classes/submission/DAO.php index 84748026bf8..e1146750368 100644 --- a/classes/submission/DAO.php +++ b/classes/submission/DAO.php @@ -25,6 +25,7 @@ use PKP\core\EntityDAO; use PKP\core\traits\EntityWithParent; use PKP\db\DAORegistry; +use PKP\funder\Funder; use PKP\log\event\EventLogEntry; use PKP\note\Note; use PKP\notification\Notification; @@ -291,6 +292,10 @@ public function deleteById(int $id): int $submissionCommentDao = DAORegistry::getDAO('SubmissionCommentDAO'); /** @var SubmissionCommentDAO $submissionCommentDao */ $submissionCommentDao->deleteBySubmissionId($id); + // Delete a submissions's Funders + Funder::withSubmissionIds([$id]) + ->delete(); + // Delete any outstanding notifications for this submission Notification::withAssoc(Application::ASSOC_TYPE_SUBMISSION, $id)->delete(); diff --git a/locale/en/api.po b/locale/en/api.po index f97be1a9b64..f63ba3f6147 100644 --- a/locale/en/api.po +++ b/locale/en/api.po @@ -155,6 +155,15 @@ msgstr "Files larger than {$maxSize} can not be uploaded." msgid "api.files.400.config" msgstr "File could not be uploaded because of a server configuration error. Please contact the system administrator." +msgid "api.funders.404.funderNotFound" +msgstr "The funder you requested was not found." + +msgid "api.funders.400.submissionsNotMatched" +msgstr "The funder you requested is not associated with this submission." + +msgid "api.funders.404.invalidOrderFormat" +msgstr "Funder order could not be saved because the provided order format is invalid." + msgid "api.highlights.400.noOrderData" msgstr "Highlight order could not be saved because no ordering information was found." diff --git a/locale/en/manager.po b/locale/en/manager.po index dd71e82e6f2..e221916daa3 100644 --- a/locale/en/manager.po +++ b/locale/en/manager.po @@ -2237,6 +2237,34 @@ msgid "manager.setup.metadata.dataCitations.require" msgstr "" "Require the author to add data citation metadata before accepting their submission." +msgid "manager.setup.metadata.funders" +msgstr "Funders" + +msgid "manager.setup.metadata.funders.description" +msgstr "Identify the Funders and Funder Grants associated with the submission." + +msgid "manager.setup.metadata.funders.enable" +msgstr "Enable funder metadata" + +msgid "manager.setup.metadata.funders.noRequest" +msgstr "Do not request funder metadata from the author during submission." + +msgid "manager.setup.metadata.funders.request" +msgstr "Ask the author for funder metadata during submission." + +msgid "manager.setup.metadata.funders.require" +msgstr "" +"Require the author to add funder metadata before accepting their submission." + +msgid "manager.setup.metadata.funders.funderGrantValidation" +msgstr "Funder Grant ID validation" + +msgid "manager.setup.metadata.funders.funderGrantValidation.description" +msgstr "Enable grant ID validation for supported funders (using the Zenodo API)." + +msgid "manager.setup.metadata.funders.funderGrantValidation.enable" +msgstr "Enable Grant ID validation." + msgid "manager.setup.metadata.keywords.description" msgstr "" "Keywords are typically one- to three-word phrases that are used to indicate " diff --git a/locale/en/submission.po b/locale/en/submission.po index 4b64261d5d8..d7c9cd22f54 100644 --- a/locale/en/submission.po +++ b/locale/en/submission.po @@ -959,6 +959,63 @@ msgstr "For the Editors" msgid "submission.forReviewerSuggestion" msgstr "For Reviewer Suggestion" +msgid "submission.funding" +msgstr "Funding" + +msgid "submission.funders" +msgstr "Funders" + +msgid "submission.funders.description" +msgstr "This table allows users to add formal funding information, ensuring funders are properly credited and appear in the publication metadata." + +msgid "submission.funders.action.addFunder" +msgstr "Add a new Funder" + +msgid "submission.funders.column.name" +msgstr "Funder Name" + +msgid "submission.funders.emptyFunders" +msgstr "No funders have been added." + +msgid "submission.funders.addFunder.title" +msgstr "Add Funder" + +msgid "submission.funders.editFunder.title" +msgstr "Edit Funder" + +msgid "submission.funders.funder" +msgstr "Funder" + +msgid "submission.funders.funder.description" +msgstr "Enter the full name of the institution below, avoiding any acronyms and select the name from the dropdown. (e.g. \"Simon Fraser University\")" + +msgid "submission.funders.funder.searchPhraseLabel" +msgstr "Search for a funder by name" + +msgid "submission.funders.funder.typeTranslationNameInLanguageLabel" +msgstr "Type the funder name in {$language}" + +msgid "submission.funders.funder.grants.label.name" +msgstr "Funder Grants" + +msgid "submission.funders.funder.grants.label.description" +msgstr "Add any grants associated with this funder (optional)." + +msgid "submission.funders.funder.grant.number" +msgstr "Grant Number" + +msgid "submission.funders.funder.grant.name" +msgstr "Grant Name" + +msgid "submission.funders.required" +msgstr "Funders are required." + +msgid "submission.funders.grantNumberInvalid" +msgstr "The given grant number could not be validated against the Funder's ROR ID." + +msgid "submission.funders.funderNameOrRorRequired" +msgstr "Search and select a Funder or enter a Funder name" + msgid "submission.galley" msgstr "Galley" diff --git a/pages/dashboard/PKPDashboardHandler.php b/pages/dashboard/PKPDashboardHandler.php index 16444d12ff6..88236cc950f 100644 --- a/pages/dashboard/PKPDashboardHandler.php +++ b/pages/dashboard/PKPDashboardHandler.php @@ -28,6 +28,7 @@ use PKP\components\forms\decision\LogReviewerResponseForm; use PKP\components\forms\publication\ContributorForm; use PKP\components\forms\dataCitation\DataCitationEditForm; +use PKP\components\forms\funder\FunderEditForm; use PKP\controllers\grid\users\reviewer\PKPReviewerGridHandler; use PKP\core\JSONMessage; use PKP\core\PKPApplication; @@ -176,6 +177,7 @@ public function index($args, $request) $citationStructuredEditForm = new CitationStructuredEditForm('emit'); $citationRawEditForm = new CitationRawEditForm('emit'); $dataCitationEditForm = new DataCitationEditForm('emit'); + $funderEditForm = new FunderEditForm('emit'); $templateMgr->setState([ 'pageInitConfig' => [ @@ -191,6 +193,7 @@ public function index($args, $request) 'supportsCitations' => !!$context->getData('citations'), 'supportsDataCitations' => !!$context->getData('dataCitations'), 'supportsDataAvailability' => !!$context->getData('dataAvailability'), + 'supportsFunders' => !!$context->getData('funders'), 'identifiersEnabled' => $identifiersEnabled, 'isReviewerSuggestionEnabled' => (bool)$context->getData('reviewerSuggestionEnabled'), ], @@ -200,7 +203,8 @@ public function index($args, $request) 'versionStageOptions' => $versionStageOptions, 'citationStructuredEditForm' => $citationStructuredEditForm->getConfig(), 'citationRawEditForm' => $citationRawEditForm->getConfig(), - 'dataCitationEditForm' => $dataCitationEditForm->getConfig() + 'dataCitationEditForm' => $dataCitationEditForm->getConfig(), + 'funderEditForm' => $funderEditForm->getConfig() ], ] ]); diff --git a/pages/submission/PKPSubmissionHandler.php b/pages/submission/PKPSubmissionHandler.php index 24730e876d4..3418cba2e71 100644 --- a/pages/submission/PKPSubmissionHandler.php +++ b/pages/submission/PKPSubmissionHandler.php @@ -31,6 +31,7 @@ use PKP\components\forms\FormComponent; use PKP\components\forms\publication\PKPCitationsForm; use PKP\components\forms\dataCitation\DataCitationEditForm; +use PKP\components\forms\funder\FunderEditForm; use PKP\components\forms\publication\PKPDataAvailabilityForm; use PKP\components\forms\publication\TitleAbstractForm; use PKP\components\forms\submission\CommentsForTheEditors; @@ -55,6 +56,7 @@ abstract class PKPSubmissionHandler extends Handler public const SECTION_TYPE_CONFIRM = 'confirm'; public const SECTION_TYPE_CONTRIBUTORS = 'contributors'; public const SECTION_TYPE_DATA_CITATIONS = 'dataCitations'; + public const SECTION_TYPE_FUNDERS = 'funders'; public const SECTION_TYPE_REVIEWER_SUGGESTIONS = 'reviewerSuggestions'; public const SECTION_TYPE_FILES = 'files'; public const SECTION_TYPE_FORM = 'form'; @@ -243,6 +245,14 @@ protected function showWizard(array $args, Request $request, Submission $submiss ]; } + $fundersSetting = $context->getData('funders'); + if (in_array($fundersSetting, [Context::METADATA_REQUEST, Context::METADATA_REQUIRE])) { + $funderEditForm = new FunderEditForm('emit'); + $components['funder'] = [ + 'funderEditForm' => $funderEditForm->getConfig(), + ]; + } + $userRoles = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_USER_ROLES); $templateMgr = TemplateManager::getManager($request); @@ -773,6 +783,16 @@ protected function getDetailsStep( ]; } + $fundersSetting = $request->getContext()->getData('funders'); + if (in_array($fundersSetting, [Context::METADATA_REQUEST, Context::METADATA_REQUIRE])) { + $sections[] = [ + 'id' => 'funders', + 'name' => __('submission.funders'), + 'type' => self::SECTION_TYPE_FUNDERS, + 'description' => __('submission.funders.description'), + ]; + } + return [ 'id' => 'details', 'name' => __('common.details'), diff --git a/schemas/context.json b/schemas/context.json index 140431759f1..0463853bcc4 100644 --- a/schemas/context.json +++ b/schemas/context.json @@ -435,6 +435,21 @@ ], "defaultLocaleKey": "default.submission.step.forTheEditors" }, + "funders": { + "type": "string", + "description": "Enable funding metadata. `0` is disabled. `enable` will make it available in the workflow. `request` will allow an author to enter a funders during submission. `require` will require that the author enter funders during submission.", + "validation": [ + "nullable", + "in:0,enable,request,require" + ] + }, + "funderGrantValidation": { + "type": "boolean", + "description": "Enable funder grant validation.", + "validation": [ + "nullable" + ] + }, "homepageImage": { "type": "object", "multilingual": true, diff --git a/schemas/funder.json b/schemas/funder.json new file mode 100644 index 00000000000..01938e65c21 --- /dev/null +++ b/schemas/funder.json @@ -0,0 +1,82 @@ +{ + "title": "Funder", + "description": "A Funder in a Publication.", + "type": "object", + "required": [ + "submissionId" + ], + "properties": { + "grants": { + "type": "array", + "origin": "setting", + "description": "A list of the grants for this funder.", + "multilingual": false, + "apiSummary": true, + "validation": [ + "nullable" + ], + "items": { + "type": "object", + "properties": { + "grantNumber": { + "type": "string" + }, + "grantName": { + "type": "string" + } + } + } + }, + "id": { + "type": "integer", + "description": "The unique id of funder in the database.", + "origin": "primary", + "readOnly": true, + "apiSummary": true + }, + "submissionId": { + "type": "integer", + "description": "The submission to which this funder is associated with.", + "origin": "primary", + "apiSummary": true, + "writeDisabledInApi": true + }, + "ror": { + "type": "string", + "description": "The [ROR](https://ror.org/) id of this funder.", + "origin": "primary", + "apiSummary": true, + "validation": [ + "nullable", + "regex:#https://ror.org/0[^ILOU]{6}\\d{2}#" + ] + }, + "rorObject": { + "type": "object", + "description": "An object representing the ROR for this funder.", + "origin": "composed", + "apiSummary": true, + "readOnly": true, + "$ref": "#/definitions/Ror" + }, + "name": { + "type": "string", + "origin": "setting", + "description": "The name of this institution.", + "multilingual": true, + "apiSummary": true, + "validation": [ + "nullable" + ] + }, + "seq": { + "type": "integer", + "origin": "primary", + "description": "The sequence number of funder.", + "default": 0, + "validation": [ + "nullable" + ] + } + } +} diff --git a/schemas/publication.json b/schemas/publication.json index 4a15d24d942..c077c59bd0d 100644 --- a/schemas/publication.json +++ b/schemas/publication.json @@ -210,6 +210,15 @@ "readOnly": true, "apiSummary": true }, + "funders": { + "type": "array", + "description": "Optional metadata that contains an array of funders for this publication.", + "apiSummary": true, + "readOnly": true, + "items": { + "$ref": "#/definitions/Funder" + } + }, "fundingStatement": { "type": "string", "description": "Optional metadata that describes funding details for this publication.", diff --git a/templates/submission/review-details.tpl b/templates/submission/review-details.tpl index d3e1e2c1eac..c3e89ff5520 100644 --- a/templates/submission/review-details.tpl +++ b/templates/submission/review-details.tpl @@ -107,6 +107,41 @@ {/if} {/if} + + {if in_array($currentContext->getData('funders'), [$currentContext::METADATA_REQUEST, $currentContext::METADATA_REQUIRE])} + {if $localeKey === $submission->getData('locale')} + +