Skip to content

Commit 193dcf0

Browse files
committed
2 parents d38df76 + 14b1c26 commit 193dcf0

17 files changed

Lines changed: 366 additions & 94 deletions

‎README.md‎

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,42 @@ python manage.py loaddata scripts\api_data_dump.json
102102
> Production does not use SQLite at all; the **AE_WPM** service runs against
103103
> MSSQL (see **Port policy**).
104104
>
105-
> On the server (MSSQL), after pulling code that includes new migration files,
106-
> run:
107-
>
108-
> ```cmd
109-
> cd /d "c:\fcous\AE WPM Demo\backend"
110-
> python manage.py migrate --database=default
111-
> ```
105+
### 1c. Sync the pulled changes to SQL Server
106+
107+
Production does not use `backend/db.sqlite3`. After pulling a revision that
108+
contains new Django migration files, run the migration against SQL Server from
109+
an elevated PowerShell session on the deployment server. The production
110+
service uses the repository virtual environment at `E:\AE_WPM_Demo\.venv`.
111+
112+
```powershell
113+
Set-Location E:\AE_WPM_Demo\backend
114+
115+
# Confirm the database connection and see what will be applied.
116+
& E:\AE_WPM_Demo\.venv\Scripts\python.exe manage.py showmigrations api
117+
& E:\AE_WPM_Demo\.venv\Scripts\python.exe manage.py migrate api --plan
118+
119+
# Apply pending migrations to the configured SQL Server database.
120+
& E:\AE_WPM_Demo\.venv\Scripts\python.exe manage.py migrate api --verbosity 1
121+
122+
# Verify the application and confirm that no migrations remain pending.
123+
& E:\AE_WPM_Demo\.venv\Scripts\python.exe manage.py check
124+
& E:\AE_WPM_Demo\.venv\Scripts\python.exe manage.py showmigrations api
125+
& E:\AE_WPM_Demo\.venv\Scripts\python.exe manage.py migrate api --plan
126+
```
127+
128+
The SQL Server target is configured through the server `.env` file and/or
129+
service environment variables, including `DJANGO_DB_NAME`, `DJANGO_DB_HOST`,
130+
`DJANGO_DB_DRIVER`, and `DJANGO_DB_EXTRA_PARAMS`. Do not replace these with
131+
the local SQLite database or commit secrets to the repository. The account
132+
running the command must have the same SQL Server permissions as the `AE_WPM`
133+
service, which normally uses Windows Trusted Connection.
134+
135+
If the migration command reports a historical migration-record or schema
136+
inconsistency, inspect the database before making manual changes. The
137+
repository helper [`repair_and_migrate_mssql.py`](backend/scripts/repair_and_migrate_mssql.py)
138+
is for that specific recovery case; it is not the normal release command.
139+
Never delete migration records or alter production data to force a migration
140+
without an approved database backup and DBA-reviewed recovery plan.
112141

113142
### 2. Frontend — install dependencies
114143

‎backend/api/services/project_artefacts.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
{"id": "mfa", "label": "MFA token / authenticator"},
2323
]
2424
BARRIER_TYPES = ["Licensing", "Permissions", "Hardware", "Network", "Security", "Other"]
25-
HIRING_TYPES = ["External", "IJP", "Efficiency"]
25+
HIRING_TYPES = ["Efficiency", "Backfill", "External", "IJP"]
2626
HIRING_STATUSES = ["Pending", "In Progress", "At Risk", "Onboarded"]
2727
LANGUAGES = ["English", "Spanish", "French", "German", "Italian", "Portuguese", "Danish", "Dutch", "Other"]
2828
EXECUTION_FIELDS = ["site", "hiringType", "language", "pidCreationDate", "gscPid", "deletedPid"]

‎backend/api/test_project_artefacts.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
from rest_framework.test import APIClient
1111

