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
2 changes: 1 addition & 1 deletion auth-api/migrations/versions/2024_09_20_aa74003de9d8_.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ def upgrade():
sa.Column("country", sa.String(length=20), autoincrement=False, nullable=True),
sa.Column("postal_code", sa.String(length=15), autoincrement=False, nullable=True),
sa.Column("delivery_instructions", sa.String(length=4096), autoincrement=False, nullable=True),
sa.Column("phone", sa.String(length=15), autoincrement=False, nullable=True),
sa.Column("phone", sa.String(length=20), autoincrement=False, nullable=True),
sa.Column("phone_extension", sa.String(length=10), autoincrement=False, nullable=True),
sa.Column("email", sa.String(length=100), autoincrement=False, nullable=True),
sa.Column("entity_id", sa.Integer(), autoincrement=False, nullable=True),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def upgrade():
sa.Column('country', sa.String(length=2), nullable=True),
sa.Column('postal_code', sa.String(length=10), nullable=True),
sa.Column('delivery_instructions', sa.String(length=4096), nullable=True),
sa.Column('phone', sa.String(length=15), nullable=True),
sa.Column('phone', sa.String(length=20), nullable=True),
sa.Column('phone_extension', sa.String(length=10), nullable=True),
sa.Column('email', sa.String(length=100), nullable=True),
sa.Column('entity_id', sa.Integer(), nullable=True),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ def upgrade():
sa.Column('country', sa.String(length=20), autoincrement=False, nullable=True),
sa.Column('postal_code', sa.String(length=10), autoincrement=False, nullable=True),
sa.Column('delivery_instructions', sa.String(length=4096), autoincrement=False, nullable=True),
sa.Column('phone', sa.String(length=15), autoincrement=False, nullable=True),
sa.Column('phone', sa.String(length=20), autoincrement=False, nullable=True),
sa.Column('phone_extension', sa.String(length=10), autoincrement=False, nullable=True),
sa.Column('email', sa.String(length=100), autoincrement=False, nullable=True),
sa.Column('entity_id', sa.Integer(), autoincrement=False, nullable=True),
Expand Down
2 changes: 1 addition & 1 deletion auth-api/src/auth_api/models/contact.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class Contact(Versioned, BaseModel): # pylint: disable=too-few-public-methods
country = Column("country", String(20))
postal_code = Column("postal_code", String(15))
delivery_instructions = Column("delivery_instructions", String(4096))
phone = Column("phone", String(15))
phone = Column("phone", String(20))
phone_extension = Column("phone_extension", String(10))
email = Column("email", String(100))
# MVP contact has been migrated over to the contact linking table (revised data model)
Expand Down
2 changes: 2 additions & 0 deletions auth-api/src/auth_api/resources/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from flask import Blueprint, Flask # noqa: I001

from .v1 import v1_endpoint
from .v2 import v2_endpoint

TEST_BLUEPRINT = Blueprint("TEST", __name__, url_prefix="/test")

Expand All @@ -39,6 +40,7 @@ def init_app(self, app: Flask):
def _mount_endpoints(self):
"""Mount the endpoints of the system."""
v1_endpoint.init_app(self.app)
v2_endpoint.init_app(self.app)

if os.getenv("FLASK_ENV", "production") in ["development", "testing"]:
self.app.register_blueprint(TEST_BLUEPRINT)
Expand Down
86 changes: 86 additions & 0 deletions auth-api/src/auth_api/resources/org_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Copyright © 2019 Province of British Columbia
#
# Licensed under the Apache License, Version 2.0 (the 'License');
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an 'AS IS' BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Shared utility functions for org endpoints."""

from dataclasses import dataclass
from http import HTTPStatus
from typing import Self

from auth_api.exceptions import BusinessException
from auth_api.schemas import utils as schema_utils
from auth_api.services import Org as OrgService
from auth_api.services import User as UserService


@dataclass
class Result[T]:
"""Encapsulates success value or error response."""

value: T | None = None
error: dict | None = None
status: HTTPStatus = HTTPStatus.OK

@property
def is_success(self) -> bool:
"""Return True if the result is successful."""
return self.error is None

@property
def is_failure(self) -> bool:
"""Return True if the result is a failure."""
return self.error is not None

@classmethod
def success(cls, value: T) -> Self:
"""Create a successful result with the given value."""
return cls(value=value)

@classmethod
def failure(cls, message: str, status: HTTPStatus, **extras) -> Self:
"""Create a failure result with the given error message and status."""
return cls(error={"message": message, **extras}, status=status)


def validate_and_get_user() -> Result:
"""Validate request and get authenticated user."""
user = UserService.find_by_jwt_token()
if not user:
return Result.failure("Not authorized to perform this action", HTTPStatus.UNAUTHORIZED)
return Result.success(user)


def validate_schema(data: dict, schema_name: str) -> Result:
"""Validate data against a schema."""
valid, errors = schema_utils.validate(data, schema_name)
if not valid:
return Result.failure(schema_utils.serialize(errors), HTTPStatus.BAD_REQUEST)
return Result.success(True)


def create_org(org_info: dict, user_id: int) -> Result:
"""Create an organization."""
try:
org = OrgService.create_org(org_info, user_id)
return Result.success(org.as_dict())
except BusinessException as e:
return Result.failure(e.message, e.status_code, code=e.code, detail=e.detail)


def add_contact(org_id: int, contact_info: dict) -> Result:
"""Add contact to organization."""
try:
contact = OrgService.add_contact(org_id, contact_info)
return Result.success(contact.as_dict())
except BusinessException as e:
return Result.failure(e.message, e.status_code, code=e.code)
45 changes: 18 additions & 27 deletions auth-api/src/auth_api/resources/v1/org.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from auth_api.models.dataclass import Affiliation as AffiliationData
from auth_api.models.dataclass import AffiliationSearchDetails, DeleteAffiliationRequest, SimpleOrgSearch
from auth_api.models.org import OrgSearch # noqa: I001
from auth_api.resources import org_utils
from auth_api.schemas import InvitationSchema, MembershipSchema
from auth_api.schemas import utils as schema_utils
from auth_api.services import Affidavit as AffidavitService
Expand Down Expand Up @@ -158,25 +159,17 @@ def post_organization():
If the org already exists, update the attributes.
"""
request_json = request.get_json()
valid_format, errors = schema_utils.validate(request_json, "org")
if not valid_format:
return {"message": schema_utils.serialize(errors)}, HTTPStatus.BAD_REQUEST
try:
user = UserService.find_by_jwt_token()
if user is None:
response, status = {"message": "Not authorized to perform this action"}, HTTPStatus.UNAUTHORIZED
return response, status
response, status = OrgService.create_org(request_json, user.identifier).as_dict(), HTTPStatus.CREATED
except BusinessException as exception:
response, status = (
{
"code": exception.code,
"message": exception.message,
"detail": exception.detail,
},
exception.status_code,
)
return response, status
if (result := org_utils.validate_schema(request_json, "org")).is_failure:
return result.error, result.status

