diff --git a/api/.pipeline/lib/api.deploy.js b/api/.pipeline/lib/api.deploy.js index f3499cfbb..c89d3be6e 100644 --- a/api/.pipeline/lib/api.deploy.js +++ b/api/.pipeline/lib/api.deploy.js @@ -40,6 +40,7 @@ const apiDeploy = async (settings) => { ELASTICSEARCH_TAXONOMY_INDEX: phases[phase].elasticsearchTaxonomyIndex, // S3 (Object Store) S3_KEY_PREFIX: phases[phase].s3KeyPrefix, + OBJECT_STORE_SECRETS: 'biohubbc-object-store', // Database TZ: phases[phase].tz, DB_SERVICE_NAME: `${phases[phase].dbName}-postgresql${phases[phase].suffix}`, @@ -60,7 +61,7 @@ const apiDeploy = async (settings) => { KEYCLOAK_API_ENVIRONMENT: phases[phase].sso.cssApi.cssApiEnvironment, // Log Level LOG_LEVEL: phases[phase].logLevel || 'info', - // OPenshift Resources + // Openshift Resources CPU_REQUEST: phases[phase].cpuRequest, CPU_LIMIT: phases[phase].cpuLimit, MEMORY_REQUEST: phases[phase].memoryRequest, diff --git a/api/.pipeline/lib/queue.deploy.js b/api/.pipeline/lib/queue.deploy.js index a7a1da976..e8021cba4 100644 --- a/api/.pipeline/lib/queue.deploy.js +++ b/api/.pipeline/lib/queue.deploy.js @@ -31,23 +31,21 @@ const queueDeploy = async (settings) => { HOST: phases[phase].host, CHANGE_ID: phases.build.changeId || changeId, APP_HOST: phases[phase].appHost, - DB_SERVICE_NAME: `${phases[phase].dbName}-postgresql${phases[phase].suffix}`, + // Node NODE_ENV: phases[phase].env || 'dev', + // Elastic Search ELASTICSEARCH_URL: phases[phase].elasticsearchURL, ELASTICSEARCH_EML_INDEX: phases[phase].elasticsearchEmlIndex, ELASTICSEARCH_TAXONOMY_INDEX: phases[phase].elasticsearchTaxonomyIndex, + // S3 (Object Store) S3_KEY_PREFIX: phases[phase].s3KeyPrefix, - TZ: phases[phase].tz, - KEYCLOAK_ADMIN_USERNAME: phases[phase].sso.adminUserName, - KEYCLOAK_SECRET: phases[phase].sso.keycloakSecret, - KEYCLOAK_SECRET_ADMIN_PASSWORD: phases[phase].sso.keycloakSecretAdminPassword, - KEYCLOAK_HOST: phases[phase].sso.url, - KEYCLOAK_CLIENT_ID: phases[phase].sso.clientId, - KEYCLOAK_REALM: phases[phase].sso.realm, - KEYCLOAK_INTEGRATION_ID: phases[phase].sso.integrationId, - KEYCLOAK_API_HOST: phases[phase].sso.apiHost, OBJECT_STORE_SECRETS: 'biohubbc-object-store', + // Database + TZ: phases[phase].tz, + DB_SERVICE_NAME: `${phases[phase].dbName}-postgresql${phases[phase].suffix}`, + // Log Level LOG_LEVEL: phases[phase].logLevel || 'info', + // Openshift Resources CPU_REQUEST: phases[phase].cpuRequest, CPU_LIMIT: phases[phase].cpuLimit, MEMORY_REQUEST: phases[phase].memoryRequest, diff --git a/api/.pipeline/queue.config.js b/api/.pipeline/queue.config.js index 5a18434da..183da9efc 100644 --- a/api/.pipeline/queue.config.js +++ b/api/.pipeline/queue.config.js @@ -88,7 +88,6 @@ const phases = { elasticsearchTaxonomyIndex: 'taxonomy_3.0.0', s3KeyPrefix: 'biohub', tz: config.timezone.api, - sso: config.sso.dev, logLevel: 'debug', queueDockerfilePath: queueDockerfilePath, cpuRequest: '100m', @@ -116,7 +115,6 @@ const phases = { elasticsearchTaxonomyIndex: 'taxonomy_3.0.0', s3KeyPrefix: 'biohub', tz: config.timezone.api, - sso: config.sso.test, logLevel: 'info', queueDockerfilePath: queueDockerfilePath, cpuRequest: '200m', @@ -144,7 +142,6 @@ const phases = { elasticsearchTaxonomyIndex: 'taxonomy_3.0.0', s3KeyPrefix: 'biohub', tz: config.timezone.api, - sso: config.sso.prod, logLevel: 'info', queueDockerfilePath: queueDockerfilePath, cpuRequest: '200m', diff --git a/api/.pipeline/templates/api.dc.yaml b/api/.pipeline/templates/api.dc.yaml index 2bbcd4e76..791fd86fa 100644 --- a/api/.pipeline/templates/api.dc.yaml +++ b/api/.pipeline/templates/api.dc.yaml @@ -31,11 +31,13 @@ parameters: - name: DB_SERVICE_NAME description: 'Database service name associated with deployment' required: true + # Node - name: NODE_ENV description: Application Environment type variable required: true value: 'dev' - name: NODE_OPTIONS + # Elastic Search - name: ELASTICSEARCH_URL description: Platform Elasticsearch URL required: true @@ -48,10 +50,6 @@ parameters: description: Platform Elasticsearch Taxonomy Index required: true vale: 'taxonomy_3.0.0' - - name: S3_KEY_PREFIX - description: S3 key optional prefix - required: false - value: 'biohub' - name: TZ description: Application timezone required: false @@ -99,9 +97,14 @@ parameters: description: Api default port name value: '6100-tcp' # Object Store (S3) + - name: S3_KEY_PREFIX + description: S3 key optional prefix + required: false + value: 'biohub' - name: OBJECT_STORE_SECRETS description: Secrets used to read and write to the S3 storage value: 'biohubbc-object-store' + # GC Notify - name: GCNOTIFY_API_SECRET description: Secret for gcnotify api key value: 'gcnotify-api-key' @@ -121,8 +124,10 @@ parameters: value: https://api.notification.canada.ca/v2/notifications/email - name: GCNOTIFY_SMS_URL value: https://api.notification.canada.ca/v2/notifications/sms + # Log Level - name: LOG_LEVEL value: 'info' + # Openshift - name: CPU_REQUEST value: '100m' - name: CPU_LIMIT @@ -420,7 +425,7 @@ objects: status: ingress: null - kind: HorizontalPodAutoscaler - apiVersion: autoscaling/v2beta2 + apiVersion: autoscaling/v2 metadata: annotations: {} creationTimestamp: null @@ -439,4 +444,4 @@ objects: name: cpu target: type: Utilization - averageUtilization: 80 \ No newline at end of file + averageUtilization: 80 diff --git a/api/.pipeline/templates/queue.dc.yaml b/api/.pipeline/templates/queue.dc.yaml index 68244842b..efbb510ad 100644 --- a/api/.pipeline/templates/queue.dc.yaml +++ b/api/.pipeline/templates/queue.dc.yaml @@ -25,10 +25,12 @@ parameters: - name: DB_SERVICE_NAME description: 'Database service name associated with deployment' required: true + # Node - name: NODE_ENV description: Application Environment type variable required: true value: 'dev' + # Elastic Search - name: ELASTICSEARCH_URL description: Platform Elasticsearch URL required: true @@ -41,43 +43,22 @@ parameters: description: Platform Elasticsearch Taxonomy Index required: true vale: 'taxonomy_3.0.0' - - name: S3_KEY_PREFIX - description: S3 key optional prefix - required: false - value: 'biohub' - name: TZ description: Application timezone required: false value: 'America/Vancouver' - - name: KEYCLOAK_HOST - description: Key clock login url - required: true - - name: KEYCLOAK_REALM - description: Realm identifier or name - required: true - - name: KEYCLOAK_INTEGRATION_ID - description: keycloak integration id - required: true - - name: KEYCLOAK_API_HOST - description: keycloak API host - required: true - - name: KEYCLOAK_CLIENT_ID - description: Client Id for application - required: true - - name: KEYCLOAK_ADMIN_USERNAME - description: keycloak host admin username - required: true - - name: KEYCLOAK_SECRET - description: The name of the keycloak secret - required: true - - name: KEYCLOAK_SECRET_ADMIN_PASSWORD - description: The key of the admin password in the keycloak secret - required: true + # Object Store (S3) + - name: S3_KEY_PREFIX + description: S3 key optional prefix + required: false + value: 'biohub' - name: OBJECT_STORE_SECRETS description: Secrets used to read and write to the S3 storage value: 'biohubbc-object-store' + # Log Level - name: LOG_LEVEL value: 'info' + # Openshift - name: CPU_REQUEST value: '100m' - name: CPU_LIMIT @@ -164,23 +145,6 @@ objects: name: ${DB_SERVICE_NAME} - name: DB_PORT value: '5432' - - name: KEYCLOAK_HOST - value: ${KEYCLOAK_HOST} - - name: KEYCLOAK_API_HOST - value: ${KEYCLOAK_API_HOST} - - name: KEYCLOAK_REALM - value: ${KEYCLOAK_REALM} - - name: KEYCLOAK_CLIENT_ID - value: ${KEYCLOAK_CLIENT_ID} - - name: KEYCLOAK_INTEGRATION_ID - value: ${KEYCLOAK_INTEGRATION_ID} - - name: KEYCLOAK_ADMIN_USERNAME - value: ${KEYCLOAK_ADMIN_USERNAME} - - name: KEYCLOAK_ADMIN_PASSWORD - valueFrom: - secretKeyRef: - name: ${KEYCLOAK_SECRET} - key: ${KEYCLOAK_SECRET_ADMIN_PASSWORD} - name: CHANGE_VERSION value: ${CHANGE_ID} - name: NODE_ENV @@ -273,7 +237,7 @@ objects: name: ${NAME}${SUFFIX} type: Opaque - kind: HorizontalPodAutoscaler - apiVersion: autoscaling/v2beta2 + apiVersion: autoscaling/v2 metadata: annotations: {} creationTimestamp: null @@ -292,4 +256,4 @@ objects: name: cpu target: type: Utilization - averageUtilization: 80 \ No newline at end of file + averageUtilization: 80 diff --git a/api/src/paths/administrative/submission/published.test.ts b/api/src/paths/administrative/submission/published.test.ts new file mode 100644 index 000000000..ee0d95a2f --- /dev/null +++ b/api/src/paths/administrative/submission/published.test.ts @@ -0,0 +1,103 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as db from '../../../database/db'; +import { HTTPError } from '../../../errors/http-error'; +import { SECURITY_APPLIED_STATUS } from '../../../repositories/security-repository'; +import { SubmissionRecordWithSecurityAndRootFeatureType } from '../../../repositories/submission-repository'; +import { SubmissionService } from '../../../services/submission-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../__mocks__/db'; +import { getPublishedSubmissionsForAdmins } from './published'; + +chai.use(sinonChai); + +describe('getPublishedSubmissionsForAdmins', () => { + afterEach(() => { + sinon.restore(); + }); + + it('re-throws any error that is thrown', async () => { + const mockDBConnection = getMockDBConnection({ + open: () => { + throw new Error('test error'); + } + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const requestHandler = getPublishedSubmissionsForAdmins(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect((actualError as HTTPError).message).to.equal('test error'); + } + }); + + it('should return an array of Reviewed submission objects', async () => { + const dbConnectionObj = getMockDBConnection({ + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub() + }); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const mockResponse: SubmissionRecordWithSecurityAndRootFeatureType[] = [ + { + submission_id: 1, + uuid: '123-456-789', + security_review_timestamp: '2023-12-12', + submitted_timestamp: '2023-12-12', + publish_timestamp: '2023-12-12', + source_system: 'SIMS', + name: 'name', + description: 'description', + create_date: '2023-12-12', + create_user: 1, + update_date: null, + update_user: null, + revision_count: 0, + security: SECURITY_APPLIED_STATUS.SECURED, + root_feature_type_id: 1, + root_feature_type_name: 'dataset' + }, + { + submission_id: 2, + uuid: '789-456-123', + security_review_timestamp: '2023-12-12', + submitted_timestamp: '2023-12-12', + source_system: 'SIMS', + publish_timestamp: '2023-12-12', + name: 'name', + description: 'description', + create_date: '2023-12-12', + create_user: 1, + update_date: '2023-12-12', + update_user: 1, + revision_count: 1, + security: SECURITY_APPLIED_STATUS.PARTIALLY_SECURED, + root_feature_type_id: 1, + root_feature_type_name: 'dataset' + } + ]; + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const getReviewedSubmissionsStub = sinon + .stub(SubmissionService.prototype, 'getPublishedSubmissionsForAdmins') + .resolves(mockResponse); + + const requestHandler = getPublishedSubmissionsForAdmins(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getReviewedSubmissionsStub).to.have.been.calledOnce; + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.jsonValue).to.eql(mockResponse); + }); +}); diff --git a/api/src/paths/administrative/submission/published.ts b/api/src/paths/administrative/submission/published.ts new file mode 100644 index 000000000..c1b1ccbd2 --- /dev/null +++ b/api/src/paths/administrative/submission/published.ts @@ -0,0 +1,157 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../constants/roles'; +import { getDBConnection } from '../../../database/db'; +import { defaultErrorResponses } from '../../../openapi/schemas/http-responses'; +import { SECURITY_APPLIED_STATUS } from '../../../repositories/security-repository'; +import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; +import { SubmissionService } from '../../../services/submission-service'; +import { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('paths/administrative/submission/published'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getPublishedSubmissionsForAdmins() +]; + +GET.apiDoc = { + description: 'Get a list of submissions that have completed security review (are published).', + tags: ['admin'], + security: [ + { + Bearer: [] + } + ], + responses: { + 200: { + description: 'List of submissions that have completed security review.', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + required: [ + 'submission_id', + 'uuid', + 'security_review_timestamp', + 'submitted_timestamp', + 'source_system', + 'name', + 'description', + 'create_date', + 'create_user', + 'update_date', + 'update_user', + 'revision_count', + 'security', + 'root_feature_type_id', + 'root_feature_type_name' + ], + properties: { + submission_id: { + type: 'integer', + minimum: 1 + }, + uuid: { + type: 'string', + format: 'uuid' + }, + security_review_timestamp: { + type: 'string', + nullable: true + }, + source_system: { + type: 'string' + }, + name: { + type: 'string', + maxLength: 200 + }, + description: { + type: 'string', + maxLength: 3000 + }, + create_date: { + type: 'string' + }, + create_user: { + type: 'integer', + minimum: 1 + }, + update_date: { + type: 'string', + nullable: true + }, + update_user: { + type: 'integer', + minimum: 1, + nullable: true + }, + revision_count: { + type: 'integer', + minimum: 0 + }, + security: { + type: 'string', + enum: [ + SECURITY_APPLIED_STATUS.PENDING, + SECURITY_APPLIED_STATUS.UNSECURED, + SECURITY_APPLIED_STATUS.SECURED, + SECURITY_APPLIED_STATUS.PARTIALLY_SECURED + ] + }, + root_feature_type_id: { + type: 'integer', + minimum: 1 + }, + root_feature_type_name: { + type: 'string' + } + } + } + } + } + } + }, + ...defaultErrorResponses + } +}; + +/** + * Get all published submissions for admins. + * + * @returns {RequestHandler} + */ +export function getPublishedSubmissionsForAdmins(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + const service = new SubmissionService(connection); + const response = await service.getPublishedSubmissionsForAdmins(); + + await connection.commit(); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'getPublishedSubmissionsForAdmins', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/repositories/submission-repository.test.ts b/api/src/repositories/submission-repository.test.ts index 43b05e2d9..41febdb7a 100644 --- a/api/src/repositories/submission-repository.test.ts +++ b/api/src/repositories/submission-repository.test.ts @@ -969,7 +969,7 @@ describe('SubmissionRepository', () => { source_system: 'SIMS', name: 'name', description: 'description', - publish_timestamp: '2023-12-12', + publish_timestamp: null, create_date: '2023-12-12', create_user: 1, update_date: null, @@ -984,7 +984,7 @@ describe('SubmissionRepository', () => { source_system: 'SIMS', name: 'name', description: 'description', - publish_timestamp: '2023-12-12', + publish_timestamp: null, create_date: '2023-12-12', create_user: 1, update_date: '2023-12-12', @@ -1005,6 +1005,56 @@ describe('SubmissionRepository', () => { }); }); + describe('getPublishedSubmissionsForAdmins', () => { + beforeEach(() => { + sinon.restore(); + }); + + it('should succeed with valid data', async () => { + const mockSubmissionRecords: SubmissionRecord[] = [ + { + submission_id: 1, + uuid: '123-456-789', + security_review_timestamp: '2023-12-12', + submitted_timestamp: '2023-12-12', + source_system: 'SIMS', + name: 'name', + description: 'description', + publish_timestamp: '2023-12-12', + create_date: '2023-12-12', + create_user: 1, + update_date: null, + update_user: null, + revision_count: 0 + }, + { + submission_id: 2, + uuid: '789-456-123', + security_review_timestamp: '2023-12-12', + submitted_timestamp: '2023-12-12', + source_system: 'SIMS', + name: 'name', + description: 'description', + publish_timestamp: '2023-12-12', + create_date: '2023-12-12', + create_user: 1, + update_date: '2023-12-12', + update_user: 1, + revision_count: 1 + } + ]; + + const mockResponse = { rowCount: 2, rows: mockSubmissionRecords } as unknown as Promise>; + + const mockDBConnection = getMockDBConnection({ sql: async () => mockResponse }); + + const submissionRepository = new SubmissionRepository(mockDBConnection); + + const response = await submissionRepository.getPublishedSubmissionsForAdmins(); + + expect(response).to.eql(mockSubmissionRecords); + }); + }); describe('getReviewedSubmissionsWithSecurity', () => { beforeEach(() => { sinon.restore(); diff --git a/api/src/repositories/submission-repository.ts b/api/src/repositories/submission-repository.ts index 08a08b74c..f3eec0e7f 100644 --- a/api/src/repositories/submission-repository.ts +++ b/api/src/repositories/submission-repository.ts @@ -1279,6 +1279,8 @@ export class SubmissionRepository extends BaseRepository { submission.security_review_timestamp IS NOT NULL AND submission_feature.parent_submission_feature_id IS NULL + AND + submission.publish_timestamp IS NULL GROUP BY submission.submission_id, submission_feature.feature_type_id, @@ -1299,9 +1301,69 @@ export class SubmissionRepository extends BaseRepository { return response.rows; } + /** + * Get all submissions that have completed security review and are published. + * + * @return {*} {Promise} + * @memberof SubmissionRepository + */ + async getPublishedSubmissionsForAdmins(): Promise { + const sqlStatement = SQL` + WITH w_unique_submissions as ( + SELECT + DISTINCT ON (submission.uuid) submission.*, + submission_feature.feature_type_id as root_feature_type_id, + feature_type.name as root_feature_type_name, + CASE + WHEN submission.security_review_timestamp is null THEN ${SECURITY_APPLIED_STATUS.PENDING} + WHEN COUNT(submission_feature_security.submission_feature_security_id) = 0 THEN ${SECURITY_APPLIED_STATUS.UNSECURED} + WHEN COUNT(submission_feature_security.submission_feature_security_id) = COUNT(submission_feature.submission_feature_id) THEN ${SECURITY_APPLIED_STATUS.SECURED} + ELSE ${SECURITY_APPLIED_STATUS.PARTIALLY_SECURED} + END as security + FROM + submission + INNER JOIN + submission_feature + ON + submission.submission_id = submission_feature.submission_id + INNER JOIN + feature_type + ON + feature_type.feature_type_id = submission_feature.feature_type_id + LEFT JOIN + submission_feature_security + ON + submission_feature.submission_feature_id = submission_feature_security.submission_feature_id + WHERE + submission.security_review_timestamp IS NOT NULL + AND + submission_feature.parent_submission_feature_id IS NULL + AND + submission.publish_timestamp IS NOT NULL + GROUP BY + submission.submission_id, + submission_feature.feature_type_id, + feature_type.name + ORDER BY + submission.uuid, submission.submission_id DESC + ) + SELECT + * + FROM + w_unique_submissions + ORDER BY + security_review_timestamp DESC; + `; + + const response = await this.connection.sql(sqlStatement, SubmissionRecordWithSecurityAndRootFeatureType); + + return response.rows; + } + /** * Get all submission features by submission id. * + * * @param {number} submissionId * @return {*} {Promise} * @memberof SubmissionRepository diff --git a/api/src/services/submission-service.test.ts b/api/src/services/submission-service.test.ts index c4461b7c2..eb577498f 100644 --- a/api/src/services/submission-service.test.ts +++ b/api/src/services/submission-service.test.ts @@ -833,7 +833,7 @@ describe('SubmissionService', () => { source_system: 'SIMS', name: 'name', description: 'description', - publish_timestamp: '2023-12-12', + publish_timestamp: null, create_date: '2023-12-12', create_user: 1, update_date: null, @@ -851,7 +851,7 @@ describe('SubmissionService', () => { source_system: 'SIMS', name: 'name', description: 'description', - publish_timestamp: '2023-12-12', + publish_timestamp: null, create_date: '2023-12-12', create_user: 1, update_date: '2023-12-12', @@ -878,6 +878,62 @@ describe('SubmissionService', () => { }); }); + describe('getPublishedSubmissionsForAdmins', () => { + it('should return an array of submission records', async () => { + const mockSubmissionRecords: SubmissionRecordWithSecurityAndRootFeatureType[] = [ + { + submission_id: 1, + uuid: '123-456-789', + security_review_timestamp: null, + submitted_timestamp: '2023-12-12', + source_system: 'SIMS', + name: 'name', + description: 'description', + publish_timestamp: null, + create_date: '2023-12-12', + create_user: 1, + update_date: null, + update_user: null, + revision_count: 0, + security: SECURITY_APPLIED_STATUS.UNSECURED, + root_feature_type_id: 1, + root_feature_type_name: 'dataset' + }, + { + submission_id: 2, + uuid: '789-456-123', + security_review_timestamp: '2023-12-12', + submitted_timestamp: '2023-12-12', + source_system: 'SIMS', + name: 'name', + description: 'description', + publish_timestamp: null, + create_date: '2023-12-12', + create_user: 1, + update_date: '2023-12-12', + update_user: 1, + revision_count: 1, + security: SECURITY_APPLIED_STATUS.SECURED, + root_feature_type_id: 1, + root_feature_type_name: 'dataset' + } + ]; + + const mockDBConnection = getMockDBConnection(); + + const getPublishedSubmissionsForAdminsStub = sinon + .stub(SubmissionRepository.prototype, 'getPublishedSubmissionsForAdmins') + .resolves(mockSubmissionRecords); + + const submissionService = new SubmissionService(mockDBConnection); + + const response = await submissionService.getPublishedSubmissionsForAdmins(); + + expect(getPublishedSubmissionsForAdminsStub).to.be.calledOnce; + expect(response).to.be.eql(mockSubmissionRecords); + }); + }); + describe('getSubmissionRecordBySubmissionIdWithSecurity', () => { it('should return a submission observation record', async () => { const mockDBConnection = getMockDBConnection(); diff --git a/api/src/services/submission-service.ts b/api/src/services/submission-service.ts index 638e7be1f..3c4592cdb 100644 --- a/api/src/services/submission-service.ts +++ b/api/src/services/submission-service.ts @@ -578,6 +578,16 @@ export class SubmissionService extends DBService { return this.submissionRepository.getReviewedSubmissionsForAdmins(); } + /** + * Get all submissions that have completed security review and are published. + * + * @return {*} {Promise} + * @memberof SubmissionService + */ + async getPublishedSubmissionsForAdmins(): Promise { + return this.submissionRepository.getPublishedSubmissionsForAdmins(); + } + /** * Get a submission record by id (with security status). * diff --git a/app/.pipeline/templates/app.dc.yaml b/app/.pipeline/templates/app.dc.yaml index 5e2737eb6..fbfe7078b 100644 --- a/app/.pipeline/templates/app.dc.yaml +++ b/app/.pipeline/templates/app.dc.yaml @@ -234,7 +234,7 @@ objects: status: ingress: null - kind: HorizontalPodAutoscaler - apiVersion: autoscaling/v2beta2 + apiVersion: autoscaling/v2 metadata: annotations: {} creationTimestamp: null diff --git a/app/src/features/admin/dashboard/DashboardPage.tsx b/app/src/features/admin/dashboard/DashboardPage.tsx index c0968fd4a..54064263d 100644 --- a/app/src/features/admin/dashboard/DashboardPage.tsx +++ b/app/src/features/admin/dashboard/DashboardPage.tsx @@ -7,9 +7,10 @@ import Typography from '@mui/material/Typography'; import ReviewedSubmissionsTable from 'features/admin/dashboard/components/ReviewedSubmissionsTable'; import UnreviewedSubmissionsTable from 'features/admin/dashboard/components/UnreviewedSubmissionsTable'; import { useState } from 'react'; +import PublishedSubmissionsTable from './components/PublishedSubmissionsTable'; const DashboardPage = () => { - const [activeTab, setActiveTab] = useState<'pending' | 'complete'>('pending'); + const [activeTab, setActiveTab] = useState<'pending' | 'complete' | 'published'>('pending'); return ( <> @@ -44,6 +45,12 @@ const DashboardPage = () => { id="submission-complete-tab" aria-controls="submission-complete-tabpanel" /> + @@ -66,6 +73,15 @@ const DashboardPage = () => { )} + + {activeTab === 'published' && ( + + )} ); diff --git a/app/src/features/admin/dashboard/components/PublishedSubmissionsTable.tsx b/app/src/features/admin/dashboard/components/PublishedSubmissionsTable.tsx new file mode 100644 index 000000000..d28260063 --- /dev/null +++ b/app/src/features/admin/dashboard/components/PublishedSubmissionsTable.tsx @@ -0,0 +1,242 @@ +import { mdiTextBoxSearchOutline, mdiTrayArrowDown } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardActions from '@mui/material/CardActions'; +import CardContent from '@mui/material/CardContent'; +import CardHeader from '@mui/material/CardHeader'; +import Chip from '@mui/material/Chip'; +import grey from '@mui/material/colors/grey'; +import Divider from '@mui/material/Divider'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import RecordsFoundSkeletonLoader from 'components/skeleton/submission-card/RecordsFoundSkeletonLoader'; +import SubmissionCardSkeletonLoader from 'components/skeleton/submission-card/SubmissionCardSkeletonLoader'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import SubmissionsListSortMenu from 'features/submissions/list/SubmissionsListSortMenu'; +import { useApi } from 'hooks/useApi'; +import useDataLoader from 'hooks/useDataLoader'; +import useDownloadJSON from 'hooks/useDownloadJSON'; +import { SubmissionRecordWithSecurityAndRootFeature } from 'interfaces/useSubmissionsApi.interface'; +import { Link as RouterLink } from 'react-router-dom'; +import { getFormattedDate, pluralize as p } from 'utils/Utils'; + +const PublishedSubmissionsTable = () => { + const biohubApi = useApi(); + const download = useDownloadJSON(); + + const publishedSubmissionsDataLoader = useDataLoader(() => biohubApi.submissions.getPublishedSubmissionsForAdmins()); + + publishedSubmissionsDataLoader.load(); + + const submissionRecords = publishedSubmissionsDataLoader.data || []; + + const onDownload = async (submission: SubmissionRecordWithSecurityAndRootFeature) => { + // make request here for JSON data of submission and children + const data = await biohubApi.submissions.getSubmissionDownloadPackage(submission.submission_id); + download(data, `${submission.name.toLowerCase().replace(/ /g, '-')}-${submission.submission_id}`); + }; + + const handleSortSubmissions = (submissions: SubmissionRecordWithSecurityAndRootFeature[]) => { + publishedSubmissionsDataLoader.setData(submissions); + }; + + if (publishedSubmissionsDataLoader.isLoading) { + return ( + <> + + + + ); + } + + if (submissionRecords.length === 0) { + return ( + <> + + + No records found + + + + + + + + No completed security reviews + + + No submissions have completed security review. + + + + ); + } + + return ( + <> + + {`${submissionRecords.length} ${p( + submissionRecords.length, + 'record' + )} found`} + + + + + + {submissionRecords.map((submissionRecord) => { + return ( + + + {submissionRecord.name} + + } + action={ + + } + sx={{ + pb: 1, + '& .MuiCardHeader-action': { + margin: 0 + } + }}> + + + {submissionRecord.description} + + + + + } + sx={{ + typography: 'body2', + whiteSpace: 'nowrap', + '& dd': { + color: 'text.secondary' + }, + '& dt': { + ml: 1 + } + }}> + +
Submitted:
+
{getFormattedDate(DATE_FORMAT.ShortDateFormat, submissionRecord.create_date)}
+
+ +
Published:
+
+ {submissionRecord.publish_timestamp && + getFormattedDate(DATE_FORMAT.ShortDateFormat, submissionRecord.publish_timestamp)} +
+
+ +
Source:
+
{submissionRecord.source_system}
+
+
+ + + + + + +
+
+
+ ); + })} +
+ + ); +}; + +export default PublishedSubmissionsTable; diff --git a/app/src/features/admin/dashboard/components/ReviewedSubmissionsTable.tsx b/app/src/features/admin/dashboard/components/ReviewedSubmissionsTable.tsx index 4d4dd3fa6..300862df8 100644 --- a/app/src/features/admin/dashboard/components/ReviewedSubmissionsTable.tsx +++ b/app/src/features/admin/dashboard/components/ReviewedSubmissionsTable.tsx @@ -20,7 +20,6 @@ import { useApi } from 'hooks/useApi'; import useDataLoader from 'hooks/useDataLoader'; import useDownload from 'hooks/useDownload'; import { SubmissionRecordWithSecurityAndRootFeature } from 'interfaces/useSubmissionsApi.interface'; -import React from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { getFormattedDate, pluralize as p } from 'utils/Utils'; diff --git a/app/src/hooks/api/useSubmissionsApi.ts b/app/src/hooks/api/useSubmissionsApi.ts index e610e6f4e..45affb599 100644 --- a/app/src/hooks/api/useSubmissionsApi.ts +++ b/app/src/hooks/api/useSubmissionsApi.ts @@ -113,6 +113,17 @@ const useSubmissionsApi = (axios: AxiosInstance) => { return data; }; + /** + * Fetch all submissions that have completed security review and published. + * + * @return {*} {Promise} + */ + const getPublishedSubmissionsForAdmins = async (): Promise => { + const { data } = await axios.get(`api/administrative/submission/published`); + + return data; + }; + /** * Update (patch) a submission record. * @@ -159,6 +170,7 @@ const useSubmissionsApi = (axios: AxiosInstance) => { getSubmissionRecordWithSecurity, getUnreviewedSubmissionsForAdmins, getReviewedSubmissionsForAdmins, + getPublishedSubmissionsForAdmins, updateSubmissionRecord, getPublishedSubmissions, getSubmissionFeatureSignedUrl