Skip to content

Commit b53c351

Browse files
committed
opportunity assesment
1 parent 9ed437c commit b53c351

8 files changed

Lines changed: 478 additions & 2177 deletions

File tree

‎README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ A full-stack Migration Portal demo with a separated frontend and backend: Vue 3
44

55
---
66

7+
## Opportunity Assessment task scoping
8+
9+
The assessment page reuses `refer doc/WPM_Pulse_OA_Task_Scoping.html`, including its complete 1,670-task catalogue and both scoping methods. Keep that file in the deployed repository: Vite bundles the UI, and the backend reads its catalogue to validate immutable task taxonomy. Restart the backend and rebuild the frontend together when changing the template.
10+
11+
Apply migration `0056_opportunity_assessment_scope` before deploying the new page. `opportunity_assessment_scope` stores both method datasets, the active method and a revision per project. Saving also synchronizes the active method into `opportunity_assessment`; the other method remains in the project snapshot to avoid double counting in downstream consumers. New row fields, including country, migration decision and exception, are retained in `scope_details`. Existing task rows and their legacy details are retained on first load.
12+
13+
Save and Reload use the authenticated project `opportunity-assessment/task-scope/` API, not browser local storage. Access remains restricted to the intake requestor and Super Admin. Stale revisions are rejected instead of silently overwriting another edit. CSV import/export uses the supplied template format and locks uploads to the project's migration request ID.
14+
15+
FTE follows the supplied template: `volume * monthly frequency * minutes per unit / 60 / 140`. The 140 productive hours per month is the template's current assumption; confirm it before adopting a different organization-wide FTE standard.
16+
717
## Prerequisites (Windows)
818

919
This guide targets **Windows** with **Command Prompt (CMD)**. Install the following before you start:
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from django.db import migrations, models
2+
import django.db.models.deletion
3+
4+
5+
class Migration(migrations.Migration):
6+
dependencies = [("api", "0055_business_impact_analysis")]
7+
8+
operations = [
9+
migrations.AddField(
10+
model_name="opportunityassessment", name="scope_details",
11+
field=models.JSONField(blank=True, default=dict),
12+
),
13+
migrations.CreateModel(
14+
name="OpportunityAssessmentScope",
15+
fields=[
16+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
17+
("state", models.JSONField(default=dict)),
18+
("revision", models.PositiveIntegerField(default=0)),
19+
("updated_by", models.CharField(blank=True, default="", max_length=254)),
20+
("updated_at", models.DateTimeField(auto_now=True)),
21+
("project", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="task_scope", to="api.migrationintakesubmission")),
22+
],
23+
options={"db_table": "opportunity_assessment_scope"},
24+
),
25+
]

‎backend/api/models.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ class OpportunityAssessment(models.Model):
371371
task_found_in_service_catalog = models.CharField(max_length=255, blank=True, default="")
372372
migratable_to_gsc = models.CharField(max_length=255, blank=True, default="")
373373
fte_calculation = models.CharField(max_length=64, blank=True, default="")
374+
scope_details = models.JSONField(default=dict, blank=True)
374375
created_at = models.DateTimeField(auto_now_add=True)
375376
updated_at = models.DateTimeField(auto_now=True)
376377

@@ -385,6 +386,17 @@ def __str__(self):
385386
return f"{self.migration_request_id} — {self.task_name or self.l4 or self.id}"
386387

387388

389+
class OpportunityAssessmentScope(models.Model):
390+
project = models.OneToOneField(MigrationIntakeSubmission, on_delete=models.CASCADE, related_name="task_scope")
391+
state = models.JSONField(default=dict)
392+
revision = models.PositiveIntegerField(default=0)
393+
updated_by = models.CharField(max_length=254, blank=True, default="")
394+
updated_at = models.DateTimeField(auto_now=True)
395+
396+
class Meta:
397+
db_table = "opportunity_assessment_scope"
398+
399+
388400
class ApprovalInput(models.Model):
389401
"""Activity Function / Product approval stakeholder email mapping."""
390402

‎backend/api/routes/migration_dashboard.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from django.urls import path
2+
from api.views.opportunity_task_scope import opportunity_task_scope
23
from api.views.business_impact_analysis import business_impact_analysis
34
from api.views.project_artefacts import project_artefact, resource_projects
45
from api.views.business_case import (
@@ -42,6 +43,7 @@
4243
)
4344

