From 50ad8a0607bf390435d24061717e591d567aaf69 Mon Sep 17 00:00:00 2001
From: Nathan Hertz <nhertz@nrao.edu>
Date: Wed, 17 Nov 2021 15:27:31 -0500
Subject: [PATCH] WS-797: Polishing QA process

---
 .../capability-request.component.html         |  52 ++--
 .../capability-request.component.ts           |  41 ++--
 .../request-operations.component.html         |  30 ++-
 .../request-operations.component.ts           |   2 +
 .../components/versions/versions.component.ts |  38 ++-
 .../test/test_capability_request.py           | 225 ++++++++++++++++++
 shared/workspaces/test/test_states.py         |   1 -
 .../workspaces/capability/schema.py           |  75 +++++-
 .../capability/schema_interfaces.py           |   8 +-
 .../capability/services/capability_info.py    |   5 +-
 .../capability/services/capability_service.py |  26 +-
 11 files changed, 407 insertions(+), 96 deletions(-)
 create mode 100644 shared/workspaces/test/test_capability_request.py

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 dbbf9f31c..edf32d4bd 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
@@ -6,10 +6,10 @@
     <div class="row">
       <div id="capability-definition-container" class="col-5">
         <span id="capability-label"
-        >Capability:
+          >Capability:
           <strong class="pl-1">{{
             this.capabilityRequest.capability_name.toUpperCase()
-            }}</strong></span
+          }}</strong></span
         >
         <app-capability-definition
           *ngIf="this.capability !== undefined"
@@ -29,7 +29,7 @@
             id="download-button"
             class="btn btn-warning"
             href="{{ this.capabilityExecution.delivery_url }}"
-          >Download</a
+            >Download</a
           >
         </div>
         <div *ngIf="this.capabilityRequest.current_execution !== null">
@@ -46,11 +46,11 @@
               href="/workflows/{{ this.capability.name }}/requests/{{
                 this.capabilityExecution.current_workflow_request_id
               }}/weblog"
-            >View Weblog</a
+              >View Weblog</a
             >
           </div>
         </div>
-        <br/>
+        <br />
         <div id="qa-container" class="col-5">
           <div
             class="d-flex justify-content-left py-3"
@@ -62,16 +62,6 @@
             "
           >
             <span id="qa-label">QA Processing: </span>
-            <button
-              id="set-qa-pass"
-              type="button"
-              class="btn btn-success"
-              (click)="passQa(this.capabilityExecution.current_workflow_request_id)"
-            >
-              <span class="fas fa-image"></span>
-              <span class="p1-2">Pass QA</span>
-            </button>
-
             <div class="btn-group">
               <div id="pass-button">
                 <button
@@ -108,11 +98,16 @@
           id="versions"
           class="pt-2"
           [capabilityRequest]="this.capabilityRequest"
-          (versionEvent) = setVersion($event)
+          [selected_version]="this.currentVersion"
+          (versionEvent)="setVersion($event)"
         ></app-versions>
-        <br/>
-        <span id="parameters-label" *ngIf="currentVersion !== undefined">Version {{currentVersion.version_number}} Parameters</span>
-        <span id="parameters-label" *ngIf="currentVersion === undefined">Version {{latestVersion.version_number}} Parameters</span>
+        <br />
+        <span id="parameters-label" *ngIf="currentVersion !== undefined"
+          >Version {{ currentVersion.version_number }} Parameters</span
+        >
+        <span id="parameters-label" *ngIf="currentVersion === undefined"
+          >Version {{ latestVersion.version_number }} Parameters</span
+        >
         <app-parameters
           id="parameters"
           class="pt-2"
@@ -125,9 +120,13 @@
           [currentVersion]="latestVersion"
           *ngIf="currentVersion === undefined"
         ></app-parameters>
