Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""Seed the Report_TypeB package type

Report_TypeB is a clone of the Report package type (same single
'Document(s) Upload' item, versioning disabled, same title/success message)
but with approval_type 'B'. Approval type B surfaces the Acknowledge step in
addition to Verify, following the existing approval-type-driven flow, whereas
the regular Report type uses approval_type 'A'.

Revision ID: a2b3c4d5e6f7
Revises: b1c2d3e4f5a7
Create Date: 2026-10-29 00:00:00.000000

"""
from datetime import datetime, timezone

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'a2b3c4d5e6f7'
down_revision = 'b1c2d3e4f5a7'
branch_labels = None
depends_on = None

REPORT_TYPE_B_NAME = 'Report_TypeB'
REPORT_TYPE_B_TITLE = 'Reports & Recurring Submissions'
REPORT_TYPE_B_SUCCESS_MESSAGE = (
'Your submission has been successfully submitted to the EAO. '
'You will also receive an email to confirm your submission.\n'
'If you have any questions or need to add, replace, or delete documents '
'in your submission, please contact the EAO at {{contact_email}}'
)
REPORT_TYPE_B_APPROVAL_TYPE = 'B'
ITEM_TYPE_NAME = 'Document(s) Upload'
ITEM_SUBMISSION_METHOD = 'DOCUMENT_UPLOAD'


def upgrade():
"""Insert the Report_TypeB package type and link its item type if missing."""
conn = op.get_bind()
now = datetime.now(timezone.utc)

# --- Item type (reuse the shared 'Document(s) Upload' type) ---
row = conn.execute(
sa.text("SELECT id FROM item_types WHERE name = :name"),
{"name": ITEM_TYPE_NAME},
).fetchone()

if row:
item_type_id = row[0]
else:
result = conn.execute(
sa.text(
"INSERT INTO item_types (name, submission_method, created_date) "
"VALUES (:name, :method, :now) RETURNING id"
),
{"name": ITEM_TYPE_NAME, "method": ITEM_SUBMISSION_METHOD, "now": now},
)
item_type_id = result.fetchone()[0]

# --- Package type ---
row = conn.execute(
sa.text("SELECT id FROM package_types WHERE name = :name AND phase_id IS NULL"),
{"name": REPORT_TYPE_B_NAME},
).fetchone()

if row:
package_type_id = row[0]
else:
result = conn.execute(
sa.text(
"INSERT INTO package_types "
"(name, title, mandatory, versioning_enabled, approval_type, "
"success_message, created_date) "
"VALUES (:name, :title, :mandatory, :versioning, :approval_type, "
":msg, :now) RETURNING id"
),
{
"name": REPORT_TYPE_B_NAME,
"title": REPORT_TYPE_B_TITLE,
"mandatory": False,
"versioning": False,
"approval_type": REPORT_TYPE_B_APPROVAL_TYPE,
"msg": REPORT_TYPE_B_SUCCESS_MESSAGE,
"now": now,
},
)
package_type_id = result.fetchone()[0]

# --- Link (package_item_types) ---
row = conn.execute(
sa.text(
"SELECT 1 FROM package_item_types "
"WHERE package_type_id = :pt AND item_type_id = :it"
),
{"pt": package_type_id, "it": item_type_id},
).fetchone()

if not row:
conn.execute(
sa.text(
"INSERT INTO package_item_types "
"(package_type_id, item_type_id, sort_order, is_required, created_date) "
"VALUES (:pt, :it, 1, true, :now)"
),
{"pt": package_type_id, "it": item_type_id, "now": now},
)


def downgrade():
"""Remove the Report_TypeB package type and its link (keep the item type)."""
conn = op.get_bind()

row = conn.execute(
sa.text("SELECT id FROM package_types WHERE name = :name AND phase_id IS NULL"),
{"name": REPORT_TYPE_B_NAME},
).fetchone()

if row:
package_type_id = row[0]
conn.execute(
sa.text("DELETE FROM package_item_types WHERE package_type_id = :pt"),
{"pt": package_type_id},
)
conn.execute(
sa.text("DELETE FROM package_types WHERE id = :pt"),
{"pt": package_type_id},
)
6 changes: 4 additions & 2 deletions submit-api/src/submit_api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from flask import g, request
from flask_jwt_oidc import JwtManager

from submit_api.enums.role import ProponentPermissionsEnum
from submit_api.models import db
from submit_api.exceptions import PermissionDeniedError
from submit_api.models import User
Expand Down Expand Up @@ -125,8 +126,9 @@ def wrapper(*args, **kwargs):
if user and user.type == UserType.STAFF:
# Always include full_access role for staff users
roles_with_full_access = list(roles) + [EpicSubmitRole.FULL_ACCESS.value]
# pylint: disable=no-value-for-parameter
if jwt.has_one_of_roles(roles_with_full_access):
if ProponentPermissionsEnum.INVITE_USERS.value in roles:
roles_with_full_access.append(EpicSubmitRole.EAO_CREATE.value)
if jwt.contains_role(roles_with_full_access):
return f(*args, **kwargs)
raise PermissionDeniedError("Access Denied")

Expand Down
7 changes: 7 additions & 0 deletions submit-api/src/submit_api/enums/package_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class PackageTypeEnum(enum.Enum):
IEM = 'IEM'
ADDITIONAL_INFORMATION = 'Additional Information'
REPORT = 'Report'
REPORT_TYPE_B = 'Report_TypeB'


class PackageTypeId(enum.Enum):
Expand All @@ -34,3 +35,9 @@ class PackageApprovalType(enum.Enum):
MANAGEMENT_PLAN_RELATED_TYPES = frozenset(
{PackageTypeEnum.MANAGEMENT_PLAN.value, PackageTypeEnum.IEM.value}
)

# Report-style package types. Report_TypeB is a clone of Report that adds an
# Acknowledge step (approval_type B); both share the same report submission flow.
REPORT_RELATED_TYPES = frozenset(
{PackageTypeEnum.REPORT.value, PackageTypeEnum.REPORT_TYPE_B.value}
)
6 changes: 3 additions & 3 deletions submit-api/src/submit_api/models/invitations.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def is_expired(cls): # noqa: N805 # pylint: disable=no-self-argument
"""SQL expression for is_expired check."""
return cls.expiry_date < func.timezone('UTC', func.now()) # pylint: disable=not-callable

def to_dict(self):
def to_dict(self, include_token=False):
"""Convert object to dictionary."""
return {
"id": self.id,
Expand All @@ -59,7 +59,7 @@ def to_dict(self):
"eligible_entries": self.eligible_entries,
"package_ids": self.package_ids,
"original_package_ids": self.original_package_ids,
"token": self.token,
**({"token": self.token} if include_token else {}),
"email": self.email,
"status": self.status,
"expiry_date": self.expiry_date,
Expand All @@ -72,7 +72,7 @@ def to_dict(self):
def validate_token(cls, token):
"""Validate token and check if it is still active."""
invitation = cls.query.filter_by(token=token, status=InvitationStatus.PENDING.value).first()
if invitation and invitation.expiry_date > datetime.now():
if invitation and not invitation.is_expired:
return invitation
return None

Expand Down
6 changes: 4 additions & 2 deletions submit-api/src/submit_api/resources/account_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
from submit_api.auth import auth
from submit_api.exceptions import PermissionDeniedError, ResourceNotFoundError
from submit_api.resources.apihelper import Api as ApiHelper
from submit_api.schemas.account_user import AccountUserSchema, EditRoleSchema, EditTermsOfServiceSchema
from submit_api.schemas.account_user import (
AccountUserSchema, EditAccountUserSchema, EditRoleSchema, EditTermsOfServiceSchema
)
from submit_api.services.account_user_service import AccountUserService
from submit_api.utils.util import allowedorigins, cors_preflight

Expand Down Expand Up @@ -107,7 +109,7 @@ def get(guid):
@cross_origin(origins=allowedorigins())
def patch(guid):
"""Edit a account user."""
edit_account_user_data = AccountUserSchema().load(API.payload)
edit_account_user_data = EditAccountUserSchema().load(API.payload)
edited_account_user = AccountUserService.update_account_user(guid, edit_account_user_data)
return AccountUserSchema().dump(edited_account_user), HTTPStatus.OK

Expand Down
15 changes: 15 additions & 0 deletions submit-api/src/submit_api/resources/invitation.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ def delete(token):
code=HTTPStatus.CREATED, model=invitation_response_schema, description="User created and role assigned"
)
@API.response(code=HTTPStatus.BAD_REQUEST, description="Invalid Token or Data")
@auth.require
def post(self, token):
"""Accept an invitation and create a user."""
payload = AccountCreateSchema().load(request.json)
Expand Down Expand Up @@ -234,3 +235,17 @@ def patch(invitation_id):
if result:
return {}, HTTPStatus.NO_CONTENT
return {"error": "Invitation not found or already used"}, HTTPStatus.NOT_FOUND


@cors_preflight("GET, OPTIONS")
@API.route("/<string:token>/proponent", methods=["GET", "OPTIONS"])
class InvitationProponentResource(Resource):
"""Minimal onboarding information for the holder of a valid invitation."""

@staticmethod
@cross_origin(origins=allowedorigins())
def get(token):
"""Fetch only company name and invited work selections."""
# pylint: disable=import-outside-toplevel # local import avoids a circular import
from submit_api.services.proponent_service import ProponentService
return ProponentService.get_invitation_proponent(token), HTTPStatus.OK
1 change: 1 addition & 0 deletions submit-api/src/submit_api/resources/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ def get(account_id):
@API.response(HTTPStatus.BAD_REQUEST, "Bad Request")
@auth.require
@cross_origin(origins=allowedorigins())
@auth.has_one_of_staff_roles([EpicSubmitRole.EAO_CREATE.value])
def post(account_id):
"""Add projects in bulk."""
projects_data = AddProjectSchema().load(API.payload)
Expand Down
2 changes: 2 additions & 0 deletions submit-api/src/submit_api/resources/proponent.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ class Proponent(Resource):
)
@API.response(HTTPStatus.BAD_REQUEST, "Bad Request")
@cross_origin(origins=allowedorigins())
@auth.require
@auth.has_one_of_staff_roles([EpicSubmitRole.EAO_VIEW.value])
def get(proponent_id):
"""Get a proponent by id."""
include_invitations = request.args.get("include-invitations", "false").lower() == "true"
Expand Down
12 changes: 12 additions & 0 deletions submit-api/src/submit_api/schemas/account_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@
from submit_api.schemas.role import UserRoleSchema


class EditAccountUserSchema(Schema):
"""Writable profile fields; identifiers and consent use separate operations."""

first_name = fields.Str()
last_name = fields.Str()
position = fields.Str()
work_email_address = fields.Email()
work_contact_number = fields.Str()
company_name = fields.Str()
extension_number = fields.Str(allow_none=True)


class AccountUserSchema(Schema):
"""Schema for representing an account user with roles."""

Expand Down
42 changes: 38 additions & 4 deletions submit-api/src/submit_api/services/account_user_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from submit_api.models import UserRole as UserRoleModel
from submit_api.models.db import db
from submit_api.utils.token_info import TokenInfo
from submit_api.schemas.account_user import EditAccountUserSchema


class AccountUserService:
Expand Down Expand Up @@ -310,6 +311,9 @@ def _apply_update_data(account_user, update_data):
@classmethod
def update_account_user(cls, guid, update_data):
"""Update submission item by id."""
if not guid or guid != TokenInfo.get_username():
raise PermissionDeniedError("You can only update your own profile.")
update_data = EditAccountUserSchema().load(update_data)
account_user = AccountUserModel.get_by_guid(guid)
if not account_user:
current_app.logger.warning(f"Account user with id {guid} not found.")
Expand All @@ -324,7 +328,7 @@ def update_account_user(cls, guid, update_data):
return account_user

@classmethod
def update_role(cls, user_guid, account_user_id, updated_role_data):
def update_role(cls, user_guid, account_user_id, updated_role_data): # pylint: disable=too-many-locals
"""Update user's role using replace-all strategy.