1212
from api.models import (
13-
AccessEnablement, MigrationIntakeSubmission, ProjectAttributesAccess,
13+
AccessEnablement, BusinessCase, MigrationIntakeSubmission, ProjectAttributesAccess,
1414
ProjectResources, ScopeStatement, ScopeStatementLine, StakeholderRegistration,
1515
)
16+
from api.services.business_case import build_business_case
1617
from api.services.project_artefacts import IT_ITEMS
1718

1819

@@ -157,6 +158,10 @@ def test_efficiency_does_not_require_recruitment_and_preserves_actual_doj(self):
157158
self.assertEqual(self.post("resources", "submit_area").status_code, 200)
158159
self.assertEqual(self.post("resources", "submit").status_code, 200)
159160

161+
def test_backfill_is_a_valid_hiring_type(self):
162+
response = self.post("resources", rows=[{**self.resource(), "hiringType": "Backfill"}])
163+
self.assertEqual(response.status_code, 200, response.data)
164+
160165
def test_resource_validation_is_atomic_and_scope_required(self):
161166
self.assertEqual(self.post("resources", rows=[self.resource()]).status_code, 200)
162167
for row in ({**self.resource(), "actualDoj": "bad"}, {**self.resource(), "hiringType": "invalid"},
@@ -243,10 +248,16 @@ def test_catalogue_and_quantities_are_validated(self):
243248
self.client.credentials(HTTP_X_USER_EMAIL="local.dev@localhost")
244249
self.assertEqual(self.post("access-enablement", itItems=[{"id": item["id"], "qtyConfirmed": 2} for item in IT_ITEMS]).status_code, 400)
245250

246-
def test_landing_only_shows_accessible_projects_and_never_uses_sample_capacity(self):
251+
def test_landing_only_shows_locked_business_case_capacity(self):
247252
response = self.client.get("/api/migration-dashboard/resources/")
248253
self.assertEqual(response.status_code, 200)
249254
self.assertEqual(response.data["count"], 1)
255+
self.assertIsNone(response.data["rows"][0]["pipelineFte"])
256+
self.assertEqual(response.data["rows"][0]["approvalStatus"], "Not locked")
257+
record = BusinessCase.objects.create(project=self.project, status="submitted")
258+
record.source_fingerprint = build_business_case(self.project, record)[3]
259+
record.save(update_fields=["source_fingerprint"])
260+
response = self.client.get("/api/migration-dashboard/resources/")
250261
self.assertEqual(response.data["rows"][0]["pipelineFte"], 1)
251262
self.scope.status = "draft"
252263
self.scope.save()

‎backend/api/test_scope_page.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,12 @@ def test_shared_upload_download_and_lock_all(self):
6464
self.assertTrue(all(line["lockedAt"] for line in response.data["lines"]))
6565
self.assertEqual(len({line["lockedAt"] for line in response.data["lines"]}), 1)
6666

67-
def test_lock_requires_attachment_and_all_values_without_partial_writes(self):
68-
self.assertEqual(self.save(self.payload("lock")).status_code, 400)
69-
self.assertTrue(all(line.final_fte is None for line in self.statement.lines.all()))
70-
self.upload()
67+
def test_lock_does_not_require_attachment_and_validates_all_values(self):
68+
response = self.save(self.payload("lock"))
69+
self.assertEqual(response.status_code, 200, response.data)
70+
self.assertTrue(response.data["pageLocked"])
71+
self.assertFalse(ScopeStatementEvidence.objects.exists())
72+
self.assertEqual(self.save({"action": "unlock"}).status_code, 200)
7173
payload = self.payload("lock")
7274
payload["lines"][2]["finalFte"] = None
7375
self.assertEqual(self.save(payload).status_code, 400)

‎backend/api/test_super_admin_access.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,52 @@ def test_super_admin_can_save_another_requestors_opportunity_assessment(self):
4242
self.assertEqual(response.data["created_count"], 1)
4343
self.assertEqual(response.data["error_count"], 0)
4444

45+
def test_project_dashboard_visibility_follows_access_control_permission(self):
46+
ProjectAttributesAccess.objects.create(
47+
email="access-admin@example.com", access_control=True,
48+
)
49+
ProjectAttributesAccess.objects.create(
50+
email="maintainer@example.com", migration_intake=True,
51+
)
52+
for email, expected_ids in (
53+
(self.admin, [self.project.pk]),
54+
("access-admin@example.com", [self.project.pk]),
55+
("maintainer@example.com", []),
56+
("OWNER@EXAMPLE.COM", [self.project.pk]),
57+
("other@example.com", []),
58+
):
59+
with self.subTest(email=email):
60+
self.client.credentials(HTTP_X_USER_EMAIL=email)
61+
response = self.client.get("/api/migration-dashboard/projects/", {"mine": 1})
62+
self.assertEqual(response.status_code, 200, response.data)
63+
self.assertEqual([row["id"] for row in response.data["rows"]], expected_ids)
64+
self.assertEqual(response.data["count"], len(expected_ids))
65+
self.assertEqual(response.data["summary"]["totalProjects"], len(expected_ids))
66+
self.assertEqual(
67+
response.data["scope"],
68+
"all" if email in (self.admin, "access-admin@example.com") else "mine",
69+
)
70+
71+
def test_project_dashboard_requires_sign_in_for_personal_scope(self):
72+
self.client.credentials()
73+
response = self.client.get("/api/migration-dashboard/projects/", {"mine": 1})
74+
self.assertEqual(response.status_code, 401, response.data)
75+
76+
def test_revoking_access_control_restores_personal_project_scope(self):
77+
access = ProjectAttributesAccess.objects.create(
78+
email="access-admin@example.com", access_control=True,
79+
)
80+
self.client.credentials(HTTP_X_USER_EMAIL=access.email)
81+
response = self.client.get("/api/migration-dashboard/projects/", {"mine": 1})
82+
self.assertEqual(response.status_code, 200, response.data)
83+
self.assertEqual(response.data["count"], 1)
84+
access.access_control = False
85+
access.save(update_fields=["access_control"])
86+
response = self.client.get("/api/migration-dashboard/projects/", {"mine": 1})
87+
self.assertEqual(response.status_code, 200, response.data)
88+
self.assertEqual(response.data["rows"], [])
89+
self.assertEqual(response.data["scope"], "mine")
90+
4591
def test_non_super_admin_cannot_manage_another_requestors_assessment(self):
4692
ProjectAttributesAccess.objects.create(
4793
email="maintainer@example.com", migration_intake=True, access_control=True,

‎backend/api/views/migration_dashboard.py‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Migration dashboard API views.
33
44
Frontend pages: /migration-dashboard, /migration-dashboard/:id
5-
Also used by /project-dashboard with ?mine=1 (current user's projects only).
5+
Also used by /project-dashboard with ?mine=1 (personal scope unless access administrator).
66
"""
77

88
import logging
@@ -16,7 +16,7 @@
1616
from django.utils import timezone
1717

1818
from api.models import ApprovalWorkflow, MigrationIntakeSubmission
19-
from api.permissions.attributes_access import get_request_email, normalize_email
19+
from api.permissions.attributes_access import can_access_table, get_request_email, normalize_email
2020
from api.services.migration_dashboard import (
2121
build_dashboard_summary,
2222
serialize_business_case_file,
@@ -40,25 +40,30 @@ def list_projects(request):
4040
"""GET /api/migration-dashboard/projects/ — submitted migration intake overview.
4141
4242
Query params:
43-
mine=1 — only projects whose requestor matches the signed-in SSO email
43+
mine=1 — current user's projects, or all projects for Access Control administrators
4444
"""
4545
qs = MigrationIntakeSubmission.objects.all()
46+
mine_only = _wants_mine_only(request)
4647

47-
if _wants_mine_only(request):
48+
if mine_only:
4849
email = get_request_email(request)
4950
if not email:
5051
return Response(
5152
{"error": "Sign in required. Missing SSO email."},
5253
status=status.HTTP_401_UNAUTHORIZED,
5354
)
54-
qs = qs.filter(requestor__iexact=normalize_email(email))
55+
if can_access_table(email, "access_control"):
56+
mine_only = False
57+
else:
58+
qs = qs.filter(requestor__iexact=normalize_email(email))
5559

5660
submissions = list(qs)
5761
rows = [serialize_project_overview(item) for item in submissions]
5862

5963
return Response(
6064
{
6165
"count": len(rows),
66+
"scope": "mine" if mine_only else "all",
6267
"summary": build_dashboard_summary(submissions),
6368
"rows": rows,
6469
}

‎backend/api/views/project_artefacts.py‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from api.models import AccessEnablement, ApprovalWorkflow, BusinessCase, MigrationIntakeSubmission, ProjectResources, StakeholderRegistration
1212
from api.permissions.attributes_access import build_access_snapshot, get_request_email, normalize_email
13-
from api.services.business_case import online_case_is_approved
13+
from api.services.business_case import approval_progress, build_business_case, case_status, online_case_is_approved
1414
from api.services.project_artefacts import (
1515
AREA_FIELDS, BARRIER_TYPES, EXECUTION_FIELDS, HIRING_STATUSES, HIRING_TYPES,
1616
HR_FIELDS, IT_ITEMS, LANGUAGES, SYSTEM_CATALOGUE, access_summary, date_value,
@@ -20,6 +20,17 @@
2020

2121
def _serialize(project, record, kind, email):
2222
source = scope_source(project)
23+
business_case = BusinessCase.objects.filter(project=project).first()
24+
business_case_source = {"locked": False, "status": "not_started", "population": None}
25+
if business_case:
26+
_, business_case_scope, _, fingerprint = build_business_case(project, business_case)
27+
business_case_status = case_status(business_case, fingerprint, approval_progress(project))
28+
locked = business_case_status in {"submitted", "approved"}
29+
business_case_source = {
30+
"locked": locked,
31+
"status": business_case_status,
32+
"population": sum(line["finalFte"] or 0 for line in business_case_scope["lines"]) if locked else None,
33+
}
2334
permissions = project_permissions(project, email, kind)
2435
changed = bool(record and record.source_version != source["version"])
2536
result = {
@@ -28,6 +39,7 @@ def _serialize(project, record, kind, email):
2839
"region": project.region, "areas": project.areas, "countries": project.countries,
2940
"products": project.products,
3041
"sites": (project.default_location_strategies or []) + (project.custom_location_strategies or []),
42+
"businessCase": business_case_source,
3143
"source": source, "sourceChanged": changed, "permissions": permissions,
3244
"saved": record is not None, "revision": record.revision if record else 0,
3345
"updatedAt": record.updated_at.isoformat() if record else "",
@@ -82,7 +94,7 @@ def _resource_rows(payload, record, permissions, action, email):
8294
value = row.get(field, "")
8395
normalized[field] = date_value(value, field) if field.endswith("Date") or field.endswith("Doj") else text(value, field)
8496
if normalized["hiringType"] not in HIRING_TYPES:
85-
raise ValueError("Choose External, IJP or Efficiency for each resource.")
97+
raise ValueError("Choose Efficiency, Backfill, External or IJP for each resource.")
8698
if normalized["language"] and normalized["language"] not in LANGUAGES:
8799
raise ValueError("Choose a language from the catalogue.")
88100
if normalized["hiringStatus"] not in HIRING_STATUSES + ["Not Applicable"]:
@@ -316,7 +328,7 @@ def resource_projects(request):
316328
if any(normalize_email(person.get("email")) == email for person in registration.team_members)
317329
]
318330
projects = projects.filter(Q(requestor__iexact=email) | Q(pk__in=member_projects))
319-
online = {case.project_id: case for case in BusinessCase.objects.only("project_id", "status", "review_required")}
331+
online = {case.project_id: case for case in BusinessCase.objects.select_related("project")}
320332
from api.views.approval_cycle import _progress
321333
legacy_progress = {
322334
workflow.migration_request_id: _progress(workflow)
@@ -326,24 +338,31 @@ def resource_projects(request):
326338
for project in projects.order_by("-id"):
327339
source = scope_source(project)
328340
case = online.get(project.pk)
341+
business_case = {"locked": False, "status": "not_started", "population": None}
329342
if case:
330-
approved = case.status == "submitted" and not case.review_required and bool(online_case_is_approved(project))
343+
_, business_case_scope, _, fingerprint = build_business_case(project, case)
344+
business_case_status = case_status(case, fingerprint, approval_progress(project))
345+
business_case["locked"] = business_case_status in {"submitted", "approved"}
346+
business_case["status"] = business_case_status
347+
business_case["population"] = sum(line["finalFte"] or 0 for line in business_case_scope["lines"]) if business_case["locked"] else None
348+
approved = business_case["status"] == "approved"
331349
else:
332350
progress = legacy_progress.get(project.migration_request_id)
333351
approved = bool(project.business_case_submission_date and progress and progress["step"] > progress["seqLen"] and progress["completed"])
334352
try:
335353
resource = project.resources
336354
except ProjectResources.DoesNotExist:
337355
resource = None
338-
population = source["population"]
356+
population = business_case["population"]
339357
rows.append({
340358
"projectId": project.pk, "projectName": project.project_name,
341359
"migrationRequestId": project.migration_request_id, "products": project.products,
342360
"region": project.region, "areas": project.areas,
343361
"sites": (project.default_location_strategies or []) + (project.custom_location_strategies or []),
344362
"approvedFte": population if approved else None,
345-
"pipelineFte": population if not approved else None,
346-
"approvalStatus": "Approved" if approved else "Pipeline",
363+
"pipelineFte": population if business_case["locked"] and not approved else None,
364+
"approvalStatus": "Approved" if approved else "Pipeline" if business_case["locked"] else "Not locked",
365+
"businessCase": business_case,
347366
"saved": bool(resource),
348367
"status": ("confirmed" if resource.source_version == source["version"] else "needs_review")
349368
if resource and resource.status == "confirmed" else "draft" if resource else "not_started",

‎backend/api/views/scope_statement.py‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ def _serialize_statement(project, statement, email):
167167
}
168168
for level in JOB_LEVELS
169169
for field, label in (
170-
("agreed_fte", "Agreed FTE"),
170+
("agreed_fte", "Migratable FTE (suggested/manual)"),
171171
("not_agreed_fte", "Not Agreed FTE"),
172172
("final_fte", "Final FTE"),
173173
("locked_at", "Locked At"),
@@ -526,8 +526,6 @@ def save_scope_statement(request, project_id):
526526
if action == "lock":
527527
if statement.migratable_fte is None or not statement.migratable_fte_confirmed:
528528
return Response({"error": "Manually confirm Migratable FTE before locking the page."}, status=400)
529-
if not statement.attachments.exists():
530-
return Response({"error": "Upload an attachment before locking the page."}, status=400)
531529
if len(all_lines) != 3 or not statement.scope_text.strip() or any(
532530
line.agreed_fte is None or line.not_agreed_fte is None or line.final_fte is None
533531
for line in all_lines
@@ -546,8 +544,8 @@ def save_scope_statement(request, project_id):
546544
return Response({"error": "Unlock the page and manually confirm Migratable FTE before submitting."}, status=400)
547545
if not statement.scope_text.strip():
548546
return Response({"error": "High-level scope statement is required before submitting."}, status=status.HTTP_400_BAD_REQUEST)
549-
if len(all_lines) != 3 or any(line.locked_at is None for line in all_lines) or not statement.attachments.exists():
550-
return Response({"error": "Lock the whole page with an attachment before submitting."}, status=status.HTTP_400_BAD_REQUEST)
547+
if len(all_lines) != 3 or any(line.locked_at is None for line in all_lines):
548+
return Response({"error": "Lock the whole page before submitting."}, status=status.HTTP_400_BAD_REQUEST)
551549
statement.ambition_fte = Decimal(str(project.fte_number or 0))
552550
statement.final_project_fte = sum(int(line.final_fte or 0) for line in all_lines)
553551
if statement.waves and sum(wave["fte"] for wave in statement.waves) != statement.final_project_fte:

0 commit comments

Comments
 (0)