4445
urlpatterns = [
46+
path("projects/<int:project_id>/opportunity-assessment/task-scope/", opportunity_task_scope, name="opportunity-task-scope"),
4547
path("projects/<int:project_id>/business-impact-analysis/", business_impact_analysis, name="business-impact-analysis"),
4648
path("resources/", resource_projects, name="resource-projects"),
4749
path("projects/<int:project_id>/resources/", project_artefact, {"kind": "resources"}, name="project-resources"),
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
from django.test import TestCase
2+
from rest_framework.test import APIClient
3+
4+
from api.models import MigrationIntakeSubmission, OpportunityAssessment, OpportunityAssessmentScope
5+
from api.views.opportunity_task_scope import _catalogue
6+
7+
8+
class OpportunityTaskScopeTests(TestCase):
9+
def setUp(self):
10+
self.project = MigrationIntakeSubmission.objects.create(
11+
migration_request_id="OA-SCOPE-TEST", requested_date="2026-11-02", requestor="owner@example.com",
12+
project_name="Scope test", migration_type="1:1 Transfer", migration_type_value="1:1-transfer",
13+
region="APA", areas=["SEA"], countries=["Singapore", "Malaysia"],
14+
function_name="CX", products=["LCL"], proposed_scope="Test", fte_number="1",
15+
)
16+
self.client = APIClient()
17+
self.client.credentials(HTTP_X_USER_EMAIL=self.project.requestor)
18+
self.url = f"/api/migration-dashboard/projects/{self.project.pk}/opportunity-assessment/task-scope/"
19+
20+
def row(self, ref=0, row_id="t1"):
21+
item = _catalogue()[ref]
22+
return {"id": row_id, "ref": ref, "ctry": "Singapore; Malaysia", "loc": "Manila",
23+
"p": item[0], "l1": item[1], "l2": item[2], "l3": item[3], "l4": item[4],
24+
"task": "Task", "desc": "Details", "cx": "High", "sop": "Y", "freq": "Daily",
25+
"uom": "Cases", "vol": "400", "tpu": "2", "mgsc": "Yes", "exc": "Language Barrier"}
26+
27+
def save(self, state, revision=0, **extra):
28+
return self.client.post(self.url, {"migration_request_id": self.project.migration_request_id,
29+
"state": state, "revision": revision, **extra}, format="json")
30+
31+
def test_round_trip_both_modes_and_sync_active_rows(self):
32+
state = {"cat": [self.row(0, "c0")], "task": [self.row(1)], "seq": 2, "mode": "task"}
33+
response = self.save(state)
34+
self.assertEqual(response.status_code, 200, response.data)
35+
row = OpportunityAssessment.objects.get()
36+
self.assertEqual(row.fte_calculation, "2.0000")
37+
self.assertEqual(row.scope_details["exc"], "Language Barrier")
38+
self.assertEqual(row.scope_details["ctry"], "Singapore; Malaysia")
39+
loaded = self.client.get(self.url).data
40+
self.assertEqual(loaded["state"]["task"][0]["desc"], "Details")
41+
self.assertEqual(len(loaded["state"]["cat"]), 1)
42+
loaded["state"]["mode"] = "cat"
43+
response = self.save(loaded["state"], revision=1)
44+
self.assertEqual(response.status_code, 200, response.data)
45+
self.assertEqual(OpportunityAssessment.objects.count(), 1)
46+
self.assertEqual(OpportunityAssessment.objects.get().scope_details["method"], "cat")
47+
self.assertEqual(len(response.data["state"]["task"]), 1)
48+
49+
def test_invalid_upload_is_atomic_and_wrong_project_is_rejected(self):
50+
state = {"cat": None, "task": [self.row()], "seq": 2, "mode": "task"}
51+
response = self.save(state, migration_request_id="ANOTHER-PROJECT")
52+
self.assertEqual(response.status_code, 400)
53+
state["task"][0]["vol"] = "NaN"
54+
self.assertEqual(self.save(state).status_code, 400)
55+
self.assertFalse(OpportunityAssessmentScope.objects.exists())
56+
self.assertFalse(OpportunityAssessment.objects.exists())
57+
58+
def test_complete_catalogue_can_be_saved_without_truncation(self):
59+
rows = [self.row(index, f"c{index}") for index in range(len(_catalogue()))]
60+
state = {"cat": rows, "task": [], "seq": 1, "mode": "cat"}
61+
response = self.save(state)
62+
self.assertEqual(response.status_code, 200, response.data)
63+
self.assertEqual(OpportunityAssessment.objects.count(), len(_catalogue()))
64+
self.assertEqual(self.save(response.data["state"], revision=1).status_code, 200)
65+
self.assertEqual(OpportunityAssessment.objects.count(), len(_catalogue()))
66+
67+
def test_intake_product_alias_preserves_display_name(self):
68+
self.project.products = ["General LCL"]
69+
self.project.save()
70+
intake = self.client.get(self.url).data["intake"]
71+
self.assertEqual(intake["product"], "General LCL")
72+
self.assertEqual(intake["catalogue_products"], ["LCL"])
73+
74+
def test_country_and_duplicate_validation(self):
75+
state = {"cat": None, "task": [self.row()], "seq": 2, "mode": "task"}
76+
state["task"][0]["ctry"] = "Denmark"
77+
self.assertEqual(self.save(state).status_code, 400)
78+
state["task"] = [self.row(), self.row()]
79+
self.assertEqual(self.save(state).status_code, 400)
80+
81+
def test_stale_save_cannot_overwrite_and_delete_is_persisted(self):
82+
state = {"cat": None, "task": [self.row()], "seq": 2, "mode": "task"}
83+
self.assertEqual(self.save(state).status_code, 200)
84+
state["task"] = []
85+
self.assertEqual(self.save(state).status_code, 409)
86+
self.assertEqual(OpportunityAssessment.objects.count(), 1)
87+
self.assertEqual(self.save(state, revision=1).status_code, 200)
88+
self.assertEqual(OpportunityAssessment.objects.count(), 0)
89+
self.assertEqual(self.client.get(self.url).data["state"]["task"], [])
90+
91+
def test_legacy_rows_are_preserved_on_first_load(self):
92+
OpportunityAssessment.objects.create(migration_request_id=self.project.migration_request_id,
93+
product="Legacy", task_name="Existing task", l3="Legacy L3", upstream="Original upstream", sop_iop_exists="Yes")
94+
response = self.client.get(self.url)
95+
self.assertEqual(response.data["state"]["mode"], "task")
96+
self.assertEqual(response.data["state"]["task"][0]["sop"], "Y")
97+
state = response.data["state"]
98+
state["task"][0]["exc"] = "Legal Issues"
99+
self.assertEqual(self.save(state).status_code, 200)
100+
self.assertEqual(OpportunityAssessment.objects.get().task_name, "Existing task")
101+
state["cat"] = [self.row(0, "c0")]
102+
state["mode"] = "cat"
103+
self.assertEqual(self.save(state, revision=1).status_code, 200)
104+
state["mode"] = "task"
105+
self.assertEqual(self.save(state, revision=2).status_code, 200)
106+
self.assertEqual(OpportunityAssessment.objects.get().upstream, "Original upstream")
107+
108+
def test_access_control_and_project_isolation(self):
109+
self.client.credentials(HTTP_X_USER_EMAIL="other@example.com")
110+
self.assertEqual(self.client.get(self.url).status_code, 403)
111+
self.assertEqual(self.save({}).status_code, 403)
112+
self.client.credentials()
113+
self.assertEqual(self.client.get(self.url).status_code, 401)
114+
self.client.credentials(HTTP_X_USER_EMAIL=self.project.requestor)
115+
OpportunityAssessment.objects.create(migration_request_id="OTHER", task_name="Other task")
116+
self.assertEqual(self.client.get(self.url).data["state"]["task"], [])

‎backend/api/views/opportunity_assessment.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
"migratable_to_gsc": "Migratable to GSC as per service catalogue",
6161
"fte_calculation": "FTE Calculation",
6262
}
63-
OA_SKIP_FIELDS = frozenset({"id", "created_at", "updated_at"})
63+
OA_SKIP_FIELDS = frozenset({"id", "created_at", "updated_at", "scope_details"})
6464

6565

6666
def _all_fields():

0 commit comments

Comments
 (0)