diff --git a/.gitignore b/.gitignore index 3adbab30e..1ac01d752 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /node_modules functionalAccounts.json datasetTypes.json +datasetExternalLinkTemplates.json proposalTypes.json loggers.json jobConfig.json 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/package-lock.json b/package-lock.json index 62aac7303..64ebcfd50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scicat-backend-next", - "version": "4.5.0", + "version": "4.23.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scicat-backend-next", - "version": "4.5.0", + "version": "4.23.0", "license": "BSD-3-Clause", "dependencies": { "@casl/ability": "^6.3.2", diff --git a/package.json b/package.json index ba8f239b6..7d73ba3fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scicat-backend-next", - "version": "4.5.0", + "version": "4.23.0", "description": "scicat-backend-next", "author": "", "private": true, 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 84897e79a..0ce61119e 100644 --- a/src/datasets/datasets.controller.ts +++ b/src/datasets/datasets.controller.ts @@ -77,6 +77,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"; @@ -912,9 +913,9 @@ export class DatasetsController { outputDatasets = datasets.map((dataset) => this.convertCurrentToObsoleteSchema(dataset), ); - await Promise.all( - outputDatasets.map(async (dataset) => { - if (includeFilters) { + if (includeFilters) { + await Promise.all( + outputDatasets.map(async (dataset) => { await Promise.all( includeFilters.map(async ({ relation }) => { switch (relation) { @@ -946,13 +947,9 @@ export class DatasetsController { } }), ); - } else { - /* eslint-disable @typescript-eslint/no-unused-expressions */ - // TODO: check the eslint error "Expected an assignment or function call and instead saw an expression" - dataset; - } - }), - ); + }), + ); + } } return outputDatasets as OutputDatasetObsoleteDto[]; } @@ -1207,8 +1204,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", @@ -1243,35 +1239,30 @@ export class DatasetsController { if (outputDataset) { const includeFilters = mergedFilters.include ?? []; - await Promise.all( - includeFilters.map(async ({ relation }) => { - switch (relation) { - case "attachments": { - outputDataset.attachments = await this.attachmentsService.findAll( - { - where: { - datasetId: outputDataset.pid, - }, - }, - ); - break; - } - case "origdatablocks": { - outputDataset.origdatablocks = - await this.origDatablocksService.findAll({ - where: { datasetId: outputDataset.pid }, - }); - break; + if (includeFilters) { + await Promise.all( + includeFilters.map(async ({ relation }) => { + switch (relation) { + case "attachments": { + outputDataset.attachments = + await this.attachmentsService.findAll({ + where: { + datasetId: outputDataset.pid, + }, + }); + break; + } + case "origdatablocks": { + outputDataset.origdatablocks = + await this.origDatablocksService.findAll({ + where: { datasetId: outputDataset.pid }, + }); + break; + } } - case "datablocks": { - outputDataset.datablocks = await this.datablocksService.findAll({ - where: { datasetId: outputDataset.pid }, - }); - break; - } - } - }), - ); + }), + ); + } } return outputDataset; } @@ -1823,6 +1814,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 ceba48a40..e5942fd01 100644 --- a/src/datasets/datasets.service.ts +++ b/src/datasets/datasets.service.ts @@ -38,6 +38,7 @@ import { PartialUpdateDatasetWithHistoryDto, UpdateDatasetDto, } from "./dto/update-dataset.dto"; +import { ExternalLinkClass } from "./schemas/externallink.class"; import { IDatasetFields } from "./interfaces/dataset-filters.interface"; import { DatasetClass, DatasetDocument } from "./schemas/dataset.schema"; import { @@ -395,6 +396,53 @@ 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((d) => { + const filterFn = new Function("dataset", `return (${d.filter});`); + return filterFn(thisDataSet); + }) + .map((d) => { + const urlFn = new Function( + "dataset", + `return (\`${d.url_template}\`);`, + ); + const descriptionFn = new Function( + "dataset", + `return (\`${d.description_template}\`);`, + ); + return { + url: urlFn(thisDataSet), + title: d.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 ac7cd4813..c6583e723 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"; import { isEqual } from "lodash"; @@ -687,6 +688,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; +}