-        <br/>
-        <span id="files-label" *ngIf="currentVersion !== undefined">Version {{currentVersion.version_number}} Files</span>
-        <span id="files-label" *ngIf="currentVersion === undefined">Version {{latestVersion.version_number}} Files</span>
+        <br />
+        <span id="files-label" *ngIf="currentVersion !== undefined"
+          >Version {{ currentVersion.version_number }} Files</span
+        >
+        <span id="files-label" *ngIf="currentVersion === undefined"
+          >Version {{ latestVersion.version_number }} Files</span
+        >
         <app-files
           id="files"
           class="pt-2"
@@ -140,22 +139,21 @@
           [capabilityVersion]="latestVersion"
           *ngIf="currentVersion === undefined"
         ></app-files>
-        <br/>
+        <br />
         <app-metadata
           id="metadata"
           class="pt-2"
           [capabilityVersion]="currentVersion"
-          *ngIf="
-            currentVersion !== undefined && currentVersion.workflow_metadata !== null
-          "
+          *ngIf="currentVersion !== undefined && currentVersion.workflow_metadata !== null"
         ></app-metadata>
-        <br/>
+        <br />
 
         <app-request-operations
           id="operations"
           class="pt-2"
           [capability]="this.capability"
           [capabilityRequest]="this.capabilityRequest"
+          [currentVersion]="currentVersion"
           (cartaLaunched)="alertMessage($event)"
         ></app-request-operations>
       </div>
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 db275da0f..daa5e1b72 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
@@ -16,19 +16,20 @@
  * 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, OnDestroy, OnInit} from "@angular/core";