Deletes all existing user_role rows and creates new ones
Expand All @@ -342,6 +346,24 @@ def update_role(cls, user_guid, account_user_id, updated_role_data):
if not account_project_ids:
raise ResourceNotFoundError("account_project_ids is required.")

target = AccountUserModel.get_users_by_account_user_id(account_user_id)
projects = AccountProjectModel.get_all_in_ids(account_project_ids)
if len(projects) != len(set(account_project_ids)) or any(
project.account_id != target.account_id for project in projects
):
raise PermissionDeniedError("Projects must belong to the user's account.")
actor = UserModel.get_by_guid(user_guid).account_user
if not cls._is_account_primary_admin(actor):
managed_ids = {r.account_project_id for r in actor.roles
if r.role.role_name == RoleEnum.PROJECT_ADMIN.value}
if not set(account_project_ids).issubset(managed_ids):
raise PermissionDeniedError("You can only assign projects you administer.")
# Validate all package assignments before replacing any existing roles.
for package_id in set((package_ids or []) + (original_package_ids or [])):
package = PackageModel.find_by_id(package_id)
if not package or package.account_project_id not in account_project_ids:
raise PermissionDeniedError("Packages must belong to the assigned projects.")

# Replace-all: delete existing roles and create new ones
UserRoleModel.delete_all_by_account_user_id(account_user_id)

