diff --git a/README.md b/README.md index 72bb7f27a..667eba452 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,10 @@ Thank you for your interest in contributing to our project! 7. _Optional_ Add [loggers.json](#loggers-configuration) file to the root folder and configure multiple loggers. 8. _Optional_ Add [proposalTypes.json](#proposal-types-configuration) file to the root folder and configure the proposal types. 9. _Optional_ Add [datasetTypes.json](#dataset-types-configuration) file to the root folder and configure the dataset types. -10. `npm run start:dev` -11. Go to http://localhost:3000/explorer to get an overview of available endpoints and database schemas. -12. To be able to run the e2e tests with the same setup as in the Github actions you will need to run `npm run prepare:local` and after that run `npm run start:dev`. This will start all needed containers and copy some configuration to the right place. +10. _Optional_ Add [datasetExternalLinkTemplates.json](#dataset-external-link-templates-configuration) file to the root folder and configure the external link types. +11. `npm run start:dev` +12. Go to http://localhost:3000/explorer to get an overview of available endpoints and database schemas. +13. To be able to run the e2e tests with the same setup as in the Github actions you will need to run `npm run prepare:local` and after that run `npm run start:dev`. This will start all needed containers and copy some configuration to the right place. ## Develop in a container using the docker-compose.dev file @@ -57,11 +58,12 @@ Thank you for your interest in contributing to our project! 5. _Optional_ Mount [loggers.json](#loggers-configuration) file to a volume in the container to configure multiple loggers. 6. _Optional_ Mount [proposalTypes.json](#proposal-types-configuration) file to a volume in the container to configure the proposal types. 7. _Optional_ Mount [datasetTypes.json](#dataset-types-configuration) file to a volume in the container to configure the dataset types. -8. _Optional_ Change the container env variables. -9. _Optional_ Create the file test/config/.env.override to override ENV vars that are used when running the tests. -10. Attach to the container. -11. `npm run start:dev` -12. Go to http://localhost:3000/explorer to get an overview of available endpoints and database schemas. +8. _Optional_ Mount [datasetExternalLinkTemplates.json](#dataset-external-link-templates-configuration) file to a volume in the container to configure the external link types. +9. _Optional_ Change the container env variables. +10. _Optional_ Create the file test/config/.env.override to override ENV vars that are used when running the tests. +11. Attach to the container. +12. `npm run start:dev` +13. Go to http://localhost:3000/explorer to get an overview of available endpoints and database schemas. ## Test the app @@ -113,16 +115,26 @@ The `loggers.example.json` file in the root directory showcases the example of c ### Proposal types configuration -Providing a file called _proposalTypes.json_ at the root of the project, locally or in the container, will be automatically loaded into the application configuration service under property called `proposalTypes` and used for validation against proposal creation and update. +If a file called _proposalTypes.json_ is provided at the root of the project, locally or in the container, it will be automatically loaded into the application configuration service under the property `proposalTypes`. + +This content is used for validation against proposal creation and update. -The `proposalTypes.json.example` file in the root directory showcases the example of configuration structure for proposal types. +The file `proposalTypes.example.json` contains an example. ### Dataset types configuration -When providing a file called _datasetTypes.json_ at the root of the project, locally or in the container, it will be automatically loaded into the application configuration service under property called `datasetTypes` and used for validation against dataset creation and update. The types `Raw` and `Derived` are always valid dataset types by default. +If a file called _datasetTypes.json_ is provided at the root of the project, locally or in the container, it will be automatically loaded into the application configuration service under the property `datasetTypes`. The `datasetTypes.example.json` file in the root directory showcases an example of configuration structure for dataset types. +### Dataset external link templates configuration + +If a file called _datasetExternalLinkTemplates.json_ is provided at at the root of the project, locally or in the container, it will be automatically loaded into the application configuration service under the property `datasetExternalLinkTemplates`. + +The content is used to create links to external websites from individual datasets, based on criteria applied to the dataset metadata. + +The file `datasetExternalLinkTemplates.example.json` contains an example. + ### Published data configuration Providing a file called _publishedDataConfig.json_ at the root of the project, locally or in the container, will be automatically loaded into the application configuration service under property called `publishedDataConfig`. It will be used for published data metadata form generation in the frontend and metadata validation in publication and registration of the published data. diff --git a/datasetExternalLinkTemplates.example.json b/datasetExternalLinkTemplates.example.json new file mode 100644 index 000000000..3ee23320f --- /dev/null +++ b/datasetExternalLinkTemplates.example.json @@ -0,0 +1,14 @@ +[ + { + "title": "Franzviewer II", + "url_template": "https://franz.site.com/franzviewer?id=${dataset.pid}", + "description_template": "View ${dataset.numberOfFiles} files in Franz' own personal viewer", + "filter": "(dataset.type == 'derived') && dataset.owner.includes('Franz')" + }, + { + "title": "High Beam-Energy View", + "url_template": "https://beamviewer.beamline.net/highenergy?id=${dataset.pid}", + "description_template": "The high-energy beamviewer (value ${dataset.scientificMetadata?.beamEnergy?.value}) at beamCo", + "filter": "(dataset.scientificMetadata?.beamEnergy?.value > 20)" + } +] diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 68cde662b..6d5d94db8 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -71,6 +71,7 @@ const configuration = () => { }; const jsonConfigMap: { [key: string]: object | object[] | boolean } = { datasetTypes: {}, + datasetExternalLinkTemplates: [], proposalTypes: {}, }; const jsonConfigFileList: { [key: string]: string } = { @@ -80,6 +81,9 @@ const configuration = () => { process.env.FRONTEND_THEME_FILE || "./src/config/frontend.theme.json", loggers: process.env.LOGGERS_CONFIG_FILE || "loggers.json", datasetTypes: process.env.DATASET_TYPES_FILE || "datasetTypes.json", + datasetExternalLinkTemplates: + process.env.DATASET_EXTERNAL_LINK_TEMPLATES_FILE || + "datasetExternalLinkTemplates.json", proposalTypes: process.env.PROPOSAL_TYPES_FILE || "proposalTypes.json", metricsConfig: process.env.METRICS_CONFIG_FILE || "metricsConfig.json", publishedDataConfig: @@ -402,6 +406,7 @@ const configuration = () => { policyRetentionShiftInYears: process.env.POLICY_RETENTION_SHIFT ?? -1, }, datasetTypes: jsonConfigMap.datasetTypes, + datasetExternalLinkTemplates: jsonConfigMap.datasetExternalLinkTemplates, proposalTypes: jsonConfigMap.proposalTypes, frontendConfig: jsonConfigMap.frontendConfig, frontendTheme: jsonConfigMap.frontendTheme, diff --git a/src/datasets/datasets.controller.ts b/src/datasets/datasets.controller.ts index 939ac5bce..36bb766f2 100644 --- a/src/datasets/datasets.controller.ts +++ b/src/datasets/datasets.controller.ts @@ -75,6 +75,7 @@ import { PartialUpdateDatablockDto } from "src/datablocks/dto/update-datablock.d import { Datablock } from "src/datablocks/schemas/datablock.schema"; import { LogbooksService } from "src/logbooks/logbooks.service"; import { Logbook } from "src/logbooks/schemas/logbook.schema"; +import { ExternalLinkClass } from "./schemas/externallink.class"; import { CreateDatasetOrigDatablockDto } from "src/origdatablocks/dto/create-dataset-origdatablock"; import { CreateOrigDatablockDto } from "src/origdatablocks/dto/create-origdatablock.dto"; import { UpdateOrigDatablockDto } from "src/origdatablocks/dto/update-origdatablock.dto"; @@ -1169,8 +1170,7 @@ export class DatasetsController { @Get("/findOne") @ApiOperation({ summary: "It returns the first dataset found.", - description: - "It returns the first dataset of the ones that matches the filter provided. The list returned can be modified by providing a filter.", + description: "Returns the first dataset that matches the provided filters.", }) @ApiQuery({ name: "filter", @@ -1751,6 +1751,46 @@ export class DatasetsController { return await this.convertCurrentToObsoleteSchema(outputDatasetDto); } + // GET /datasets/:id/externallinks + @UseGuards(PoliciesGuard) + @CheckPolicies( + "datasets", + (ability: AppAbility) => + ability.can(Action.DatasetRead, DatasetClass) || + ability.can(Action.DatasetReadOnePublic, DatasetClass), + ) + @Get("/:pid/externallinks") + @ApiOperation({ + summary: "Returns dataset external links.", + description: + "Returns the applicable external links for the dataset with the given pid.", + }) + @ApiParam({ + name: "pid", + description: "Id of the dataset to return external links", + type: String, + }) + @ApiResponse({ + status: HttpStatus.OK, + type: ExternalLinkClass, + isArray: true, + description: "A list of exernal link objects.", + }) + async findExternalLinksById( + @Req() request: Request, + @Param("pid") id: string, + ) { + const links = await this.datasetsService.findExternalLinksById(id); + + await this.checkPermissionsForDatasetExtended( + request, + id, + Action.DatasetRead, + ); + + return links; + } + // GET /datasets/:id/thumbnail @UseGuards(PoliciesGuard) @CheckPolicies( diff --git a/src/datasets/datasets.service.ts b/src/datasets/datasets.service.ts index f937cdd03..46cf36f38 100644 --- a/src/datasets/datasets.service.ts +++ b/src/datasets/datasets.service.ts @@ -52,6 +52,7 @@ import { IDatasetRelation, IDatasetScopes, } from "./interfaces/dataset-filters.interface"; +import { ExternalLinkClass } from "./schemas/externallink.class"; import { DatasetClass, DatasetDocument } from "./schemas/dataset.schema"; import { DATASET_LOOKUP_FIELDS, @@ -485,6 +486,56 @@ export class DatasetsService { throw new NotFoundException(error); } } + + async findExternalLinksById(id: string): Promise { + const thisDataSet = await this.findOneComplete({ + where: { pid: id }, + include: [DatasetLookupKeysEnum.all], + }); + + if (!thisDataSet) { + // no luck. we need to create a new dataset + throw new NotFoundException(`Dataset #${id} not found`); + } + + interface ExternalLinkTemplateConfig { + title: string; + url_template: string; + description_template: string; + filter: string; + } + + const templates: ExternalLinkTemplateConfig[] | undefined = + this.configService.get("datasetExternalLinkTemplates"); + if (!templates) { + return []; + } + + return templates + .filter((template) => { + const filterFn = new Function( + "dataset", + `return (${template.filter});`, + ); + return filterFn(thisDataSet); + }) + .map((template) => { + const urlFn = new Function( + "dataset", + `return (\`${template.url_template}\`);`, + ); + const descriptionFn = new Function( + "dataset", + `return (\`${template.description_template}\`);`, + ); + return { + url: urlFn(thisDataSet), + title: template.title, + description: descriptionFn(thisDataSet), + }; + }); + } + // Get metadata keys async metadataKeys( filters: IFilters, diff --git a/src/datasets/datasets.v4.controller.ts b/src/datasets/datasets.v4.controller.ts index 00f6ba3fc..fcc8d0a36 100644 --- a/src/datasets/datasets.v4.controller.ts +++ b/src/datasets/datasets.v4.controller.ts @@ -88,6 +88,7 @@ import { HistoryClass } from "./schemas/history.schema"; import { LifecycleClass } from "./schemas/lifecycle.schema"; import { RelationshipClass } from "./schemas/relationship.schema"; import { TechniqueClass } from "./schemas/technique.schema"; +import { ExternalLinkClass } from "./schemas/externallink.class"; @ApiBearerAuth() @ApiExtraModels( @@ -691,6 +692,43 @@ export class DatasetsV4Controller { return this.datasetsService.count(finalFilters); } + // GET /datasets/:id/externallinks + @UseGuards(PoliciesGuard) + @CheckPolicies("datasets", (ability: AppAbility) => + ability.can(Action.DatasetRead, DatasetClass), + ) + @Get("/:pid/externallinks") + @ApiOperation({ + summary: "Returns dataset external links.", + description: + "Returns the applicable external links for the dataset with the given pid.", + }) + @ApiParam({ + name: "pid", + description: "Id of the dataset to return external links", + type: String, + }) + @ApiResponse({ + status: HttpStatus.OK, + type: ExternalLinkClass, + isArray: true, + description: "A list of exernal link objects.", + }) + async findExternalLinksById( + @Req() request: Request, + @Param("pid") id: string, + ) { + const links = await this.datasetsService.findExternalLinksById(id); + + await this.checkPermissionsForDatasetExtended( + request, + id, + Action.DatasetRead, + ); + + return links; + } + // GET /datasets/:id //@UseGuards(PoliciesGuard) @UseGuards(PoliciesGuard) diff --git a/src/datasets/schemas/externallink.class.ts b/src/datasets/schemas/externallink.class.ts new file mode 100644 index 000000000..86222d630 --- /dev/null +++ b/src/datasets/schemas/externallink.class.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString } from "class-validator"; + +// This class defines the externalLinks field in a dataset. +// That field is not represented in the Mongoose data store, +// so there is no equivalent schema representation for it. + +export class ExternalLinkClass { + @ApiProperty({ + type: String, + required: true, + description: "URL of the external link.", + }) + @IsString() + readonly url: string; + + @ApiProperty({ + type: String, + required: true, + description: "Text to display representing the external link.", + }) + @IsString() + readonly title: string; + + @ApiProperty({ + type: String, + required: false, + description: "Description of the link destination.", + }) + @IsString() + readonly description?: string; +}