-import {CapabilityRequest} from "../../model/capability-request";
-import {ActivatedRoute} from "@angular/router";
-import {DataRetrieverService} from "../../services/data-retriever.service";
-import {CapabilityExecution} from "../../model/capability-execution";
-import {CapabilityRequestService} from "../../services/capability-request.service";
-import {CapabilityVersion} from "../../model/capability-version";
-import {PollingDataUpdaterService} from "../../services/polling-data-updater.service";
-import {AlertService} from "../../../shared/alert/alert.service";
-import {JsonObject} from "@angular/compiler-cli/ngcc/src/packages/entry_point";
-import {Subject} from "rxjs";
-import {takeUntil} from "rxjs/operators";
-import {Capability} from "../../model/capability";
+import { Component, OnDestroy, OnInit } from "@angular/core";
+
+import { CapabilityRequest } from "../../model/capability-request";
+import { ActivatedRoute } from "@angular/router";
+import { DataRetrieverService } from "../../services/data-retriever.service";
+import { CapabilityExecution } from "../../model/capability-execution";
+import { CapabilityRequestService } from "../../services/capability-request.service";
+import { CapabilityVersion } from "../../model/capability-version";
+import { PollingDataUpdaterService } from "../../services/polling-data-updater.service";
+import { AlertService } from "../../../shared/alert/alert.service";
+import { JsonObject } from "@angular/compiler-cli/ngcc/src/packages/entry_point";
+import { Subject } from "rxjs";
+import { takeUntil } from "rxjs/operators";
+import { Capability } from "../../model/capability";
 
 @Component({
   selector: "app-capability-request",
@@ -65,7 +66,6 @@ export class CapabilityRequestComponent implements OnInit, OnDestroy {
           type: "CapabilityExecution",
         };
       }
-
       this.dataRetriever
         .getCapability(this.capabilityRequest.capability_name)
         .pipe(takeUntil(this.ngUnsubscribe))
@@ -85,6 +85,14 @@ export class CapabilityRequestComponent implements OnInit, OnDestroy {
     next: (capabilityVersion) => {
       if (capabilityVersion !== undefined) {
         this.latestVersion = capabilityVersion;
+        // Set selected version to be the request's latest version
+        // If no version is selected or if the selected version is the latest version
+        if (
+          this.currentVersion === undefined ||
+          this.currentVersion.version_number === this.latestVersion.version_number
+        ) {
+          this.setVersion(this.latestVersion);
+        }
       } else {
         console.error("Current version returned undefined.");
       }
@@ -173,12 +181,11 @@ export class CapabilityRequestComponent implements OnInit, OnDestroy {
       .subscribe(addRestCallObservable);
   }
 
-  setVersion(version: CapabilityVersion){
+  setVersion(version: CapabilityVersion): void {
     this.currentVersion = version;
   }
 
-  ngOnInit(): void {
-  }
+  ngOnInit(): void {}
 
   ngOnDestroy(): void {
     // Prevent memory leakage by unsubscribing all observers
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 454eb3a16..e13051fdf 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
@@ -7,28 +7,46 @@
         [capabilityRequest]="capabilityRequest"
       ></app-create-new-version-form>
     </div>
-    <div class="col-auto d-flex" *ngIf="capabilityRequest.state === 'Complete' &&
-    (capabilityRequest.capability_name === 'std_calibration' || capabilityRequest.capability_name === 'restore_cms')">
+    <div
+      class="col-auto d-flex"
+      *ngIf="
+        capabilityRequest.state === 'Complete' &&
+        (capabilityRequest.capability_name === 'std_calibration' ||
+          capabilityRequest.capability_name === 'restore_cms')
+      "
+    >
       <button
         id="create-image-request"
         type="button"
         class="btn btn-warning"
-        (click)="capabilityLauncherService.createImageRequestFromPreviousCal('std_cms_imaging', capabilityRequest.id).subscribe(followonRequestObserver)">
+        (click)="
+          capabilityLauncherService
+            .createImageRequestFromPreviousCal('std_cms_imaging', capabilityRequest.id)
+            .subscribe(followonRequestObserver)
+        "
+      >
         <span class="fas fa-camera"></span><span class="pl-2">Create Image Request</span>
       </button>
     </div>
-    <div class="col-auto d-flex" *ngIf="capability !== undefined && capability.has_image_products === true && capabilityRequest.state === 'Complete'">
+    <div
+      class="col-auto d-flex"
+      *ngIf="
+        capability !== undefined &&
+        capability.has_image_products === true &&
+        capabilityRequest.state === 'Complete'
+      "
+    >
       <button
         id="launch-carta"
         type="button"
         class="btn btn-outline-success"
         (click)="launchCarta()"
         [disabled]="hasBeenClicked"
-        >
+      >
         <span class="fas fa-image"></span><span class="pl-2">Launch CARTA</span>
       </button>
     </div>
-    <div class="col-auto d-flex" *ngIf="capabilityRequest.state === 'Created'">
+    <div class="col-auto d-flex" *ngIf="currentVersion.state === 'Created'">
       <button
         id="submit-button"
         type="button"
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 82912ae1a..ab5cdfccc 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
@@ -23,6 +23,7 @@ import { CapabilityLauncherService } from "../../../../services/capability-launc
 import { WorkflowLauncherService } from "../../../../services/workflow-launcher.service";
 import { CapabilityExecution } from "../../../../model/capability-execution";
 import { CapabilityRequestService } from "../../../../services/capability-request.service";
+import { CapabilityVersion } from "../../../../model/capability-version";
 
 @Component({
   selector: "app-request-operations",
@@ -36,6 +37,7 @@ export class RequestOperationsComponent implements OnInit {
     private capabilityRequestService: CapabilityRequestService,
   ) {}
   @Input() capabilityRequest: CapabilityRequest;
+  @Input() currentVersion: CapabilityVersion;
   @Input() capability: Capability;
   public capabilityExecution: CapabilityExecution;
   @Output() cartaLaunched: EventEmitter<any> = new EventEmitter<any>();
diff --git a/apps/web/src/app/workspaces/components/capability-request/components/versions/versions.component.ts b/apps/web/src/app/workspaces/components/capability-request/components/versions/versions.component.ts
index 4c5d48428..956d08559 100644
--- a/apps/web/src/app/workspaces/components/capability-request/components/versions/versions.component.ts
+++ b/apps/web/src/app/workspaces/components/capability-request/components/versions/versions.component.ts
@@ -16,32 +16,28 @@
  * 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, Input, OnInit, Output, EventEmitter} from '@angular/core';
-import {CapabilityVersion} from "../../../../model/capability-version";
-import {CapabilityRequestService} from "../../../../services/capability-request.service";
-import {CapabilityRequest} from "../../../../model/capability-request";
-import {FormControl, FormGroup, Validators} from "@angular/forms";
-import {JsonObject} from "@angular/compiler-cli/ngcc/src/packages/entry_point";
+import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
+import { CapabilityVersion } from "../../../../model/capability-version";
+import { CapabilityRequestService } from "../../../../services/capability-request.service";
+import { CapabilityRequest } from "../../../../model/capability-request";
+import { JsonObject } from "@angular/compiler-cli/ngcc/src/packages/entry_point";
 
 @Component({
-  selector: 'app-versions',
-  templateUrl: './versions.component.html',
-  styleUrls: ['./versions.component.scss']
+  selector: "app-versions",
+  templateUrl: "./versions.component.html",
+  styleUrls: ["./versions.component.scss"],
 })
 export class VersionsComponent implements OnInit {
-
   @Input() capabilityRequest: CapabilityRequest;
   @Output() versionEvent = new EventEmitter<CapabilityVersion>();
   public capabilityVersions: Array<CapabilityVersion>;
-  public selected_version: CapabilityVersion
-
+  @Input() public selected_version: CapabilityVersion;
 
   constructor(private capabilityRequestService: CapabilityRequestService) {
     // this.getSpecificVersion(this.capabilityRequest.id, this.capabilityRequest.versions[this.capabilityRequest.versions.length-1].version_number);
   }
 
-  ngOnInit(): void {
-  }
+  ngOnInit(): void {}
 
   getVersion(): number {
     return this.selected_version.version_number;
@@ -50,15 +46,15 @@ export class VersionsComponent implements OnInit {
   getAllVersions(requestId: string) {
     const versionsObserver = {
       next: (request) => {
-          this.capabilityVersions = request;
-          console.log(this.capabilityVersions)
+        this.capabilityVersions = request;
+        console.log(this.capabilityVersions);
       },
       error: (error) => console.error("Error when retrieving capability versions:" + error),
     };
-   this.capabilityRequestService.getAllVersions(requestId).subscribe(versionsObserver);
+    this.capabilityRequestService.getAllVersions(requestId).subscribe(versionsObserver);
   }
 
-  getSpecificVersion(request_id: string, version_id: number){
+  getSpecificVersion(request_id: string, version_id: number) {
     const specificVersionObserver = {
       next: (version) => {
         this.selected_version = version;
@@ -66,10 +62,11 @@ export class VersionsComponent implements OnInit {
       },
       error: (error) => console.error("Error when retrieving capability version:" + error),
     };
-    this.capabilityRequestService.getSpecificVersion(request_id, version_id).subscribe(specificVersionObserver);
+    this.capabilityRequestService
+      .getSpecificVersion(request_id, version_id)
+      .subscribe(specificVersionObserver);
   }
 
-
   /**
    * Check if JsonObject is empty
    * Taken from: https://stackoverflow.com/questions/679915/how-do-i-test-for-an-empty-javascript-object
@@ -85,5 +82,4 @@ export class VersionsComponent implements OnInit {
 
     return true;
   }
-
 }
diff --git a/shared/workspaces/test/test_capability_request.py b/shared/workspaces/test/test_capability_request.py
new file mode 100644
index 000000000..d09274c4d
--- /dev/null
+++ b/shared/workspaces/test/test_capability_request.py
@@ -0,0 +1,225 @@
+#
+# Copyright (C) 2021 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/>.
+
+from typing import Dict
+
+from hypothesis import given
+from hypothesis import strategies as st
+
+from workspaces.capability.enums import CapabilityRequestState, CapabilityVersionState
+from workspaces.capability.schema import CapabilityRequest, CapabilityVersion
+from workspaces.capability.services.capability_info import CapabilityInfo
+
+from .conftest import (
+    SAMPLE_CAPABILITY_NAMES,
+    SQLITE_MAX_INT,
+    SQLITE_MIN_INT,
+    clear_test_database,
+)
+
+pytest_plugins = ["testing.utils.conftest"]
+
+
+# Register CapabilityRequest JSON blueprint as a hypothesis type strategy
+# To use:
+# >>> @given(st.from_type(CapabilityRequest))
+# >>> def test(generated_request_json: Dict):
+# >>>   request = CapabilityRequest.from_json(generated_request_json)
+st.register_type_strategy(
+    CapabilityRequest,
+    st.fixed_dictionaries(
+        {
+            "type": st.just("CapabilityRequest"),
+            "id": st.integers(min_value=SQLITE_MIN_INT, max_value=SQLITE_MAX_INT),
+            "capability_name": st.sampled_from(SAMPLE_CAPABILITY_NAMES),
+            "state": st.sampled_from([name for name, _ in CapabilityRequestState.__members__.items()]),
+            "parameters": st.one_of(st.lists(st.text()), st.none()),
+            "ingested": st.booleans(),
+            "created_at": st.datetimes().map(
+                lambda time: time.isoformat(),
+            ),
+            "updated_at": st.datetimes().map(
+                lambda time: time.isoformat(),
+            ),
+            "current_execution": st.none(),
+        }
+    ),
+)
+
+# Register CapabilityVersion JSON blueprint as a hypothesis type strategy
+# To use:
+# >>> @given(st.from_type(CapabilityVersion))
+# >>> def test(generated_version_json: Dict):
+# >>>   version = CapabilityVersion.from_json(generated_version_json)
+st.register_type_strategy(
+    CapabilityVersion,
+    st.fixed_dictionaries(
+        {
+            "type": st.just("CapabilityVersion"),
+            "capability_request_id": st.integers(min_value=SQLITE_MIN_INT, max_value=SQLITE_MAX_INT),
+            "version_number": st.integers(min_value=0, max_value=50),
+            "parameters": st.one_of(st.lists(st.text()), st.none()),
+            "workflow_metadata": st.one_of(
+                st.none(), st.dictionaries(st.text(min_size=1), st.text(min_size=1), min_size=1)
+            ),
+            "files": st.none(),
+        }
+    ),
+)
+
+
+@given(
+    st.from_type(CapabilityRequest),
+    st.lists(
+        st.from_type(CapabilityVersion), min_size=1, max_size=5, unique_by=lambda version: version["version_number"]
+    ),
+)
+def test_determine_state_complete(mock_capability_info: CapabilityInfo, request_json: Dict, list_of_version_json: Dict):
+    """
+    Given: A capability request with multiple versions
+    When: The current version status is set to Complete
+    Then: The request state should be set to Complete
+    """
+    request = CapabilityRequest.from_json(request_json)
+    versions = [CapabilityVersion.from_json(blob) for blob in list_of_version_json]
+    current_version_number = max(version.version_number for version in versions)
+
+    with clear_test_database(mock_capability_info):
+        mock_capability_info.save_entity(request)
+
+        for version in versions:
+            # Associate version with request
+            version.capability_request = request
+            version.capability_request_id = request.id
+
+            # Set one version to Complete, the rest to Failed or Executing
+            if version.version_number == current_version_number:
+                version.state = CapabilityVersionState.Complete.name
+            else:
+                version.state = (
+                    CapabilityVersionState.Failed.name
+                    if version.state == CapabilityVersionState.Complete.name or not version.state
+                    else version.state
+                )
+
+            mock_capability_info.save_entity(version)
+
+        request.determine_state()
+        assert request.state == CapabilityRequestState.Complete.name
+
+
+@given(
+    st.from_type(CapabilityRequest),
+    st.lists(
+        st.from_type(CapabilityVersion), min_size=1, max_size=5, unique_by=lambda version: version["version_number"]
+    ),
+)
+def test_determine_state_submitted(
+    mock_capability_info: CapabilityInfo, request_json: Dict, list_of_version_json: Dict
+):
+    """
+    Given: A capability request with multiple versions
+    When: There are no versions in the Complete state
+        And: There is a mix of versions in either the Created, Submitted, or Failed states
+    Then: The request state is set to Executing
+    """
+    request = CapabilityRequest.from_json(request_json)
+    versions = [CapabilityVersion.from_json(blob) for blob in list_of_version_json]
+
+    with clear_test_database(mock_capability_info):
+        mock_capability_info.save_entity(request)
+
+        for version in versions:
+            # Associate version with request
+            version.capability_request = request
+            version.capability_request_id = request.id
+
+            # Get rid of Complete versions (set their state to Executing instead)
+            version.state = (
+                CapabilityVersionState.Running.name
+                if version.state == CapabilityVersionState.Complete.name or not version.state
+                else version.state
+            )
+
+            mock_capability_info.save_entity(version)
+
+        request.determine_state()
+        assert request.state == CapabilityRequestState.Submitted.name
+
+
+@given(
+    st.from_type(CapabilityRequest),
+    st.lists(
+        st.from_type(CapabilityVersion), min_size=1, max_size=5, unique_by=lambda version: version["version_number"]
+    ),
+)
+def test_determine_state_failed(mock_capability_info: CapabilityInfo, request_json: Dict, list_of_version_json: Dict):
+    """
+    Given: A capability request with multiple versions
+    When: All versions for the request are in the Failed state
+    Then: The request state is set to Failed
+    """
+    request = CapabilityRequest.from_json(request_json)
+    versions = [CapabilityVersion.from_json(blob) for blob in list_of_version_json]
+
+    with clear_test_database(mock_capability_info):
+        mock_capability_info.save_entity(request)
+
+        for version in versions:
+            # Associate version with request
+            version.capability_request = request
+            version.capability_request_id = request.id
+
+            # Set all versions to Failed state
+            version.state = CapabilityVersionState.Failed.name
+
+            mock_capability_info.save_entity(version)
+
+        request.determine_state()
+        assert request.state == CapabilityRequestState.Failed.name
+
+
+@given(
+    st.from_type(CapabilityRequest),
+    st.lists(
+        st.from_type(CapabilityVersion), min_size=1, max_size=5, unique_by=lambda version: version["version_number"]
+    ),
+)
+def test_determine_state_created(mock_capability_info: CapabilityInfo, request_json: Dict, list_of_version_json: Dict):
+    """
+    Given: A capability request with multiple versions
+    When: All versions for the request are in the Created state
+    Then: The request state is set to Created
+    """
+    request = CapabilityRequest.from_json(request_json)
+    versions = [CapabilityVersion.from_json(blob) for blob in list_of_version_json]
+
+    with clear_test_database(mock_capability_info):
+        mock_capability_info.save_entity(request)
+        for version in versions:
+            # Associate version with request
+            version.capability_request = request
+            version.capability_request_id = request.id
+
+            # Set all versions to Failed state
+            version.state = CapabilityVersionState.Created.name
+
+            mock_capability_info.save_entity(version)
+
+        request.determine_state()
+        assert request.state == CapabilityRequestState.Created.name
diff --git a/shared/workspaces/test/test_states.py b/shared/workspaces/test/test_states.py
index 1a6515a6c..b59af32f0 100644
--- a/shared/workspaces/test/test_states.py
+++ b/shared/workspaces/test/test_states.py
@@ -23,7 +23,6 @@ The objective of this file is to help with prototyping and testing the capabilit
 from typing import Callable, Dict, List
 
 import pytest
-import requests
 
 
 class Noop:
diff --git a/shared/workspaces/workspaces/capability/schema.py b/shared/workspaces/workspaces/capability/schema.py
index 90f9f9af2..2cdafe834 100644
--- a/shared/workspaces/workspaces/capability/schema.py
+++ b/shared/workspaces/workspaces/capability/schema.py
@@ -25,7 +25,7 @@ import json
 import logging
 import pathlib
 import re
-from typing import Dict, List
+from typing import Dict, List, Optional
 
 import pendulum
 import requests
@@ -239,15 +239,42 @@ class State(Base):
         transition.actions.append(action)
         self.transitions.append(transition)
 
-    def signal(self, message: Dict, execution: CapabilityExecutionIF, manager: ExecutionManagerIF) -> State:
+    def find_matching_transition(self, message: Dict) -> Optional[Transition]:
+        """
+        Find matching transition for state based on a given message
+
+        :param message: Message to match transition with
+        :return: Matching transition if one is found; else None
+        """
         for transition in self.transitions:
             if transition.matches(message):
-                logger.info("Found matching transition")
-                transition.action(execution, manager)
-                return transition.to_state
-        logger.info("No matching transitions found")
+                return transition
+        return None
+
+    def get_next_state(self, message: Dict) -> State:
+        """
+        Get next state for current transition, given a message
+
+        :param message: Message to match transition with
+        :return: Next state if matching transition is found; else current state
+        """
+        transition = self.find_matching_transition(message)
+        if transition:
+            return transition.to_state
         return self
 
+    def perform_action(self, message: Dict, execution: CapabilityExecutionIF, manager: ExecutionManagerIF):
+        """
+        Perform action for the current transition, if one exists
+
+        :param message: Message to match transition with
+        :param execution: Execution that is performing the action
+        :param manager: Execution manager to send to the action
+        """
+        transition = self.find_matching_transition(message)
+        if transition:
+            transition.action(execution, manager)
+
     def final(self) -> bool:
         return not self.transitions
 
@@ -440,6 +467,36 @@ class CapabilityRequest(Base, CapabilityRequestIF):
     def update_state(self, state: CapabilityRequestState):
         self.state = state.name
 
+    def determine_state(self):
+        """
+        Determine state of request based on the state of its versions and set it accordingly
+
+        RULES:
+        - If there is a complete version, the request is complete
+        - If all versions are failed, the request is failed
+        - If all versions are created, the request is created
+        - Otherwise, it is submitted
+        """
+        logger.info("Determine request state...")
+        logger.info(f"Current version: {self.current_version.__json__()}")
+        version_states = [version.state for version in self.versions]
+
+        if self.current_version.state == CapabilityVersionState.Complete.name:
+            # The current version is complete, so the request is complete
+            logger.info("Current version is complete. Setting request to Complete.")
+            self.state = CapabilityRequestState.Complete.name
+        elif all(state == CapabilityRequestState.Failed.name for state in version_states):
+            # Request has all failed versions, so it is failed
+            logger.info("All versions are failed. Setting request to Failed.")
+            self.state = CapabilityRequestState.Failed.name
+        elif all(state == CapabilityRequestState.Created.name for state in version_states):
+            # Request has no submitted versions, so it is still in the created state
+            logger.info("All versions are created. Setting request to Created.")
+            self.state = CapabilityRequestState.Created.name
+        else:
+            logger.info("Versions are in a mixture of states. Setting request to Submitted.")
+            self.state = CapabilityRequestState.Submitted.name
+
     def __str__(self):
         return f"CapabilityRequest object: {self.__dict__}"
 
@@ -684,4 +741,8 @@ class CapabilityExecution(Base, CapabilityExecutionIF, JSONSerializable):
 
     def signal(self, manager: ExecutionManagerIF, message: Dict):
         logger.info("Signalling my state %s with this message of type %s", self.state_name, message["type"])
-        self.state = self.state.signal(message, self, manager)
+        previous_state = self.state
+        # Transition execution state
+        self.state = self.state.get_next_state(message)
+        # Perform action
+        previous_state.perform_action(message, self, manager)
diff --git a/shared/workspaces/workspaces/capability/schema_interfaces.py b/shared/workspaces/workspaces/capability/schema_interfaces.py
index 787f037fd..ffd40012d 100644
--- a/shared/workspaces/workspaces/capability/schema_interfaces.py
+++ b/shared/workspaces/workspaces/capability/schema_interfaces.py
@@ -15,13 +15,14 @@
 #
 # You should have received a copy of the GNU General Public License
 # along with Workspaces.  If not, see <https://www.gnu.org/licenses/>.
+
+# pylint: disable=C0114, C0115, C0116, R0903
+
 from __future__ import annotations
 
 import pathlib
 from typing import Dict, List
 
-# pylint: disable=C0114, C0115, C0116, R0903
-
 from workspaces.capability.helpers_interfaces import ParameterIF
 from workspaces.products.schema_interfaces import FutureProductIF
 from workspaces.system.schema import JSONSerializable
@@ -48,6 +49,9 @@ class CapabilityRequestIF(JSONSerializable):
     def current_version(self):
         raise NotImplementedError
 
+    def determine_state(self):
+        pass
+
 
 class CapabilityVersionIF:
     capability_request: CapabilityRequestIF
diff --git a/shared/workspaces/workspaces/capability/services/capability_info.py b/shared/workspaces/workspaces/capability/services/capability_info.py
index e73b061e5..2aea8a830 100644
--- a/shared/workspaces/workspaces/capability/services/capability_info.py
+++ b/shared/workspaces/workspaces/capability/services/capability_info.py
@@ -178,8 +178,9 @@ class CapabilityInfo(CapabilityInfoIF):
         :return: new CapabilityVersion
         """
         request = self.lookup_capability_request(capability_request_id)
-        # Reset request state to Created
-        request.state = CapabilityRequestState.Created.name
+        # Reset request state accordingly
+
+        request.determine_state()
         self.save_entity(request)
         logger.info(f"Parent Request: {request.__json__()}")
 
diff --git a/shared/workspaces/workspaces/capability/services/capability_service.py b/shared/workspaces/workspaces/capability/services/capability_service.py
index 5b24a69a0..b990b3b1c 100644
--- a/shared/workspaces/workspaces/capability/services/capability_service.py
+++ b/shared/workspaces/workspaces/capability/services/capability_service.py
@@ -22,7 +22,7 @@ import transaction
 from messaging.messenger import MessageSender
 from messaging.router import Router, on_message
 
-from workspaces.capability.enums import CapabilityRequestState, CapabilityVersionState
+from workspaces.capability.enums import CapabilityVersionState
 from workspaces.capability.helpers import Parameter
 from workspaces.capability.message_architect import CapabilityMessageArchitect
 from workspaces.capability.schema import CapabilityRequest
@@ -70,17 +70,18 @@ class CapabilityService(CapabilityServiceIF):
     def complete_request(self, **message: Dict):
         logger.info(f"RECEIVED EXECUTION-COMPLETE: {message}")
 
-        # Set request state to Complete
         execution = message["subject"]
         capability_request = self.capability_info.lookup_capability_request(execution["capability_request_id"])
-        capability_request.state = CapabilityRequestState.Complete.name
-        self.capability_info.save_entity(capability_request)
-
-        # Set version state to Complete
         version = self.capability_info.lookup_version(capability_request.id, execution["version_number"])
-        version.state = CapabilityVersionState.Complete.name
+
+        # Set version state to Complete if the execution is indeed complete; else, leave it alone
+        version.state = CapabilityVersionState.Complete.name if execution["state_name"] == "Complete" else version.state
         self.capability_info.save_entity(version)
 
+        # Update request state
+        capability_request.determine_state()
+        self.capability_info.save_entity(capability_request)
+
         capability_complete_msg = CapabilityMessageArchitect(request=capability_request).compose_message(
             "capability_complete"
         )
@@ -91,18 +92,17 @@ class CapabilityService(CapabilityServiceIF):
         logger.info(f"RECEIVED EXECUTION-FAILED: {message}")
 
         execution = message["subject"]
-
-        # Set request state to Failed
-        # TODO(nhertz): Dynamically calculate request state based on the states of its versions
         capability_request = self.capability_info.lookup_capability_request(execution["capability_request_id"])
-        capability_request.state = CapabilityRequestState.Failed.name
-        self.capability_info.save_entity(capability_request)
+        version = self.capability_info.lookup_version(capability_request.id, execution["version_number"])
 
         # Set version state to Failed
-        version = self.capability_info.lookup_version(capability_request.id, execution["version_number"])
         version.state = CapabilityVersionState.Failed.name
         self.capability_info.save_entity(version)
 
+        # Update request state
+        capability_request.determine_state()
+        self.capability_info.save_entity(capability_request)
+
         capability_failed_msg = CapabilityMessageArchitect(request=capability_request).compose_message(
             "capability_failed"
         )
-- 
GitLab