Expand Down Expand Up @@ -375,7 +397,8 @@ def update_role(cls, user_guid, account_user_id, updated_role_data):
return user_dict

@staticmethod
def _validate_user_permission(user_guid: str, account_user_id: int, new_role_name: str = None) -> None:
def _validate_user_permission( # pylint: disable=too-many-branches
user_guid: str, account_user_id: int, new_role_name: str = None) -> None:
"""Validate permissions for editing a user's role.

Rules:
Expand All @@ -385,6 +408,11 @@ def _validate_user_permission(user_guid: str, account_user_id: int, new_role_nam
- PROJECT_ADMIN cannot assign ACCOUNT_PRIMARY_ADMIN role
"""
user = UserModel.get_by_guid(user_guid)
target_user = AccountUserModel.get_users_by_account_user_id(account_user_id)
if not user or not user.account_user or not target_user:
raise PermissionDeniedError("Only authorized account/project admins can edit users.")
if target_user.account_id != user.account_user.account_id:
raise PermissionDeniedError("You can only administer users in your account.")

# Prevent users from updating their own role
if user.account_user and user.account_user.id == account_user_id:
Expand All @@ -408,10 +436,16 @@ def _validate_user_permission(user_guid: str, account_user_id: int, new_role_nam

# Project Admins have restricted scope
if is_project_admin and not is_account_admin:
managed_ids = {r.account_project_id for r in user.account_user.roles
if r.role.role_name == RoleEnum.PROJECT_ADMIN.value}
# Both operations affect all roles, including historical access on reactivation.
target_roles = target_user.all_roles
if not target_roles or any(r.account_project_id not in managed_ids for r in target_roles):
raise PermissionDeniedError("You cannot change access outside the projects you administer.")
# Cannot edit Regulated Party Account Administrator users
target_user = AccountUserModel.get_users_by_account_user_id(account_user_id)
if target_user and target_user.roles:
for target_role in target_user.roles:
if target_user and target_user.all_roles:
for target_role in target_user.all_roles:
if target_role.role.role_name == RoleEnum.ACCOUNT_PRIMARY_ADMIN.value:
raise PermissionDeniedError(
"Project Admins cannot edit Regulated Party Account Administrator users."
Expand Down
Loading
Loading