if (result := org_utils.validate_and_get_user()).is_failure:
return result.error, result.status
user = result.value

if (result := org_utils.create_org(request_json, user.identifier)).is_failure:
return result.error, result.status

return result.value, HTTPStatus.CREATED


@bp.route("/<int:org_id>", methods=["GET", "OPTIONS"])
Expand Down Expand Up @@ -326,15 +319,13 @@ def get(org_id):
def post_organization_contact(org_id):
"""Create a new contact for the specified org."""
request_json = request.get_json()
valid_format, errors = schema_utils.validate(request_json, "contact")
if not valid_format:
return {"message": schema_utils.serialize(errors)}, HTTPStatus.BAD_REQUEST
if (result := org_utils.validate_schema(request_json, "contact")).is_failure:
return result.error, result.status

try:
response, status = OrgService.add_contact(org_id, request_json).as_dict(), HTTPStatus.CREATED
except BusinessException as exception:
response, status = {"code": exception.code, "message": exception.message}, exception.status_code
return response, status
if (result := org_utils.add_contact(org_id, request_json)).is_failure:
return result.error, result.status

return result.value, HTTPStatus.CREATED


@bp.route("/<int:org_id>/contacts", methods=["PUT"])
Expand Down
37 changes: 37 additions & 0 deletions auth-api/src/auth_api/resources/v2/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Copyright © 2025 Province of British Columbia
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Exposes all of the resource endpoints mounted in Flask-Blueprints - V2."""

from flask import Flask # noqa: TC002

from .org import bp as org_bp


class V2Endpoint:
"""Setup all the V2 Endpoints."""

def __init__(self):
"""Create the endpoint setup, without initializations."""
self.app: Flask | None = None

def init_app(self, app):
"""Register and initialize the Endpoint setup."""
if not app:
raise Exception("Cannot initialize without a Flask App.") # pylint: disable=broad-exception-raised

self.app = app
self.app.register_blueprint(org_bp)


v2_endpoint = V2Endpoint()
73 changes: 73 additions & 0 deletions auth-api/src/auth_api/resources/v2/org.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Copyright © 2019 Province of British Columbia
#
# Licensed under the Apache License, Version 2.0 (the 'License');
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an 'AS IS' BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""API endpoints for managing an Org resource - V2."""

from http import HTTPStatus

from flask import Blueprint, request
from flask_cors import cross_origin

from auth_api.resources import org_utils
from auth_api.utils.auth import jwt as _jwt
from auth_api.utils.endpoints_enums import EndpointEnum
from auth_api.utils.role_validator import validate_roles
from auth_api.utils.roles import Role

bp = Blueprint("ORGS_V2", __name__, url_prefix=f"{EndpointEnum.API_V2.value}/orgs")


@bp.route("", methods=["POST"])
@cross_origin(origins="*")
@validate_roles(
allowed_roles=[Role.PUBLIC_USER.value, Role.STAFF_CREATE_ACCOUNTS.value, Role.SYSTEM.value],
not_allowed_roles=[Role.ANONYMOUS_USER.value],
)
@_jwt.has_one_of_roles([Role.PUBLIC_USER.value, Role.STAFF_CREATE_ACCOUNTS.value, Role.SYSTEM.value])
def post_organization():
"""Post a new org with contact using the request body.

Creates an organization and then adds a contact if provided.
Validates both org and contact schemas before processing.
"""
request_json = request.get_json()
if not request_json:
return {"message": "Request body cannot be empty"}, HTTPStatus.BAD_REQUEST

org_info = request_json.copy()
contact_info = org_info.pop("contact", None)

if contact_info:
org_info.pop("mailingAddress", None)

if (result := org_utils.validate_schema(org_info, "org")).is_failure:
return result.error, result.status

if contact_info:
if (result := org_utils.validate_schema(contact_info, "contact")).is_failure:
return result.error, result.status

if (result := org_utils.validate_and_get_user()).is_failure:
return result.error, result.status
user = result.value

if (result := org_utils.create_org(org_info, user.identifier)).is_failure:
return result.error, result.status
org_dict = result.value

if contact_info and (org_id := org_dict.get("id")):
if (result := org_utils.add_contact(org_id, contact_info)).is_failure:
return result.error, result.status
org_dict["contact"] = result.value

return org_dict, HTTPStatus.CREATED
Loading
Loading