From 88d490959a3a3d408aa66524f6a4c45c214fb7ad Mon Sep 17 00:00:00 2001 From: dinesh Date: Mon, 2 Feb 2026 16:05:43 -0800 Subject: [PATCH 1/2] demo env setup related changes --- .../submit-api/templates/demo-db-secret.yaml | 14 ++++++++ .../submit-api/templates/deployment.yaml | 36 +++++++++++++++++++ .../charts/submit-api/templates/secret.yaml | 2 +- deployment/charts/submit-api/values.yaml | 6 ++++ .../versions/fb95dbfcb9d9_add_new_status.py | 17 +++++++-- 5 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 deployment/charts/submit-api/templates/demo-db-secret.yaml diff --git a/deployment/charts/submit-api/templates/demo-db-secret.yaml b/deployment/charts/submit-api/templates/demo-db-secret.yaml new file mode 100644 index 000000000..5d4992a60 --- /dev/null +++ b/deployment/charts/submit-api/templates/demo-db-secret.yaml @@ -0,0 +1,14 @@ +{{- if .Values.database.demo.enabled }} +apiVersion: v1 +kind: Secret +metadata: + labels: + app: {{ .Release.Name }} + name: {{ .Release.Name }}-{{ .Values.database.demo.suffix }} + name: {{ .Values.database.secret }}-{{ .Values.database.demo.suffix }} +type: Opaque +stringData: + app-db-username: {{ .Values.database.demo.username | quote }} + app-db-password: {{ .Values.database.demo.password | quote }} + app-db-name: {{ .Values.database.demo.name | quote }} +{{- end }} diff --git a/deployment/charts/submit-api/templates/deployment.yaml b/deployment/charts/submit-api/templates/deployment.yaml index d7a11d8ac..60f6ed92d 100644 --- a/deployment/charts/submit-api/templates/deployment.yaml +++ b/deployment/charts/submit-api/templates/deployment.yaml @@ -25,6 +25,23 @@ spec: command: - /opt/app-root/pre-hook-update-db.sh env: + {{- if .Values.database.demo.enabled }} + - name: DATABASE_USERNAME + valueFrom: + secretKeyRef: + name: {{ .Values.database.secret }}-{{ .Values.database.demo.suffix }} + key: app-db-username + - name: DATABASE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.database.secret }}-{{ .Values.database.demo.suffix }} + key: app-db-password + - name: DATABASE_NAME + valueFrom: + secretKeyRef: + name: {{ .Values.database.secret }}-{{ .Values.database.demo.suffix }} + key: app-db-name + {{- else }} - name: DATABASE_USERNAME valueFrom: secretKeyRef: @@ -40,6 +57,7 @@ spec: secretKeyRef: name: {{ .Values.database.secret }} key: app-db-name + {{- end }} - name: DATABASE_HOST value: {{ .Values.database.service.name }} - name: DATABASE_PORT @@ -52,6 +70,23 @@ spec: - containerPort: 8080 protocol: TCP env: + {{- if .Values.database.demo.enabled }} + - name: DATABASE_USERNAME + valueFrom: + secretKeyRef: + name: {{ .Values.database.secret }}-{{ .Values.database.demo.suffix }} + key: app-db-username + - name: DATABASE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.database.secret }}-{{ .Values.database.demo.suffix }} + key: app-db-password + - name: DATABASE_NAME + valueFrom: + secretKeyRef: + name: {{ .Values.database.secret }}-{{ .Values.database.demo.suffix }} + key: app-db-name + {{- else }} - name: DATABASE_USERNAME valueFrom: secretKeyRef: @@ -67,6 +102,7 @@ spec: secretKeyRef: name: {{ .Values.database.secret }} key: app-db-name + {{- end }} - name: DATABASE_HOST value: {{ .Values.database.service.name }} - name: DATABASE_PORT diff --git a/deployment/charts/submit-api/templates/secret.yaml b/deployment/charts/submit-api/templates/secret.yaml index cef14ea84..ca3737c9b 100644 --- a/deployment/charts/submit-api/templates/secret.yaml +++ b/deployment/charts/submit-api/templates/secret.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: Secret metadata: labels: - app: {{ .Values.app_group }} + app: {{ .Release.Name }} name: {{ .Release.Name }} name: {{ .Release.Name }} stringData: diff --git a/deployment/charts/submit-api/values.yaml b/deployment/charts/submit-api/values.yaml index 13df662af..f2df987f1 100644 --- a/deployment/charts/submit-api/values.yaml +++ b/deployment/charts/submit-api/values.yaml @@ -19,6 +19,12 @@ database: service: name: submit-patroni port: 5432 + demo: + enabled: false + suffix: demo + username: "" + password: "" + name: "" service: type: ClusterIP diff --git a/submit-api/migrations/versions/fb95dbfcb9d9_add_new_status.py b/submit-api/migrations/versions/fb95dbfcb9d9_add_new_status.py index 169733936..a78db92cc 100644 --- a/submit-api/migrations/versions/fb95dbfcb9d9_add_new_status.py +++ b/submit-api/migrations/versions/fb95dbfcb9d9_add_new_status.py @@ -22,7 +22,13 @@ def upgrade(): DECLARE itemstatus_values text[]; packagestatus_values text[]; + items_count integer; + packages_count integer; BEGIN + -- Check if tables have data + SELECT COUNT(*) INTO items_count FROM items; + SELECT COUNT(*) INTO packages_count FROM packages; + -- Get existing values SELECT array_agg(quote_literal(enumlabel)) INTO itemstatus_values FROM pg_enum WHERE enumtypid = 'itemstatus'::regtype; SELECT array_agg(quote_literal(enumlabel)) INTO packagestatus_values FROM pg_enum WHERE enumtypid = 'packagestatus'::regtype; @@ -35,9 +41,14 @@ def upgrade(): ALTER TABLE items ALTER COLUMN status TYPE itemstatus_new USING status::text::itemstatus_new; ALTER TABLE packages ALTER COLUMN status TYPE packagestatus_new[] USING status::text[]::packagestatus_new[]; - -- Update data - UPDATE items SET status = 'NEW' WHERE status = 'NEW_SUBMISSION'; - UPDATE packages SET status = array_replace(status, 'NEW_SUBMISSION', 'NEW'); + -- Update data only if tables have rows + IF items_count > 0 THEN + UPDATE items SET status = 'NEW' WHERE status = 'NEW_SUBMISSION'; + END IF; + + IF packages_count > 0 THEN + UPDATE packages SET status = array_replace(status, 'NEW_SUBMISSION', 'NEW'); + END IF; -- Drop old types and rename new ones DROP TYPE itemstatus; From 20064cd9619ddeb49930aa6686056fef7eacf5e5 Mon Sep 17 00:00:00 2001 From: dinesh Date: Thu, 26 Feb 2026 18:40:50 -0800 Subject: [PATCH 2/2] changes to track_works and phases table --- submit-api/README.md | 276 +++++++++++++++++- ...2d30_adding_display_name_for_phase_name.py | 66 +++++ submit-api/src/submit_api/enums/ea_act.py | 33 +++ submit-api/src/submit_api/enums/work_type.py | 71 +++++ submit-api/src/submit_api/models/package.py | 6 + .../src/submit_api/models/package_type.py | 2 - .../src/submit_api/models/track_phase.py | 37 +++ .../src/submit_api/resources/__init__.py | 2 + .../resources/staff/package_type.py | 74 +++++ .../submit_api/schemas/package_type_create.py | 87 ++++++ .../schemas/package_type_response.py | 76 +++++ .../src/submit_api/services/package_type.py | 178 ++++++++++- 12 files changed, 902 insertions(+), 6 deletions(-) create mode 100644 submit-api/migrations/versions/f1d8ab8c2d30_adding_display_name_for_phase_name.py create mode 100644 submit-api/src/submit_api/enums/ea_act.py create mode 100644 submit-api/src/submit_api/enums/work_type.py create mode 100644 submit-api/src/submit_api/resources/staff/package_type.py create mode 100644 submit-api/src/submit_api/schemas/package_type_create.py create mode 100644 submit-api/src/submit_api/schemas/package_type_response.py diff --git a/submit-api/README.md b/submit-api/README.md index 86a6efbfe..b46e7ff3a 100644 --- a/submit-api/README.md +++ b/submit-api/README.md @@ -47,4 +47,278 @@ Open [http://localhost:5000/api](http://localhost:5000/api) to view it in the br Ensure the latest version of [VS Code](https://code.visualstudio.com) is installed. -The [`launch.json`](.vscode/launch.json) is already configured with a launch task (SUBMIT-API Launch) that allows you to launch chrome in a debugging capacity and debug through code within the editor. \ No newline at end of file +The [`launch.json`](.vscode/launch.json) is already configured with a launch task (SUBMIT-API Launch) that allows you to launch chrome in a debugging capacity and debug through code within the editor. + +## Documentation + +- [Database Design: Works Integration](../docs/database-design-works-integration.md) - Database schema for integrating Works, Phases, and work-specific packages from EPIC.track + +## API Endpoints + +### Staff API: Package Type Management + +#### Create or Update Package Type + +**Endpoint:** `POST /api/staff/package-types` + +**Description:** Creates or updates a package type with phase association. This endpoint is **idempotent** - it will create a new package type if it doesn't exist, or update the existing one if it does. + +**Authentication:** Staff users only + +**How it Works:** +1. The endpoint identifies the phase using three parameters: + - `ea_act_name`: Environmental Assessment Act name + - `work_type_name`: Work Type name + - `phase_name`: Phase name (matches either `display_name` or `name` in the database) +2. Once the phase is found, it creates or updates a package type and associates it with the specified item types +3. Item types are associated in the order provided, with `sort_order` automatically assigned + +**Request Body:** +```json +{ + "ea_act_name": "EA Act (2018)", + "work_type_name": "Assessment", + "phase_name": "Early Engagement", + "package_type_name": "IPD", + "item_types": [ + {"id": 1}, + {"id": 2}, + {"name": "Custom Document", "submission_method": "DOCUMENT_UPLOAD"} + ] +} +``` + +**Request Parameters:** + +| Parameter | Type | Required | Description | Example | +|-----------|------|----------|-------------|---------| +| `ea_act_name` | string | Yes | Environmental Assessment Act name | `"EA Act (2018)"` | +| `work_type_name` | string | Yes | Work type name from EPIC.track | `"Assessment"` | +| `phase_name` | string | Yes | Phase name (display name or actual name) | `"Early Engagement"` | +| `package_type_name` | string | Yes | Name for the package type to create/update | `"IPD"` | +| `item_types` | array[object] | Yes | List of item types (existing IDs or new definitions) | See below | + +**Valid EA Act Names:** +- `"EA Act (1996)"` (ID: 1) +- `"EA Act (2002)"` (ID: 2) +- `"EA Act (2018)"` (ID: 3) +- `"Other"` (ID: 4) + +**Valid Work Type Names:** +- `"Project Notification"` (ID: 1) +- `"Minister's Designation"` (ID: 2) +- `"CEAO's Designation"` (ID: 3) +- `"Intake (Pre-EA)"` (ID: 4) +- `"Exemption Order"` (ID: 5) +- `"Assessment"` (ID: 6) +- `"Amendment"` (ID: 7) +- `"Post-EAC Document Review"` (ID: 8) +- `"EAC Extension"` (ID: 9) +- `"Substantial Start Determination"` (ID: 10) +- `"EAC/Order Transfer"` (ID: 11) +- `"EAC/Order Suspension"` (ID: 12) +- `"EAC/Order Cancellation"` (ID: 13) +- `"Other"` (ID: 14) +- `"Material Alteration"` (ID: 15) + +**Item Types Format:** + +Each item in the `item_types` array can be specified in two ways: + +1. **Existing Item Type (by ID):** +```json +{"id": 1} +``` + +2. **New Item Type (by name and submission method):** +```json +{ + "name": "Custom Document Type", + "submission_method": "DOCUMENT_UPLOAD" // or "FORM_SUBMISSION" +} +``` + +**Valid Submission Methods:** +- `"DOCUMENT_UPLOAD"` - Item requires document upload +- `"FORM_SUBMISSION"` - Item uses form-based submission + +**Example Phase Names (for Assessment work type):** +- `"Pre-EA (EAC Assessment)"` +- `"Early Engagement"` +- `"DPD Development (Proponent Time)"` +- `"Process Planning"` +- `"EAC Application Development (Proponent Time)"` +- `"EAC Application Review"` +- `"Effects Assessment & Recommendation"` +- `"EAC Decision"` + +**Success Response (200 OK):** +```json +{ + "id": 5, + "name": "IPD", + "phase_id": 10, + "phase_name": "Early Engagement", + "ea_act_name": "EA Act (2018)", + "work_type_name": "Assessment", + "item_type_ids": [1, 2, 5], + "created_item_types": [ + { + "id": 5, + "name": "Custom Document", + "submission_method": "DOCUMENT_UPLOAD" + } + ], + "created": true +} +``` + +**Response Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | integer | Package type ID (database primary key) | +| `name` | string | Package type name | +| `phase_id` | integer | Associated phase ID from `track_phases` table | +| `phase_name` | string | Display name of the phase | +| `ea_act_name` | string | Environmental Assessment Act name | +| `work_type_name` | string | Work type name | +| `item_type_ids` | array[int] | List of all associated item type IDs | +| `created_item_types` | array[object] | List of newly created item types (empty if all existed) | +| `created` | boolean | `true` if package type newly created, `false` if updated | + +**Error Responses:** + +**400 Bad Request** - Validation error +```json +{ + "message": "Validation error", + "errors": { + "item_types": ["item_types must contain at least one item type"] + } +} +``` + +Or for invalid item type definition: +```json +{ + "message": "Validation error", + "errors": { + "item_types": { + "0": { + "_schema": ["Must provide either id OR both name and submission_method"] + } + } + } +} +``` + +**404 Not Found** - Phase or item types not found +```json +{ + "message": "Phase not found for EA Act: 'EA Act (2018)', Work Type: 'Assessment', Phase: 'Invalid Phase'" +} +``` + +Or for invalid item type ID: +```json +{ + "message": "An error occurred while creating/updating the package type", + "error": "Item type with ID 999 not found" +} +``` + +**500 Internal Server Error** - Server error +```json +{ + "message": "An error occurred while creating/updating the package type", + "error": "Error details..." +} +``` + +**Usage Examples:** + +**Example 1: Create a new package type using existing item types** +```bash +curl -X POST http://localhost:5000/api/staff/package-types \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{ + "ea_act_name": "EA Act (2018)", + "work_type_name": "Assessment", + "phase_name": "Early Engagement", + "package_type_name": "IPD", + "item_types": [{"id": 1}, {"id": 2}, {"id": 3}] + }' +``` + +**Example 2: Create package type with mixed item types (existing + new)** +```bash +curl -X POST http://localhost:5000/api/staff/package-types \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{ + "ea_act_name": "EA Act (2018)", + "work_type_name": "Assessment", + "phase_name": "Early Engagement", + "package_type_name": "IPD", + "item_types": [ + {"id": 1}, + {"id": 2}, + {"name": "Environmental Impact Assessment", "submission_method": "DOCUMENT_UPLOAD"} + ] + }' +``` + +**Example 3: Create IPD package type for Early Engagement (real-world example)** +```bash +curl -X POST http://localhost:5000/api/staff/package-types \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{ + "ea_act_name": "EA Act (2018)", + "work_type_name": "Assessment", + "phase_name": "Early Engagement", + "package_type_name": "IPD", + "item_types": [ + { + "name": "Initial Project Description", + "submission_method": "DOCUMENT_UPLOAD" + }, + { + "name": "Engagement Plan", + "submission_method": "DOCUMENT_UPLOAD" + }, + { + "name": "Geospatial Information", + "submission_method": "DOCUMENT_UPLOAD" + }, + { + "name": "Early Engagement Proponent Checklist", + "submission_method": "FORM_SUBMISSION" + } + ] + }' +``` + +**Important Notes:** + +1. **Idempotency**: Running the same request multiple times will update the existing package type rather than creating duplicates +2. **Phase Matching**: The phase is matched by `ea_act_name`, `work_type_name`, and `phase_name`. The `phase_name` can match either the `display_name` or `name` field in the database +3. **Item Type Creation**: New item types are created automatically if they don't exist. If an item type with the same name already exists, the existing one will be reused +4. **Item Type Order**: Item types are associated in the order provided in the `item_types` array, with `sort_order` starting from 1 +5. **Existing Associations**: When updating, all existing item type associations are removed and replaced with the new ones +6. **Phase Data**: Phase data must exist in the `track_phases` table before creating package types. See the [Database Design documentation](../docs/database-design-works-integration.md) for phase data structure + +**Related Enums:** + +The following enums are available in the codebase for reference: + +- `EAActName` (`src/submit_api/enums/ea_act.py`) - Environmental Assessment Act names +- `WorkTypeName` (`src/submit_api/enums/work_type.py`) - Work type names + +**Swagger Documentation:** + +Interactive API documentation is available at: +- Development: `http://localhost:5000/api/staff/` +- The Swagger UI provides a complete list of available phases and item types \ No newline at end of file diff --git a/submit-api/migrations/versions/f1d8ab8c2d30_adding_display_name_for_phase_name.py b/submit-api/migrations/versions/f1d8ab8c2d30_adding_display_name_for_phase_name.py new file mode 100644 index 000000000..2bdc18999 --- /dev/null +++ b/submit-api/migrations/versions/f1d8ab8c2d30_adding_display_name_for_phase_name.py @@ -0,0 +1,66 @@ +"""adding display_name for phase name + +Revision ID: f1d8ab8c2d30 +Revises: e27054af162b +Create Date: 2026-02-26 15:12:55.957537 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f1d8ab8c2d30' +down_revision = 'e27054af162b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # Drop unnecessary indexes from small tables + with op.batch_alter_table('package_types', schema=None) as batch_op: + batch_op.drop_index('idx_package_types_name') + batch_op.drop_index('idx_package_types_phase_id') + + # Drop old index and create optimized indexes for packages table + with op.batch_alter_table('packages', schema=None) as batch_op: + batch_op.drop_index('idx_packages_account_project_work_id') + + # Create new indexes for packages + op.create_index('idx_packages_account_project_id', 'packages', ['account_project_id']) + op.create_index( + 'idx_packages_account_project_work_id', + 'packages', + ['account_project_work_id'], + postgresql_where=sa.text('account_project_work_id IS NOT NULL') + ) + + # Add display_name and ea_act_name columns to track_phases + with op.batch_alter_table('track_phases', schema=None) as batch_op: + batch_op.add_column(sa.Column('display_name', sa.String(length=255), nullable=True, comment='Submit-specific phase name override; defaults to name field if not set')) + batch_op.add_column(sa.Column('ea_act_name', sa.String(length=255), nullable=True, comment='Environmental Assessment Act name')) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # Drop display_name and ea_act_name columns from track_phases + with op.batch_alter_table('track_phases', schema=None) as batch_op: + batch_op.drop_column('ea_act_name') + batch_op.drop_column('display_name') + + # Drop new optimized indexes + op.drop_index('idx_packages_account_project_work_id', table_name='packages') + op.drop_index('idx_packages_account_project_id', table_name='packages') + + # Restore old indexes + with op.batch_alter_table('packages', schema=None) as batch_op: + batch_op.create_index('idx_packages_account_project_work_id', ['account_project_work_id'], unique=False) + + with op.batch_alter_table('package_types', schema=None) as batch_op: + batch_op.create_index('idx_package_types_phase_id', ['phase_id'], unique=False) + batch_op.create_index('idx_package_types_name', ['name'], unique=False) + + # ### end Alembic commands ### diff --git a/submit-api/src/submit_api/enums/ea_act.py b/submit-api/src/submit_api/enums/ea_act.py new file mode 100644 index 000000000..67ba91fe3 --- /dev/null +++ b/submit-api/src/submit_api/enums/ea_act.py @@ -0,0 +1,33 @@ +"""Environmental Assessment Act enum.""" +from enum import Enum + + +class EAActName(str, Enum): + """Environmental Assessment Act names.""" + + EA_ACT_1996 = "EA Act (1996)" + EA_ACT_2002 = "EA Act (2002)" + EA_ACT_2018 = "EA Act (2018)" + OTHER = "Other" + + @classmethod + def get_by_id(cls, ea_act_id: int): + """Get EA Act name by ID.""" + mapping = { + 1: cls.EA_ACT_1996, + 2: cls.EA_ACT_2002, + 3: cls.EA_ACT_2018, + 4: cls.OTHER, + } + return mapping.get(ea_act_id) + + @classmethod + def get_id_by_name(cls, name: str): + """Get EA Act ID by name.""" + mapping = { + cls.EA_ACT_1996.value: 1, + cls.EA_ACT_2002.value: 2, + cls.EA_ACT_2018.value: 3, + cls.OTHER.value: 4, + } + return mapping.get(name) diff --git a/submit-api/src/submit_api/enums/work_type.py b/submit-api/src/submit_api/enums/work_type.py new file mode 100644 index 000000000..63885bf65 --- /dev/null +++ b/submit-api/src/submit_api/enums/work_type.py @@ -0,0 +1,71 @@ +"""Work Type enum.""" +from enum import Enum + + +class WorkTypeName(str, Enum): + """Work Type names from EPIC.track.""" + + PROJECT_NOTIFICATION = "Project Notification" + MINISTERS_DESIGNATION = "Minister's Designation" + CEAOS_DESIGNATION = "CEAO's Designation" + INTAKE_PRE_EA = "Intake (Pre-EA)" + EXEMPTION_ORDER = "Exemption Order" + ASSESSMENT = "Assessment" + AMENDMENT = "Amendment" + POST_EAC_DOCUMENT_REVIEW = "Post-EAC Document Review" + EAC_EXTENSION = "EAC Extension" + SUBSTANTIAL_START_DETERMINATION = "Substantial Start Determination" + EAC_ORDER_TRANSFER = "EAC/Order Transfer" + EAC_ORDER_SUSPENSION = "EAC/Order Suspension" + EAC_ORDER_CANCELLATION = "EAC/Order Cancellation" + OTHER = "Other" + MATERIAL_ALTERATION = "Material Alteration" + + @classmethod + def get_by_id(cls, work_type_id: int): + """Get Work Type name by ID.""" + mapping = { + 1: cls.PROJECT_NOTIFICATION, + 2: cls.MINISTERS_DESIGNATION, + 3: cls.CEAOS_DESIGNATION, + 4: cls.INTAKE_PRE_EA, + 5: cls.EXEMPTION_ORDER, + 6: cls.ASSESSMENT, + 7: cls.AMENDMENT, + 8: cls.POST_EAC_DOCUMENT_REVIEW, + 9: cls.EAC_EXTENSION, + 10: cls.SUBSTANTIAL_START_DETERMINATION, + 11: cls.EAC_ORDER_TRANSFER, + 12: cls.EAC_ORDER_SUSPENSION, + 13: cls.EAC_ORDER_CANCELLATION, + 14: cls.OTHER, + 15: cls.MATERIAL_ALTERATION, + } + return mapping.get(work_type_id) + + @classmethod + def get_id_by_name(cls, name: str): + """Get Work Type ID by name.""" + mapping = { + cls.PROJECT_NOTIFICATION.value: 1, + cls.MINISTERS_DESIGNATION.value: 2, + cls.CEAOS_DESIGNATION.value: 3, + cls.INTAKE_PRE_EA.value: 4, + cls.EXEMPTION_ORDER.value: 5, + cls.ASSESSMENT.value: 6, + cls.AMENDMENT.value: 7, + cls.POST_EAC_DOCUMENT_REVIEW.value: 8, + cls.EAC_EXTENSION.value: 9, + cls.SUBSTANTIAL_START_DETERMINATION.value: 10, + cls.EAC_ORDER_TRANSFER.value: 11, + cls.EAC_ORDER_SUSPENSION.value: 12, + cls.EAC_ORDER_CANCELLATION.value: 13, + cls.OTHER.value: 14, + cls.MATERIAL_ALTERATION.value: 15, + } + return mapping.get(name) + + @classmethod + def list_all(cls): + """Return all work type names.""" + return [member.value for member in cls] diff --git a/submit-api/src/submit_api/models/package.py b/submit-api/src/submit_api/models/package.py index 3dff44e1f..22d327a26 100644 --- a/submit-api/src/submit_api/models/package.py +++ b/submit-api/src/submit_api/models/package.py @@ -102,6 +102,12 @@ class Package(BaseModel): version = db.relationship('PackageVersion', foreign_keys=[ version_id], lazy='joined') + __table_args__ = ( + db.Index('idx_packages_account_project_id', 'account_project_id'), + db.Index('idx_packages_account_project_work_id', 'account_project_work_id', + postgresql_where=db.text('account_project_work_id IS NOT NULL')), + ) + _update_requests = db.relationship( 'UpdateRequest', backref='package', diff --git a/submit-api/src/submit_api/models/package_type.py b/submit-api/src/submit_api/models/package_type.py index 50ee5c373..07618cf15 100644 --- a/submit-api/src/submit_api/models/package_type.py +++ b/submit-api/src/submit_api/models/package_type.py @@ -22,8 +22,6 @@ class PackageType(BaseModel): phase = relationship('TrackPhase', foreign_keys=[phase_id], lazy='joined') item_types = relationship('ItemType', secondary='package_item_types', back_populates='package_types') - __table_args__ = (db.Index('idx_package_types_name', 'name'),) - @classmethod def find_by_name(cls, name: str): """Return model by name.""" diff --git a/submit-api/src/submit_api/models/track_phase.py b/submit-api/src/submit_api/models/track_phase.py index ab49093ee..3beed82e9 100644 --- a/submit-api/src/submit_api/models/track_phase.py +++ b/submit-api/src/submit_api/models/track_phase.py @@ -18,10 +18,15 @@ class TrackPhase(BaseModel): id = Column(db.Integer, primary_key=True, autoincrement=False, comment='Phase ID from EPIC.track') name = Column(db.String(255), nullable=False, comment='Phase name') ea_act_id = Column(db.Integer, nullable=True, comment='Environmental Assessment Act ID') + ea_act_name = Column(db.String(255), nullable=True, comment='Environmental Assessment Act name') work_type_id = Column(db.Integer, nullable=False, comment='Work type ID from EPIC.track') work_type_name = Column(db.String(255), nullable=True, comment='Work type name for display') sort_order = Column(db.Integer, nullable=True, comment='Order of phase in workflow') number_of_days = Column(db.Integer, nullable=True, comment='Number of days allocated for this phase') + display_name = Column( + db.String(255), nullable=True, + comment='Submit-specific phase name override; defaults to name field if not set' + ) legislated = Column( db.Boolean, nullable=False, default=False, comment='Whether this phase has legislated time requirements' @@ -49,3 +54,35 @@ def find_by_work_type(cls, work_type_id: int): def find_active_phases(cls): """Return all active phases.""" return cls.query.filter_by(is_active=True, is_deleted=False).order_by(cls.work_type_id, cls.sort_order).all() + + @classmethod + def find_by_identifiers(cls, ea_act_name: str, work_type_name: str, phase_name: str): + """Find phase by EA Act name, Work Type name, and Phase name. + + Args: + ea_act_name: Environmental Assessment Act name + work_type_name: Work type name + phase_name: Phase name (can be display_name or name) + + Returns: + TrackPhase: The matching phase or None + """ + # Try to find by display_name first, then fall back to name + phase = cls.query.filter( + cls.ea_act_name == ea_act_name, + cls.work_type_name == work_type_name, + cls.display_name == phase_name, + cls.is_active.is_(True), + cls.is_deleted.is_(False) + ).first() + + if not phase: + phase = cls.query.filter( + cls.ea_act_name == ea_act_name, + cls.work_type_name == work_type_name, + cls.name == phase_name, + cls.is_active.is_(True), + cls.is_deleted.is_(False) + ).first() + + return phase diff --git a/submit-api/src/submit_api/resources/__init__.py b/submit-api/src/submit_api/resources/__init__.py index b3b1c16b7..0e64bfc8b 100644 --- a/submit-api/src/submit_api/resources/__init__.py +++ b/submit-api/src/submit_api/resources/__init__.py @@ -39,6 +39,7 @@ from .staff.internal_document import API as STAFF_INTERNAL_DOCUMENT_API from .staff.item import API as STAFF_ITEM_API from .staff.package import API as STAFF_PACKAGE_API +from .staff.package_type import API as STAFF_PACKAGE_TYPE_API from .staff.project import API as STAFF_PROJECT_API from .staff.staff_user import API as STAFF_USER_API from .staff.submission_item_note import API as STAFF_SUBMISSION_ITEM_NOTE_API @@ -94,6 +95,7 @@ STAFF_API.add_namespace(STAFF_PROJECT_API) STAFF_API.add_namespace(STAFF_PACKAGE_API) +STAFF_API.add_namespace(STAFF_PACKAGE_TYPE_API) STAFF_API.add_namespace(STAFF_ITEM_API) STAFF_API.add_namespace(STAFF_INTERNAL_DOCUMENT_API) STAFF_API.add_namespace(STAFF_SUBMISSION_ITEM_NOTE_API) diff --git a/submit-api/src/submit_api/resources/staff/package_type.py b/submit-api/src/submit_api/resources/staff/package_type.py new file mode 100644 index 000000000..c120e2a5d --- /dev/null +++ b/submit-api/src/submit_api/resources/staff/package_type.py @@ -0,0 +1,74 @@ +"""Package Type resource for staff users.""" +from http import HTTPStatus + +from flask import request +from flask_restx import Namespace, Resource + +from submit_api.auth import auth +from submit_api.resources.apihelper import Api as ApiHelper +from submit_api.schemas.package_type_create import PackageTypeCreateSchema +from submit_api.schemas.package_type_response import PackageTypeResponseSchema +from submit_api.services.package_type import PackageTypeService +from submit_api.utils.roles import EpicSubmitRole +from submit_api.utils.util import cors_preflight + + +API = Namespace('package-types', description='Package Type Management') + +"""Custom exception messages +""" + +package_type_create_model = ApiHelper.convert_ma_schema_to_restx_model( + API, PackageTypeCreateSchema(), "PackageTypeCreate" +) + +package_type_response_model = ApiHelper.convert_ma_schema_to_restx_model( + API, PackageTypeResponseSchema(), "PackageTypeResponse" +) + + +@cors_preflight('POST,OPTIONS') +@API.route('', methods=['POST', 'OPTIONS']) +class PackageTypeResource(Resource): + """Resource for creating/updating package types.""" + + @API.doc('create_package_type') + @API.expect(package_type_create_model) + @API.response( + code=HTTPStatus.OK, + model=package_type_response_model, + description='Package type created/updated successfully' + ) + @API.response(HTTPStatus.BAD_REQUEST, 'Validation error') + @API.response(HTTPStatus.NOT_FOUND, 'Phase or item types not found') + @auth.has_one_of_staff_roles([EpicSubmitRole.EAO_CREATE.value]) + def post(self): + """Create or update a package type with phase association. + + This endpoint is idempotent - it will create a new package type if it doesn't exist, + or update the existing one if it does. It also supports creating new item types on the fly. + + The phase is identified by: + - EA Act name (e.g., "EA Act (2018)") + - Work Type name (e.g., "Assessment", "Amendment") + - Phase name (e.g., "Early Engagement", "EAC Application Review") + + Item types can be specified in two ways: + 1. By ID for existing item types: {"id": 1} + 2. By name and submission_method for new item types: + {"name": "Custom Document", "submission_method": "DOCUMENT_UPLOAD"} + """ + # Validate request data + schema = PackageTypeCreateSchema() + data = schema.load(request.get_json()) + + # Create or update package type + result = PackageTypeService.create_or_update_package_type( + ea_act_name=data['ea_act_name'], + work_type_name=data['work_type_name'], + phase_name=data['phase_name'], + package_type_name=data['package_type_name'], + item_types=data['item_types'] + ) + + return result, HTTPStatus.OK diff --git a/submit-api/src/submit_api/schemas/package_type_create.py b/submit-api/src/submit_api/schemas/package_type_create.py new file mode 100644 index 000000000..2e815d6d4 --- /dev/null +++ b/submit-api/src/submit_api/schemas/package_type_create.py @@ -0,0 +1,87 @@ +"""Package type creation schema. + +Schema for creating or updating package types with phase associations. +""" + +from marshmallow import EXCLUDE, Schema, fields, validates_schema, ValidationError, validate + + +class ItemTypeSchema(Schema): + """Schema for item type (can be ID or new item type definition).""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + id = fields.Int( + required=False, + data_key="id", + metadata={"description": "Existing item type ID"} + ) + name = fields.Str( + required=False, + data_key="name", + metadata={"description": "Name for new item type"} + ) + submission_method = fields.Str( + required=False, + data_key="submission_method", + validate=validate.OneOf(['FORM_SUBMISSION', 'DOCUMENT_UPLOAD']), + metadata={"description": "Submission method: FORM_SUBMISSION or DOCUMENT_UPLOAD"} + ) + + @validates_schema + def validate_item_type(self, data, **kwargs): + """Validate that either id is provided OR both name and submission_method.""" + has_id = 'id' in data and data['id'] is not None + has_name = 'name' in data and data['name'] + has_method = 'submission_method' in data and data['submission_method'] + + if has_id and (has_name or has_method): + raise ValidationError('Provide either id OR (name and submission_method), not both') + + if not has_id and not (has_name and has_method): + raise ValidationError('Must provide either id OR both name and submission_method') + + +class PackageTypeCreateSchema(Schema): + """Schema for creating/updating package types with phase association.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + ea_act_name = fields.Str( + required=True, + data_key="ea_act_name", + metadata={"description": "Environmental Assessment Act name (e.g., 'EA Act (2018)')"} + ) + work_type_name = fields.Str( + required=True, + data_key="work_type_name", + metadata={"description": "Work type name (e.g., 'Assessment', 'Amendment')"} + ) + phase_name = fields.Str( + required=True, + data_key="phase_name", + metadata={"description": "Phase name (e.g., 'Early Engagement', 'EAC Application Review')"} + ) + package_type_name = fields.Str( + required=True, + data_key="package_type_name", + metadata={"description": "Name of the package type to create"} + ) + item_types = fields.List( + fields.Nested(ItemTypeSchema), + required=True, + data_key="item_types", + metadata={"description": "List of item types (can be IDs or new item type definitions)"} + ) + + @validates_schema + def validate_item_types(self, data, **kwargs): + """Validate that item_types is not empty.""" + if not data.get('item_types'): + raise ValidationError('item_types must contain at least one item type') diff --git a/submit-api/src/submit_api/schemas/package_type_response.py b/submit-api/src/submit_api/schemas/package_type_response.py new file mode 100644 index 000000000..600cd4cae --- /dev/null +++ b/submit-api/src/submit_api/schemas/package_type_response.py @@ -0,0 +1,76 @@ +"""Package type response schema. + +Schema for package type creation/update response. +""" + +from marshmallow import EXCLUDE, Schema, fields + + +class CreatedItemTypeSchema(Schema): + """Schema for created item type in response.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + id = fields.Int( + required=True, + metadata={"description": "Created item type ID"} + ) + name = fields.Str( + required=True, + metadata={"description": "Item type name"} + ) + submission_method = fields.Str( + required=True, + metadata={"description": "Submission method (FORM_SUBMISSION or DOCUMENT_UPLOAD)"} + ) + + +class PackageTypeResponseSchema(Schema): + """Schema for package type creation/update response.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + id = fields.Int( + required=True, + metadata={"description": "Package type ID"} + ) + name = fields.Str( + required=True, + metadata={"description": "Package type name"} + ) + phase_id = fields.Int( + required=True, + metadata={"description": "Associated phase ID from track_phases table"} + ) + phase_name = fields.Str( + required=True, + metadata={"description": "Display name of the phase"} + ) + ea_act_name = fields.Str( + required=True, + metadata={"description": "Environmental Assessment Act name"} + ) + work_type_name = fields.Str( + required=True, + metadata={"description": "Work type name"} + ) + item_type_ids = fields.List( + fields.Int(), + required=True, + metadata={"description": "List of all associated item type IDs"} + ) + created_item_types = fields.List( + fields.Nested(CreatedItemTypeSchema), + required=True, + metadata={"description": "List of newly created item types (empty if all existed)"} + ) + created = fields.Boolean( + required=True, + metadata={"description": "True if package type newly created, False if updated"} + ) diff --git a/submit-api/src/submit_api/services/package_type.py b/submit-api/src/submit_api/services/package_type.py index 431b84bd3..2c4d6fe4e 100644 --- a/submit-api/src/submit_api/services/package_type.py +++ b/submit-api/src/submit_api/services/package_type.py @@ -1,8 +1,10 @@ """Service for package management.""" -from submit_api.utils.constants import SUBMISSION_PACKAGE_TYPE_EMAIL_SENDER_MAP - +from typing import List, Dict, Any, Tuple -# Set up logging configuration +from submit_api.models import PackageType, TrackPhase, ItemType, PackageItemType +from submit_api.models.db import db +from submit_api.models.item_type import SubmissionMethod +from submit_api.utils.constants import SUBMISSION_PACKAGE_TYPE_EMAIL_SENDER_MAP class PackageTypeService: @@ -12,3 +14,173 @@ class PackageTypeService: def get_email_sender_for_package_type(package_type: str) -> str: """Get the email sender for the package type.""" return SUBMISSION_PACKAGE_TYPE_EMAIL_SENDER_MAP.get(package_type, None) + + @staticmethod + def _process_item_types( + item_types: List[Dict[str, Any]] + ) -> Tuple[List[int], List[Dict[str, Any]]]: + """Process item types - validate existing or create new ones. + + Args: + item_types: List of item type definitions + + Returns: + Tuple of (processed_item_type_ids, created_item_types) + + Raises: + ValueError: If item type ID not found + """ + processed_item_type_ids = [] + created_item_types = [] + + for item_type_def in item_types: + if 'id' in item_type_def and item_type_def['id'] is not None: + # Existing item type - validate it exists + item_type_id = item_type_def['id'] + item_type = ItemType.query.get(item_type_id) + if not item_type: + raise ValueError(f"Item type with ID {item_type_id} not found") + processed_item_type_ids.append(item_type_id) + else: + # New item type - create it + item_type_id, created_info = PackageTypeService._create_or_get_item_type( + item_type_def['name'], + item_type_def['submission_method'] + ) + processed_item_type_ids.append(item_type_id) + if created_info: + created_item_types.append(created_info) + + return processed_item_type_ids, created_item_types + + @staticmethod + def _create_or_get_item_type( + name: str, + submission_method: str + ) -> Tuple[int, Dict[str, Any]]: + """Create a new item type or get existing one by name. + + Args: + name: Item type name + submission_method: Submission method (FORM_SUBMISSION or DOCUMENT_UPLOAD) + + Returns: + Tuple of (item_type_id, created_info_dict or None) + """ + # Check if item type with this name already exists + existing_item_type = ItemType.query.filter_by(name=name).first() + if existing_item_type: + return existing_item_type.id, None + + # Create new item type + new_item_type = ItemType( + name=name, + submission_method=SubmissionMethod[submission_method], + created_by='system' # TODO: Get from auth context + ) + db.session.add(new_item_type) + db.session.flush() # Get the ID + + created_info = { + 'id': new_item_type.id, + 'name': new_item_type.name, + 'submission_method': submission_method + } + return new_item_type.id, created_info + + @staticmethod + def _create_item_type_associations( + package_type_id: int, + item_type_ids: List[int] + ) -> None: + """Create package-item type associations. + + Args: + package_type_id: Package type ID + item_type_ids: List of item type IDs to associate + """ + for idx, item_type_id in enumerate(item_type_ids): + package_item_type = PackageItemType( + package_type_id=package_type_id, + item_type_id=item_type_id, + sort_order=idx + 1 + ) + db.session.add(package_item_type) + + @staticmethod + def create_or_update_package_type( + ea_act_name: str, + work_type_name: str, + phase_name: str, + package_type_name: str, + item_types: List[Dict[str, Any]] + ) -> Dict[str, Any]: + """Create or update a package type with phase association. + + This method is idempotent - it will create a new package type if it doesn't exist, + or update the existing one if it does. It also creates new item types if they don't exist. + + Args: + ea_act_name: Environmental Assessment Act name + work_type_name: Work type name + phase_name: Phase name (can be display_name or name) + package_type_name: Name of the package type to create/update + item_types: List of item type definitions (either {'id': int} or + {'name': str, 'submission_method': str}) + + Returns: + Dict containing the created/updated package type information + + Raises: + ValueError: If phase not found or item types are invalid + """ + # Find the phase + phase = TrackPhase.find_by_identifiers(ea_act_name, work_type_name, phase_name) + if not phase: + raise ValueError( + f"Phase not found for EA Act: '{ea_act_name}', " + f"Work Type: '{work_type_name}', Phase: '{phase_name}'" + ) + + # Process item types - create new ones or validate existing ones + processed_item_type_ids, created_item_types = PackageTypeService._process_item_types(item_types) + + # Check if package type already exists + existing_package_type = PackageType.find_by_name(package_type_name) + + if existing_package_type: + # Update existing package type + package_type = existing_package_type + package_type.phase_id = phase.id + package_type.updated_by = 'system' # TODO: Get from auth context + + # Remove existing item type associations + PackageItemType.query.filter_by(package_type_id=package_type.id).delete() + else: + # Create new package type + package_type = PackageType( + name=package_type_name, + phase_id=phase.id, + created_by='system' # TODO: Get from auth context + ) + db.session.add(package_type) + + # Flush to get the package_type.id + db.session.flush() + + # Create item type associations + PackageTypeService._create_item_type_associations(package_type.id, processed_item_type_ids) + + db.session.commit() + + return { + 'id': package_type.id, + 'name': package_type.name, + 'phase_id': package_type.phase_id, + 'phase_name': phase.display_name or phase.name, + 'ea_act_name': phase.ea_act_name, + 'work_type_name': phase.work_type_name, + 'item_type_ids': processed_item_type_ids, + 'created_item_types': created_item_types, + 'created': existing_package_type is None + }