diff --git a/cmd/server/pactasrv/initiative.go b/cmd/server/pactasrv/initiative.go index b5c521c..9aabe3d 100644 --- a/cmd/server/pactasrv/initiative.go +++ b/cmd/server/pactasrv/initiative.go @@ -118,3 +118,72 @@ func (s *Server) ListInitiatives(ctx context.Context, request api.ListInitiative } return api.ListInitiatives200JSONResponse(result), nil } + +// Returns all of the portfolios that are participating in the initiative +// (GET /initiative/{id}/all-data) +func (s *Server) AllInitiativeData(ctx context.Context, request api.AllInitiativeDataRequestObject) (api.AllInitiativeDataResponseObject, error) { + actorInfo, err := s.getactorInfoOrErrIfAnon(ctx) + if err != nil { + return nil, err + } + // TODO(#12) Implement Authorization, along the lines of #121 + i, err := s.DB.Initiative(s.DB.NoTxn(ctx), pacta.InitiativeID(request.Id)) + if err != nil { + if db.IsNotFound(err) { + return nil, oapierr.NotFound("initiative not found", zap.String("initiative_id", request.Id)) + } + return nil, oapierr.Internal("failed to load initiative", zap.String("initiative_id", request.Id), zap.Error(err)) + } + portfolioMembers, err := s.DB.PortfolioInitiativeMembershipsByInitiative(s.DB.NoTxn(ctx), i.ID) + if err != nil { + return nil, oapierr.Internal("failed to load portfolio memberships for initiative", zap.String("initiative_id", string(i.ID)), zap.Error(err)) + } + portfolioIDs := []pacta.PortfolioID{} + for _, pm := range portfolioMembers { + portfolioIDs = append(portfolioIDs, pm.Portfolio.ID) + } + portfolios, err := s.DB.Portfolios(s.DB.NoTxn(ctx), portfolioIDs) + if err != nil { + return nil, oapierr.Internal("failed to load portfolios for initiative", zap.String("initiative_id", string(i.ID)), zap.Error(err)) + } + if err := s.populateBlobsInPortfolios(ctx, values(portfolios)...); err != nil { + return nil, err + } + auditLogs := []*pacta.AuditLog{} + for _, p := range portfolios { + auditLogs = append(auditLogs, &pacta.AuditLog{ + Action: pacta.AuditLogAction_Download, + ActorType: pacta.AuditLogActorType_Admin, // TODO(#12) When merging with #121, use the actor type from authorization + ActorID: string(actorInfo.UserID), + ActorOwner: &pacta.Owner{ID: actorInfo.OwnerID}, + PrimaryTargetType: pacta.AuditLogTargetType_Portfolio, + PrimaryTargetID: string(p.ID), + PrimaryTargetOwner: p.Owner, + SecondaryTargetType: pacta.AuditLogTargetType_Initiative, + SecondaryTargetID: string(i.ID), + SecondaryTargetOwner: &pacta.Owner{ID: "SYSTEM"}, // TODO(#12) When merging with #121, use the const type. + }) + } + if err := s.DB.CreateAuditLogs(s.DB.NoTxn(ctx), auditLogs); err != nil { + return nil, oapierr.Internal("failed to create audit logs nescessary to return download urls", zap.Error(err)) + } + + // Note, it is likely this code will need to be parallelized in the future - initiatives may eventually become large. + // However, since this action is unlikely to be taken frequently, and will only be taken by admins, getting the experience + // perfect here is not a priority. + response := api.InitiativeAllData{} + for _, portfolio := range portfolios { + url, expiryTime, err := s.Blob.SignedDownloadURL(ctx, string(portfolio.Blob.BlobURI)) + if err != nil { + return nil, oapierr.Internal("error getting signed download url", zap.Error(err), zap.String("blob_uri", string(portfolio.Blob.BlobURI))) + } + response.Items = append(response.Items, api.InitiativeAllDataPortfolioItem{ + Name: portfolio.Name, + BlobId: string(portfolio.Blob.BlobURI), + DownloadUrl: url, + ExpirationTime: expiryTime, + }) + } + + return api.AllInitiativeData200JSONResponse(response), nil +} diff --git a/cmd/server/pactasrv/pactasrv.go b/cmd/server/pactasrv/pactasrv.go index 66f59a7..f724d0b 100644 --- a/cmd/server/pactasrv/pactasrv.go +++ b/cmd/server/pactasrv/pactasrv.go @@ -167,6 +167,16 @@ func dereference[T any](ts []*T, e error) ([]T, error) { return result, nil } +func values[K comparable, V any](m map[K]*V) []*V { + result := make([]*V, len(m)) + i := 0 + for _, v := range m { + result[i] = v + i++ + } + return result +} + func getUserID(ctx context.Context) (pacta.UserID, error) { userID, err := session.UserIDFromContext(ctx) if err != nil { diff --git a/cmd/server/pactasrv/populate.go b/cmd/server/pactasrv/populate.go index f12efd7..3d9d68a 100644 --- a/cmd/server/pactasrv/populate.go +++ b/cmd/server/pactasrv/populate.go @@ -101,6 +101,29 @@ func (s *Server) populateArtifactsInAnalyses( return nil } +func (s *Server) populateBlobsInPortfolios( + ctx context.Context, + ps ...*pacta.Portfolio, +) error { + getFn := func(p *pacta.Portfolio) ([]*pacta.Blob, error) { + result := []*pacta.Blob{} + if p.Blob != nil { + result = append(result, p.Blob) + } + return result, nil + } + lookupFn := func(ids []pacta.BlobID) (map[pacta.BlobID]*pacta.Blob, error) { + return s.DB.Blobs(s.DB.NoTxn(ctx), ids) + } + getIDFn := func(a *pacta.Blob) pacta.BlobID { + return a.ID + } + if err := populateAll(ps, getFn, getIDFn, lookupFn); err != nil { + return oapierr.Internal("populating blobs in portfolios failed", zap.Error(err)) + } + return nil +} + func (s *Server) populateBlobsInAnalysisArtifacts( ctx context.Context, ts ...*pacta.AnalysisArtifact, diff --git a/frontend/openapi/generated/pacta/index.ts b/frontend/openapi/generated/pacta/index.ts index c3af15e..3f66591 100644 --- a/frontend/openapi/generated/pacta/index.ts +++ b/frontend/openapi/generated/pacta/index.ts @@ -39,6 +39,8 @@ export type { HoldingsDate } from './models/HoldingsDate'; export type { IncompleteUpload } from './models/IncompleteUpload'; export type { IncompleteUploadChanges } from './models/IncompleteUploadChanges'; export type { Initiative } from './models/Initiative'; +export type { InitiativeAllData } from './models/InitiativeAllData'; +export type { InitiativeAllDataPortfolioItem } from './models/InitiativeAllDataPortfolioItem'; export type { InitiativeChanges } from './models/InitiativeChanges'; export type { InitiativeCreate } from './models/InitiativeCreate'; export type { InitiativeInvitation } from './models/InitiativeInvitation'; diff --git a/frontend/openapi/generated/pacta/models/InitiativeAllData.ts b/frontend/openapi/generated/pacta/models/InitiativeAllData.ts new file mode 100644 index 0000000..96e898f --- /dev/null +++ b/frontend/openapi/generated/pacta/models/InitiativeAllData.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { InitiativeAllDataPortfolioItem } from './InitiativeAllDataPortfolioItem'; + +export type InitiativeAllData = { + /** + * the list of portfolios that are members of this initiative + */ + items: Array; +}; + diff --git a/frontend/openapi/generated/pacta/models/InitiativeAllDataPortfolioItem.ts b/frontend/openapi/generated/pacta/models/InitiativeAllDataPortfolioItem.ts new file mode 100644 index 0000000..cc9b574 --- /dev/null +++ b/frontend/openapi/generated/pacta/models/InitiativeAllDataPortfolioItem.ts @@ -0,0 +1,24 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type InitiativeAllDataPortfolioItem = { + /** + * the name of the portfolio + */ + name: string; + /** + * the id of the blob of the portfolio, which can be used to start a new partial download if the first download times out + */ + blobId: string; + /** + * the url to download the portfolio + */ + downloadUrl: string; + /** + * the time at which the download url will expire + */ + expirationTime: string; +}; + diff --git a/frontend/openapi/generated/pacta/services/DefaultService.ts b/frontend/openapi/generated/pacta/services/DefaultService.ts index 0ab0fe8..f904089 100644 --- a/frontend/openapi/generated/pacta/services/DefaultService.ts +++ b/frontend/openapi/generated/pacta/services/DefaultService.ts @@ -14,6 +14,7 @@ import type { CompletePortfolioUploadResp } from '../models/CompletePortfolioUpl import type { IncompleteUpload } from '../models/IncompleteUpload'; import type { IncompleteUploadChanges } from '../models/IncompleteUploadChanges'; import type { Initiative } from '../models/Initiative'; +import type { InitiativeAllData } from '../models/InitiativeAllData'; import type { InitiativeChanges } from '../models/InitiativeChanges'; import type { InitiativeCreate } from '../models/InitiativeCreate'; import type { InitiativeInvitation } from '../models/InitiativeInvitation'; @@ -233,6 +234,24 @@ export class DefaultService { }); } + /** + * Returns all of the portfolios that are participating in the initiative + * @param id ID of the initiative to fetch data for + * @returns InitiativeAllData the initiative data + * @throws ApiError + */ + public allInitiativeData( + id: string, + ): CancelablePromise { + return this.httpRequest.request({ + method: 'GET', + url: '/initiative/{id}/all-data', + path: { + 'id': id, + }, + }); + } + /** * Returns all initiatives * @returns Initiative gets all initiatives diff --git a/openapi/pacta.yaml b/openapi/pacta.yaml index 9ff9b66..2866255 100644 --- a/openapi/pacta.yaml +++ b/openapi/pacta.yaml @@ -208,6 +208,24 @@ paths: responses: '204': description: initiative deleted successfully + /initiative/{id}/all-data: + get: + summary: Returns all of the portfolios that are participating in the initiative + operationId: allInitiativeData + parameters: + - name: id + in: path + description: ID of the initiative to fetch data for + required: true + schema: + type: string + responses: + '200': + description: the initiative data + content: + application/json: + schema: + $ref: '#/components/schemas/InitiativeAllData' /initiatives: get: summary: Returns all initiatives @@ -1299,6 +1317,37 @@ components: pactaVersion: type: string description: The pacta model that this initiative should use, if not specified, the default pacta model will be used. + InitiativeAllData: + type: object + required: + - items + properties: + items: + type: array + description: the list of portfolios that are members of this initiative + items: + $ref: '#/components/schemas/InitiativeAllDataPortfolioItem' + InitiativeAllDataPortfolioItem: + type: object + required: + - name + - blobId + - downloadUrl + - expirationTime + properties: + name: + type: string + description: the name of the portfolio + blobId: + type: string + description: the id of the blob of the portfolio, which can be used to start a new partial download if the first download times out + downloadUrl: + type: string + description: the url to download the portfolio + expirationTime: + type: string + format: date-time + description: the time at which the download url will expire InitiativeInvitationCreate: type: object required: