diff --git a/apps/cli/executables/go/mod_analyst/README.md b/apps/cli/executables/go/mod_analyst/README.md index 95652e2df9c378e43a03b1de8cdba9e7ef472c2e..aa15b71e0a118d055ce7b1a56eef57d0270409ea 100644 --- a/apps/cli/executables/go/mod_analyst/README.md +++ b/apps/cli/executables/go/mod_analyst/README.md @@ -1,6 +1,6 @@ # mod_analyst: -mod_analyst is a command line utility to add/remove DAs and AODs from the +mod_analyst is a command line utility to add/remove QA reviewers from the `qa_staff` table in the database. It is recommended that you have the `CAPO_PROFILE` @@ -9,27 +9,27 @@ CAPO file with the database login information. ## Usage ``` -usage: mod_analyst -name "First Last" [-aod] [-email EMAIL] [-rm] [-ssl] [-port PORT] [-dbname DATABASE_NAME] [-host DATABASE_HOST] [-prop CAPO_PATH] - -Add or remove DAs and AODs from the qa_staff table in the database - - -name Provide the name of the analyst, with quotes for more than one name i.e. "First Last" - -aod Flag to add the user as an AOD. When not included they are added as a DA - -email Provide the user's email - -rm Remove the user instead of adding them - -ssl Use SSL when connecting to the database (typically not needed) - -port Provide a different port for the database (default 5432) - -dbname Provide the name of the database (default 'archive') - -host Provide the name of the host where the database is located (default $HOSTNAME) - -prop Provide the path to the CAPO profile with the database login information (default $CAPO_PROFILE.properties) +usage: mod_analyst -name "First Last" [-email EMAIL] [-group GROUP] [-up] [-rm] [-available] [-unavailable] [-path CAPO_PATH] [-profile PROFILE] [] + +Add or remove QA reviewers from the qa_staff table in the database + -available Mark the user as available, must be used with '-up' flag + -email The email of the QA reviewer to be added + -group When adding a user, makes them part of the relevant QA group (by default they are added to the Stage 1 group) (default "Stage 1") + -name The full name of the QA reviewer to be added, use quotes for the first and last name (e.g. -name 'First Last') + -path Path to the properties file with the login details for the database (default "/home/casa/capo:/home/ssa/capo") + -profile CAPO Profile to use for database connection, defaults to CAPO_PROFILE of the current environment (e.g. dsoc-dev) + -rm Remove the user from the database, Requires -name and -group flags to be present. + -unavailable Mark the user as unavailable, must be used with '-up' flag + -up Update a user already in the database. Updatable fields are email and availability only. To change an existing user's group add a new record. + ``` ## Examples -### To add Nathan Bockisch as an aod +### To add Nathan Bockisch as a Stage 2 reviewer 1. ssh to the machine hosting the database -2. `mod_analyst -name "Nathan Bockisch" -aod -email "nbockisc@nrao.edu"` +2. `mod_analyst -name "Nathan Bockisch" -group="Stage 2" -email "nbockisc@nrao.edu"` -### To remove Nathan Bockisch as an aod +### To remove Nathan Bockisch as a reviewer 1. ssh to the machine hosting the database 2. `mod_analyst -name "Nathan Bockisch" -rm` diff --git a/apps/cli/executables/go/mod_analyst/main.go b/apps/cli/executables/go/mod_analyst/main.go index c9ce969abd30050e572ccc23614ab4dec6390ba4..8719fcdfa3e468779218453d70e1bc5e5df89a6d 100644 --- a/apps/cli/executables/go/mod_analyst/main.go +++ b/apps/cli/executables/go/mod_analyst/main.go @@ -29,21 +29,25 @@ import ( func main() { // user args var user db.UserInfo - var is_remove bool + var isRemove bool + var isUpdate bool // DB connection args var connectionInfo = initDbInfo() // Get user properties - flag.BoolVar(&user.IsAod, "aod", false, "When adding a user, makes them part of the AOD group (by default they are added to the DA group)") - flag.StringVar(&user.Name, "name", "", "The full name of the DA/AOD to be added, use quotes for the first and last name (e.g. -name 'First Last')") - flag.StringVar(&user.Email, "email", "", "The email of the DA/AOD to be added") - flag.BoolVar(&is_remove, "rm", false, "Remove the user from the database") + flag.StringVar(&user.Group, "group", "Stage 1", "When adding a user, makes them part of the relevant QA group (by default they are added to the Stage 1 group)") + flag.StringVar(&user.Name, "name", "", "The full name of the QA reviewer to be added, use quotes for the first and last name (e.g. -name 'First Last')") + flag.StringVar(&user.Email, "email", "", "The email of the QA reviewer to be added") + flag.BoolVar(&isRemove, "rm", false, "Remove the user from the database") + flag.BoolVar(&isUpdate, "up", false, "Update a user already in the database. Updatable fields are email and availability only. To change an existing user's group add a new record.") + flag.BoolVar(&user.IsUnavailable, "unavailable", false, "Mark the user as unavailable, must be used with '-up' flag") + flag.BoolVar(&user.IsAvailable, "available", false, "Mark the user as available, must be used with '-up' flag") // Get DB connection args flag.StringVar(&connectionInfo.AltCapoPath, "path", helpers.DefaultCapoPath, "Path to the properties file with the login details for the database") - flag.StringVar(&connectionInfo.Profile, "profile", os.Getenv("CAPO_PROFILE"), "CAPO Profile to use for database connection, defaults to CAPO_PROFILE env variable") + flag.StringVar(&connectionInfo.Profile, "profile", os.Getenv("CAPO_PROFILE"), "CAPO Profile to use for database connection, defaults to CAPO_PROFILE of the current environment") flag.Parse() // Make sure name is given @@ -52,8 +56,10 @@ func main() { return } - if is_remove { + if isRemove { db.RemoveUser(user, connectionInfo) + } else if isUpdate { + db.UpdateUser(user, connectionInfo) } else { db.AddUser(user, connectionInfo) } diff --git a/apps/cli/executables/go/mod_analyst/pkg/db/mod_db.go b/apps/cli/executables/go/mod_analyst/pkg/db/mod_db.go index 5d8a10501c0da2b36359444b5694917123ebf5ec..6148ff2ce2d82d52db0beb9e4d088eae8bb07158 100644 --- a/apps/cli/executables/go/mod_analyst/pkg/db/mod_db.go +++ b/apps/cli/executables/go/mod_analyst/pkg/db/mod_db.go @@ -26,9 +26,11 @@ import ( ) type UserInfo struct { - Name string - Email string - IsAod bool + Name string + Email string + Group string + IsUnavailable bool + IsAvailable bool } /** @@ -46,8 +48,7 @@ func checkError(err error) { /** * Add a user to the qa_staff table in the database * - * @param name a string with the name of the person to be added - * @param is_aod a bool to check if the user is an AOD or not + * @param user a UserInfo struct instance with the name, email, and stage of the person to be added * @param db_info a DbInfo type with information to connect to the database **/ func AddUser(user UserInfo, connectionInfo DbInfo) { @@ -59,24 +60,74 @@ func AddUser(user UserInfo, connectionInfo DbInfo) { checkError(err) }(db) - group := "DA" - if user.IsAod { - group = "AOD" - } - - // Add the user + // Add the user, new users are assumed to always be available insertStatement := `insert into "qa_staff"("user_name", "group", "available", "email") values($1, $2, $3, $4)` - _, err := db.Exec(insertStatement, user.Name, group, true, user.Email) + _, err := db.Exec(insertStatement, user.Name, user.Group, true, user.Email) checkError(err) - fmt.Println("Added " + user.Name + " to qa_staff") + fmt.Println("Updated " + user.Name + " to qa_staff") +} + +//UpdateUser +/** +* Update existing user in the qa_staff table + */ +func UpdateUser(user UserInfo, connectionInfo DbInfo) { + // Get a connection to the database + db := GetConnection(connectionInfo) + + defer func(db *sql.DB) { + err := db.Close() + checkError(err) + }(db) + + // update the user + conditions := buildUpdateConditions(user) + updateStatement := `update qa_staff set ` + conditions + ` where user_name=$1` + + _, err := db.Exec(updateStatement, user.Name) + checkError(err) + + fmt.Println("Updated " + user.Name) +} + +func buildUpdateConditions(user UserInfo) string { + // possible updatable field are email and available, changes in user groups should be new table entries + updateEmail := user.Email + updateUnavailability := user.IsUnavailable + updateAvailability := user.IsAvailable + //always set availability since we can't tell if it's actually being changed. + setCondition := "" + + if updateEmail != "" { + setCondition = "email='" + user.Email + "'" + } + if updateUnavailability == true { + //user is now unavailable + + if len(setCondition) > 0 { + // we are updating multiple fields + setCondition = setCondition + ", " + } + setCondition = setCondition + "available=false" + } + if updateAvailability == true { + //user is now available + + if len(setCondition) > 0 { + // we are updating multiple fields + setCondition = setCondition + ", " + } + setCondition = setCondition + "available=true" + } + return setCondition } //RemoveUser /** * Remove a user from the qa_staff table in the database * - * @param name a string with the name of the person to be removed + * @param user a UserInfo struct instance with the information of the person to be removed * @param db_info a DbInfo type with information to connect to the database **/ func RemoveUser(user UserInfo, connectionInfo DbInfo) { @@ -89,9 +140,9 @@ func RemoveUser(user UserInfo, connectionInfo DbInfo) { }(db) // Remove the user - deleteStatement := `delete from "qa_staff" where "user_name"=$1` - _, err := db.Exec(deleteStatement, user.Name) + deleteStatement := `delete from "qa_staff" where "user_name"=$1 and "group"=$2` + _, err := db.Exec(deleteStatement, user.Name, user.Group) checkError(err) - fmt.Println("Removed " + user.Name + " from qa_staff") + fmt.Println("Removed " + user.Name + ",group " + user.Group + " from qa_staff") } diff --git a/apps/cli/executables/pexable/casa_envoy/casa_envoy/launchers.py b/apps/cli/executables/pexable/casa_envoy/casa_envoy/launchers.py index 91b011c180464d36364ea645c25b510f0af640ce..318d9c61874a0afa7fe9d4202e3cdbaab38c4df0 100644 --- a/apps/cli/executables/pexable/casa_envoy/casa_envoy/launchers.py +++ b/apps/cli/executables/pexable/casa_envoy/casa_envoy/launchers.py @@ -19,6 +19,7 @@ import glob import json import logging import os +import pathlib import subprocess import sys from typing import Dict, Union @@ -93,7 +94,14 @@ class CasaLauncher: run_type = self.parameters.get("product_type") metadata = self.parameters.get("metadata") ppr = self.parameters.get("ppr") - subprocess.run(["./vela", "-f", metadata, ppr, "--" + run_type]) + # casa_envoy might be running in a condor scratch directory or + # directly from the processing directory. Account for both cases + vela_path = ( + "./vela" + if pathlib.Path("./vela").exists() + else f"/lustre/aoc/cluster/pipeline/{os.environ.get('CAPO_PROFILE')}/workspaces/sbin/vela" + ) + subprocess.run([vela_path, "-f", metadata, ppr, "--" + run_type]) def check_logs(self): """ diff --git a/apps/cli/executables/pexable/conveyor/README.md b/apps/cli/executables/pexable/conveyor/README.md index 738b181c72c8cf77207351775a36d48a428da155..2ba92d7b60e5cba2202256930840da60e8d15bf6 100644 --- a/apps/cli/executables/pexable/conveyor/README.md +++ b/apps/cli/executables/pexable/conveyor/README.md @@ -48,7 +48,7 @@ from the request status page. ## QA Retrieval -Once the DAs and/or AOD are finished with the QA process, the files and directories need to be moved back to +Once the QA reviewers are finished with the QA process, the files and directories need to be moved back to their original parent processing directory. Conveyor first checks for and break any symlinks, and then moves the requested directories back to their original diff --git a/apps/cli/executables/pexable/productfetcher/productfetcher/locations.py b/apps/cli/executables/pexable/productfetcher/productfetcher/locations.py index f95d4a969e166580392badadfaad2118afbd0fa5..79b511abb2e09e46168f4df5da39c147129f2595 100644 --- a/apps/cli/executables/pexable/productfetcher/productfetcher/locations.py +++ b/apps/cli/executables/pexable/productfetcher/productfetcher/locations.py @@ -102,6 +102,10 @@ class NgasServer(NamedTuple): :param dest: the destination to write to :return: true if direct copy is possible """ + exec_site = CapoConfig().settings("edu.nrao.workspaces.ProductFetcherSettings").executionSite + # Catch the case of local testing + if "local" in exec_site: + return False return self.cluster == Cluster.DSOC and self.location == Location.in_location(dest) diff --git a/apps/cli/executables/pexable/productfetcher/tests/test_locations.py b/apps/cli/executables/pexable/productfetcher/tests/test_locations.py index 37d654a4ae131deacb5ab5cf397931b45bc06a91..9530ed58969c565b3d18f2bf3c56ed4fa4aa5974 100644 --- a/apps/cli/executables/pexable/productfetcher/tests/test_locations.py +++ b/apps/cli/executables/pexable/productfetcher/tests/test_locations.py @@ -152,22 +152,26 @@ def test_direct_copy_detection(): naasc = Path("/lustre/naasc/foo") home = Path("/home/nobody/foo") - # there are two cases we can direct copy to, currently: DSOC -> DSOC - # and NAASC -> NAASC (with cluster=DSOC) - assert NgasServer("", Location.DSOC, Cluster.DSOC).can_direct_copy_to(aoc) - assert NgasServer("", Location.NAASC, Cluster.DSOC).can_direct_copy_to(naasc) - - # all the other permutations we cannot: NAASC -> AOC, AOC -> NAASC, NAASC -> NAASC - assert not NgasServer("", Location.NAASC, Cluster.DSOC).can_direct_copy_to(aoc) - assert not NgasServer("", Location.DSOC, Cluster.NAASC).can_direct_copy_to(aoc) - assert not NgasServer("", Location.DSOC, Cluster.NAASC).can_direct_copy_to(naasc) - assert not NgasServer("", Location.NAASC, Cluster.NAASC).can_direct_copy_to(naasc) - assert not NgasServer("", Location.NAASC, Cluster.NAASC).can_direct_copy_to(aoc) - - # and of course, we can never direct copy to your house - for location in Location: - for cluster in Cluster: - assert not NgasServer("", location, cluster).can_direct_copy_to(home) + settings = FakeProductfetcherSettings(4, "DSOC", "http://localhost/location?locator=") + with patch("productfetcher.locations.CapoConfig") as capo: + capo.return_value = MagicMock() + capo.return_value.settings.return_value = settings + # there are two cases we can direct copy to, currently: DSOC -> DSOC + # and NAASC -> NAASC (with cluster=DSOC) + assert NgasServer("", Location.DSOC, Cluster.DSOC).can_direct_copy_to(aoc) + assert NgasServer("", Location.NAASC, Cluster.DSOC).can_direct_copy_to(naasc) + + # all the other permutations we cannot: NAASC -> AOC, AOC -> NAASC, NAASC -> NAASC + assert not NgasServer("", Location.NAASC, Cluster.DSOC).can_direct_copy_to(aoc) + assert not NgasServer("", Location.DSOC, Cluster.NAASC).can_direct_copy_to(aoc) + assert not NgasServer("", Location.DSOC, Cluster.NAASC).can_direct_copy_to(naasc) + assert not NgasServer("", Location.NAASC, Cluster.NAASC).can_direct_copy_to(naasc) + assert not NgasServer("", Location.NAASC, Cluster.NAASC).can_direct_copy_to(aoc) + + # and of course, we can never direct copy to your house + for location in Location: + for cluster in Cluster: + assert not NgasServer("", location, cluster).can_direct_copy_to(home) class TestNgasFileSchema: diff --git a/apps/cli/executables/pexable/ws_metrics/ws_metrics/queries/query_definitions.py b/apps/cli/executables/pexable/ws_metrics/ws_metrics/queries/query_definitions.py index 80b00d0e2b67555d2fdbe74b4c58b23c38c383f2..5ca84fe06574b7081e8e8f29a41d16fef24a10de 100644 --- a/apps/cli/executables/pexable/ws_metrics/ws_metrics/queries/query_definitions.py +++ b/apps/cli/executables/pexable/ws_metrics/ws_metrics/queries/query_definitions.py @@ -35,8 +35,8 @@ OPERATIONS = Query( mjd_to_timestamp(eb.starttime) as dateObs, cr.updated_at as dateArchived, science_products.is_srdp as srdpStatus, - cr.assigned_da as assDa, - cr.assigned_aod as assAod""", + cr.stage_1_reviewer as stage1Reviewer, + cr.stage_2_reviewer as stage2Reviewer""", """FROM science_products JOIN capability_versions cv ON CASE WHEN science_product_locator = cv.parameters->>'product_locator' then 1 @@ -46,8 +46,10 @@ OPERATIONS = Query( JOIN capability_requests cr on cv.capability_request_id = cr.capability_request_id""", "WHERE cr.ingested = True", [ + "AND updated_at between %(beginning)s and %(end)s", "GROUP BY cv.capability_request_id, cv.capability_name, external_name, eb.band_code," - " mjd_to_timestamp(eb.starttime), cr.updated_at, science_products.is_srdp, cr.assigned_da, cr.assigned_aod" + " mjd_to_timestamp(eb.starttime), cr.updated_at, science_products.is_srdp," + " cr.stage_1_reviewer, cr.stage_2_reviewer", ], ) # OPERATIONS_COUNT = Query( diff --git a/apps/web/src/app/workspaces/components/active-capability-requests/active-capability-requests.component.html b/apps/web/src/app/workspaces/components/active-capability-requests/active-capability-requests.component.html index 1df166a99887d61b8ad073e2da7aa6a0c23eeaf4..7465ef3d7385528a9a4f06803f1a1cf3306d0d18 100644 --- a/apps/web/src/app/workspaces/components/active-capability-requests/active-capability-requests.component.html +++ b/apps/web/src/app/workspaces/components/active-capability-requests/active-capability-requests.component.html @@ -50,7 +50,7 @@ <br /> <div> - <app-filter-menu *ngIf="showFilterMenu" [state]="statesToFilter" [daStaff]="qaStaff['DA']" [aodStaff]="qaStaff['AOD']" [srdpStatus]="srdpOptions" [filters]="filters" (filterMenuEventEmitter)="emitFilterEvent($event)"></app-filter-menu> + <app-filter-menu *ngIf="showFilterMenu" [state]="statesToFilter" [exec_status]="execStatusToFilter" [stage1QaStaff]="qaStaff['Stage 1']" [stage2QaStaff]="qaStaff['Stage 2']" [srdpStatus]="srdpOptions" [filters]="filters" (filterMenuEventEmitter)="emitFilterEvent($event)"></app-filter-menu> <mat-paginator #requestPaginator [length]="(sortedActiveRequests$ | async)?.length" [pageSize]="pageSize" @@ -82,6 +82,9 @@ ><i class="text-dark small fas fa-arrow-up"></i ></span> </button> + <button class="btn bg-transparent border-0 btn-light btn-sm" (click)="toggleFilterMenu()"> + <span><i class="text-dark small fas fa-filter"></i></span> + </button> </th> <th>SDM ID</th> <th>Bands</th> @@ -115,14 +118,14 @@ </button> </th> <th>Current Processing Start Time</th> - <th> - DA + <th *ngIf="capability.requires_qa"> + Stage 1 Reviewer <button class="btn bg-transparent border-0 btn-light btn-sm" (click)="toggleFilterMenu()"> <span><i class="text-dark small fas fa-filter"></i></span> </button> </th> - <th> - AOD + <th *ngIf="capability.requires_qa"> + Stage 2 Reviewer <button class="btn bg-transparent border-0 btn-light btn-sm" (click)="toggleFilterMenu()"> <span><i class="text-dark small fas fa-filter"></i></span> </button> @@ -132,14 +135,15 @@ <tbody> <tr *ngFor="let request of (sortedActiveRequests$ | async) | slice:pageStart:pageStop; trackBy: trackActiveRequests"> <td> - <button + <a + routerLink="/workspaces/request-status/{{ request.id }}" type="button" class="btn btn-light" (click)="capabilityRequestService.redirectToRequestStatusPage(request.id, false)" > <strong class="pr-2">{{ request.id }}</strong> <app-status-badge [capabilityRequest]="request"></app-status-badge> - </button> + </a> </td> <td>{{ getExecutionStatusName(request) }}</td> <td>{{ getMetadata(request).sdm_id }}</td> @@ -193,23 +197,23 @@ > Processing has not started </td> - <td> - <span *ngIf="qaStaff.DA"> - Assigned DA: - <div ngbDropdown #daDrop="ngbDropdown" (openChange)="pausePolling($event)"> + <td *ngIf="capability.requires_qa" + [ngClass]="{ + 'staff-assigned' : request.stage_1_reviewer, + 'staff-unassigned' : !request.stage_1_reviewer + }" + > + <span *ngIf="qaStaff['Stage 1']"> + Reviewer: + <div ngbDropdown #stage1QaDrop="ngbDropdown" (openChange)="pausePolling($event)"> <form> <mat-form-field> - <mat-label - [ngClass]="{ - 'staff-assigned' : request.assigned_da, - 'staff-unassigned' : !request.assigned_da - }" - > - {{ request.assigned_da ? request.assigned_da : "Assign DA" }} + <mat-label> + {{ request.stage_1_reviewer ? request.stage_1_reviewer : "Assign Stage 1 Reviewer" }} </mat-label> <input type="text" matInput - [formControl]="daControl" + [formControl]="stage1QaControl" [matAutocomplete]="auto" (onfocus)="this.value=''" ngbDropdownToggle @@ -217,10 +221,10 @@ <div ngbDropdownMenu [hidden]="true"> <mat-autocomplete autoActiveFirstOption #auto="matAutocomplete" - (optionSelected)="setStaff(request.id, $event.option.value);daDrop.close()" + (optionSelected)="setStaff(request.id, $event.option.value);stage1QaDrop.close()" [displayWith]="staffSelectDisplay" > - <mat-option *ngFor="let assignableStaff of filteredDaStaff | async" + <mat-option *ngFor="let assignableStaff of filteredStage1QaStaff | async" [value]="assignableStaff" > {{assignableStaff.user_name}} @@ -232,7 +236,7 @@ id="reset-da" class="btn btn-danger btn-sm" style="margin-left: 10px" - (click)="clearStaff(request.id, 'DA')" + (click)="clearStaff(request.id, 'Stage 1')" > <span class="fas fa-times"></span> </button> @@ -240,33 +244,34 @@ </div> </span> </td> - <td> - <span *ngIf="qaStaff.AOD"> - Assigned AOD: - <div ngbDropdown #aodDrop="ngbDropdown" (openChange)="pausePolling($event)"> + <!-- for non-srdp entries, DAs want AOD color highlighting to match the DA one --> + <td *ngIf="capability.requires_qa" + [ngClass]="{ + 'staff-assigned' : (getMetadata(request).is_srdp && request.stage_2_reviewer) || (!getMetadata(request).is_srdp && request.stage_1_reviewer), + 'staff-unassigned' : (getMetadata(request).is_srdp && !request.stage_2_reviewer) || (!getMetadata(request).is_srdp && !request.stage_1_reviewer) + }" + > + <span *ngIf="qaStaff['Stage 2']"> + Reviewer: + <div ngbDropdown #stage2QaDrop="ngbDropdown" (openChange)="pausePolling($event)"> <form> <mat-form-field> - <mat-label - [ngClass]="{ - 'staff-assigned' : request.assigned_aod, - 'staff-unassigned' : !request.assigned_aod - }" - > - {{ request.assigned_aod ? request.assigned_aod : "Assign AOD" }} + <mat-label> + {{ request.stage_2_reviewer ? request.stage_2_reviewer : "Assign Stage 2 Reviewer" }} </mat-label> <input type="text" matInput - [formControl]="aodControl" + [formControl]="stage2QaControl" [matAutocomplete]="auto" ngbDropdownToggle > <div ngbDropdownMenu [hidden]="true"> <mat-autocomplete autoActiveFirstOption #auto="matAutocomplete" - (optionSelected)="setStaff(request.id, $event.option.value);aodDrop.close()" + (optionSelected)="setStaff(request.id, $event.option.value);stage2QaDrop.close()" [displayWith]="staffSelectDisplay" > - <mat-option *ngFor="let assignableStaff of filteredAodStaff | async" + <mat-option *ngFor="let assignableStaff of filteredStage2QaStaff | async" [value]="assignableStaff" > {{assignableStaff.user_name}} @@ -275,10 +280,10 @@ </div> </mat-form-field> <button - id="reset-aod" + id="reset-stage-2" class="btn btn-danger btn-sm" style="margin-left: 10px" - (click)="clearStaff(request.id, 'AOD')" + (click)="clearStaff(request.id, 'Stage 2')" > <span class="fas fa-times"></span> </button> diff --git a/apps/web/src/app/workspaces/components/active-capability-requests/active-capability-requests.component.scss b/apps/web/src/app/workspaces/components/active-capability-requests/active-capability-requests.component.scss index 20f799f9d95cb60ed47372287a46961960a842b2..7404b88e3f268e761bd3a52c5e8de26a96a5689c 100644 --- a/apps/web/src/app/workspaces/components/active-capability-requests/active-capability-requests.component.scss +++ b/apps/web/src/app/workspaces/components/active-capability-requests/active-capability-requests.component.scss @@ -1,10 +1,16 @@ .staff-assigned { - color: black; - opacity: 1 !important; + mat-label { + color: black; + opacity: 1 !important; + } } .staff-unassigned { - opacity: 1 !important; + background-color: #bfbfbf; + + mat-label { + opacity: 1 !important; + } } mat-option:last-child:before { diff --git a/apps/web/src/app/workspaces/components/active-capability-requests/active-capability-requests.component.ts b/apps/web/src/app/workspaces/components/active-capability-requests/active-capability-requests.component.ts index bc8612201e75597cd1ec806d983bdc621d53c19f..1557b63c829ba9a6425b5006598342df37fe7b75 100644 --- a/apps/web/src/app/workspaces/components/active-capability-requests/active-capability-requests.component.ts +++ b/apps/web/src/app/workspaces/components/active-capability-requests/active-capability-requests.component.ts @@ -30,16 +30,16 @@ import { PollingDataUpdaterService } from "../../services/polling-data-updater.s import { BehaviorSubject, combineLatest, Observable, Subject } from "rxjs"; import { map, repeatWhen, scan, takeUntil, startWith } from "rxjs/operators"; import { Staff } from "../../model/staff"; -import {ActiveRequestsService} from "../../services/active-requests.service"; -import {StorageService} from "../../../shared/storage/storage.service"; +import { ActiveRequestsService } from "../../services/active-requests.service"; +import { StorageService } from "../../../shared/storage/storage.service"; import { Filter } from "./components/filter-menu/filter-menu.component"; import { FormControl } from "@angular/forms"; -import { MatPaginator, PageEvent } from '@angular/material/paginator'; +import { MatPaginator, PageEvent } from "@angular/material/paginator"; import { WsHeaderComponent } from "../../ws-header/ws-header.component"; -import { RxState } from '@rx-angular/state'; +import { RxState } from "@rx-angular/state"; -export const defaultSortOrder = "desc" -export const defaultColumn = "id" +export const defaultSortOrder = "desc"; +export const defaultColumn = "id"; /** * Encapsulates data necessary for sorting so it can be managed by RxState @@ -54,21 +54,21 @@ interface SortState { } const initSortState = { - sortCol: 'id', - sortDir: {dir: defaultSortOrder, col: defaultColumn}, -} + sortCol: "id", + sortDir: { dir: defaultSortOrder, col: defaultColumn }, +}; @Component({ selector: "app-active-capability-requests", templateUrl: "./active-capability-requests.component.html", styleUrls: ["./active-capability-requests.component.scss"], providers: [RxState], - encapsulation : ViewEncapsulation.None, + encapsulation: ViewEncapsulation.None, }) export class ActiveCapabilityRequestsComponent implements OnInit, OnDestroy { model$: Observable<SortState> = this.state.select(); - public title = 'Active Capability Requests'; + public title = "Active Capability Requests"; public activeCapabilityRequests: Array<CapabilityRequest>; public capability: Capability; public isPaused: boolean; @@ -80,6 +80,7 @@ export class ActiveCapabilityRequestsComponent implements OnInit, OnDestroy { private paramsObj: any; private filters: any; public showFilterMenu = false; + private notSubmittedState = "Latest version has not been submitted"; private newParams: Params; private filterSets = {}; private availableFilters: any; @@ -88,56 +89,49 @@ export class ActiveCapabilityRequestsComponent implements OnInit, OnDestroy { // Filter sets public statesSet = new Set<string>(); - public daSet = new Set<string>(); - public aodSet = new Set<string>(); + public execStatusSet = new Set<string>(); + public stage1QaSet = new Set<string>(); + public stage2QaSet = new Set<string>(); public srdpSet = new Set<string>(); public obsDateSet = new Set<string>(); // Needed for autocomplete - aodControl: FormControl = new FormControl(); - daControl: FormControl = new FormControl(); - filteredAodStaff: Observable<Staff[]>; - filteredDaStaff: Observable<Staff[]>; - - // Needed for pagination of requests table - @ViewChild('requestPaginator') requestsPaginator!: MatPaginator; - pageSize: number; - pageStart: number; - pageStop: number; - pageAllVal: number; - getPageSizeOptions(): number[]{ - let pageSizes = [5, 10, 20, 40]; - let pageSizeOptions = [this.pageAllVal]; - - pageSizes.forEach(size => { - if (this.pageAllVal > size) pageSizeOptions.unshift(size); - }); - - return pageSizeOptions; - } + stage2QaControl: FormControl = new FormControl(); + stage1QaControl: FormControl = new FormControl(); + filteredStage2QaStaff: Observable<Staff[]>; + filteredStage1QaStaff: Observable<Staff[]>; public statesToFilter = [ - {"name":"Complete"}, - {"name":"Submitted"}, - {"name":"Created"}, - {"name":"Cancelled"}, - {"name":"Failed"}, - ] - public srdpOptions = [ - {"name":"true"}, - {"name":"false"}, - ] - public obsDateOptions = [ - {"obs_min":""}, - {"obs_max":""}, + { name: "Complete" }, + { name: "Submitted" }, + { name: "Created" }, + { name: "Cancelled" }, + { name: "Failed" }, + ]; + public execStatusToFilter = [ + { "name":"Not submitted", "filter_val":this.notSubmittedState }, + { "name":"Start", "filter_val":"Start" }, + { "name":"Queued", "filter_val":"Queued" }, + { "name":"Executing", "filter_val":"Executing" }, + { "name":"Ingesting", "filter_val":"Ingesting" }, + { "name":"Awaiting QA", "filter_val":"Awaiting QA" }, + { "name":"Stage 2 Review", "filter_val":"Stage 2 Review" }, + { "name":"QA Closed", "filter_val":"QA Closed" }, + { "name":"Failed", "filter_val":"Failed" }, + { "name":"Error", "filter_val":"Error" }, + { "name":"Cancelled", "filter_val":"Cancelled" }, + { "name":"Complete", "filter_val":"Complete" }, ] + public srdpOptions = [{ name: "true" }, { name: "false" }]; + public obsDateOptions = [{ obs_min: "" }, { obs_max: "" }]; // Observers private activeRequestsObserver = { next: (request) => { this.activeCapabilityRequests = request.active_requests; }, - error: (error) => console.error("Error when retrieving list of active capability requests:", error), + error: (error) => + console.error("Error when retrieving list of active capability requests:", error), }; public capabilityObserver = { next: (capability) => { @@ -157,7 +151,7 @@ export class ActiveCapabilityRequestsComponent implements OnInit, OnDestroy { scan<string, { col: string; dir: string }>( (sort, val) => { if (sort.col === val) { - this.activeRequestsService.saveReqIdSortOrder(this.sortDir === "asc" ? "desc": "asc"); + this.activeRequestsService.saveReqIdSortOrder(this.sortDir === "asc" ? "desc" : "asc"); this.sortDir = this.getSortDirection(); } @@ -166,6 +160,27 @@ export class ActiveCapabilityRequestsComponent implements OnInit, OnDestroy { { dir: this.sortDir, col: "" }, ), ); + + // to signal when all subscribers should unsubscribe (triggered in tear down) + private ngUnsubscribe = new Subject(); + + // Needed for pagination of requests table + @ViewChild("requestPaginator") requestsPaginator!: MatPaginator; + pageSize: number; + pageStart: number; + pageStop: number; + pageAllVal: number; + getPageSizeOptions(): number[] { + const pageSizes = [5, 10, 20, 40]; + const pageSizeOptions = [this.pageAllVal]; + + pageSizes.forEach((size) => { + if (this.pageAllVal > size) pageSizeOptions.unshift(size); + }); + + return pageSizeOptions; + } + public sortOn(column: string) { this.sortedColumn$.next(column); } @@ -173,10 +188,6 @@ export class ActiveCapabilityRequestsComponent implements OnInit, OnDestroy { public getSortDirection(): string { return this.activeRequestsService.getSortOrder() ?? defaultSortOrder; } - - // to signal when all subscribers should unsubscribe (triggered in tear down) - private ngUnsubscribe = new Subject(); - constructor( public state: RxState<SortState>, private capabilityRequestService: CapabilityRequestService, @@ -193,17 +204,17 @@ export class ActiveCapabilityRequestsComponent implements OnInit, OnDestroy { // Initialize table sorting to sort on request ID this.sortOn("id"); // Set page title - this.titleService.setTitle(this.title) + this.titleService.setTitle(this.title); // Track the current sorting state this.state.set(initSortState); - this.state.connect('sortCol', this.sortedColumn$); - this.state.connect('sortDir', this.sortDirection$); + this.state.connect("sortCol", this.sortedColumn$); + this.state.connect("sortDir", this.sortDirection$); } ngOnInit(): void { this.qaStaff = []; - this.getRoutes() + this.getRoutes(); this.pageSize = this.getPageSizeOptions()[2]; this.pageStart = 0; @@ -219,153 +230,178 @@ export class ActiveCapabilityRequestsComponent implements OnInit, OnDestroy { this.capabilitiesService.getEnabledCapabilities().subscribe(enabledCapabilitiesObserver); this.pollingDataUpdaterService - .getDataPoller$(this.dataRetriever.getCapabilityUrl(this.capabilityName)).pipe( + .getDataPoller$(this.dataRetriever.getCapabilityUrl(this.capabilityName)) + .pipe( takeUntil(this.ngUnsubscribe), - repeatWhen(() => this.ngUnsubscribe) + repeatWhen(() => this.ngUnsubscribe), ) .subscribe(this.capabilityObserver); const qaStaffObserver = { next: (req) => { - // this'll distinguish DA and AOD in this.qaStaff - // like, this.qaStaff["DA"] and this.qaStaff["AOD"] - if (req["DA"]) { - this.qaStaff["DA"] = req["DA"]; - this.availableFilters['assigned_da'] = this.qaStaff["DA"] - this.checkFilters() - } else if (req["AOD"]) { - this.qaStaff["AOD"] = req["AOD"]; - this.availableFilters['assigned_aod'] = this.qaStaff["AOD"] - this.checkFilters() + // this will distinguish Stage 1 and Stage 2 reviewers in this.qaStaff + // like, this.qaStaff["Stage 1"] and this.qaStaff["Stage 2"] + if (req["Stage 1"]) { + this.qaStaff["Stage 1"] = req["Stage 1"]; + this.availableFilters.stage_1_reviewer = this.qaStaff["Stage 1"]; + this.checkFilters(); + } else if (req["Stage 2"]) { + this.qaStaff["Stage 2"] = req["Stage 2"]; + this.availableFilters.stage_2_reviewer = this.qaStaff["Stage 2"]; + this.checkFilters(); } }, error: (error) => console.error("Error when retrieving QA staff:", error), }; - this.capabilitiesService.getQaStaff("DA").subscribe(qaStaffObserver); - this.capabilitiesService.getQaStaff("AOD").subscribe(qaStaffObserver); + this.capabilitiesService.getQaStaff("Stage 1").subscribe(qaStaffObserver); + this.capabilitiesService.getQaStaff("Stage 2").subscribe(qaStaffObserver); - this.setupAvailableFilters() - this.checkFilters() + this.setupAvailableFilters(); + this.checkFilters(); // Initial sorting - this.sortedActiveRequests$ = combineLatest([this.getActiveCapabilityRequests$(), this.sortDirection$]).pipe( + this.sortedActiveRequests$ = combineLatest([ + this.getActiveCapabilityRequests$(), + this.sortDirection$, + ]).pipe( map(([list, sort]) => { - list.active_requests = this.filterFrontendFilters(list.active_requests) - // Set max page size while we already have the count - this.pageAllVal = list.active_requests.length; - return !sort.col ? list.active_requests : this.sortByColumn(list.active_requests, sort.col, sort.dir) - }), + list.active_requests = this.filterFrontendFilters(list.active_requests); + // Set max page size while we already have the count + this.pageAllVal = list.active_requests.length; + return !sort.col + ? list.active_requests + : this.sortByColumn(list.active_requests, sort.col, sort.dir); + }), ); // Get autocomplete suggestion list - this.filteredAodStaff = this.aodControl.valueChanges.pipe( - startWith(''), - map(value => typeof value === 'string' ? value : value.user_name), - map(val => this.filter(val, "AOD")) + this.filteredStage2QaStaff = this.stage2QaControl.valueChanges.pipe( + startWith(""), + map((value) => (typeof value === "string" ? value : value.user_name)), + map((val) => this.filter(val, "Stage 2")), ); - this.filteredDaStaff = this.daControl.valueChanges.pipe( - startWith(''), - map(value => typeof value === 'string' ? value : value.user_name), - map(val => this.filter(val, "DA")) + this.filteredStage1QaStaff = this.stage1QaControl.valueChanges.pipe( + startWith(""), + map((value) => (typeof value === "string" ? value : value.user_name)), + map((val) => this.filter(val, "Stage 1")), ); } private setupAvailableFilters() { this.availableFilters = { - 'state': this.statesToFilter, - 'is_srdp': this.srdpOptions, - 'obs_min': this.obsDateOptions, - 'obs_max': this.obsDateOptions - } + state: this.statesToFilter, + exec_status: this.execStatusToFilter, + is_srdp: this.srdpOptions, + obs_min: this.obsDateOptions, + obs_max: this.obsDateOptions, + }; } private checkFilters() { if (this.filters) { Object.entries(this.filters).map(([paramKey, paramValue]) => { - if (paramKey != 'capability') { - if(Array.isArray(paramValue)) { + if (paramKey != "capability") { + if (Array.isArray(paramValue)) { if (this.availableFilters[paramKey]) { paramValue.forEach((val) => { - this.createSelectedFilters(paramKey, val as string) - }) + this.createSelectedFilters(paramKey, val as string); + }); } } else if (this.availableFilters[paramKey]) { - this.createSelectedFilters(paramKey, paramValue as string) + this.createSelectedFilters(paramKey, paramValue as string); } else if (this.availableFilters[paramKey] === "") { - this.createSelectedFilters(paramKey, paramValue as string) + this.createSelectedFilters(paramKey, paramValue as string); } } - }) + }); } } - createSelectedFilters(paramKey: string, paramValue: string){ - let initialFilterSets = this.getSetByFilterType(paramKey) - let selectedFilters = this.availableFilters[paramKey].find(f => { + createSelectedFilters(paramKey: string, paramValue: string) { + const initialFilterSets = this.getSetByFilterType(paramKey); + const selectedFilters = this.availableFilters[paramKey].find((f) => { if (paramKey.includes("obs_")) { - return true + return true; } if (f.user_name) { - return f.user_name == paramValue + return f.user_name == paramValue; + } else if (f.name) { + return f.name == paramValue; } - else if (f.name) { - return f.name == paramValue - } - }) + }); selectedFilters["isChecked"] = true; this.showFilterMenu = true; - this.handleFilterSets({data: [paramValue], filter: paramKey, event: true }, initialFilterSets) + this.handleFilterSets({ data: [paramValue], filter: paramKey, event: true }, initialFilterSets); } public getRoutes() { this.route.queryParamMap.subscribe((params) => { - this.paramsObj = {...params.keys, ...params} - // if there are no query parameters, set to std_calibration - this.capabilityName = this.paramsObj.params["capability"] ? this.paramsObj.params["capability"] : this.stdCalibrationCapability; - this.filters = this.paramsObj.params - }) + this.paramsObj = { ...params.keys, ...params }; + // if there are no query parameters, set to std_calibration + this.capabilityName = this.paramsObj.params["capability"] + ? this.paramsObj.params["capability"] + : this.stdCalibrationCapability; + this.filters = this.paramsObj.params; + }); } public filterFrontendFilters(activeRequests: any): any { - if (this.filters && (this.filters.hasOwnProperty("is_srdp") || this.filters.hasOwnProperty("obs_min") || this.filters.hasOwnProperty("obs_max"))) { + if (this.filters && ( + this.filters.hasOwnProperty("is_srdp") || + this.filters.hasOwnProperty("obs_min") || + this.filters.hasOwnProperty("obs_max") || + this.filters.hasOwnProperty("exec_status"))) { // SRDP filter if (this.filters.hasOwnProperty("is_srdp")) { - return activeRequests.filter(r => { - let workingVersion = r.versions[r.versions.length - 1] + return activeRequests.filter((r) => { + const workingVersion = r.versions[r.versions.length - 1]; if (workingVersion.parameters.metadata) { - let isTrue = (Array.isArray(this.filters.is_srdp)) ? (this.filters.is_srdp.includes('true')) : (this.filters.is_srdp === 'true'); - let isFalse = (Array.isArray(this.filters.is_srdp)) ? (this.filters.is_srdp.includes('false')) : (this.filters.is_srdp === 'false'); + const isTrue = Array.isArray(this.filters.is_srdp) + ? this.filters.is_srdp.includes("true") + : this.filters.is_srdp === "true"; + const isFalse = Array.isArray(this.filters.is_srdp) + ? this.filters.is_srdp.includes("false") + : this.filters.is_srdp === "false"; if (isTrue && isFalse) { - return (workingVersion.parameters.metadata.is_srdp == true) || (workingVersion.parameters.metadata.is_srdp == false) + return ( + workingVersion.parameters.metadata.is_srdp == true || + workingVersion.parameters.metadata.is_srdp == false + ); } else if (isFalse) { - return workingVersion.parameters.metadata.is_srdp == false + return workingVersion.parameters.metadata.is_srdp == false; } else if (isTrue) { - return workingVersion.parameters.metadata.is_srdp == true + return workingVersion.parameters.metadata.is_srdp == true; } } - }) + }); } // Observation Date filter else if (this.filters.hasOwnProperty("obs_min") || this.filters.hasOwnProperty("obs_max")) { - var startDate = (this.filters["obs_min"]) ? new Date(this.filters["obs_min"]) : new Date(0); - var endDate = (this.filters["obs_max"]) ? new Date(this.filters["obs_max"]) : new Date(); - - var filteredDates = activeRequests.filter(function (request) { - let md = request.versions[0].parameters.metadata - if (md && md.hasOwnProperty('obs_end_time')) { - var dateToFilterBy = md.obs_end_time || {}; - var date = new Date(dateToFilterBy); - return date >= startDate && date <= endDate - } else { - return false - } + const startDate = this.filters["obs_min"] ? new Date(this.filters["obs_min"]) : new Date(0); + const endDate = this.filters["obs_max"] ? new Date(this.filters["obs_max"]) : new Date(); + + const filteredDates = activeRequests.filter(function (request) { + const md = request.versions[0].parameters.metadata; + if (md && md.hasOwnProperty("obs_end_time")) { + const dateToFilterBy = md.obs_end_time || {}; + const date = new Date(dateToFilterBy); + return date >= startDate && date <= endDate; + } else { + return false; + } }); return filteredDates; } + // Execution Status filter + else if (this.filters.hasOwnProperty("exec_status")) { + return activeRequests.filter(r => { + return this.filters.exec_status.includes(this.getExecutionStatusName(r)); + }); + } } else { // return unfiltered activeRequests - return activeRequests + return activeRequests; } } @@ -375,7 +411,7 @@ export class ActiveCapabilityRequestsComponent implements OnInit, OnDestroy { * @return metadata Metadata section of parameters */ public getMetadata(request: CapabilityRequest): any { - let latestVersion = request.versions[request.versions.length - 1]; + const latestVersion = request.versions[request.versions.length - 1]; if (latestVersion.parameters.hasOwnProperty("metadata")) { return latestVersion.parameters.metadata.valueOf(); } else { @@ -391,7 +427,7 @@ export class ActiveCapabilityRequestsComponent implements OnInit, OnDestroy { }, }; - let pauseAction = this.isPaused ? "unpause" : "pause"; + const pauseAction = this.isPaused ? "unpause" : "pause"; this.dataRetriever .togglePauseCapability(this.stdCalibrationCapability, pauseAction) .subscribe(togglePauseCapabilityObservable); @@ -423,23 +459,33 @@ export class ActiveCapabilityRequestsComponent implements OnInit, OnDestroy { this.capabilityName = capabilityName; // Sorting - this.sortedActiveRequests$ = combineLatest([this.getActiveCapabilityRequests$(), this.sortDirection$]).pipe( + this.sortedActiveRequests$ = combineLatest([ + this.getActiveCapabilityRequests$(), + this.sortDirection$, + ]).pipe( map(([list, sort]) => { - list.active_requests = this.filterFrontendFilters(list.active_requests) + list.active_requests = this.filterFrontendFilters(list.active_requests); // Set max page size while we already have the count this.pageAllVal = list.active_requests.length; - return !sort.col ? list.active_requests : this.sortByColumn(list.active_requests, sort.col, sort.dir) + return !sort.col + ? list.active_requests + : this.sortByColumn(list.active_requests, sort.col, sort.dir); }), ); // Store the current capability selection so the header can access it - this.storageService.saveSession(WsHeaderComponent.previousCapabilityKey, this.capabilityName) + this.storageService.saveSession(WsHeaderComponent.previousCapabilityKey, this.capabilityName); // update query parameters to new capabilityName - this.router.navigate(["."], { - relativeTo: this.route, - queryParams: { capability: this.capabilityName }, - }); + this.router + .navigate(["."], { + relativeTo: this.route, + queryParams: { capability: this.capabilityName }, + }) + .then(() => { + // reload to make sure QA specific columns only show for QA-able capabilities + window.location.reload(); + }); // Reset paginator to the first page this.requestsPaginator.firstPage(); @@ -455,13 +501,15 @@ export class ActiveCapabilityRequestsComponent implements OnInit, OnDestroy { this.ngUnsubscribe.next(true); }, error: (error) => { - console.error("Error when setting" + staffMember.group + ", error: ", error); + console.error("Error when setting " + staffMember.group + ", error: ", error); // start polling again when user selects qa staff and req fails // TODO: error message to user this.ngUnsubscribe.next(true); }, }; - this.capabilityRequestService.assignQaStaff(requestId, staffMember).subscribe(assignQaStaffObservable); + this.capabilityRequestService + .assignQaStaff(requestId, staffMember) + .subscribe(assignQaStaffObservable); } // pause polling when user opens qa staff dropdown @@ -479,7 +527,12 @@ export class ActiveCapabilityRequestsComponent implements OnInit, OnDestroy { private getActiveCapabilityRequests$(): Observable<any> { return this.pollingDataUpdaterService - .getDataPoller$(this.capabilityRequestService.getActiveCapabilityRequestsUrl(this.capabilityName, this.filters)) + .getDataPoller$( + this.capabilityRequestService.getActiveCapabilityRequestsUrl( + this.capabilityName, + this.filters, + ), + ) .pipe( takeUntil(this.ngUnsubscribe), repeatWhen(() => this.ngUnsubscribe), @@ -488,22 +541,25 @@ export class ActiveCapabilityRequestsComponent implements OnInit, OnDestroy { // Returns the execution status name based on the current execution state for displaying, sorting, and filtering public getExecutionStatusName(request: any): string { - let statusName = "" - if (request.state !== 'Created' && request.versions[request.versions.length - 1].state !== 'Created') { + let statusName = ""; + if ( + request.state !== "Created" && + request.versions[request.versions.length - 1].state !== "Created" + ) { statusName = request.versions[request.versions.length - 1].current_execution ? request.versions[request.versions.length - 1].current_execution.state_name - : "" + : ""; } if (request.state === 'Created' || (request.state === 'Submitted' && request.versions[request.versions.length - 1].state === 'Created')) { - statusName = "Latest version has not been submitted"; + statusName = this.notSubmittedState; } return statusName; } // Compares 2 values and returns 1, -1, or 0 to determine sorting order - private compareColumns(a: any, b: any, direction: string = "asc"): number { + private compareColumns(a: any, b: any, direction = "asc"): number { if (!a || !b) { return !a ? 1 : -1; } else if (a > b) { @@ -522,7 +578,6 @@ export class ActiveCapabilityRequestsComponent implements OnInit, OnDestroy { // Do sort return (list || []).sort((a, b) => { - if (column.includes("obs_")) { // Handle observation times (or really any metadata field) a = this.getMetadata(a); @@ -542,83 +597,99 @@ export class ActiveCapabilityRequestsComponent implements OnInit, OnDestroy { } toggleFilterMenu() { - this.showFilterMenu = !this.showFilterMenu + this.showFilterMenu = !this.showFilterMenu; } emitFilterEvent(e: Filter) { - let existingQueryParams = this.route.snapshot.queryParams; - let workingSet = this.getSetByFilterType(e.filter) + const existingQueryParams = this.route.snapshot.queryParams; + const workingSet = this.getSetByFilterType(e.filter); - this.handleFilterSets(e, workingSet) - this.handleNewParams(e, existingQueryParams) + this.handleFilterSets(e, workingSet); + this.handleNewParams(e, existingQueryParams); // Filtering and Sorting - this.filters = this.newParams - this.sortedActiveRequests$ = combineLatest([this.getActiveCapabilityRequests$(), this.sortDirection$]).pipe( + this.filters = this.newParams; + this.sortedActiveRequests$ = combineLatest([ + this.getActiveCapabilityRequests$(), + this.sortDirection$, + ]).pipe( map(([list, sort]) => { - list.active_requests = this.filterFrontendFilters(list.active_requests) + list.active_requests = this.filterFrontendFilters(list.active_requests); // Set max page size while we already have the count this.pageAllVal = list.active_requests.length; - return !sort.col ? list.active_requests : this.sortByColumn(list.active_requests, sort.col, sort.dir) + return !sort.col + ? list.active_requests + : this.sortByColumn(list.active_requests, sort.col, sort.dir); }), ); this.router.navigate(["."], { relativeTo: this.route, - queryParams: this.newParams + queryParams: this.newParams, }); } handleNewParams(filter: Filter, existingQueryParams: any) { - this.newParams = {...existingQueryParams} + this.newParams = { ...existingQueryParams }; if (this.filterSets.hasOwnProperty(filter.filter) && this.filterSets[filter.filter].size == 0) { if (this.shouldDeleteFilter(existingQueryParams, filter)) { - delete this.newParams[filter.filter] + delete this.newParams[filter.filter]; } else { - this.newParams[filter.filter] = this.newParams[filter.filter].filter(f => f !== filter.data[0]) + this.newParams[filter.filter] = this.newParams[filter.filter].filter( + (f) => f !== filter.data[0], + ); } - this.newParams = {...this.newParams,} + this.newParams = { ...this.newParams }; } else { this.newParams = { ...this.newParams, [filter.filter]: [...this.filterSets[filter.filter]], - } + }; } } shouldDeleteFilter(existingQueryParams: any, filter: Filter): boolean { - let isLengthIsEqualToOne = Array.isArray(existingQueryParams[filter.filter]) ? (existingQueryParams[filter.filter].length == 1) : (existingQueryParams[filter.filter].size == 1) - return (existingQueryParams.hasOwnProperty(filter.filter) && isLengthIsEqualToOne && filter.event == false) || !Array.isArray(existingQueryParams[filter.filter]) + const isLengthIsEqualToOne = Array.isArray(existingQueryParams[filter.filter]) + ? existingQueryParams[filter.filter].length == 1 + : existingQueryParams[filter.filter].size == 1; + return ( + (existingQueryParams.hasOwnProperty(filter.filter) && + isLengthIsEqualToOne && + filter.event == false) || + !Array.isArray(existingQueryParams[filter.filter]) + ); } - getSetByFilterType(s:string): any { - switch(s) { + getSetByFilterType(s: string): any { + switch (s) { case "state": return this.statesSet - case "assigned_da": - return this.daSet - case "assigned_aod": - return this.aodSet + case "exec_status": + return this.execStatusSet; + case "stage_1_reviewer": + return this.stage1QaSet; + case "stage_2_reviewer": + return this.stage2QaSet; case "is_srdp": - return this.srdpSet + return this.srdpSet; case "obs_min": - return this.obsDateSet + return this.obsDateSet; case "obs_max": - return this.obsDateSet + return this.obsDateSet; default: - return [] + return []; } } handleFilterSets(filter: Filter, setOfFilters: Set<string>) { if (filter.filter.includes("obs_")) { - setOfFilters.clear() + setOfFilters.clear(); } - filter.event ? setOfFilters.add(filter.data[0]) : setOfFilters.delete(filter.data[0]) + filter.event ? setOfFilters.add(filter.data[0]) : setOfFilters.delete(filter.data[0]); if (!this.filterSets.hasOwnProperty(filter.filter)) { - this.filterSets[filter.filter] = setOfFilters + this.filterSets[filter.filter] = setOfFilters; } - this.filterSets[filter.filter] + this.filterSets[filter.filter]; } ngOnDestroy(): void { @@ -629,17 +700,17 @@ export class ActiveCapabilityRequestsComponent implements OnInit, OnDestroy { // Filter staff in dropdown text boxes as user inputs text filter(input: string, staffMember: string): Staff[] { return this.qaStaff[staffMember].filter( - option => option.user_name.toLowerCase().indexOf(input.toLowerCase()) === 0 + (option) => option.user_name.toLowerCase().indexOf(input.toLowerCase()) === 0, ); } - // Control what gets displayed in the text input when selecting an AOD/DA + // Control what gets displayed in the text input when selecting a QA staff member staffSelectDisplay(staff: Staff): string { return ""; } // Handles event when paginator changes page size or moves to next page - changePage(pageChange: PageEvent) { + changePage(pageChange: PageEvent): void { this.pageStart = pageChange.pageIndex * pageChange.pageSize; this.pageStop = this.pageStart + pageChange.pageSize; } diff --git a/apps/web/src/app/workspaces/components/active-capability-requests/components/filter-menu/filter-menu.component.html b/apps/web/src/app/workspaces/components/active-capability-requests/components/filter-menu/filter-menu.component.html index ccbfd6627cb00a6fff16c9d57d67e46851fe54f7..d08bf5839b81ebd9cba1e2e87171adcfe2cf0b1f 100644 --- a/apps/web/src/app/workspaces/components/active-capability-requests/components/filter-menu/filter-menu.component.html +++ b/apps/web/src/app/workspaces/components/active-capability-requests/components/filter-menu/filter-menu.component.html @@ -9,18 +9,26 @@ </div> <div class="bg-light p-2"> - <p>DA staff</p> - <div *ngFor="let da of daStaff" class="form-check"> - <input class="form-check-input" type="checkbox" [id]="'da-' + da.user_name" [value]="da.user_name" (change)="addFilter(da.user_name, 'assigned_da', $event.target.checked)" [checked]="da.isChecked"/> - <label class="form-check-label" [for]="'da-' + da.user_name">{{da.user_name}}</label> + <p>Execution Status</p> + <div *ngFor="let s of exec_status" class="form-check"> + <input class="form-check-input" type="checkbox" [id]="s.name" [value]="s.filter_val" (change)="addFilter(s.filter_val, 'exec_status', $event.target.checked)" [checked]="s.isChecked" /> + <label class="form-check-label" [for]="s.name">{{s.name}}</label> + </div> + </div> + + <div class="bg-light p-2"> + <p>Stage 1 QA Staff</p> + <div *ngFor="let reviewer of stage1QaStaff" class="form-check"> + <input class="form-check-input" type="checkbox" [id]="'stage-1-reviewer-' + reviewer.user_name" [value]="reviewer.user_name" (change)="addFilter(reviewer.user_name, 'stage_1_reviewer', $event.target.checked)" [checked]="reviewer.isChecked"/> + <label class="form-check-label" [for]="'stage-1-reviewer-' + reviewer.user_name">{{reviewer.user_name}}</label> </div> </div> <div class="bg-light p-2"> - <p>AOD staff</p> - <div *ngFor="let aod of aodStaff" class="form-check"> - <input class="form-check-input" type="checkbox" [id]="'aod-' + aod.user_name" [value]="aod.user_name" (change)="addFilter(aod.user_name, 'assigned_aod', $event.target.checked)" [checked]="aod.isChecked"/> - <label class="form-check-label" [for]="'aod-' + aod.user_name">{{aod.user_name}}</label> + <p>Stage 2 QA staff</p> + <div *ngFor="let reviewer of stage2QaStaff" class="form-check"> + <input class="form-check-input" type="checkbox" [id]="'stage-2-reviewer-' + reviewer.user_name" [value]="reviewer.user_name" (change)="addFilter(reviewer.user_name, 'stage_2_reviewer', $event.target.checked)" [checked]="reviewer.isChecked"/> + <label class="form-check-label" [for]="'stage-2-reviewer-' + reviewer.user_name">{{reviewer.user_name}}</label> </div> </div> diff --git a/apps/web/src/app/workspaces/components/active-capability-requests/components/filter-menu/filter-menu.component.ts b/apps/web/src/app/workspaces/components/active-capability-requests/components/filter-menu/filter-menu.component.ts index c6a5fcccb4f27712b2714b67747969442e782f58..d9b4b26cd2488fae0d7b6aac92e4e14d50bb2095 100644 --- a/apps/web/src/app/workspaces/components/active-capability-requests/components/filter-menu/filter-menu.component.ts +++ b/apps/web/src/app/workspaces/components/active-capability-requests/components/filter-menu/filter-menu.component.ts @@ -34,8 +34,9 @@ export interface Filter { export class FilterMenuComponent implements OnInit { @Input() state: any; - @Input() daStaff: any; - @Input() aodStaff: any; + @Input() exec_status: any; + @Input() stage1QaStaff: any; + @Input() stage2QaStaff: any; @Input() srdpStatus: any; @Input() filters: any; diff --git a/apps/web/src/app/workspaces/components/capability-request/capability-request.component.html b/apps/web/src/app/workspaces/components/capability-request/capability-request.component.html index 6991d908f1721fc85593b5be4c22a1111f1988ab..499e04eacb4e24824a461c6b1e0f5317924d31b0 100644 --- a/apps/web/src/app/workspaces/components/capability-request/capability-request.component.html +++ b/apps/web/src/app/workspaces/components/capability-request/capability-request.component.html @@ -104,15 +104,15 @@ *ngIf="!currentVersion && currentVersion.workflow_metadata" ></app-metadata> <span id="metadata-label" *ngIf="capability && capability.requires_qa" - >Version {{ currentVersion.version_number }} DA Notes</span + >Version {{ currentVersion.version_number }} Internal Notes</span > - <da-notes + <internal-notes *ngIf="this.capability" - id="da-notes-button" + id="internal-notes-button" [capabilityRequest]="this.capabilityRequest" [capability]="this.capability" [currentVersion]="this.currentVersion" - ></da-notes> + ></internal-notes> <br /> <app-request-operations id="operations" diff --git a/apps/web/src/app/workspaces/components/capability-request/capability-request.component.ts b/apps/web/src/app/workspaces/components/capability-request/capability-request.component.ts index 86797985d496b3a4accf98768f71798320f60c08..235731c7700bd8e4147b87cf7e8016b20e62a237 100644 --- a/apps/web/src/app/workspaces/components/capability-request/capability-request.component.ts +++ b/apps/web/src/app/workspaces/components/capability-request/capability-request.component.ts @@ -201,7 +201,7 @@ export class CapabilityRequestComponent implements OnInit, OnDestroy { return ( version.current_execution.state_name === "Awaiting QA" || - version.current_execution.state_name === "AoD Review" + version.current_execution.state_name === "Stage 2 Review" ); } diff --git a/apps/web/src/app/workspaces/components/capability-request/components/capability-data-access/capability-data-access.component.ts b/apps/web/src/app/workspaces/components/capability-request/components/capability-data-access/capability-data-access.component.ts index 41b27fd3c17bc32cdb968a4b754f23afe77047ef..71d9880eddcd1effdd266d46e08fdd9a589bc6dd 100644 --- a/apps/web/src/app/workspaces/components/capability-request/components/capability-data-access/capability-data-access.component.ts +++ b/apps/web/src/app/workspaces/components/capability-request/components/capability-data-access/capability-data-access.component.ts @@ -88,7 +88,7 @@ export class CapabilityDataAccessComponent implements OnInit { const request = this.capabilityRequest; const version = this.currentVersion; const names = ["std_calibration", "std_cms_imaging", "restore_cms", "std_restore_imaging"]; - const states = ["Awaiting QA", "AoD Review", "Complete", "Error", "Failed", "QA Closed"]; + const states = ["Awaiting QA", "Stage 2 Review", "Complete", "Error", "Failed", "QA Closed"]; if (states.includes(version.current_execution.state_name)) { return names.includes(request.capability_name); diff --git a/apps/web/src/app/workspaces/components/capability-request/components/create-new-version-form/create-new-version-form.component.ts b/apps/web/src/app/workspaces/components/capability-request/components/create-new-version-form/create-new-version-form.component.ts index 3f9445ed81147186097fad15ec1fd4cc3e892129..6402083e37462ac8e84415ee0f16a7c07398002f 100644 --- a/apps/web/src/app/workspaces/components/capability-request/components/create-new-version-form/create-new-version-form.component.ts +++ b/apps/web/src/app/workspaces/components/capability-request/components/create-new-version-form/create-new-version-form.component.ts @@ -98,6 +98,8 @@ export class CreateNewVersionFormComponent implements OnInit { if (this.inputFileList) { this.capabilityRequestService.addFilesToVersion(idString, this.inputFileList); } + // Refresh the window to load the new version + window.location.reload(); }, error: (error) => alert("Error when creating new version:" + error), }; diff --git a/apps/web/src/app/workspaces/components/capability-request/components/da-notes/da-notes.component.html b/apps/web/src/app/workspaces/components/capability-request/components/internal-notes/internal-notes.component.html similarity index 51% rename from apps/web/src/app/workspaces/components/capability-request/components/da-notes/da-notes.component.html rename to apps/web/src/app/workspaces/components/capability-request/components/internal-notes/internal-notes.component.html index a1d4354bcfe410a3b7e0f77336ea793716d60f5b..f7c89adebb0339763c7a76bde90f4d1d22c205b6 100644 --- a/apps/web/src/app/workspaces/components/capability-request/components/da-notes/da-notes.component.html +++ b/apps/web/src/app/workspaces/components/capability-request/components/internal-notes/internal-notes.component.html @@ -1,15 +1,15 @@ -<div id="da-notes-container" class="container-fluid rounded-top rounded-3 p-3 bg-light" *ngIf="capability.requires_qa"> +<div id="internal-notes-container" class="container-fluid rounded-top rounded-3 p-3 bg-light" *ngIf="capability.requires_qa"> <div class="row my-2"> <div class="col"> <div class="d-flex justify-content-left"> <app-editor - modalTitleText="DA Notes" - [textToEdit]="daNotes" + modalTitleText="Internal Notes" + [textToEdit]="internalNotes" (newEditEvent)="emitEditEventToParent($event)" - (click)="getDaNotesURL()" + (click)="getInternalNotesURL()" > <span class="fas fa-edit"></span> - DA Notes + Internal Notes </app-editor> </div> </div> diff --git a/apps/web/src/app/workspaces/components/capability-request/components/da-notes/da-notes.component.scss b/apps/web/src/app/workspaces/components/capability-request/components/internal-notes/internal-notes.component.scss similarity index 100% rename from apps/web/src/app/workspaces/components/capability-request/components/da-notes/da-notes.component.scss rename to apps/web/src/app/workspaces/components/capability-request/components/internal-notes/internal-notes.component.scss diff --git a/apps/web/src/app/workspaces/components/capability-request/components/da-notes/da-notes.component.spec.ts b/apps/web/src/app/workspaces/components/capability-request/components/internal-notes/internal-notes.component.spec.ts similarity index 77% rename from apps/web/src/app/workspaces/components/capability-request/components/da-notes/da-notes.component.spec.ts rename to apps/web/src/app/workspaces/components/capability-request/components/internal-notes/internal-notes.component.spec.ts index 1f629c5468bbb81c51c60dee901e78dbd238ecd1..26b83e1b54dd2ac54dc88c3b050074b086581746 100644 --- a/apps/web/src/app/workspaces/components/capability-request/components/da-notes/da-notes.component.spec.ts +++ b/apps/web/src/app/workspaces/components/capability-request/components/internal-notes/internal-notes.component.spec.ts @@ -18,21 +18,21 @@ */ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { DANotesComponent } from './da-notes.component'; +import { InternalNotesComponent } from './internal-notes.component'; -describe('DANotesComponent', () => { - let component: DANotesComponent; - let fixture: ComponentFixture<DANotesComponent>; +describe('InternalNotesComponent', () => { + let component: InternalNotesComponent; + let fixture: ComponentFixture<InternalNotesComponent>; beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ DANotesComponent ] + declarations: [ InternalNotesComponent ] }) .compileComponents(); }); beforeEach(() => { - fixture = TestBed.createComponent(DANotesComponent); + fixture = TestBed.createComponent(InternalNotesComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/apps/web/src/app/workspaces/components/capability-request/components/da-notes/da-notes.component.ts b/apps/web/src/app/workspaces/components/capability-request/components/internal-notes/internal-notes.component.ts similarity index 58% rename from apps/web/src/app/workspaces/components/capability-request/components/da-notes/da-notes.component.ts rename to apps/web/src/app/workspaces/components/capability-request/components/internal-notes/internal-notes.component.ts index 8df9aca6a0f21bb117f4e11ac0a5d82415d8de9a..62bd6a37fd33e1f5568ff2a2ed930736291f2bc9 100644 --- a/apps/web/src/app/workspaces/components/capability-request/components/da-notes/da-notes.component.ts +++ b/apps/web/src/app/workspaces/components/capability-request/components/internal-notes/internal-notes.component.ts @@ -25,57 +25,56 @@ import {Observable} from "rxjs"; import {WorkflowService} from "../../../../services/workflow.service"; @Component({ - selector: 'da-notes', - templateUrl: './da-notes.component.html', - styleUrls: ['./da-notes.component.scss'] + selector: 'internal-notes', + templateUrl: './internal-notes.component.html', + styleUrls: ['./internal-notes.component.scss'] }) -export class DANotesComponent implements OnInit { +export class InternalNotesComponent implements OnInit { @Input() capabilityRequest: CapabilityRequest; @Input() capability: Capability; @Input() currentVersion: CapabilityVersion; - public daNotes: string; + public internalNotes: string; constructor( private workflowService: WorkflowService, private httpClient: HttpClient, ) {} ngOnInit(): void { - - if (!this.daNotes) { - this.getDaNotesURL(); + if (!this.internalNotes) { + this.getInternalNotesURL(); } } - // Get da_notes contents for editing - async getDaNotesURL() { - this.daNotes = await new Promise<string>(resolve => - this.retrieveFromDaNotesEndpoint().subscribe(notes => resolve(notes['resp']))); + // Get internal_notes contents for editing + async getInternalNotesURL() { + this.internalNotes = await new Promise<string>(resolve => + this.retrieveFromInternalNotesEndpoint().subscribe(notes => resolve(notes['resp']))); } - private retrieveFromDaNotesEndpoint(): Observable<string> { - var curCapabilityRequestId = this.capabilityRequest.id; - var curCapabilityVersion = this.currentVersion.version_number.toString(); - const url = "/capability/request/" + curCapabilityRequestId + "/version/" + curCapabilityVersion + "/da_notes"; + private retrieveFromInternalNotesEndpoint(): Observable<string> { + const curCapabilityRequestId = this.capabilityRequest.id; + const curCapabilityVersion = this.currentVersion.version_number.toString(); + const url = "/capability/request/" + curCapabilityRequestId + "/version/" + curCapabilityVersion + "/internal_notes"; return this.httpClient.get<any>(url); } - private saveDaNotesObserver = { + private saveInternalNotesObserver = { next: () => {}, - error: (error) => console.error("Error when saving DA notes edit:", error), + error: (error) => console.error("Error when saving internal notes edit:", error), }; - private saveDaNotes(edits: string): Observable<string> { - var curCapabilityRequestId = this.capabilityRequest.id; - var curCapabilityVersion = this.currentVersion.version_number.toString(); - const url = "/capability/request/" + curCapabilityRequestId + "/version/" + curCapabilityVersion + "/da_notes"; + private saveInternalNotes(edits: string): Observable<string> { + const curCapabilityRequestId = this.capabilityRequest.id; + const curCapabilityVersion = this.currentVersion.version_number.toString(); + const url = "/capability/request/" + curCapabilityRequestId + "/version/" + curCapabilityVersion + "/internal_notes"; return this.httpClient.post<any>(url, JSON.stringify({"edits":edits})); } emitEditEventToParent(edits: string): void { - this.saveDaNotes(edits).subscribe(this.saveDaNotesObserver); - this.getDaNotesURL(); + this.saveInternalNotes(edits).subscribe(this.saveInternalNotesObserver); + this.getInternalNotesURL(); } } diff --git a/apps/web/src/app/workspaces/components/capability-request/components/qa-controls/qa-controls.component.html b/apps/web/src/app/workspaces/components/capability-request/components/qa-controls/qa-controls.component.html index 6577b74ec430f38d4eafe956af47f3f5b2217142..35dbe1fa728ffbe6416042805ac997550a885f60 100644 --- a/apps/web/src/app/workspaces/components/capability-request/components/qa-controls/qa-controls.component.html +++ b/apps/web/src/app/workspaces/components/capability-request/components/qa-controls/qa-controls.component.html @@ -21,7 +21,7 @@ modalTitleText="Fail QA Email" customStyle="btn btn-secondary btn-sm" templateName="std_calibration_fail" - [defaultCC]="getDefaultCC()" + [defaultCC]="defaultCC" [emailParameters]="getEmailParams()" (emailSentEvent)="failQa(currentVersion)"> <span class="fas fa-times"></span> @@ -34,7 +34,7 @@ modalTitleText="Fail Entire Request Email" customStyle="btn btn-danger btn-sm" templateName="std_calibration_fail" - [defaultCC]="getDefaultCC()" + [defaultCC]="defaultCC" [emailParameters]="getEmailParams()" (emailSentEvent)="abandonQa(currentVersion)"> <span class="far fa-times-circle"></span> @@ -48,7 +48,7 @@ <!--Two Stage QA Controls--> <span id="double-qa-label" *ngIf="this.capability && this.capability.state_machine === 'double_qa' ">Two Stage QA Processing</span> <div id="double-qa-container" class="container-fluid rounded-top rounded-3 p-3" *ngIf="this.capability && this.capability.state_machine === 'double_qa' "> - <span id="qa-label">DA Review</span> + <span id="qa-label">Stage 1 Review</span> <div id="qa-container" class="container-fluid rounded-top rounded-3 p-3"> <div class="btn-group"> <button @@ -57,10 +57,10 @@ class="btn btn-success btn-sm" style="margin-right: 10px" (click)="passQa(currentVersion)" - [disabled]="currentVersion.current_execution.state_name === 'AoD Review'" + [disabled]="currentVersion.current_execution.state_name === 'Stage 2 Review'" > <span class="fas fa-people-arrows"> </span> - <span class="pl-2">Send to AoD</span> + <span class="pl-2">Send to Stage 2</span> </button> <send-email @@ -69,10 +69,10 @@ modalTitleText="Fail QA Email" customStyle="btn btn-secondary btn-sm" templateName="std_calibration_fail" - [defaultCC]="getDefaultCC()" + [defaultCC]="defaultCC" [emailParameters]="getEmailParams()" (emailSentEvent)="failQa(currentVersion)" - [shouldDisable]="currentVersion.current_execution.state_name === 'AoD Review'"> + [shouldDisable]="currentVersion.current_execution.state_name === 'Stage 2 Review'"> <span class="fas fa-times"></span> <span class="pl-2">Fail QA</span> </send-email> @@ -83,10 +83,10 @@ modalTitleText="Fail Entire Request Email" customStyle="btn btn-danger btn-sm" templateName="std_calibration_fail" - [defaultCC]="getDefaultCC()" + [defaultCC]="defaultCC" [emailParameters]="getEmailParams()" (emailSentEvent)="abandonQa(currentVersion)" - [shouldDisable]="currentVersion.current_execution.state_name === 'AoD Review'"> + [shouldDisable]="currentVersion.current_execution.state_name === 'Stage 2 Review'"> <span class="far fa-times-circle"></span> <span class="pl-2">Fail Entire Request</span> </send-email> @@ -94,11 +94,11 @@ </div> <br /> - <span id="aod-label">AoD Review </span> - <div id="aod-container" class="container-fluid rounded-top rounded-3 p-3"> + <span id="stage-2-qa-label">Stage 2 Review </span> + <div id="stage-2-qa-container" class="container-fluid rounded-top rounded-3 p-3"> <div class="btn-group"> <button - id="set-aod-pass" + id="set-stage-2-pass" type="button" class="btn btn-success btn-sm" style="margin-right: 10px" @@ -110,7 +110,7 @@ </button> <button - id="set-aod-revisit" + id="set-qa-revisit" type="button" class="btn btn-secondary btn-sm" style="margin-left: 120px" @@ -118,7 +118,7 @@ [disabled]="currentVersion.current_execution.state_name === 'Awaiting QA'" > <span class="fas fa-people-arrows"></span> - <span class="pl-2">Return to DA</span> + <span class="pl-2">Return to Stage 1</span> </button> </div> diff --git a/apps/web/src/app/workspaces/components/capability-request/components/qa-controls/qa-controls.component.scss b/apps/web/src/app/workspaces/components/capability-request/components/qa-controls/qa-controls.component.scss index bb169ba27863730be54fd7f3b8c78b6d661c1cf0..81860c9a2b956db1075df537899809f1952445e9 100644 --- a/apps/web/src/app/workspaces/components/capability-request/components/qa-controls/qa-controls.component.scss +++ b/apps/web/src/app/workspaces/components/capability-request/components/qa-controls/qa-controls.component.scss @@ -8,7 +8,7 @@ #qa-container { background-color: $parameters-container-bg; } -#aod-container { +#stage-2-qa-container { background-color: $parameters-container-bg; } diff --git a/apps/web/src/app/workspaces/components/capability-request/components/qa-controls/qa-controls.component.ts b/apps/web/src/app/workspaces/components/capability-request/components/qa-controls/qa-controls.component.ts index 8530d42d36192fea013053034d189ee2a84ec19f..31d607c4ea4d5b32968929a60f748a6cac99c7fc 100644 --- a/apps/web/src/app/workspaces/components/capability-request/components/qa-controls/qa-controls.component.ts +++ b/apps/web/src/app/workspaces/components/capability-request/components/qa-controls/qa-controls.component.ts @@ -3,6 +3,7 @@ import {CapabilityVersion} from "../../../../model/capability-version"; import {Capability} from "../../../../model/capability"; import {CapabilityRequestService} from "../../../../services/capability-request.service"; import {CapabilityRequest} from "../../../../model/capability-request"; +import {CapabilitiesService} from "../../../../services/capabilities.service"; @Component({ selector: 'app-qa-controls', @@ -14,19 +15,22 @@ export class QaControlsComponent implements OnInit { @Input() public capability: Capability; @Input() public capabilityRequest: CapabilityRequest; @Input() public currentVersion: CapabilityVersion; + public defaultCC: string; constructor( + private capabilitiesService: CapabilitiesService, private capabilityRequestService: CapabilityRequestService, ) {} ngOnInit(): void { + this.loadDefaultCC(); } /** - * DA Level QAPass this request (single or double QA) + * Stage 1 QAPass this request (single or double QA) * - In single case: runs QAPass state machine action - * - In double case: sends version to AoD for review + * - In double case: sends version to Stage 2 review * * @param version the version of the request on which to pass QA */ @@ -53,7 +57,7 @@ export class QaControlsComponent implements OnInit { } /** - * DA Level QAFail this request (runs QAFail workflow for specified version, all QA cases) + * Stage 1 QAFail this request (runs QAFail workflow for specified version, all QA cases) * * @param version the version of the request on which to pass QA */ @@ -81,7 +85,7 @@ export class QaControlsComponent implements OnInit { /** * Abandon (i.e. fail all outstanding versions) QA for this Request - * - Same functionality for all QA cases, DA level only on Double QA + * - Same functionality for all QA cases, Stage 1 level only on Double QA * * @param version Version of the Request from which the request was sent. */ @@ -108,7 +112,7 @@ export class QaControlsComponent implements OnInit { /** - * AOD confirmation of DA results + * Stage 2 confirmation of QA results * - Double QA Only: Runs QAPass state machine action * * @param version @@ -116,11 +120,11 @@ export class QaControlsComponent implements OnInit { public confirmQa(version: CapabilityVersion){ const addRestCallObservable = { next: (response) => { - console.log(">>> AoD Pass response:'" + response + "'"); + console.log(">>> Stage 2 Pass response:'" + response + "'"); }, error: (error) => console.error( - "Error when trying to pass AoD Review on request " + + "Error when trying to pass Stage 2 Review on request " + version.current_execution.current_workflow_request_id + ": " + error, @@ -128,7 +132,7 @@ export class QaControlsComponent implements OnInit { }; return this.capabilityRequestService - .sendAoDPassRequest( + .sendStage2QaPassRequest( parseInt(version.current_execution.current_workflow_request_id), version.version_number, ) @@ -136,19 +140,19 @@ export class QaControlsComponent implements OnInit { } /** - * AoD Failure, return results to DA - * - Double QA Only: Returns request to Awaiting QA state and notifies assigned DA + * Stage 2 Failure, return results to Stage 1 + * - Double QA Only: Returns request to Awaiting QA state and notifies assigned Stage 1 reviewer * * @param version */ public revisitQa(version: CapabilityVersion){ const addRestCallObservable = { next: (response) => { - console.log(">>> AoD Revisit response:'" + response + "'"); + console.log(">>> QA Revisit response:'" + response + "'"); }, error: (error) => console.error( - "Error when trying to reset to DAs for QA review on request " + + "Error when trying to reset to Stage 1 for QA review on request " + version.current_execution.current_workflow_request_id + ": " + error, @@ -156,15 +160,27 @@ export class QaControlsComponent implements OnInit { }; return this.capabilityRequestService - .sendAoDReviewRequest( + .sendQaRevisitRequest( parseInt(version.current_execution.current_workflow_request_id), version.version_number, ) .subscribe(addRestCallObservable); } - public getDefaultCC() { - return "schedsoc@nrao.edu" + public loadDefaultCC() { + const getAnalystEmailObserver = { + next: (response) => { + if (response.resp) { + this.defaultCC = "schedsoc@nrao.edu," + response.resp; + } + }, + error: (error) => { + console.error("Failed to load analyst email:", error); + this.defaultCC = "schedsoc@nrao.edu"; + } + }; + + return this.capabilitiesService.getAnalystEmail().subscribe(getAnalystEmailObserver); } public getEmailParams() { @@ -172,7 +188,6 @@ export class QaControlsComponent implements OnInit { "destination_email": this.currentVersion.parameters.user_email, "version": this.currentVersion, "workflow_metadata": this.currentVersion.workflow_metadata - } + }; } - } diff --git a/apps/web/src/app/workspaces/components/capability-request/components/request-operations/request-operations.component.html b/apps/web/src/app/workspaces/components/capability-request/components/request-operations/request-operations.component.html index 752f6abd311ad3a5fbec7318f91b66511721e26a..b8448f0042314e2ebda2f49ef1965e9c0847ddb5 100644 --- a/apps/web/src/app/workspaces/components/capability-request/components/request-operations/request-operations.component.html +++ b/apps/web/src/app/workspaces/components/capability-request/components/request-operations/request-operations.component.html @@ -36,7 +36,7 @@ capability.has_image_products === true && (capabilityRequest.state === 'Complete' || (selectedVersion.current_execution && selectedVersion.current_execution.state_name === 'Awaiting QA') || - (selectedVersion.current_execution && selectedVersion.current_execution.state_name === 'AoD Review') + (selectedVersion.current_execution && selectedVersion.current_execution.state_name === 'Stage 2 Review') ) && selectedVersion.current_execution " > @@ -82,7 +82,7 @@ modalTitleText="Close Failed Request Email" customStyle="btn btn-danger" templateName="std_calibration_fail" - [defaultCC]="'schedsoc@nrao.edu'" + [defaultCC]="defaultCC" [emailParameters]="getEmailParams()" (emailSentEvent)="closeRequest()"> <span class="fas fa-times-circle"></span> diff --git a/apps/web/src/app/workspaces/components/capability-request/components/request-operations/request-operations.component.ts b/apps/web/src/app/workspaces/components/capability-request/components/request-operations/request-operations.component.ts index ee398f99494d503103dff9f0c4e537666218c175..104b083a8349e05a2b9e7fbcb7786ed85712e601 100644 --- a/apps/web/src/app/workspaces/components/capability-request/components/request-operations/request-operations.component.ts +++ b/apps/web/src/app/workspaces/components/capability-request/components/request-operations/request-operations.component.ts @@ -25,6 +25,7 @@ import { WorkflowService } from "../../../../services/workflow.service"; import { CapabilityExecution } from "../../../../model/capability-execution"; import { CapabilityRequestService } from "../../../../services/capability-request.service"; import { CapabilityVersion } from "../../../../model/capability-version"; +import { CapabilitiesService } from "../../../../services/capabilities.service"; @Component({ selector: "app-request-operations", @@ -36,6 +37,7 @@ export class RequestOperationsComponent implements OnInit { public capabilityLauncherService: CapabilityLauncherService, public workflowLauncherService: WorkflowLauncherService, public workflowService: WorkflowService, + private capabilitiesService: CapabilitiesService, private capabilityRequestService: CapabilityRequestService, ) {} @Input() capabilityRequest: CapabilityRequest; @@ -47,6 +49,7 @@ export class RequestOperationsComponent implements OnInit { public cartaResponse: any; public hasBeenClicked: boolean = false; public userEmail: string; + private defaultCC: string; // Observer for submitting capability request objects (returns a capability execution) public submitRequestObserver = { @@ -134,7 +137,7 @@ export class RequestOperationsComponent implements OnInit { return ( this.selectedVersion && this.selectedVersion.state === 'Running' && - this.selectedVersion.current_execution.state_name !== 'AoD Review' && + this.selectedVersion.current_execution.state_name !== 'Stage 2 Review' && this.selectedVersion.current_execution.state_name !== 'Awaiting QA' && this.selectedVersion.current_execution.state_name !== 'Ingesting' ) @@ -159,6 +162,22 @@ export class RequestOperationsComponent implements OnInit { ) } + public loadDefaultCC() { + const getAnalystEmailObserver = { + next: (response) => { + if (response.resp) { + this.defaultCC = "schedsoc@nrao.edu," + response.resp; + } + }, + error: (error) => { + console.error("Failed to load analyst email:", error); + this.defaultCC = "schedsoc@nrao.edu"; + } + }; + + return this.capabilitiesService.getAnalystEmail().subscribe(getAnalystEmailObserver); + } + public getEmailParams() { return { "destination_email": this.selectedVersion.parameters.user_email, @@ -173,5 +192,7 @@ export class RequestOperationsComponent implements OnInit { } else { this.userEmail = null; } + + this.loadDefaultCC(); } } diff --git a/apps/web/src/app/workspaces/components/capability-request/components/versions/versions.component.html b/apps/web/src/app/workspaces/components/capability-request/components/versions/versions.component.html index f0ad962160c537da7770c7a1403bc81e87d32062..aff85107f69a1c2e6397e4040ece3b36ec88adc7 100644 --- a/apps/web/src/app/workspaces/components/capability-request/components/versions/versions.component.html +++ b/apps/web/src/app/workspaces/components/capability-request/components/versions/versions.component.html @@ -90,10 +90,10 @@ *ngIf="selectedVersion.current_execution.state_name === 'Awaiting QA'" ><span id="execution-status-badge-txt-awaiting">{{ selectedVersion.current_execution.state_name.toUpperCase() }}</span></span> <span - id="execution-status-badge-aod" + id="execution-status-badge-stage-2-qa" class="badge badge-pill badge-info py-2" - *ngIf="selectedVersion.current_execution.state_name === 'AoD Review'" - ><span id="execution-status-badge-txt-aod">{{ selectedVersion.current_execution.state_name.toUpperCase() }}</span></span> + *ngIf="selectedVersion.current_execution.state_name === 'Stage 2 Review'" + ><span id="execution-status-badge-txt-stage-2-qa">{{ selectedVersion.current_execution.state_name.toUpperCase() }}</span></span> <span id="execution-status-badge-ingesting" class="badge badge-pill badge-info py-2" diff --git a/apps/web/src/app/workspaces/components/editor/editor.component.html b/apps/web/src/app/workspaces/components/editor/editor.component.html index daf39755039141973ad282abf48aaa4787aedac3..b2598b4059a4029a6637c11183086b3b26a7b765 100644 --- a/apps/web/src/app/workspaces/components/editor/editor.component.html +++ b/apps/web/src/app/workspaces/components/editor/editor.component.html @@ -17,7 +17,7 @@ <div class="modal-body"> <div class="md-form"> <textarea - [ngClass]="this.modalTitleText === 'DA Notes' ? 'bg-dark text-light' : ''" + [ngClass]="this.modalTitleText === 'Internal Notes' ? 'bg-dark text-light' : ''" class="w-100" rows="20" cols="60" diff --git a/apps/web/src/app/workspaces/components/send-email/send-email.component.html b/apps/web/src/app/workspaces/components/send-email/send-email.component.html index 2aef6ef3800a14a4308097fb94b30e49b77a57e9..222ddf0c4d8003a363f33c00bf30d0985ca3e1b7 100644 --- a/apps/web/src/app/workspaces/components/send-email/send-email.component.html +++ b/apps/web/src/app/workspaces/components/send-email/send-email.component.html @@ -16,27 +16,28 @@ </div> <div class="modal-body"> <div class="md-form"> - <div *ngIf="allowCC"> - <label for="ccAddresses">CC Email(s) (comma separated)</label> - <input type="email" name="ccAddresses" class="w-100" [ngModel]="ccAddresses"> - </div> - <div *ngIf="enableCustomText"> - <label for="customText">Custom Text</label> - <textarea - name="customText" - class="w-100" - rows="20" - cols="60" - type="text" - style="font-size:20px; font-family:monospace;" - [ngModel]="customText" (change)="edit($event.target.value)" - spellcheck="true"> - </textarea> + <div *ngIf="allowCC"> + <label for="ccAddresses">CC Email(s) (comma separated)</label> + <input type="email" name="ccAddresses" class="w-100" [ngModel]="ccAddresses" (change)="editCC($event.target.value)"> + </div> + <div *ngIf="enableCustomText"> + <label for="customText">Custom Text</label> + <textarea + name="customText" + class="w-100" + rows="20" + cols="60" + type="text" + style="font-size:20px; font-family:monospace;" + [ngModel]="customText" (change)="editText($event.target.value)" + spellcheck="true"> + </textarea> </div> </div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" (click)="modal.close('exit')">Cancel</button> + <button type="button" class="btn btn-primary" (click)="modal.close('skip')">Submit Without Email</button> <button type="button" class="btn btn-primary" mdbBtn (click)="modal.close('send')">Send</button> </div> </ng-template> diff --git a/apps/web/src/app/workspaces/components/send-email/send-email.component.ts b/apps/web/src/app/workspaces/components/send-email/send-email.component.ts index c416927986037a73178b68f78c782b4cb7483b12..c0b7637bfa2ab660356b309c83520b69a697ef7a 100644 --- a/apps/web/src/app/workspaces/components/send-email/send-email.component.ts +++ b/apps/web/src/app/workspaces/components/send-email/send-email.component.ts @@ -16,7 +16,7 @@ * You should have received a copy of the GNU General Public License * along with Workspaces. If not, see <https://www.gnu.org/licenses/>. */ -import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'; +import { Component, OnInit, OnChanges, Input, Output, EventEmitter } from '@angular/core'; import { NgbModal } from "@ng-bootstrap/ng-bootstrap"; import { NotificationsService } from "../../services/notifications.service"; @@ -29,7 +29,7 @@ import { NotificationsService } from "../../services/notifications.service"; templateUrl: './send-email.component.html', styleUrls: ['./send-email.component.scss'] }) -export class SendEmailComponent implements OnInit { +export class SendEmailComponent implements OnInit, OnChanges { @Input() modalTitleText: string; @Input() customStyle: string = ""; @Input() shouldDisable: boolean = false; @@ -57,11 +57,23 @@ export class SendEmailComponent implements OnInit { this.ccAddresses = this.defaultCC; } + /** + * Listens for changes to inputs on this component. + * @param changes List of input changes + */ + ngOnChanges(changes): void { + // Update the defaultCC here to allow observers to change it + if (changes.defaultCC) { + this.defaultCC = changes.defaultCC.currentValue; + this.ccAddresses = this.defaultCC; + } + } + /** * Populates the original text of the modal with the content of * the email template from the notification service. */ - loadEmailTemplate() { + loadEmailTemplate(): void { const emailTemplatesObserver = { next: (request) => { this.allTemplates = request; @@ -90,46 +102,83 @@ export class SendEmailComponent implements OnInit { this.notificationsService.getEmailTemplates().subscribe(emailTemplatesObserver); } - public open(content) { + /** + * Opens the modal prepopulated with the given content and listens for submit button clicks. + * @param content Content to populate the form + */ + public open(content: string): void { this.toggleSendEmailOpen() this.modalService.open(content, { ariaLabelledBy: "modal-title", centered: true, size: "lg" }).result.then( (result) => { if (result === "send" && this.customTextTag && this.customText) { - // "Send" button clicked, add our fields to the email parameters + // "Send" button clicked + const sendEmailTemplatesObserver = { + next: (response) => { + this.emailSentEvent.emit(this.customText); + this.toggleSendEmailOpen(); + }, + error: (error) => { + console.error("Error when sending the email:", error) + this.toggleSendEmailOpen(); + } + }; + + // Add our fields to the email parameters this.emailParameters["template_name"] = this.templateName; this.emailParameters[this.customTextTag] = this.customText; if (this.allowCC && this.ccAddresses) { - this.emailParameters["cc_email"] = this.ccAddresses + this.emailParameters["cc_email"] = this.ccAddresses; } // Send the email and then emit edited data to parent component - this.notificationsService.sendEmail(this.emailParameters); - this.emailSentEvent.emit(this.customText) - this.toggleSendEmailOpen() + this.notificationsService.sendEmail(this.emailParameters).subscribe(sendEmailTemplatesObserver); + } else if(result === "skip") { + // User chose to skip sending an email + this.emailSentEvent.emit(this.customText); + this.toggleSendEmailOpen(); } else { // Form was exited by clicking out of it or pressing ESC this.resetForm(); - this.toggleSendEmailOpen() + this.toggleSendEmailOpen(); } }, (reason) => { // Form was exited using Cancel button or X this.resetForm(); - this.toggleSendEmailOpen() + this.toggleSendEmailOpen(); }, ); } - private toggleSendEmailOpen() { - this.isEmailModalOpen = !this.isEmailModalOpen - this.emailOpenEvent.emit(this.isEmailModalOpen) + /** + * Toggles this modal being open and fires an event when the state changes. + */ + private toggleSendEmailOpen(): void { + this.isEmailModalOpen = !this.isEmailModalOpen; + this.emailOpenEvent.emit(this.isEmailModalOpen); } - private resetForm() { + /** + * Resets the default text in the modal. + */ + private resetForm(): void { this.customText = this.defaultText; + this.ccAddresses = this.defaultCC; } - public edit(changes: string) { - this.customText = changes + /** + * Defines how to update the email text from the form. + * @param changes Changes to propagate from the form + */ + public editText(changes: string): void { + this.customText = changes; + } + + /** + * Defines how to update the CC email addresses from the form. + * @param changes Changes to propagate from the form + */ + public editCC(changes: string): void { + this.ccAddresses = changes; } } diff --git a/apps/web/src/app/workspaces/services/capabilities.service.ts b/apps/web/src/app/workspaces/services/capabilities.service.ts index bb91e8be159e6ebe6121aa458686aeb344be9fd8..db4c7a3bfc8ed8d04f205aa2fc10da08505df841 100644 --- a/apps/web/src/app/workspaces/services/capabilities.service.ts +++ b/apps/web/src/app/workspaces/services/capabilities.service.ts @@ -41,10 +41,19 @@ export class CapabilitiesService { /** * Gets list of available staff for QA assignment - * @param group Group of staff members; AODs or DAs + * @param group Group of staff members */ public getQaStaff(group: string): Observable<any> { const url = `/capabilities/available_qa_staff?group=${group}`; return this.httpClient.get<JsonObject>(url); } + + /** + * Get the workspaces-analyst email address from the capo setting. + * @return Observable<CapabilityVersion> Result of REST call to get the email address + */ + public getAnalystEmail(): Observable<string> { + const url = `/capabilities/analyst_email`; + return this.httpClient.get<string>(url); + } } diff --git a/apps/web/src/app/workspaces/services/capability-request.service.ts b/apps/web/src/app/workspaces/services/capability-request.service.ts index f695a63fab85f3ee6724d3f367a7ca2c3992b100..97b03b9d660000c016b596fa70659c25c5c49850 100644 --- a/apps/web/src/app/workspaces/services/capability-request.service.ts +++ b/apps/web/src/app/workspaces/services/capability-request.service.ts @@ -172,25 +172,25 @@ export class CapabilityRequestService { } /** - * AoD Pass (i.e. proceed to ingestion) this request by POSTing URL to REST endpoint + * Stage 2 Pass (i.e. proceed to ingestion) this request by POSTing URL to REST endpoint * * @param requestId ID of request whose capability execution is to be passed * @param version The capability version that begat this workflow request */ - public sendAoDPassRequest(requestId: number, version: number): Observable<string> { - const aodPassUrl = `workflows/requests/${requestId}/qa/aod_pass`; - return this.httpClient.post<string>(aodPassUrl, {capability_version: version}); + public sendStage2QaPassRequest(requestId: number, version: number): Observable<string> { + const stage2PassUrl = `workflows/requests/${requestId}/qa/stage_2_pass`; + return this.httpClient.post<string>(stage2PassUrl, { capability_version: version }); } /** - * AoD review (i.e. pass back to DA) this request by POSTing URL to REST endpoint + * Qa Revisit (i.e. pass back to DA) this request by POSTing URL to REST endpoint * * @param requestId ID of request whose capability execution is to be passed * @param version The capability version that begat this workflow request */ - public sendAoDReviewRequest(requestId: number, version: number): Observable<string> { - const aodReviewUrl = `workflows/requests/${requestId}/qa/aod_review`; - return this.httpClient.post<string>(aodReviewUrl, {capability_version: version}); + public sendQaRevisitRequest(requestId: number, version: number): Observable<string> { + const revisitUrl = `workflows/requests/${requestId}/qa/qa_revisit`; + return this.httpClient.post<string>(revisitUrl, { capability_version: version }); } /** @@ -279,7 +279,7 @@ export class CapabilityRequestService { } /** - * Assigns DA or AOD to a capability request + * Assigns QA Stage 1 and 2 reviewers to a capability request * @param requestId Given request ID, string * @param staff staff member; Staff */ diff --git a/apps/web/src/app/workspaces/workspaces.module.ts b/apps/web/src/app/workspaces/workspaces.module.ts index 2af29a1bfdb177199a6e78472ced2fb745c9ffae..e356f3b0544fa403d72b4edb27b31face23a5923 100644 --- a/apps/web/src/app/workspaces/workspaces.module.ts +++ b/apps/web/src/app/workspaces/workspaces.module.ts @@ -40,7 +40,7 @@ import {FilterMenuComponent} from './components/active-capability-requests/compo import {QaControlsComponent} from './components/capability-request/components/qa-controls/qa-controls.component'; import {SendEmailComponent} from './components/send-email/send-email.component'; import {CapabilityDataAccessComponent} from "./components/capability-request/components/capability-data-access/capability-data-access.component"; -import {DANotesComponent} from './components/capability-request/components/da-notes/da-notes.component'; +import {InternalNotesComponent} from './components/capability-request/components/./internal-notes/internal-notes.component'; import {WsHeaderComponent} from './ws-header/ws-header.component'; import {WsHomeComponent} from './ws-home/ws-home.component'; import {MatAutocompleteModule} from '@angular/material/autocomplete'; @@ -50,41 +50,41 @@ import {MatInputModule} from '@angular/material/input'; import {MatPaginatorModule} from '@angular/material/paginator'; @NgModule({ - declarations: [ - WorkspacesComponent, - CapabilityRequestComponent, - RequestHeaderComponent, - StatusBadgeComponent, - ParametersComponent, - CreateNewVersionFormComponent, - ActiveCapabilityRequestsComponent, - FilesComponent, - RequestOperationsComponent, - MetadataComponent, - ManualCalibrateObservationComponent, - VersionsComponent, - EmailTemplatesComponent, - TemplateCardsComponent, - EditorComponent, - FilterMenuComponent, - QaControlsComponent, - SendEmailComponent, - CapabilityDataAccessComponent, - DANotesComponent, - WsHeaderComponent, - WsHomeComponent, - ], + declarations: [ + WorkspacesComponent, + CapabilityRequestComponent, + RequestHeaderComponent, + StatusBadgeComponent, + ParametersComponent, + CreateNewVersionFormComponent, + ActiveCapabilityRequestsComponent, + FilesComponent, + RequestOperationsComponent, + MetadataComponent, + ManualCalibrateObservationComponent, + VersionsComponent, + EmailTemplatesComponent, + TemplateCardsComponent, + EditorComponent, + FilterMenuComponent, + QaControlsComponent, + SendEmailComponent, + CapabilityDataAccessComponent, + InternalNotesComponent, + WsHeaderComponent, + WsHomeComponent, +], imports: [ - CommonModule, - NgbModule, - WorkspacesRoutingModule, - ReactiveFormsModule, - FormsModule, - MatAutocompleteModule, - MatFormFieldModule, - MatSelectModule, - MatInputModule, - MatPaginatorModule, - ], + CommonModule, + NgbModule, + WorkspacesRoutingModule, + ReactiveFormsModule, + FormsModule, + MatAutocompleteModule, + MatFormFieldModule, + MatSelectModule, + MatInputModule, + MatPaginatorModule, + ], }) export class WorkspacesModule {} diff --git a/docs/source/overview.rst b/docs/source/overview.rst index 7340f51adda6604eb0c3bb516c0d74049c6f149a..2e08cb932490c3ec214b098384353151be55b24e 100644 --- a/docs/source/overview.rst +++ b/docs/source/overview.rst @@ -138,7 +138,7 @@ Utilities for Developers and DAs - :doc:`wf_inspector <tools/wf_inspector>` makes it easy to get into an executing workflow - :doc:`ws_metrics <tools/ws_metrics>` is a tool for retrieving Workspaces metrics - :doc:`mediator <tools/mediator>` allows workspaces requests to be destructively modified -- :doc:`mod_analyst <tools/mod_analyst>` manages the DAs and AODs in the stopgap users table +- :doc:`mod_analyst <tools/mod_analyst>` manages the QA reviewers in the stopgap users table - :doc:`seci_ingestion_status <tools/seci_ingestion_status>` checks on the ingestion status of a SECI imaging job .. toctree:: diff --git a/docs/source/overview/capability-schema.rst b/docs/source/overview/capability-schema.rst index 52d1fd659096f727a451d46adc7f3ec609259939..57e2c4313f6fde421e036c0f3c08da37778daff6 100644 --- a/docs/source/overview/capability-schema.rst +++ b/docs/source/overview/capability-schema.rst @@ -53,7 +53,7 @@ are timestamps that are automatically updated. The fields ``ingested`` and ``sealed`` are booleans that indicate some supplemental facts about the capability request. Sealed requests cannot have new versions or executions made. Ingested requests have their results in the archive. -Finally, we have the temporary fields ``assigned_da`` and ``assigned_aod``, which are used by the QA system. +Finally, we have the temporary fields ``stage_1_reviewer`` and ``stage_2_reviewer``, which are used by the QA system. Wondering where the JSON argument lives? It's in the ``capability_versions`` table. @@ -99,7 +99,7 @@ obvious meaning. And once again we have ``sealed``, which controls whether new versions can be made. -Finally, the ``da_notes`` column is for storing notes from the DAs about this version. +Finally, the ``internal_notes`` column is for storing notes from the DAs about this version. The ``capability_version_files`` table diff --git a/docs/source/overview/capability-states.rst b/docs/source/overview/capability-states.rst index 4354da55908e1bd685430a254d31fa26726f6ecb..50fbd187b1a23d15f8717442a7a6722012813b6b 100644 --- a/docs/source/overview/capability-states.rst +++ b/docs/source/overview/capability-states.rst @@ -102,7 +102,7 @@ The DoubleQA state machine is intended for scenarios where a data analyst does a :width: 555px :height: 714px -Notice the addition of a QA Abandon state as well as the AoD Review state. +Notice the addition of a QA Abandon state as well as the Stage 2 Review state. This state machine is currently used by: diff --git a/docs/swagger-schema.yaml b/docs/swagger-schema.yaml index 7fe5ac9853e2340979aae8bcc906d4d72403b883..7fe1677674115358b33d3255a57507aaa6f5ca3c 100644 --- a/docs/swagger-schema.yaml +++ b/docs/swagger-schema.yaml @@ -56,6 +56,24 @@ paths: $ref: "#/definitions/QaStaffList" "404": description: "no staff found" + /capabilities/analyst_email: + get: + tags: + - "capabilities" + summary: "Get workspaces analyst email address" + description: "" + operationId: "get_analyst_email" + consumes: + - "application/json" + produces: + - "application/json" + responses: + "200": + description: "successful operation" + schema: + $ref: "#/definitions/AnalystEmail" + "404": + description: "No email address found" /capability/create: post: tags: @@ -437,7 +455,7 @@ paths: description: "successful operation" schema: $ref: "#/definitions/CapabilityRequest" - /capability/request/{id}/version/{version_id}/da_notes: + /capability/request/{id}/version/{version_id}/internal_notes: parameters: - $ref: "#/parameters/capability-request-id" - $ref: "#/parameters/capability-version-id" @@ -446,7 +464,7 @@ paths: - "requests" summary: "Update the data analyst notes for this version" description: "" - operationId: "view_da_notes" + operationId: "view_internal_notes" consumes: - "application/json" produces: @@ -465,7 +483,7 @@ paths: - "requests" summary: "Update the data analyst notes for this version" description: "" - operationId: "update_da_notes" + operationId: "update_internal_notes" consumes: - "application/json" produces: @@ -1219,7 +1237,7 @@ definitions: type: "string" status_url: type: "string" - da_notes: + internal_notes: type: "string" CapabilityVersionFile: type: "object" @@ -1266,6 +1284,11 @@ definitions: type: "array" items: $ref: "#/definitions/QaStaff" + AnalystEmail: + type: "object" + properties: + resp: + type: "string" StaleDirectories: type: "object" properties: diff --git a/services/capability/capability/routes.py b/services/capability/capability/routes.py index bd4f78bb34d8ca4fd87f5d2b5d34dfe3007eb7ad..cd88d4786f8abff50f40dc50cc95f465fb778e8f 100644 --- a/services/capability/capability/routes.py +++ b/services/capability/capability/routes.py @@ -75,6 +75,11 @@ def capability_routes(config: Configurator): pattern="/capabilities/available_qa_staff", request_method="GET", ) + config.add_route( + name="get_analyst_email", + pattern="capabilities/analyst_email", + request_method="GET" + ) # POST config.add_route( @@ -115,11 +120,7 @@ def capability_request_routes(config: Configurator): pattern="capability/{capability_name}/request/create-and-submit", request_method="POST", ) - config.add_route( - name="close_capability_request", - pattern=f"{request_url}/close", - request_method="POST" - ) + config.add_route(name="close_capability_request", pattern=f"{request_url}/close", request_method="POST") version_url = request_url + "/version/{version}" followon_pattern = f"{version_url}" + "/followon/{followon_type}" @@ -133,19 +134,15 @@ def capability_request_routes(config: Configurator): pattern=f"{request_url}", request_method="POST", ) - config.add_route( - name="cancel_capability_request", - pattern=f"{request_url}/cancel", - request_method="POST" - ) + config.add_route(name="cancel_capability_request", pattern=f"{request_url}/cancel", request_method="POST") config.add_route( name="assign_capability_request", - pattern=f"{request_url}/assign/group/" + "{group}/staff/{user_name}", + pattern=f"{request_url}" + "/assign/group/{group}/staff/{user_name}", request_method="POST", ) config.add_route( name="update_system_messages", - pattern=f"{request_url}/system_msg/" + "{msg_id}/{action}", + pattern=f"{request_url}" + "/system_msg/{msg_id}/{action}", request_method="POST", ) @@ -177,8 +174,8 @@ def capability_version_routes(config: Configurator): request_method="GET", ) config.add_route( - name="view_da_notes", - pattern="capability/request/{capability_request_id}/version/{version_id}/da_notes", + name="view_internal_notes", + pattern="capability/request/{capability_request_id}/version/{version_id}/internal_notes", request_method="GET", ) @@ -199,8 +196,8 @@ def capability_version_routes(config: Configurator): request_method="GET", ) config.add_route( - name="update_da_notes", - pattern="capability/request/{capability_request_id}/version/{version_id}/da_notes", + name="update_internal_notes", + pattern="capability/request/{capability_request_id}/version/{version_id}/internal_notes", request_method="POST", ) # PUT diff --git a/services/capability/capability/views/capability.py b/services/capability/capability/views/capability.py index 9b515e36b016a324d5710e3f1fcf8c9634d5a422..b1c51b9e0bcbaf4d8b67e130747c627b11a82672 100644 --- a/services/capability/capability/views/capability.py +++ b/services/capability/capability/views/capability.py @@ -24,6 +24,8 @@ concerning capabilities themselves import http +from pycapo import CapoConfig + from pyramid.httpexceptions import ( HTTPBadRequest, HTTPExpectationFailed, @@ -254,3 +256,19 @@ def retrieve_available_qa_staff_in_group(request: Request) -> Response: return Response(json_body={f"{request.params['group']}": available_staff}) else: return HTTPNotFound(detail=f"No available staff found.") + + +@view_config(route_name="get_analyst_email", renderer="json") +def get_analyst_email(request: Request) -> Response: + """ + Pyramid view that gets the workspaces-analyst email address from the capo config. + + :param request: GET request + :return: Response containing the workspaces-analyst email address from the capo config + or 404 response (HTTPNotFound) if it isn't found in the capo config + """ + analyst_email = CapoConfig().settings("edu.nrao.workspaces.NotificationSettings").analystEmail + if analyst_email: + return Response(status_int=http.HTTPStatus.OK, json_body={"resp": f"{analyst_email}"}) + else: + return HTTPNotFound(detail=f"No workspaces-analyst email found.") diff --git a/services/capability/capability/views/capability_request.py b/services/capability/capability/views/capability_request.py index c69d17fbd2ac63615de983ce9efcc1a9f23fe16b..a6e453adfa510dab3d9c0763a33caa5843a07ccc 100644 --- a/services/capability/capability/views/capability_request.py +++ b/services/capability/capability/views/capability_request.py @@ -162,8 +162,8 @@ def create_follow_on_capability_request(request: Request) -> Response: # Carry staff from previous request to followon prev_request = capability_info.lookup_capability_request(request_id) - prev_da = prev_request.da - prev_aod = prev_request.aod + prev_stage_1 = prev_request.reviewer_1 + prev_stage_2 = prev_request.reviewer_2 new_capability_request = capability_info.create_capability_request( capability_name=followon_type, @@ -174,8 +174,8 @@ def create_follow_on_capability_request(request: Request) -> Response: "parent_request_id": request_id, }, ) - new_capability_request.da = prev_da - new_capability_request.aod = prev_aod + new_capability_request.reviewer_1 = prev_stage_1 + new_capability_request.reviewer_2 = prev_stage_2 return Response(json_body=new_capability_request.__json__()) @@ -272,7 +272,7 @@ def delete_capability_request(request: Request) -> Response: @view_config(route_name="assign_capability_request", renderer="json") def assign_capability_request(request: Request) -> Response: """ - Assign a DA or AOD to a request. + Assign a QA Reviewer to a request. - URL: capability/request/{request_id}/assign/group/{group}/staff/{user_name} @@ -306,15 +306,15 @@ def assign_capability_request(request: Request) -> Response: return HTTPBadRequest(detail=params_not_given_msg) staff_dict = params["qa_staff"] - staff_member = request.capability_info.get_staff_member(staff_dict) + staff_member = request.capability_info.lookup_staff_member(staff_dict) response_body = f"{group} {user_name} has been assigned to capability request {request_id}" - if group == "DA": - capability_request.assigned_da = user_name - capability_request.da = staff_member - elif group == "AOD": - capability_request.assigned_aod = user_name - capability_request.aod = staff_member + if group == "Stage 1": + capability_request.stage_1_reviewer = user_name + capability_request.reviewer_1 = staff_member + elif group == "Stage 2": + capability_request.stage_2_reviewer = user_name + capability_request.reviewer_2 = staff_member else: dunno_whatta_do_msg = f"Don't know how to assign request {request_id}" return HTTPBadRequest(detail=dunno_whatta_do_msg) diff --git a/services/capability/capability/views/capability_version.py b/services/capability/capability/views/capability_version.py index da6146b860d146fe56a633c5341063caa151cb8c..8feabe9940b7182c6ab69581f070c2cb6d43fd12 100644 --- a/services/capability/capability/views/capability_version.py +++ b/services/capability/capability/views/capability_version.py @@ -124,8 +124,8 @@ def view_specific_version(request: Request) -> Response: return HTTPNotFound(detail=not_found_msg) -@view_config(route_name="view_da_notes", renderer="json") -def view_da_notes(request: Request) -> Response: +@view_config(route_name="view_internal_notes", renderer="json") +def view_internal_notes(request: Request) -> Response: """ Pyramid view that accepts a request to get notes for a specific version URL: capability/request/{capability_request_id}/version/{version_id}/notes @@ -142,7 +142,7 @@ def view_da_notes(request: Request) -> Response: capability_request = request.capability_info.lookup_capability_request(capability_request_id) if capability_request: if version := capability_request.versions[int(version_id) - 1]: - return Response(status_int=http.HTTPStatus.OK, json_body={"resp": f"{version.da_notes}"}) + return Response(status_int=http.HTTPStatus.OK, json_body={"resp": f"{version.internal_notes}"}) else: no_versions_msg = f"Capability request with ID {capability_request_id} has no version {version_id}" return HTTPPreconditionFailed(no_versions_msg) @@ -151,8 +151,8 @@ def view_da_notes(request: Request) -> Response: return HTTPNotFound(detail=not_found_msg) -@view_config(route_name="update_da_notes", renderer="json") -def update_da_notes(request: Request) -> Response: +@view_config(route_name="update_internal_notes", renderer="json") +def update_internal_notes(request: Request) -> Response: """ Pyramid view that accepts a request to update notes for a specific version URL: capability/request/{capability_request_id}/version/{version_id}/notes @@ -174,9 +174,9 @@ def update_da_notes(request: Request) -> Response: capability_request = request.capability_info.lookup_capability_request(capability_request_id) if capability_request: if version := capability_request.versions[int(version_id) - 1]: - version.da_notes = new_notes + version.internal_notes = new_notes return Response( - body=f"Successfully updated da_notes for capability request #{capability_request_id} v{version_id}." + body=f"Successfully updated internal_notes for capability request #{capability_request_id} v{version_id}." ) else: no_versions_msg = f"Capability request with ID {capability_request_id} has no version {version_id}" diff --git a/services/capability/test/test_capability_request_views.py b/services/capability/test/test_capability_request_views.py index 2b5324a2e9e2909a56a90da27f753f083907d884..6ead542bb523d1d28b6869e1bd8b511489e5fe09 100644 --- a/services/capability/test/test_capability_request_views.py +++ b/services/capability/test/test_capability_request_views.py @@ -50,8 +50,8 @@ def test_view_capability_request(request_null_capability: DummyRequest): "capability_name": "null", "id": 0, "state": "Created", - "assigned_da": "ricardo", - "assigned_aod": "lucy", + "stage_1_reviewer": "ricardo", + "stage_2_reviewer": "lucy", } request_null_capability.matchdict["capability_name"] = "null" request_null_capability.matchdict["request_id"] = 0 diff --git a/services/capability/test/test_capability_routes.py b/services/capability/test/test_capability_routes.py index d9177bcde232fed684c81960bece446e820c7485..1a097e81180fc1ca31cc72879e62b5612cfe98dc 100644 --- a/services/capability/test/test_capability_routes.py +++ b/services/capability/test/test_capability_routes.py @@ -156,7 +156,7 @@ def test_create_version_from_previous_execution_script(request_null_capability: def test_assign_capability_request(request_null_capability: DummyRequest): """ - Tests that a specified DA/AOD is assigned to a capability request + Tests that a specified QA Reviewer is assigned to a capability request :param request_null_capability: Dummy Pyramid request object set up with mocked DB access supporting the null capability @@ -167,23 +167,23 @@ def test_assign_capability_request(request_null_capability: DummyRequest): request_id = request_null_capability.matchdict["request_id"] = 1 request_null_capability.matchdict["user_name"] = "debbie_da" - request_null_capability.matchdict["group"] = "DA" - da_body = {"user_name": "debbie_da", "group": "DA", "available": True, "email": None} - request_null_capability.json_body = {"qa_staff": da_body} + request_null_capability.matchdict["group"] = "Stage 1" + staff_body = {"user_name": "debbie_da", "group": "Stage 1", "available": True, "email": None} + request_null_capability.json_body = {"qa_staff": staff_body} response = assign_capability_request(request_null_capability) assert response.status_code == http.HTTPStatus.OK request_null_capability.matchdict["user_name"] = "alice_aod" - request_null_capability.matchdict["group"] = "AOD" - aod_body = {"user_name": "alice_aod", "group": "AOD", "available": True, "email": None} - request_null_capability.json_body = {"qa_staff": aod_body} + request_null_capability.matchdict["group"] = "Stage 2" + staff_body_2 = {"user_name": "alice_aod", "group": "Stage 2", "available": True, "email": None} + request_null_capability.json_body = {"qa_staff": staff_body_2} response = assign_capability_request(request_null_capability) assert response.status_code == http.HTTPStatus.OK - # confirm that DA and AOD have been assigned + # confirm that reviewers have been assigned capability_request = request_null_capability.capability_info.lookup_capability_request(request_id) - assert capability_request.assigned_da == "debbie_da" - assert capability_request.assigned_aod == "alice_aod" + assert capability_request.stage_1_reviewer == "debbie_da" + assert capability_request.stage_2_reviewer == "alice_aod" finally: request_null_capability.matchdict = original_matchdict @@ -198,13 +198,13 @@ def test_get_available_staff_by_group(request_null_capability: DummyRequest): """ original_matchdict = request_null_capability.matchdict - da_group = "DA" - request_null_capability.matchdict["group"] = da_group + group = "Stage 1" + request_null_capability.matchdict["group"] = group try: - avail_das = request_null_capability.capability_info.get_available_staff_by_group(da_group) - for da in avail_das: - assert da.group == da_group - assert da.available + avail_staff = request_null_capability.capability_info.get_available_staff_by_group(group) + for staff in avail_staff: + assert staff.group == group + assert staff.available finally: request_null_capability.matchdict = original_matchdict diff --git a/services/capability/test/test_capability_version_views.py b/services/capability/test/test_capability_version_views.py index 8b21a05ac148fd2af3811bd9ae09f08bafef4dd1..a8cc46f0a8ebd4385144bfb907bfba4dc1494bb9 100644 --- a/services/capability/test/test_capability_version_views.py +++ b/services/capability/test/test_capability_version_views.py @@ -54,7 +54,7 @@ def test_view_latest_version(request_null_capability: DummyRequest): "type": "CapabilityExecution", "updated_at": "2022-01-04T19:40:59.963144", "version_number": None, - "da_notes": None, + "internal_notes": None, }, } assert response.status_code == http.HTTPStatus.OK @@ -95,7 +95,7 @@ def test_view_latest_version(request_null_capability: DummyRequest): "workflow_metadata": {"carta_url": "https://pr-dsoc-dev.nrao.edu/pKP8pcGvI02M/"}, "files": [], "current_execution": None, - "da_notes": None, + "internal_notes": None, } request_null_capability.matchdict["capability_request_id"] = expected_json["capability_request_id"] request_null_capability.matchdict["capability_name"] = "std_restore_imaging" diff --git a/shared/workspaces/alembic/versions/0dc708eecbc6_fix_pims_split_templates.py b/shared/workspaces/alembic/versions/0dc708eecbc6_fix_pims_split_templates.py new file mode 100644 index 0000000000000000000000000000000000000000..3d38d22f663f2c844969451ab3aad5992aeed392 --- /dev/null +++ b/shared/workspaces/alembic/versions/0dc708eecbc6_fix_pims_split_templates.py @@ -0,0 +1,181 @@ +"""fix pims split templates + +Revision ID: 0dc708eecbc6 +Revises: 7018d6a27433 +Create Date: 2023-04-04 10:24:33.728436 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0dc708eecbc6' +down_revision = '7018d6a27433' +branch_labels = None +depends_on = None + +old_pims_notification = """ +Subject: PIMS Split Workflow Finished + +Dear DA, + +{{status}} + +Calibration: {{calibration}} +CASA from: {{casa_path}} +Restore path: {{restore_path}} +Lustre processing area: {{lustre_dir}} +Cache directory: {{cache_dir}} + +{{#num_products.length}} +SE Coarse Cube and Continuum images per tile in the database: +{{#num_products}} +Tile: {{tile_name}}, CC: {{num_coarse_cube}}, SE: {{num_continuum}} +{{/num_products}} +{{/num_products.length}} + +Failed Splits/Total Splits: {{num_failed_splits}}/{{num_splits}} +{{#failed_splits.length}} +Failed splits: +{{#failed_splits}} +{{.}} +{{/failed_splits}} +{{/failed_splits.length}} + +Best regards, +NRAO Workspaces +""" + + +new_pims_notification = b"""Subject: PIMS Split Workflow Finished + +Dear DA, + +{{status}} + +Calibration: {{calibration}} +CASA from: {{casa_path}} +Restore path: {{restore_path}} +Lustre processing area: {{lustre_dir}} +Cache directory: {{cache_dir}} + +SE Coarse Cube and Continuum images per tile in the database: +{{#num_products}} +- Tile: {{tile_name}}, CC: {{num_coarse_cube}}, SE: {{num_continuum}} +{{/num_products}} + +Failed Splits ({{num_failed_splits}}/{{num_splits}}): +{{#failed_splits}} +- {{.}} +{{/failed_splits}} + +Best regards, +NRAO Workspaces +""" + +old_split_sh = b"""#!/bin/sh +export HOME=$TMPDIR +TILE=$(echo $1 | cut -d "/" -f 1) +PHCENTER=$(echo $1 | cut -d "/" -f 2) + +# Get the measurement set path +{{^existing_restore}} +MS={{data_location}}/working/*.ms +{{/existing_restore}} +{{#existing_restore}} +MS={{existing_restore}} +{{/existing_restore}} + +# Link it in the splits rawdata +ln -s $MS rawdata/ + +# Run CASA +./casa_envoy --split metadata.json PPR.xml + +# Populate cache +./pimscache cp -c {{vlass_product}} -t $TILE -p $PHCENTER working/*_split.ms + +touch {{data_location}}/failed_splits.txt + +# If pimscache call failed, output the failed split to a file for pims_analyzer +if [[ $? -ne 0 ]] ; then + echo "${TILE}.${PHCENTER}" >> {{data_location}}/failed_splits.txt +fi + +# Run quicklook if second parameter was given +if ! [[ -z "$2" ]]; then + curl --request PUT --header "Content-Length: 0" $2 +fi +""" + +new_split_sh = b"""#!/bin/sh +export HOME=$TMPDIR +TILE=$(echo $1 | cut -d "/" -f 1) +PHCENTER=$(echo $1 | cut -d "/" -f 2) + +# Get the measurement set path +{{^existing_restore}} +MS={{data_location}}/working/*.ms +{{/existing_restore}} +{{#existing_restore}} +MS={{existing_restore}} +{{/existing_restore}} + +# Link it in the splits rawdata +ln -s $MS rawdata/ + +# failed_splits.txt needs to be present even if its empty for pims_analyzer +touch {{data_location}}/failed_splits.txt + +# Run CASA +./casa_envoy --split metadata.json PPR.xml + +# Populate cache +./pimscache cp -c {{vlass_product}} -t $TILE -p $PHCENTER working/*_split.ms + +# If pimscache call failed, output the failed split to a file for pims_analyzer +if [[ $? -ne 0 ]] ; then + echo "${TILE}.${PHCENTER}" >> {{data_location}}/failed_splits.txt +fi + +# Run quicklook if second parameter was given +if ! [[ -z "$2" ]]; then + curl --request PUT --header "Content-Length: 0" $2 +fi +""" + +def upgrade(): + conn = op.get_bind() + conn.execute( + f""" + UPDATE workflow_templates + SET content=%s WHERE filename='split.sh' + """, + new_split_sh + ) + conn.execute( + f""" + UPDATE notification_templates + SET template=%s WHERE name='pims_notification' + """, + new_pims_notification + ) + + +def downgrade(): + conn = op.get_bind() + conn.execute( + f""" + UPDATE workflow_templates + SET content=%s WHERE filename='split.sh' + """, + old_split_sh + ) + conn.execute( + f""" + UPDATE notification_templates + SET template=%s WHERE name='pims_notification' + """, + old_pims_notification + ) diff --git a/shared/workspaces/alembic/versions/68a8ad53ad74_change_qa_terminology.py b/shared/workspaces/alembic/versions/68a8ad53ad74_change_qa_terminology.py new file mode 100644 index 0000000000000000000000000000000000000000..bdeb4710ba7760fd41e8638268636da7bd0a45e3 --- /dev/null +++ b/shared/workspaces/alembic/versions/68a8ad53ad74_change_qa_terminology.py @@ -0,0 +1,138 @@ +"""change QA terminology + +Revision ID: 68a8ad53ad74 +Revises: 7018d6a27433 +Create Date: 2023-04-03 14:25:39.373189 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "68a8ad53ad74" +down_revision = "0dc708eecbc6" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute( + """ + ALTER TABLE capability_requests + RENAME COLUMN assigned_da to stage_1_reviewer; + """ + ) + op.execute( + """ + ALTER TABLE capability_requests + RENAME COLUMN assigned_aod to stage_2_reviewer; + """ + ) + op.execute( + """ + ALTER TABLE capability_versions + RENAME COLUMN da_notes to internal_notes; + """ + ) + op.execute( + """ + UPDATE qa_staff + SET "group" = 'Stage 1' + WHERE "group" = 'DA'; + """ + ) + + # Remove duplicate entries + op.execute( + """ + CREATE TABLE qa_staff_tmp (LIKE qa_staff); + """ + ) + op.execute( + """ + INSERT INTO qa_staff_tmp(user_name, "group", available, email) + SELECT + DISTINCT ON (user_name, "group") user_name, "group", available, email + FROM qa_staff; + """ + ) + op.execute( + """ + DROP TABLE qa_staff; + """ + ) + op.execute( + """ + ALTER TABLE qa_staff_tmp + RENAME TO qa_staff; + """ + ) + # Put back unique constraint + op.execute( + """ + ALTER TABLE qa_staff + ADD PRIMARY KEY (user_name,"group"); + """ + ) + op.execute( + """ + UPDATE qa_staff + SET "group" = 'Stage 2' + WHERE "group" = 'AOD'; + """ + ) + op.execute( + """ + UPDATE capability_executions + SET state='Stage 2 Review' + WHERE state='AoD Review'; + """ + ) + + +def downgrade(): + op.execute( + """ + ALTER TABLE capability_requests + RENAME COLUMN stage_1_reviewer to assigned_da; + """ + ) + op.execute( + """ + ALTER TABLE capability_requests + RENAME COLUMN stage_2_reviewer to assigned_aod; + """ + ) + op.execute( + """ + ALTER TABLE capability_versions + RENAME COLUMN internal_notes to da_notes; + """ + ) + op.execute( + """ + UPDATE qa_staff + SET "group" = 'DA' + WHERE "group" = 'Stage 1'; + """ + ) + op.execute( + """ + UPDATE qa_staff + SET "group" = 'AOD' + WHERE "group" = 'Stage 2'; + """ + ) + op.execute( + """ + ALTER TABLE qa_staff + DROP CONSTRAINT qa_staff_pkey; + """ + ) + op.execute( + """ + UPDATE capability_executions + SET state='AoD Review' + WHERE state='Stage 2 Review'; + """ + ) diff --git a/shared/workspaces/alembic/versions/762c98a8adf1_pims_split_quicklook_corrections.py b/shared/workspaces/alembic/versions/762c98a8adf1_pims_split_quicklook_corrections.py new file mode 100644 index 0000000000000000000000000000000000000000..52b246ecb1619de070075cc0bcf60dead44d88c2 --- /dev/null +++ b/shared/workspaces/alembic/versions/762c98a8adf1_pims_split_quicklook_corrections.py @@ -0,0 +1,242 @@ +# Copyright (C) 2023 Associated Universities, Inc. Washington DC, USA. +# +# This file is part of NRAO Workspaces. +# +# Workspaces is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Workspaces is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Workspaces. If not, see <https://www.gnu.org/licenses/>. +# +"""pims split quicklook corrections + +Revision ID: 762c98a8adf1 +Revises: e00812d93608 +Create Date: 2023-04-14 12:25:42.235329 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "762c98a8adf1" +down_revision = "e00812d93608" +branch_labels = None +depends_on = None + + +""" +Iterating over {{#splits}} to make the "splits" array which has the format: +"splits": [ + { + "split_dir": {{split_dir}}, + "quicklook_url": {{quicklook_url}} + }, +] +""" +old_metadata = """{"systemId": "{{request_id}}", "fileSetIds": ["{{sdmId}}", "{{calSdmId}}"], "creationTime": "{{created_at}}", "workflowName": "pims_split", "productLocator": "{{product_locator}}", "calProductLocator": "{{cal_locator}}", "destinationDirectory": "{{root_directory}}/{{relative_path}}", "token": "{{token}}", "splits": ["{{split_dir}}", "{{quicklook_url}}"], "casaHome": "{{casaHome}}", "data_location": "{{data_location}}", "vlass_product": "{{vlass_product}}", "existing_restore": "{{existing_restore}}", "need_project_metadata": "{{need_project_metadata}}"}""" + +new_metadata = """{"systemId": "{{request_id}}", "fileSetIds": ["{{sdmId}}", "{{calSdmId}}"], "creationTime": "{{created_at}}", "workflowName": "pims_split", "productLocator": "{{product_locator}}", "calProductLocator": "{{cal_locator}}", "destinationDirectory": "{{root_directory}}/{{relative_path}}", "token": "{{token}}", "splits": [{{#splits}}{"split_dir": "{{split_dir}}", "quicklook_url": "{{quicklook_url}}"},{{/splits}}], "casaHome": "{{casaHome}}", "data_location": "{{data_location}}", "vlass_product": "{{vlass_product}}", "existing_restore": "{{existing_restore}}", "need_project_metadata": "{{need_project_metadata}}"}""" + + +# Conditionalize the quicklook_url argument in the condor file +old_condor_args = 'arguments = "$(split_dir)" "$(quicklook_url)"' + +new_condor_args = 'arguments = "$(split_dir)"{{#quicklook_url}} "$(quicklook_url)"{{/quicklook_url}}' + +# Add that pesky comma when transferring input files +old_write_finished_file_condor = """executable = write_finished_file.sh + +output = write_finished.out +error = write_finished.err +log = condor.log + +SBIN_PATH = /lustre/aoc/cluster/pipeline/$ENV(CAPO_PROFILE)/workspaces/sbin +SPOOL_DIR = {{spool_dir}} +should_transfer_files = yes +transfer_input_files = $ENV(HOME)/.ssh/condor_transfer, nraorsync://$(SBIN_PATH)/pycapo nraorsync://$(SBIN_PATH)/pims_analyzer +transfer_output_files = .job.ad ++nrao_output_files = "finished" +output_destination = nraorsync://$(SPOOL_DIR) ++WantIOProxy = True + +getenv = True +environment = "CAPO_PATH=/home/casa/capo" + +requirements = (VLASS == True) && (HasLustre == True) ++partition = "VLASS" + +queue + +""" + +new_write_finished_file_condor = """executable = write_finished_file.sh + +output = write_finished.out +error = write_finished.err +log = condor.log + +SBIN_PATH = /lustre/aoc/cluster/pipeline/$ENV(CAPO_PROFILE)/workspaces/sbin +SPOOL_DIR = {{spool_dir}} +should_transfer_files = yes +transfer_input_files = $ENV(HOME)/.ssh/condor_transfer, nraorsync://$(SBIN_PATH)/pycapo, nraorsync://$(SBIN_PATH)/pims_analyzer +transfer_output_files = .job.ad ++nrao_output_files = "finished" +output_destination = nraorsync://$(SPOOL_DIR) ++WantIOProxy = True + +getenv = True +environment = "CAPO_PATH=/home/casa/capo" + +requirements = (VLASS == True) && (HasLustre == True) ++partition = "VLASS" + +queue + +""" + +old_write_finished_file_sh = b"""#!/bin/sh + +cd {{data_location}} + +# Set up for emails +ADDRESS_CAPO_PROPERTY="edu.nrao.workspaces.NotificationSettings.vlassAnalystEmail" +ADDRESS=$(./pycapo ${ADDRESS_CAPO_PROPERTY} | cut -d "'" -f 2) + +NOTIFICATION_CAPO_PROPERTY="edu.nrao.workspaces.NotificationSettings.serviceUrl" +NOTIFICATION_URL=$(./pycapo ${NOTIFICATION_CAPO_PROPERTY} | cut -d "'" -f 2)/pims_notification/send + +ANALYZER_JSON=$(./pims_analyzer --id {{request_id}} 2> analyzer_call.log) + +# The analyzer call failed +if [[ $? -ne 0 ]] ; then + FAIL_MESSAGE="Error getting metadata for pims job, check {{data_location}}/analyzer_call.log for more information" + FAIL_SUBJECT="Failure to analyze pims_split for {{vlass_product}}" + FAIL_JSON="{"destination_email": "$ADDRESS", "subject": "$FAIL_SUBJECT", "message": "$FAIL_MESSAGE"}" + FAIL_NOTIFICATION_URL=$(./pycapo ${NOTIFICATION_CAPO_PROPERTY} | cut -d "'" -f 2)/email/send + + /bin/curl --location --request POST $FAIL_NOTIFICATION_URL --header 'Content-Type: application/json' --data-raw "$FAIL_JSON" + + exit 1 +fi + +# Append address information to the analyzer JSON +JSON="${ANALYZER_JSON%\\}}"destination_email": "$ADDRESS"}" + +# Send the email +/bin/curl --location --request POST $NOTIFICATION_URL --header 'Content-Type: application/json' --data-raw "$JSON" + +/bin/date > finished +""" + +new_write_finished_file_sh = b"""#!/bin/sh + +cd {{data_location}} + +# Set up for emails +ADDRESS_CAPO_PROPERTY="edu.nrao.workspaces.NotificationSettings.vlassAnalystEmail" +ADDRESS=$(/lustre/aoc/cluster/pipeline/$CAPO_PROFILE/workspaces/sbin/pycapo ${ADDRESS_CAPO_PROPERTY} | cut -d '"' -f 2) + +NOTIFICATION_CAPO_PROPERTY="edu.nrao.workspaces.NotificationSettings.serviceUrl" +NOTIFICATION_URL=$(/lustre/aoc/cluster/pipeline/$CAPO_PROFILE/workspaces/sbin/pycapo ${NOTIFICATION_CAPO_PROPERTY} | cut -d '"' -f 2)/notify/pims_notification/send + +ANALYZER_JSON=$(/lustre/aoc/cluster/pipeline/$CAPO_PROFILE/workspaces/sbin/pims_analyzer --id {{request_id}} 2> analyzer_call.err) + +# The analyzer call failed +if [[ $? -ne 0 ]] ; then + FAIL_MESSAGE="Error getting metadata for pims job, check {{data_location}}/analyzer_call.log for more information" + FAIL_SUBJECT="Failure to analyze pims_split for {{vlass_product}}" + FAIL_JSON="{"destination_email": "$ADDRESS", "subject": "$FAIL_SUBJECT", "message": "$FAIL_MESSAGE"}" + FAIL_NOTIFICATION_URL=$(/lustre/aoc/cluster/pipeline/$CAPO_PROFILE/workspaces/sbin/pycapo ${NOTIFICATION_CAPO_PROPERTY} | cut -d '"' -f 2)/email/send + + /bin/curl --location --request POST $FAIL_NOTIFICATION_URL --header 'Content-Type: application/json' --data "$FAIL_JSON" + + exit 1 +fi + +# Append address information to the analyzer JSON +JSON="${ANALYZER_JSON%\\}}" +JSON+=",\\"destination_email\\":\\"$ADDRESS\\"}" + +# Send the email +/bin/curl --location --request POST $NOTIFICATION_URL --header 'Content-Type: application/json' --data "$JSON" + +/bin/date > finished +""" + + +def upgrade(): + op.execute( + f""" + UPDATE workflow_templates + SET content = E'{new_metadata}' + WHERE workflow_name = 'pims_split' AND filename = 'metadata.json' + """ + ) + + op.execute( + f""" + UPDATE workflow_templates + SET content = replace(convert_from(content, 'utf8'), E'{old_condor_args}', E'{new_condor_args}')::bytea + WHERE workflow_name = 'pims_split' AND filename = 'split.condor' + """ + ) + + op.execute( + f""" + UPDATE workflow_templates + SET content = E'{new_write_finished_file_condor}' + WHERE workflow_name = 'pims_split' AND filename = 'write_finished_file.condor' + """ + ) + + conn = op.get_bind() + conn.execute( + f""" + UPDATE workflow_templates + SET content = %s WHERE filename='write_finished_file.sh' + """, + new_write_finished_file_sh, + ) + + +def downgrade(): + op.execute( + f""" + UPDATE workflow_templates + SET content = E'{old_metadata}' + WHERE workflow_name = 'pims_split' AND filename = 'metadata.json' + """ + ) + + op.execute( + f""" + UPDATE workflow_templates + SET content = replace(convert_from(content, 'utf8'), E'{new_condor_args}', E'{old_condor_args}')::bytea + WHERE workflow_name = 'pims_split' AND filename = 'split.condor' + """ + ) + + op.execute( + f""" + UPDATE workflow_templates + SET content = E'{old_write_finished_file_condor}' + WHERE workflow_name = 'pims_split' AND filename = 'write_finished_file.condor' + """ + ) + + conn = op.get_bind() + conn.execute( + f""" + UPDATE workflow_templates + SET content = %s WHERE filename='write_finished_file.sh' + """, + old_write_finished_file_sh, + ) diff --git a/shared/workspaces/alembic/versions/e00812d93608_update_da_aod_notifications.py b/shared/workspaces/alembic/versions/e00812d93608_update_da_aod_notifications.py new file mode 100644 index 0000000000000000000000000000000000000000..2762544b8368ab11015cff8d573918a95d669458 --- /dev/null +++ b/shared/workspaces/alembic/versions/e00812d93608_update_da_aod_notifications.py @@ -0,0 +1,99 @@ +"""update da/aod notifications + +Revision ID: e00812d93608 +Revises: 68a8ad53ad74 +Create Date: 2023-04-04 10:41:18.048962 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "e00812d93608" +down_revision = "68a8ad53ad74" +branch_labels = None +depends_on = None + +new_greeting = """To Whom It May Concern,""" + + +def upgrade(): + op.execute( + f""" + UPDATE notification_templates + SET template = replace(template, 'Dear DAs,', E'{new_greeting}') + """ + ) + op.execute( + f""" + UPDATE notification_templates + SET template = replace(template, 'Dear AOD,', E'{new_greeting}') + """ + ) + op.execute( + """ + UPDATE notification_templates + SET description = replace(description, 'DAs', 'Stage 1 QA reviewer') + """ + ) + op.execute( + """ + UPDATE notification_templates + SET description = replace(description, 'AOD', 'Stage 2 QA reviewer') + """ + ) + op.execute( + """ + UPDATE notification_templates + SET name = 'stage_2_review' + WHERE name = 'aod_review' + """ + ) + op.execute( + """ + UPDATE notification_templates + SET name = 'qa_revisit' + WHERE name = 'aod_revisit' + """ + ) + + +def downgrade(): + op.execute( + f""" + UPDATE notification_templates + SET template = replace(template, E'{new_greeting}', 'Dear DAs,') + """ + ) + op.execute( + f""" + UPDATE notification_templates + SET template = replace(template, E'{new_greeting}', 'Dear AOD,') + """ + ) + op.execute( + """ + UPDATE notification_templates + SET description = replace(description, 'Stage 1 QA reviewer', 'DAs') + """ + ) + op.execute( + """ + UPDATE notification_templates + SET description = replace(description, 'Stage 2 QA reviewer', 'AOD') + """ + ) + op.execute( + """ + UPDATE notification_templates + SET name = 'aod_review' + WHERE name = 'stage_2_review' + """ + ) + op.execute( + """ + UPDATE notification_templates + SET name = 'aod_revisit' + WHERE name = 'qa_revisit' + """ + ) diff --git a/shared/workspaces/alembic/versions/templates/vlass_calibration/envoy_2.8.1.txt b/shared/workspaces/alembic/versions/templates/vlass_calibration/envoy_2.8.1.txt index 28a3fbe723bd9e91fd797ac3b8b2f16a7a96242e..7c234a9a2495865a514ba1b34da58665feec5e86 100644 --- a/shared/workspaces/alembic/versions/templates/vlass_calibration/envoy_2.8.1.txt +++ b/shared/workspaces/alembic/versions/templates/vlass_calibration/envoy_2.8.1.txt @@ -7,5 +7,5 @@ SBIN_PATH=/lustre/aoc/cluster/pipeline/$CAPO_PROFILE/workspaces/sbin ${SBIN_PATH}/update_stage ENVOY cd {{spool_dir}} -# testing $SBIN_PATH/casa_envoy --vlass-cal $1 $2 +$SBIN_PATH/casa_envoy --vlass-cal $1 $2 ${SBIN_PATH}/update_stage ENVOY --complete diff --git a/shared/workspaces/test/test_remote_processing_service.py b/shared/workspaces/test/test_remote_processing_service.py index 845f1f8ee1226257aff9e4be51c0affa0a6f4a1d..a48fe15d86c25d4134ac85e6f6e057c700c84427 100644 --- a/shared/workspaces/test/test_remote_processing_service.py +++ b/shared/workspaces/test/test_remote_processing_service.py @@ -16,7 +16,7 @@ # You should have received a copy of the GNU General Public License # along with Workspaces. If not, see <https://www.gnu.org/licenses/>. from datetime import datetime -from unittest.mock import patch +from unittest.mock import patch, MagicMock from test.test_workflow_info import FakeWorkflowInfo from workspaces.system.schema import AbstractFile @@ -92,6 +92,7 @@ class TestCapoInjector: @patch("pathlib.Path.unlink") @patch("glob.glob") @patch("os.listdir") + @patch("pathlib.Path.exists", MagicMock(return_value=True)) def test_clear_subspace(self, mock_os, mock_glob, mock_remove): injector.clear_subspace() assert mock_os.call_count == 1 diff --git a/shared/workspaces/workspaces/capability/schema.py b/shared/workspaces/workspaces/capability/schema.py index 6334c3d373b4f16a026f89124b24a84bbc8b53a1..dd466cc8513b9dcc1e7f44f9ecb2efbf6698b438 100644 --- a/shared/workspaces/workspaces/capability/schema.py +++ b/shared/workspaces/workspaces/capability/schema.py @@ -217,7 +217,7 @@ class SingleQAStateMachine(StateMachine): @mapper_registry.mapped class DoubleQAStateMachine(StateMachine): """ - Class representation of a Two-Layered QA (DA and AOD) review process. + Class representation of a Two-Layered QA (Stage 1 and Stage 2) review process. TODO: In the future, there will be multiple types of QaPass/QaFail; that needs to be handled here """ @@ -251,18 +251,20 @@ class DoubleQAStateMachine(StateMachine): ], }, "Awaiting QA": { - ("AoD Review", "qa-pass"): [ - SendNotification(arguments=json.dumps({"template": "aod_review", "send_to_aod": True})) + ("Stage 2 Review", "qa-pass"): [ + SendNotification( + arguments=json.dumps({"template": "stage_2_review", "send_to_stage_2_review": True}) + ) ], ("Failed", "qa-fail"): [QaFail(), SendMessage(arguments="execution_failed")], ("Failed", "qa-complete"): [], ("Cancelled", "cancel"): [SendMessage(arguments="capability_cancelled")], ("QA Closed", "qa-abandon"): [QaAbandon()], }, - "AoD Review": { - ("Ingesting", "aod-pass"): [QaPass()], - ("Awaiting QA", "aod-review"): [ - SendNotification(arguments=json.dumps({"template": "aod_revisit", "send_to_qa": True})) + "Stage 2 Review": { + ("Ingesting", "stage-2-pass"): [QaPass()], + ("Awaiting QA", "qa-revisit"): [ + SendNotification(arguments=json.dumps({"template": "qa_revisit", "send_to_qa": True})) ], ("Failed", "qa-complete"): [], ("Cancelled", "cancel"): [SendMessage(arguments="capability_cancelled")], @@ -310,7 +312,8 @@ class Action: "action_type", sa.String, nullable=False, - comment="The type of action this is. Most likely the name of an Action subclass such as SendNotification or ExecuteWorkflow", + comment="The type of action this is. Most likely the name of an Action subclass " + "such as SendNotification or ExecuteWorkflow", ) arguments = sa.Column("arguments", sa.String, comment="Additional arguments for the action") transition = relationship("Transition", back_populates="actions") @@ -402,15 +405,15 @@ class SendNotification(Action): :return: None """ # - # Perform any necessary preparatory work (including email overrides and a check + # Perform any necessary preparatory work, including email overrides and a check # of CAPO, in order to send appropriate emails via the notification service. # ... args = json.loads(self.arguments) # Check if sending to DAs or user - send_to_aod = "send_to_aod" in args and args["send_to_aod"] - send_to_das = "send_to_qa" in args and args["send_to_qa"] + send_to_stage_2_review = "send_to_stage_2_review" in args and args["send_to_stage_2_review"] + send_to_stage_1_review = "send_to_qa" in args and args["send_to_qa"] obtain_contacts = "needs_contacts" in args and args["needs_contacts"] if execution.version.parameters is not None: send_to_user = "user_email" in execution.version.parameters @@ -420,17 +423,17 @@ class SendNotification(Action): cc_email = None # Set the destination email - if send_to_das: + if send_to_stage_1_review: # If this flag is set, overwrite any provided user email address: - # Check for an assigned DA, and send to them if you can, otherwise use the list - dest_email = self.obtain_da_email(execution) - elif send_to_aod: - # Get the assigned AoD's email, or the address of available AoDs as a backup - dest_email = self.find_aod_email(execution) + # Check for an assigned stage 1 reviewer, and send to them if you can, otherwise use the list + dest_email = self.obtain_reviewer_email(execution, 1) + elif send_to_stage_2_review: + # Get the assigned stage 2 reviewer's email, or the address of available reviewers as a backup + dest_email = self.obtain_reviewer_email(execution, 2) elif obtain_contacts: # If this flag is set, we expect no user_email information. - # Obtain the list of recipients (or the DA list if not production) - # If the PI is the recipient, then the DA list will be cc'd + # Obtain the list of recipients (or the analyst list if not production) + # If the PI is the recipient, then the analyst list will be cc'd contacts = self.obtain_contacts(execution.version) dest_email = contacts["send_to"] cc_email = contacts["cc_to"] @@ -468,22 +471,24 @@ class SendNotification(Action): manager.notifier.send_email(args["template"], notification_parameters) @staticmethod - def obtain_da_email(execution): - # fallback to the list - dest_email = CapoConfig().settings(NOTIF_SETTINGS_KEY).analystEmail - # but overwrite it if we have an assigned DA's email - if execution.capability_request.assigned_da is not None: - if execution.capability_request.da.email is not None: - dest_email = execution.capability_request.da.email - return dest_email + def obtain_reviewer_email(execution: CapabilityExecution, stage: int): + """ + find the relevant reviewer's email address if available - @staticmethod - def find_aod_email(execution): - # Grab the email for the assigned AoD, or don't send anything. - if execution.capability_request.assigned_aod is not None: - dest_email = execution.capability_request.aod.email - else: - dest_email = None + :param execution: Capability Execution under review + :param stage: which review stage to link to for email retrieval (some reviewers are multi-stage available) + :return: the relevant email address + """ + # fallback to the list + dest_email = CapoConfig().settings(NOTIF_SETTINGS_KEY).analystEmail if stage == 1 else None + # but overwrite it if we have an assigned stage 1 reviewer's email + if stage == 1 and execution.capability_request.stage_1_reviewer is not None: + if execution.capability_request.reviewer_1.email is not None: + dest_email = execution.capability_request.reviewer_1.email + elif stage == 2 and execution.capability_request.stage_2_reviewer is not None: + # Grab the email for the assigned stage 2 reviewer, or don't send anything. + if execution.capability_request.reviewer_2.email is not None: + dest_email = execution.capability_request.reviewer_2.email return dest_email def obtain_contacts(self, version: CapabilityVersion): @@ -507,13 +512,13 @@ class SendNotification(Action): else: send_to = email_list - # If we're emailing a PI, cc the DA list + # If we're emailing a PI, cc the analyst list cc_to = CapoConfig().settings(NOTIF_SETTINGS_KEY).analystEmail logger.info(f"Email to be sent to project contacts: {send_to}") - logger.info(f"The DA list in the CAPO config will be cc'd.") + logger.info(f"The analyst list in the CAPO config will be cc'd.") else: - logger.info("User email overridden to DA list via CAPO setting.") + logger.info("User email overridden to analyst list via CAPO setting.") # Otherwise, we send the email to the analysts list: send_to = CapoConfig().settings(NOTIF_SETTINGS_KEY).analystEmail cc_to = None @@ -823,7 +828,8 @@ class AnnounceQa(Action): @mapper_registry.mapped class Cancel(Action): """ - Action that makes a REST call to the abort workflow endpoint in the workflow service, which kills the htcondor job for a given workflow + Action that makes a REST call to the abort workflow endpoint in the workflow service, + which kills the htcondor job for a given workflow """ __mapper_args__ = {"polymorphic_identity": "Cancel"} @@ -934,7 +940,8 @@ class QaStaff(JSONSerializable): group = sa.Column( "group", sa.String, - comment="Is this staff member a DA or an AOD?", + primary_key=True, + comment="Is this staff member a Stage 1 Reviewer or a Stage 2 Reviewer?", ) available = sa.Column( "available", @@ -1029,7 +1036,6 @@ class Capability(JSONSerializable): # Pyramid support method: must accept a "request" argument that is unused by us def __json__(self, request=None) -> dict: - return { "type": self.__class__.__name__, "name": self.name, @@ -1246,28 +1252,28 @@ class CapabilityRequest(JSONSerializable): server_onupdate=sa.func.now(), nullable=False, ) - assigned_da = sa.Column( - "assigned_da", + stage_1_reviewer = sa.Column( + "stage_1_reviewer", sa.String, sa.ForeignKey(QaStaff.user_name), ) - assigned_aod = sa.Column( - "assigned_aod", + stage_2_reviewer = sa.Column( + "stage_2_reviewer", sa.String, sa.ForeignKey(QaStaff.user_name), ) system_messages = sa.Column("system_messages", MutableDict.as_mutable(sa.JSON)) - # I suspect the trying to set up bidirectional connections for the AoD/DA is going to + # I suspect the trying to set up bidirectional connections for the assigned reviewers is going to # get confusing, so skipping until desired. - da = relationship( + reviewer_1 = relationship( "QaStaff", - primaryjoin="and_(CapabilityRequest.assigned_da==QaStaff.user_name, QaStaff.group=='DA')", + primaryjoin="and_(CapabilityRequest.stage_1_reviewer==QaStaff.user_name, QaStaff.group=='Stage 1')", ) - aod = relationship( + reviewer_2 = relationship( "QaStaff", - primaryjoin="and_(CapabilityRequest.assigned_aod==QaStaff.user_name, QaStaff.group=='AOD')", + primaryjoin="and_(CapabilityRequest.stage_2_reviewer==QaStaff.user_name, QaStaff.group=='Stage 2')", ) versions = relationship( @@ -1324,7 +1330,7 @@ class CapabilityRequest(JSONSerializable): # Pyramid support method: must accept a "request" argument that is unused by us def __json__(self, request=None) -> dict: - # Calculate state to ensure it's up to date + # Calculate state to ensure it's up-to-date self.determine_state() return { @@ -1332,8 +1338,8 @@ class CapabilityRequest(JSONSerializable): "id": self.id, "capability_name": self.capability_name, "state": self.state, - "assigned_da": self.assigned_da, - "assigned_aod": self.assigned_aod, + "stage_1_reviewer": self.stage_1_reviewer, + "stage_2_reviewer": self.stage_2_reviewer, "sealed": self.sealed, "ingested": self.ingested, "created_at": self.created_at.isoformat(), @@ -1385,8 +1391,8 @@ class CapabilityVersion(JSONSerializable): files = relationship("CapabilityVersionFile", back_populates="version") capability_name = sa.Column("capability_name", sa.String, sa.ForeignKey(CAPABILITY_NAME_FK)) capability = relationship(Capability) - da_notes = sa.Column( - "da_notes", sa.String, default=f"Version 1: {datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S')}\n" + internal_notes = sa.Column( + "internal_notes", sa.String, default=f"Version 1: {datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S')}\n" ) @property @@ -1407,7 +1413,7 @@ class CapabilityVersion(JSONSerializable): "files": [file.__json__() for file in self.files], "capability_name": self.capability.name if self.capability else None, "status_url": self.request.status_url, - "da_notes": self.da_notes, + "internal_notes": self.internal_notes, } @classmethod diff --git a/shared/workspaces/workspaces/capability/services/capability_info.py b/shared/workspaces/workspaces/capability/services/capability_info.py index be25e814eed58eda0841bb90dbcca6f68907bdf3..04290e4648b4789c47479bc89bf67a35ed7e420d 100644 --- a/shared/workspaces/workspaces/capability/services/capability_info.py +++ b/shared/workspaces/workspaces/capability/services/capability_info.py @@ -171,7 +171,7 @@ class CapabilityInfo: transaction.commit() @staticmethod - def get_metadata_from_wrester(parameters: List) -> List: + def get_metadata_from_wrester(parameters: List) -> Dict: """ Run AAT Wrest to get Observation metadata. Only needs to occur on first version creation @@ -385,10 +385,14 @@ class CapabilityInfo: ) if filters.__contains__("state"): default_query = default_query.filter(CapabilityRequest.state.in_(filters.get("state"))) - if filters.__contains__("assigned_da"): - default_query = default_query.filter(CapabilityRequest.assigned_da.in_(filters.get("assigned_da"))) - if filters.__contains__("assigned_aod"): - default_query = default_query.filter(CapabilityRequest.assigned_aod.in_(filters.get("assigned_aod"))) + if filters.__contains__("stage_1_reviewer"): + default_query = default_query.filter( + CapabilityRequest.stage_1_reviewer.in_(filters.get("stage_1_reviewer")) + ) + if filters.__contains__("stage_2_reviewer"): + default_query = default_query.filter( + CapabilityRequest.stage_2_reviewer.in_(filters.get("stage_2_reviewer")) + ) if filters.__contains__("date_observed"): default_query = default_query.filter(CapabilityRequest.state.in_(filters.get("state"))) @@ -653,11 +657,14 @@ class CapabilityInfo: json_staff.append(staff.__json__()) return json_staff - @staticmethod - def get_staff_member(staff_json: dict) -> QaStaff: - # remove filter setting from object, don't understand how this got here in the first place, but causes errors - staff_json.pop("isChecked", None) - return QaStaff.from_json(staff_json) + def lookup_staff_member(self, staff_json: dict) -> QaStaff: + """ + Lookup a requested QA Staff member + + :param staff_json: the staff member to retrieve + :return: + """ + return self.session.query(QaStaff).filter_by(user_name=staff_json["user_name"], group=staff_json["group"]).one() def update_system_messages(self, request_id: int, msg_id: str, action: str): """ @@ -761,7 +768,7 @@ class RestrictedInfo(CapabilityInfo): parameters=parameters, request=request, capability=request.capability, - da_notes=f"{request.current_version.da_notes}\n\nVersion {len(request.versions) + 1}: {datetime.now().strftime('%Y-%m-%dT%H:%M:%S')}\n", + internal_notes=f"{request.current_version.internal_notes}\n\nVersion {len(request.versions) + 1}: {datetime.now().strftime('%Y-%m-%dT%H:%M:%S')}\n", ) self.save_entity(version) logger.info(f"New Version: {version.__json__()}") diff --git a/shared/workspaces/workspaces/workflow/schema.py b/shared/workspaces/workspaces/workflow/schema.py index a7f85918f943045958c32eecd1f50b2de152b51d..4f799f01e45939e528b0241175710e347aaedc48 100644 --- a/shared/workspaces/workspaces/workflow/schema.py +++ b/shared/workspaces/workspaces/workflow/schema.py @@ -280,20 +280,20 @@ class WorkflowRequest(JSONSerializable): # Pyramid support method: must accept a "request" argument that is unused by us def __json__(self, request=None) -> dict: - return dict( - type=self.__class__.__name__, - workflow_request_id=self.workflow_request_id, - workflow_name=self.workflow_name, - argument=self.argument, - state=self.state, - results_dir=self.results_dir, - files=self.files, - created_at=self.created_at.isoformat(), - updated_at=self.updated_at.isoformat(), - cleaned=self.cleaned, - controller=self.controller, - progress=self.progress, - ) + return { + "type": self.__class__.__name__, + "workflow_request_id": self.workflow_request_id, + "workflow_name": self.workflow_name, + "argument": self.argument, + "state": self.state, + "results_dir": self.results_dir, + "files": self.files, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + "cleaned": self.cleaned, + "controller": self.controller, + "progress": self.progress, + } @classmethod def from_json(cls, json: dict) -> any: @@ -395,8 +395,8 @@ class WorkflowProgress(JSONSerializable): return { "workflow_request_id": self.workflow_request_id, "stage_name": self.stage_name, - "start": self.start, - "end": self.end, + "start": self.start.isoformat(), + "end": self.end.isoformat() if self.end is not None else None, } diff --git a/shared/workspaces/workspaces/workflow/services/remote_processing_service.py b/shared/workspaces/workspaces/workflow/services/remote_processing_service.py index 9e23b5f345e25a7f2b32282f7d3890d080dfc087..33e115e9b4c3f49f26ebae14ca68e745cb3233a2 100644 --- a/shared/workspaces/workspaces/workflow/services/remote_processing_service.py +++ b/shared/workspaces/workspaces/workflow/services/remote_processing_service.py @@ -115,12 +115,17 @@ class CapoInjector: path.write_bytes(subspace.content) logger.info(f"Writing capo subspace file to {self.dir_path.__str__()}") - def clear_subspace(self): + def clear_subspace(self) -> bool: logger.info(f"Clearing capo subspace file from {self.dir_path.__str__()}...") - for file in os.listdir(self.dir_path): - if file.endswith(".properties"): - Path.unlink(self.dir_path / file) - - check = glob.glob("*.properties") - if check is None: - logger.info("Capo subspace cleared successfully.") + if self.dir_path.exists(): + for file in os.listdir(self.dir_path): + if file.endswith(".properties"): + Path.unlink(self.dir_path / file) + + check = glob.glob("*.properties") + if check is None: + logger.info("Capo subspace cleared successfully.") + return True + else: + logger.info(f"Directory {self.dir_path.__str__()} has already been cleaned.") + return False diff --git a/shared/workspaces/workspaces/workflow/services/workflow_service.py b/shared/workspaces/workspaces/workflow/services/workflow_service.py index 4ac3cf7011b892708b34460e6be1c358ce3c2cc8..be1cce6d65c06f6722392e4c91ffdacdf698094b 100644 --- a/shared/workspaces/workspaces/workflow/services/workflow_service.py +++ b/shared/workspaces/workspaces/workflow/services/workflow_service.py @@ -437,7 +437,11 @@ class WorkflowService(WorkflowServiceIF): :return: """ if ( - ("product_locator" in wf_request.argument and "," in wf_request.argument["product_locator"]) + ( + "product_locator" in wf_request.argument + and wf_request.argument["product_locator"] is not None + and "," in wf_request.argument["product_locator"] + ) or ("need_data" in wf_request.argument and wf_request.argument["need_data"] is True) or wf_request.workflow_name == "download" ): @@ -752,10 +756,7 @@ class WorkflowService(WorkflowServiceIF): :param capability_version: version that requires qa :return: """ - logger.info( - f"ANNOUNCING QA {msg_type.upper()} for request #{workflow_request_id}, capability version" - f" {capability_version}!" - ) + logger.info(f"ANNOUNCING {msg_type.upper()} for workflow request #{workflow_request_id}!") wf_request = self.info.lookup_workflow_request(workflow_request_id) qa_event_msg = WorkflowMessageArchitect(request=wf_request, ui_info=capability_version).compose_message( @@ -946,7 +947,7 @@ class WorkflowMessageHandler: elif message["type"] == "workflow-continuing": status = WorkflowRequestState.Running.name - elif message["type"] == "update-wf-metadata": + elif message["type"] in ("update-wf-metadata", "ingestion-failed"): # no action, keep existing state: return @@ -1140,7 +1141,10 @@ class WorkflowMessageHandler: if injector.is_remote_workflow(): logger.debug("Cleaning remote workflow") - injector.clear_subspace() + result = injector.clear_subspace() + if result is False: + # the processing directory somehow disappeared, mark as cleaned to avoid further errors + request.cleaned = True @staticmethod def clean_workflow(request: WorkflowRequest):