forked from sr2/cloud-api
fix: ty compliant & issues from change to mapped columns
This commit is contained in:
parent
55927946c7
commit
58e7ae6c5c
31 changed files with 271 additions and 254 deletions
|
|
@ -5,7 +5,7 @@
|
||||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||||
# format, relative to the token %(here)s which refers to the location of this
|
# format, relative to the token %(here)s which refers to the location of this
|
||||||
# ini file
|
# ini file
|
||||||
script_location = %(here)s/.alembic
|
script_location = %(here)s/alembic
|
||||||
|
|
||||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
# Uncomment the line below if you want the files to be prepended with date and time
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
|
|
||||||
100
alembic/versions/2026-06-22_mapped_columns.py
Normal file
100
alembic/versions/2026-06-22_mapped_columns.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
"""mapped columns
|
||||||
|
|
||||||
|
Revision ID: 869d48618a1c
|
||||||
|
Revises: 85edbf9a176c
|
||||||
|
Create Date: 2026-06-22 11:18:34.592199
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '869d48618a1c'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '85edbf9a176c'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.alter_column('group', 'org_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
nullable=False)
|
||||||
|
op.alter_column('organisation', 'name',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
nullable=False)
|
||||||
|
op.alter_column('organisation', 'status',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
nullable=False)
|
||||||
|
op.alter_column('organisation', 'root_user_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
nullable=False)
|
||||||
|
op.drop_constraint(op.f('organisation_name_key'), 'organisation', type_='unique')
|
||||||
|
op.alter_column('permission', 'service_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
nullable=False)
|
||||||
|
op.alter_column('service', 'name',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
nullable=False)
|
||||||
|
op.alter_column('service', 'api_key',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
nullable=False)
|
||||||
|
op.drop_constraint(op.f('service_api_key_key'), 'service', type_='unique')
|
||||||
|
op.alter_column('user', 'email',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
nullable=False)
|
||||||
|
op.alter_column('user', 'first_name',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
nullable=False)
|
||||||
|
op.alter_column('user', 'last_name',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
nullable=False)
|
||||||
|
op.alter_column('user', 'oidc_id',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
nullable=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.alter_column('user', 'oidc_id',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
nullable=True)
|
||||||
|
op.alter_column('user', 'last_name',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
nullable=True)
|
||||||
|
op.alter_column('user', 'first_name',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
nullable=True)
|
||||||
|
op.alter_column('user', 'email',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
nullable=True)
|
||||||
|
op.create_unique_constraint(op.f('service_api_key_key'), 'service', ['api_key'], postgresql_nulls_not_distinct=False)
|
||||||
|
op.alter_column('service', 'api_key',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
nullable=True)
|
||||||
|
op.alter_column('service', 'name',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
nullable=True)
|
||||||
|
op.alter_column('permission', 'service_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
nullable=True)
|
||||||
|
op.create_unique_constraint(op.f('organisation_name_key'), 'organisation', ['name'], postgresql_nulls_not_distinct=False)
|
||||||
|
op.alter_column('organisation', 'root_user_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
nullable=True)
|
||||||
|
op.alter_column('organisation', 'status',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
nullable=True)
|
||||||
|
op.alter_column('organisation', 'name',
|
||||||
|
existing_type=sa.VARCHAR(),
|
||||||
|
nullable=True)
|
||||||
|
op.alter_column('group', 'org_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
nullable=True)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
@ -43,3 +43,6 @@ dev = [
|
||||||
"pytest>=9.0.3",
|
"pytest>=9.0.3",
|
||||||
"ty>=0.0.44,<0.0.45",
|
"ty>=0.0.44,<0.0.45",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.ty.src]
|
||||||
|
exclude = ["alembic"]
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,33 @@ async def org_query_user_claims(
|
||||||
org_query_user_claims_dependency = Annotated[bool, Depends(org_query_user_claims)]
|
org_query_user_claims_dependency = Annotated[bool, Depends(org_query_user_claims)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_super_admin_list():
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def empty_su_list():
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def testing_su_list():
|
||||||
|
return ["admin@test.com"]
|
||||||
|
|
||||||
|
|
||||||
|
su_list_dependency = Annotated[list[str | None], Depends(get_super_admin_list)]
|
||||||
|
|
||||||
|
|
||||||
|
async def user_model_super_admin(
|
||||||
|
user_model: user_model_claims_dependency, super_admin_emails: su_list_dependency
|
||||||
|
):
|
||||||
|
if user_model.email in super_admin_emails:
|
||||||
|
return user_model
|
||||||
|
|
||||||
|
raise ForbiddenException(message="Must be super admin")
|
||||||
|
|
||||||
|
|
||||||
|
super_admin_dependency = Annotated[User, Depends(user_model_super_admin)]
|
||||||
|
|
||||||
|
|
||||||
async def org_query_root_claims(
|
async def org_query_root_claims(
|
||||||
user_model: user_model_claims_dependency,
|
user_model: user_model_claims_dependency,
|
||||||
org_model: org_model_query_dependency,
|
org_model: org_model_query_dependency,
|
||||||
|
|
@ -54,9 +81,7 @@ async def org_query_root_claims(
|
||||||
raise ForbiddenException(message="Must be the org's root user")
|
raise ForbiddenException(message="Must be the org's root user")
|
||||||
|
|
||||||
|
|
||||||
org_model_root_claim_query_dependency = Annotated[
|
org_model_root_claim_query_dependency = Annotated[Org, Depends(org_query_root_claims)]
|
||||||
type[Org], Depends(org_query_root_claims)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def org_body_root_claims(
|
async def org_body_root_claims(
|
||||||
|
|
@ -79,33 +104,4 @@ async def org_body_root_claims(
|
||||||
raise ForbiddenException(message="Must be the org's root user")
|
raise ForbiddenException(message="Must be the org's root user")
|
||||||
|
|
||||||
|
|
||||||
org_model_root_claim_body_dependency = Annotated[
|
org_model_root_claim_body_dependency = Annotated[Org, Depends(org_body_root_claims)]
|
||||||
type[Org], Depends(org_body_root_claims)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_super_admin_list():
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def empty_su_list():
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def testing_su_list():
|
|
||||||
return ["admin@test.com"]
|
|
||||||
|
|
||||||
|
|
||||||
su_list_dependency = Annotated[list[User], Depends(get_super_admin_list)]
|
|
||||||
|
|
||||||
|
|
||||||
async def user_model_super_admin(
|
|
||||||
user_model: user_model_claims_dependency, super_admin_emails: su_list_dependency
|
|
||||||
):
|
|
||||||
if user_model.email in super_admin_emails:
|
|
||||||
return user_model
|
|
||||||
|
|
||||||
raise ForbiddenException(message="Must be super admin")
|
|
||||||
|
|
||||||
|
|
||||||
super_admin_dependency = Annotated[type[User], Depends(user_model_super_admin)]
|
|
||||||
|
|
|
||||||
|
|
@ -43,14 +43,11 @@ async def get_current_user(
|
||||||
key_response = requests.get(jwks_uri)
|
key_response = requests.get(jwks_uri)
|
||||||
jwk_keys = KeySet.import_key_set(key_response.json())
|
jwk_keys = KeySet.import_key_set(key_response.json())
|
||||||
|
|
||||||
claims_options = {
|
|
||||||
"exp": {"essential": True},
|
|
||||||
"iss": {"essential": True, "value": auth_settings.OIDC_ISSUER},
|
|
||||||
}
|
|
||||||
|
|
||||||
token = jwt.decode(oidc_auth_string.replace("Bearer ", ""), jwk_keys)
|
token = jwt.decode(oidc_auth_string.replace("Bearer ", ""), jwk_keys)
|
||||||
|
|
||||||
claims_requests = jwt.JWTClaimsRegistry(**claims_options)
|
claims_requests = jwt.JWTClaimsRegistry(
|
||||||
|
exp={"essential": True}, iss={"essential": True, "value": auth_settings.OIDC_ISSUER}
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
claims_requests.validate(token.claims)
|
claims_requests.validate(token.claims)
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ class CustomBaseSettings(BaseSettings):
|
||||||
class Config(CustomBaseSettings):
|
class Config(CustomBaseSettings):
|
||||||
APP_VERSION: str = "0.1"
|
APP_VERSION: str = "0.1"
|
||||||
ENVIRONMENT: Environment = Environment.PRODUCTION
|
ENVIRONMENT: Environment = Environment.PRODUCTION
|
||||||
SECRET_KEY: SecretStr = ""
|
SECRET_KEY: SecretStr = SecretStr("")
|
||||||
DISABLE_AUTH: bool = False
|
DISABLE_AUTH: bool = False
|
||||||
|
|
||||||
CORS_ORIGINS: list[str] = ["*"]
|
CORS_ORIGINS: list[str] = ["*"]
|
||||||
|
|
@ -34,7 +34,7 @@ class Config(CustomBaseSettings):
|
||||||
DATABASE_NAME: str = "fastapi-exp"
|
DATABASE_NAME: str = "fastapi-exp"
|
||||||
DATABASE_PORT: str = "5432"
|
DATABASE_PORT: str = "5432"
|
||||||
DATABASE_HOSTNAME: str = "localhost"
|
DATABASE_HOSTNAME: str = "localhost"
|
||||||
DATABASE_CREDENTIALS: SecretStr = ":"
|
DATABASE_CREDENTIALS: SecretStr = SecretStr(":")
|
||||||
|
|
||||||
|
|
||||||
settings = Config()
|
settings = Config()
|
||||||
|
|
@ -44,9 +44,9 @@ DATABASE_PORT = settings.DATABASE_PORT
|
||||||
DATABASE_HOSTNAME = settings.DATABASE_HOSTNAME
|
DATABASE_HOSTNAME = settings.DATABASE_HOSTNAME
|
||||||
DATABASE_CREDENTIALS = settings.DATABASE_CREDENTIALS.get_secret_value()
|
DATABASE_CREDENTIALS = settings.DATABASE_CREDENTIALS.get_secret_value()
|
||||||
# this will support special chars for credentials
|
# this will support special chars for credentials
|
||||||
_DATABASE_CREDENTIAL_USER, _DATABASE_CREDENTIAL_PASSWORD = str(
|
_DATABASE_CREDENTIAL_USER, _DATABASE_CREDENTIAL_PASSWORD = str(DATABASE_CREDENTIALS).split(
|
||||||
DATABASE_CREDENTIALS
|
":"
|
||||||
).split(":")
|
)
|
||||||
_QUOTED_DATABASE_PASSWORD = parse.quote_plus(str(_DATABASE_CREDENTIAL_PASSWORD))
|
_QUOTED_DATABASE_PASSWORD = parse.quote_plus(str(_DATABASE_CREDENTIAL_PASSWORD))
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URI = SecretStr(
|
SQLALCHEMY_DATABASE_URI = SecretStr(
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,10 @@ class Environment(StrEnum):
|
||||||
Enumeration of environments.
|
Enumeration of environments.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
LOCAL (str): Application is running locally
|
LOCAL (str): Application is running locally
|
||||||
TESTING (str): Application is running in testing mode
|
TESTING (str): Application is running in testing mode
|
||||||
STAGING (str): Application is running in staging mode (ie not testing)
|
STAGING (str): Application is running in staging mode (ie not testing)
|
||||||
PRODUCTION (str): Application is running in production mode
|
PRODUCTION (str): Application is running in production mode
|
||||||
"""
|
"""
|
||||||
|
|
||||||
LOCAL = auto()
|
LOCAL = auto()
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ Models:
|
||||||
- Contact: id[pk], email, first_name, last_name, phonenumber, vat_number
|
- Contact: id[pk], email, first_name, last_name, phonenumber, vat_number
|
||||||
street_address, street_address_line_2, post_office_box_number, address_locality, country_code, address_region, postal_code
|
street_address, street_address_line_2, post_office_box_number, address_locality, country_code, address_region, postal_code
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey
|
from sqlalchemy import ForeignKey
|
||||||
from sqlalchemy.orm import mapped_column, Mapped
|
from sqlalchemy.orm import mapped_column, Mapped
|
||||||
|
|
||||||
|
|
@ -15,19 +16,19 @@ class Contact(CustomBase):
|
||||||
__tablename__ = "contact"
|
__tablename__ = "contact"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
email: Mapped[str]
|
email: Mapped[str] = mapped_column(default=None, nullable=True)
|
||||||
first_name: Mapped[str]
|
first_name: Mapped[str] = mapped_column(default=None, nullable=True)
|
||||||
last_name: Mapped[str]
|
last_name: Mapped[str] = mapped_column(default=None, nullable=True)
|
||||||
phonenumber: Mapped[str]
|
phonenumber: Mapped[str] = mapped_column(default=None, nullable=True)
|
||||||
vat_number: Mapped[str | None] = mapped_column(default=None)
|
vat_number: Mapped[str | None] = mapped_column(default=None, nullable=True)
|
||||||
|
|
||||||
street_address : Mapped[str]
|
street_address: Mapped[str] = mapped_column(default=None, nullable=True)
|
||||||
street_address_line_2 : Mapped[str]
|
street_address_line_2: Mapped[str] = mapped_column(default=None, nullable=True)
|
||||||
post_office_box_number: Mapped[str | None] = mapped_column(default=None)
|
post_office_box_number: Mapped[str | None] = mapped_column(default=None, nullable=True)
|
||||||
locality : Mapped[str] # Ie City
|
locality: Mapped[str] = mapped_column(default=None, nullable=True) # Ie City
|
||||||
country_code : Mapped[str] # Eg GB
|
country_code: Mapped[str] = mapped_column(default=None, nullable=True) # Eg GB
|
||||||
address_region: Mapped[str | None] = mapped_column(default=None)
|
address_region: Mapped[str | None] = mapped_column(default=None, nullable=True)
|
||||||
postal_code : Mapped[str]
|
postal_code: Mapped[str] = mapped_column(default=None, nullable=True)
|
||||||
|
|
||||||
org_id: Mapped[int] = mapped_column(
|
org_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("organisation.id", ondelete="CASCADE"), nullable=False
|
ForeignKey("organisation.id", ondelete="CASCADE"), nullable=False
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ Exports:
|
||||||
- db_dependency
|
- db_dependency
|
||||||
- Base (sqlalchemy base model)
|
- Base (sqlalchemy base model)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from sqlalchemy import create_engine, StaticPool
|
from sqlalchemy import create_engine, StaticPool
|
||||||
from sqlalchemy.orm import sessionmaker, Session
|
from sqlalchemy.orm import sessionmaker, Session
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ from src.iam.schemas import GroupIDMixin, PermIDMixin
|
||||||
|
|
||||||
def get_group_model_query(
|
def get_group_model_query(
|
||||||
db: db_dependency, group_id: Annotated[int, Query(gt=0)]
|
db: db_dependency, group_id: Annotated[int, Query(gt=0)]
|
||||||
) -> type[Group]:
|
) -> Group:
|
||||||
group_model = db.get(Group, group_id)
|
group_model = db.get(Group, group_id)
|
||||||
if group_model is None:
|
if group_model is None:
|
||||||
raise GroupNotFoundException(group_id)
|
raise GroupNotFoundException(group_id)
|
||||||
|
|
@ -28,12 +28,12 @@ def get_group_model_query(
|
||||||
return group_model
|
return group_model
|
||||||
|
|
||||||
|
|
||||||
group_model_query_dependency = Annotated[type[Group], Depends(get_group_model_query)]
|
group_model_query_dependency = Annotated[Group, Depends(get_group_model_query)]
|
||||||
|
|
||||||
|
|
||||||
def get_group_model_body(
|
def get_group_model_body(
|
||||||
db: db_dependency, request_model: Optional[GroupIDMixin] = None
|
db: db_dependency, request_model: Optional[GroupIDMixin] = None
|
||||||
) -> type[Group]:
|
) -> Group:
|
||||||
group_id = getattr(request_model, "group_id", None)
|
group_id = getattr(request_model, "group_id", None)
|
||||||
if group_id is None:
|
if group_id is None:
|
||||||
raise GroupNotFoundException()
|
raise GroupNotFoundException()
|
||||||
|
|
@ -44,12 +44,12 @@ def get_group_model_body(
|
||||||
return group_model
|
return group_model
|
||||||
|
|
||||||
|
|
||||||
group_model_body_dependency = Annotated[type[Group], Depends(get_group_model_body)]
|
group_model_body_dependency = Annotated[Group, Depends(get_group_model_body)]
|
||||||
|
|
||||||
|
|
||||||
def get_perm_model_body(
|
def get_perm_model_body(
|
||||||
db: db_dependency, request_model: Optional[PermIDMixin] = None
|
db: db_dependency, request_model: Optional[PermIDMixin] = None
|
||||||
) -> type[Permission]:
|
) -> Permission:
|
||||||
perm_id = getattr(request_model, "permission_id", None)
|
perm_id = getattr(request_model, "permission_id", None)
|
||||||
if perm_id is None:
|
if perm_id is None:
|
||||||
raise PermNotFoundException
|
raise PermNotFoundException
|
||||||
|
|
@ -60,12 +60,12 @@ def get_perm_model_body(
|
||||||
return perm_model
|
return perm_model
|
||||||
|
|
||||||
|
|
||||||
perm_model_body_dependency = Annotated[type[Permission], Depends(get_perm_model_body)]
|
perm_model_body_dependency = Annotated[Permission, Depends(get_perm_model_body)]
|
||||||
|
|
||||||
|
|
||||||
def get_perm_model_query(
|
def get_perm_model_query(
|
||||||
db: db_dependency, perm_id: Annotated[int, Query(gt=0)]
|
db: db_dependency, perm_id: Annotated[int, Query(gt=0)]
|
||||||
) -> type[Permission]:
|
) -> Permission:
|
||||||
perm_model = db.get(Permission, perm_id)
|
perm_model = db.get(Permission, perm_id)
|
||||||
if perm_model is None:
|
if perm_model is None:
|
||||||
raise PermNotFoundException(perm_id)
|
raise PermNotFoundException(perm_id)
|
||||||
|
|
@ -73,4 +73,4 @@ def get_perm_model_query(
|
||||||
return perm_model
|
return perm_model
|
||||||
|
|
||||||
|
|
||||||
perm_model_query_dependency = Annotated[type[Permission], Depends(get_perm_model_query)]
|
perm_model_query_dependency = Annotated[Permission, Depends(get_perm_model_query)]
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,9 @@ class Permission(CustomBase):
|
||||||
)
|
)
|
||||||
|
|
||||||
service_rel = relationship(
|
service_rel = relationship(
|
||||||
"Service", back_populates="permission_rel", foreign_keys="Permission.service_id"
|
"Service",
|
||||||
|
back_populates="permission_rel",
|
||||||
|
foreign_keys="Permission.service_id",
|
||||||
)
|
)
|
||||||
|
|
||||||
group_rel = relationship(
|
group_rel = relationship(
|
||||||
|
|
|
||||||
|
|
@ -207,9 +207,7 @@ async def can_act_on_resource(
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"examples": {
|
"examples": {
|
||||||
"db_id": {
|
"db_id": {"summary": "User not found in db when checking claims."},
|
||||||
"summary": "User not found in db when checking claims."
|
|
||||||
},
|
|
||||||
"user_model": {"summary": "User model not found in db."},
|
"user_model": {"summary": "User model not found in db."},
|
||||||
"org_model": {"summary": "Org model not found in db."},
|
"org_model": {"summary": "Org model not found in db."},
|
||||||
"group_model": {"summary": "Group model not found in db."},
|
"group_model": {"summary": "Group model not found in db."},
|
||||||
|
|
@ -268,9 +266,7 @@ async def get_group_users(
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
response_model=IAMPostGroupResponse,
|
response_model=IAMPostGroupResponse,
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_409_CONFLICT: {
|
status.HTTP_409_CONFLICT: {"description": "Group with this name already exists"},
|
||||||
"description": "Group with this name already exists"
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def create_group(
|
async def create_group(
|
||||||
|
|
@ -568,9 +564,7 @@ async def permissions_search(
|
||||||
)
|
)
|
||||||
|
|
||||||
if not (request_model.resource is None or request_model.resource == ""):
|
if not (request_model.resource is None or request_model.resource == ""):
|
||||||
permission_query = permission_query.filter(
|
permission_query = permission_query.filter(Perm.resource == request_model.resource)
|
||||||
Perm.resource == request_model.resource
|
|
||||||
)
|
|
||||||
|
|
||||||
if not (request_model.action is None or request_model.action == ""):
|
if not (request_model.action is None or request_model.action == ""):
|
||||||
permission_query = permission_query.filter(Perm.action == request_model.action)
|
permission_query = permission_query.filter(Perm.action == request_model.action)
|
||||||
|
|
@ -633,9 +627,7 @@ async def invitation(
|
||||||
response_model=IAMPutGroupInvitationAcceptResponse,
|
response_model=IAMPutGroupInvitationAcceptResponse,
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_404_NOT_FOUND: {"description": "User|Org|Group not found"},
|
status.HTTP_404_NOT_FOUND: {"description": "User|Org|Group not found"},
|
||||||
status.HTTP_403_FORBIDDEN: {
|
status.HTTP_403_FORBIDDEN: {"description": "Group and organisation do not match"},
|
||||||
"description": "Group and organisation do not match"
|
|
||||||
},
|
|
||||||
status.HTTP_409_CONFLICT: {"description": "User is already in the group"},
|
status.HTTP_409_CONFLICT: {"description": "User is already in the group"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -647,9 +639,7 @@ async def accept_invitation(
|
||||||
"""
|
"""
|
||||||
Accepts an invitation to join an org's group
|
Accepts an invitation to join an org's group
|
||||||
"""
|
"""
|
||||||
email_claims = await verify_email_token(
|
email_claims = await verify_email_token(token=request_model.jwt, user_model=user_model)
|
||||||
token=request_model.jwt, user_model=user_model
|
|
||||||
)
|
|
||||||
|
|
||||||
org_model = db.get(Org, email_claims["org_id"])
|
org_model = db.get(Org, email_claims["org_id"])
|
||||||
if org_model is None:
|
if org_model is None:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Global database models
|
Global database models
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -13,4 +14,3 @@ class CustomBase(DeclarativeBase):
|
||||||
datetime: DateTime(timezone=True),
|
datetime: DateTime(timezone=True),
|
||||||
dict[str, Any]: JSON,
|
dict[str, Any]: JSON,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,12 @@ class Status(StrEnum):
|
||||||
Enumeration of organisation statuses.
|
Enumeration of organisation statuses.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
PARTIAL(str): Organisation has been created but questionnaire hasn't been submitted.
|
PARTIAL(str): Organisation has been created but questionnaire hasn't been submitted.
|
||||||
SUBMITTED (str): Questionnaire submitted but not approved.
|
SUBMITTED (str): Questionnaire submitted but not approved.
|
||||||
REMEDIATION (str): Questionnaire submitted but requires revisions.
|
REMEDIATION (str): Questionnaire submitted but requires revisions.
|
||||||
APPROVED (str): Questionnaire has been approved by an admin.
|
APPROVED (str): Questionnaire has been approved by an admin.
|
||||||
REJECTED (str): Questionnaire has been rejected by an admin.
|
REJECTED (str): Questionnaire has been rejected by an admin.
|
||||||
REMOVED (str): Organisation has been removed.
|
REMOVED (str): Organisation has been removed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PARTIAL = auto()
|
PARTIAL = auto()
|
||||||
|
|
@ -47,9 +47,9 @@ class ContactType(StrEnum):
|
||||||
Enumeration of organisation contact types.
|
Enumeration of organisation contact types.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
BILLING(str): Billing contact.
|
BILLING(str): Billing contact.
|
||||||
SECURITY (str): Security contact.
|
SECURITY (str): Security contact.
|
||||||
OWNER (str): Owner contact.
|
OWNER (str): Owner contact.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BILLING = auto()
|
BILLING = auto()
|
||||||
|
|
|
||||||
|
|
@ -17,19 +17,17 @@ from src.organisation.models import Organisation as Org
|
||||||
from src.organisation.exceptions import OrgNotFoundException
|
from src.organisation.exceptions import OrgNotFoundException
|
||||||
|
|
||||||
|
|
||||||
def get_org_model_query(
|
def get_org_model_query(db: db_dependency, org_id: Annotated[int, Query(gt=0)]) -> Org:
|
||||||
db: db_dependency, org_id: Annotated[int, Query(gt=0)]
|
|
||||||
) -> type[Org]:
|
|
||||||
org_model = db.get(Org, org_id)
|
org_model = db.get(Org, org_id)
|
||||||
if org_model is None:
|
if org_model is None:
|
||||||
raise OrgNotFoundException(org_id)
|
raise OrgNotFoundException(org_id)
|
||||||
return org_model
|
return org_model
|
||||||
|
|
||||||
|
|
||||||
org_model_query_dependency = Annotated[type[Org], Depends(get_org_model_query)]
|
org_model_query_dependency = Annotated[Org, Depends(get_org_model_query)]
|
||||||
|
|
||||||
|
|
||||||
def get_org_model_body(db: db_dependency, request_model: OrgIDMixin) -> type[Org]:
|
def get_org_model_body(db: db_dependency, request_model: OrgIDMixin) -> Org:
|
||||||
org_id: Optional[int] = getattr(request_model, "organisation_id", None)
|
org_id: Optional[int] = getattr(request_model, "organisation_id", None)
|
||||||
if org_id is None:
|
if org_id is None:
|
||||||
raise OrgNotFoundException()
|
raise OrgNotFoundException()
|
||||||
|
|
@ -41,4 +39,4 @@ def get_org_model_body(db: db_dependency, request_model: OrgIDMixin) -> type[Org
|
||||||
return org_model
|
return org_model
|
||||||
|
|
||||||
|
|
||||||
org_model_body_dependency = Annotated[type[Org], Depends(get_org_model_body)]
|
org_model_body_dependency = Annotated[Org, Depends(get_org_model_body)]
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ Models:
|
||||||
- owner_contact_rel: ORM relationship to Contact with owner_contact FK
|
- owner_contact_rel: ORM relationship to Contact with owner_contact FK
|
||||||
- OrgUsers: org_id[FK][PK], user_id[FK][PK]
|
- OrgUsers: org_id[FK][PK], user_id[FK][PK]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey
|
from sqlalchemy import ForeignKey
|
||||||
|
|
@ -30,15 +31,17 @@ class Organisation(CustomBase):
|
||||||
intake_questionnaire: Mapped[dict[str, Any] | None]
|
intake_questionnaire: Mapped[dict[str, Any] | None]
|
||||||
|
|
||||||
root_user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
|
root_user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
|
||||||
billing_contact_id: Mapped[int] = mapped_column(ForeignKey("contact.id"))
|
billing_contact_id: Mapped[int] = mapped_column(ForeignKey("contact.id"), nullable=True)
|
||||||
security_contact_id: Mapped[int] = mapped_column(ForeignKey("contact.id"))
|
security_contact_id: Mapped[int] = mapped_column(
|
||||||
owner_contact_id: Mapped[int] = mapped_column(ForeignKey("contact.id"))
|
ForeignKey("contact.id"), nullable=True
|
||||||
|
|
||||||
user_rel = relationship(
|
|
||||||
"User", secondary="orgusers", back_populates="organisation_rel"
|
|
||||||
)
|
)
|
||||||
|
owner_contact_id: Mapped[int] = mapped_column(ForeignKey("contact.id"), nullable=True)
|
||||||
|
|
||||||
group_rel = relationship("Group", back_populates="org_rel")
|
user_rel = relationship("User", secondary="orgusers", back_populates="organisation_rel")
|
||||||
|
|
||||||
|
group_rel = relationship(
|
||||||
|
"Group", back_populates="org_rel", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
root_user_rel = relationship("User", foreign_keys="Organisation.root_user_id")
|
root_user_rel = relationship("User", foreign_keys="Organisation.root_user_id")
|
||||||
|
|
||||||
billing_contact_rel = relationship(
|
billing_contact_rel = relationship(
|
||||||
|
|
@ -56,8 +59,9 @@ class Organisation(CustomBase):
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def root_user_email(self):
|
def root_user_email(self) -> str:
|
||||||
return self.root_user_rel.email if self.root_user_rel else None
|
return self.root_user_rel.email if self.root_user_rel else ""
|
||||||
|
|
||||||
|
|
||||||
class OrgUsers(CustomBase):
|
class OrgUsers(CustomBase):
|
||||||
__tablename__ = "orgusers"
|
__tablename__ = "orgusers"
|
||||||
|
|
@ -65,4 +69,6 @@ class OrgUsers(CustomBase):
|
||||||
org_id: Mapped[int] = mapped_column(
|
org_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("organisation.id", ondelete="CASCADE"), primary_key=True
|
ForeignKey("organisation.id", ondelete="CASCADE"), primary_key=True
|
||||||
)
|
)
|
||||||
user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), primary_key=True)
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("user.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -133,9 +133,7 @@ async def get_org_by_id(
|
||||||
response_model=OrgPostOrgResponse,
|
response_model=OrgPostOrgResponse,
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_201_CREATED: {"description": "Successfully created organisation."},
|
status.HTTP_201_CREATED: {"description": "Successfully created organisation."},
|
||||||
status.HTTP_422_UNPROCESSABLE_CONTENT: {
|
status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "Invalid data in request."},
|
||||||
"description": "Invalid data in request."
|
|
||||||
},
|
|
||||||
status.HTTP_401_UNAUTHORIZED: {
|
status.HTTP_401_UNAUTHORIZED: {
|
||||||
"description": "User must be logged in with OIDC to create organisation."
|
"description": "User must be logged in with OIDC to create organisation."
|
||||||
},
|
},
|
||||||
|
|
@ -169,6 +167,7 @@ async def create_org(
|
||||||
org_model = Org(
|
org_model = Org(
|
||||||
name=request_model.name,
|
name=request_model.name,
|
||||||
intake_questionnaire=intake_questionnaire.model_dump(mode="json"),
|
intake_questionnaire=intake_questionnaire.model_dump(mode="json"),
|
||||||
|
root_user_id=user_model.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
org_model.status = "partial"
|
org_model.status = "partial"
|
||||||
|
|
@ -181,13 +180,10 @@ async def create_org(
|
||||||
isinstance(e.orig, UniqueViolation) # Postgres unique violation
|
isinstance(e.orig, UniqueViolation) # Postgres unique violation
|
||||||
or "UNIQUE constraint failed" in str(e.orig) # SQLite unique violation
|
or "UNIQUE constraint failed" in str(e.orig) # SQLite unique violation
|
||||||
):
|
):
|
||||||
raise ConflictException(
|
raise ConflictException(message="Organisation with this name already exists")
|
||||||
message="Organisation with this name already exists"
|
|
||||||
)
|
|
||||||
raise
|
raise
|
||||||
# Adds currently logged-in user to org users list and sets them as root_user
|
# Adds currently logged-in user to org users list and sets them as root_user
|
||||||
org_model.user_rel.append(user_model)
|
org_model.user_rel.append(user_model)
|
||||||
org_model.root_user_rel = user_model
|
|
||||||
|
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
assign_defaults, db, org_id=org_model.id, user_id=user_model.id
|
assign_defaults, db, org_id=org_model.id, user_id=user_model.id
|
||||||
|
|
@ -214,9 +210,7 @@ async def create_org(
|
||||||
response_model=OrgPatchQuestionnaireResponse,
|
response_model=OrgPatchQuestionnaireResponse,
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_200_OK: {"description": "Successfully updated questionnaire."},
|
status.HTTP_200_OK: {"description": "Successfully updated questionnaire."},
|
||||||
status.HTTP_422_UNPROCESSABLE_CONTENT: {
|
status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "Invalid data in request."},
|
||||||
"description": "Invalid data in request."
|
|
||||||
},
|
|
||||||
status.HTTP_403_FORBIDDEN: {
|
status.HTTP_403_FORBIDDEN: {
|
||||||
"description": "Not authorised. Must be org root user."
|
"description": "Not authorised. Must be org root user."
|
||||||
},
|
},
|
||||||
|
|
@ -234,12 +228,22 @@ async def update_questionnaire(
|
||||||
"""
|
"""
|
||||||
org_status = StatusEnum(org_model.status)
|
org_status = StatusEnum(org_model.status)
|
||||||
if not org_status.is_pre_submission:
|
if not org_status.is_pre_submission:
|
||||||
raise ForbiddenException(
|
raise ForbiddenException("Questionnaire may only be modified prior to submission.")
|
||||||
"Questionnaire may only be modified prior to submission."
|
update_data: dict = request_model.intake_questionnaire.model_dump(exclude_none=True)
|
||||||
)
|
|
||||||
update_data = request_model.intake_questionnaire.model_dump(exclude_none=True)
|
|
||||||
questionnaire = org_model.intake_questionnaire
|
questionnaire = org_model.intake_questionnaire
|
||||||
questions_model = QuestionnaireQuestionsVersion0(**questionnaire["questions"])
|
if questionnaire is None:
|
||||||
|
questionnaire_questions = QuestionnaireQuestionsVersion0().model_dump()
|
||||||
|
|
||||||
|
questionnaire_metadata = QuestionnaireMetadata(version=0, submission_date=None)
|
||||||
|
|
||||||
|
questionnaire = Questionnaire(
|
||||||
|
metadata=questionnaire_metadata,
|
||||||
|
questions=questionnaire_questions,
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
questions_model = QuestionnaireQuestionsVersion0(**questionnaire["questions"])
|
||||||
|
else:
|
||||||
|
questions_model = QuestionnaireQuestionsVersion0(**questionnaire["questions"])
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
if hasattr(questions_model, key):
|
if hasattr(questions_model, key):
|
||||||
setattr(questions_model, key, value)
|
setattr(questions_model, key, value)
|
||||||
|
|
@ -271,15 +275,9 @@ async def update_questionnaire(
|
||||||
status_code=status.HTTP_200_OK,
|
status_code=status.HTTP_200_OK,
|
||||||
response_model=OrgPatchStatusResponse,
|
response_model=OrgPatchStatusResponse,
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_200_OK: {
|
status.HTTP_200_OK: {"description": "Successfully updated organisation status."},
|
||||||
"description": "Successfully updated organisation status."
|
status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "Invalid data in request."},
|
||||||
},
|
status.HTTP_403_FORBIDDEN: {"description": "Not authorised. Must be super admin."},
|
||||||
status.HTTP_422_UNPROCESSABLE_CONTENT: {
|
|
||||||
"description": "Invalid data in request."
|
|
||||||
},
|
|
||||||
status.HTTP_403_FORBIDDEN: {
|
|
||||||
"description": "Not authorised. Must be super admin."
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def update_status(
|
async def update_status(
|
||||||
|
|
@ -329,15 +327,11 @@ async def get_users(org_model: org_model_root_claim_query_dependency):
|
||||||
status_code=status.HTTP_200_OK,
|
status_code=status.HTTP_200_OK,
|
||||||
response_model=OrgPostUserResponse,
|
response_model=OrgPostUserResponse,
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_200_OK: {
|
status.HTTP_200_OK: {"description": "Successfully added user to the organisation."},
|
||||||
"description": "Successfully added user to the organisation."
|
|
||||||
},
|
|
||||||
status.HTTP_403_FORBIDDEN: {
|
status.HTTP_403_FORBIDDEN: {
|
||||||
"description": "Not authorised. Must be org root user."
|
"description": "Not authorised. Must be org root user."
|
||||||
},
|
},
|
||||||
status.HTTP_422_UNPROCESSABLE_CONTENT: {
|
status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "Invalid data in request."},
|
||||||
"description": "Invalid data in request."
|
|
||||||
},
|
|
||||||
status.HTTP_409_CONFLICT: {
|
status.HTTP_409_CONFLICT: {
|
||||||
"description": "User is already a member of the organisation."
|
"description": "User is already a member of the organisation."
|
||||||
},
|
},
|
||||||
|
|
@ -378,12 +372,8 @@ async def add_user_to_org(
|
||||||
summary="Delete organisation from the hub.",
|
summary="Delete organisation from the hub.",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_204_NO_CONTENT: {
|
status.HTTP_204_NO_CONTENT: {"description": "Successfully deleted organisation."},
|
||||||
"description": "Successfully deleted organisation."
|
status.HTTP_403_FORBIDDEN: {"description": "Not authorised. Must be super admin."},
|
||||||
},
|
|
||||||
status.HTTP_403_FORBIDDEN: {
|
|
||||||
"description": "Not authorised. Must be super admin."
|
|
||||||
},
|
|
||||||
status.HTTP_422_UNPROCESSABLE_CONTENT: {
|
status.HTTP_422_UNPROCESSABLE_CONTENT: {
|
||||||
"description": "Org ID missing or invalid."
|
"description": "Org ID missing or invalid."
|
||||||
},
|
},
|
||||||
|
|
@ -406,9 +396,7 @@ async def delete_organisation_by_id(
|
||||||
summary="Delete organisation from the hub as root user before it has been approved.",
|
summary="Delete organisation from the hub as root user before it has been approved.",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_204_NO_CONTENT: {
|
status.HTTP_204_NO_CONTENT: {"description": "Successfully deleted organisation."},
|
||||||
"description": "Successfully deleted organisation."
|
|
||||||
},
|
|
||||||
status.HTTP_422_UNPROCESSABLE_CONTENT: {
|
status.HTTP_422_UNPROCESSABLE_CONTENT: {
|
||||||
"description": "Unprocessable content.",
|
"description": "Unprocessable content.",
|
||||||
"content": {
|
"content": {
|
||||||
|
|
@ -452,9 +440,7 @@ async def delete_organisation_by_id(
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"examples": {
|
"examples": {
|
||||||
"db_id": {
|
"db_id": {"summary": "User not found in db when checking claims."},
|
||||||
"summary": "User not found in db when checking claims."
|
|
||||||
},
|
|
||||||
"user_model": {"summary": "User model not found in db."},
|
"user_model": {"summary": "User model not found in db."},
|
||||||
"org_model": {"summary": "Org model not found in db."},
|
"org_model": {"summary": "Org model not found in db."},
|
||||||
}
|
}
|
||||||
|
|
@ -472,9 +458,7 @@ async def delete_preapproved_organisation_by_id(
|
||||||
"""
|
"""
|
||||||
org_status = StatusEnum(org_model.status)
|
org_status = StatusEnum(org_model.status)
|
||||||
if not org_status.is_pre_approval:
|
if not org_status.is_pre_approval:
|
||||||
raise ForbiddenException(
|
raise ForbiddenException(message="Organisation is no longer in pre-approval state.")
|
||||||
message="Organisation is no longer in pre-approval state."
|
|
||||||
)
|
|
||||||
|
|
||||||
db.delete(org_model)
|
db.delete(org_model)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -487,9 +471,7 @@ async def delete_preapproved_organisation_by_id(
|
||||||
response_model=OrgPatchRootResponse,
|
response_model=OrgPatchRootResponse,
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_200_OK: {"description": "Successfully updated root user."},
|
status.HTTP_200_OK: {"description": "Successfully updated root user."},
|
||||||
status.HTTP_422_UNPROCESSABLE_CONTENT: {
|
status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "Invalid data in request."},
|
||||||
"description": "Invalid data in request."
|
|
||||||
},
|
|
||||||
status.HTTP_401_UNAUTHORIZED: {
|
status.HTTP_401_UNAUTHORIZED: {
|
||||||
"description": "Not authorised. Must be super admin."
|
"description": "Not authorised. Must be super admin."
|
||||||
},
|
},
|
||||||
|
|
@ -539,9 +521,7 @@ async def get_org_groups(org_model: org_model_root_claim_query_dependency):
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"organisation": org_model,
|
"organisation": org_model,
|
||||||
"groups": [
|
"groups": [{"id": group.id, "name": group.name} for group in org_model.group_rel],
|
||||||
{"id": group.id, "name": group.name} for group in org_model.group_rel
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -554,9 +534,7 @@ async def get_org_groups(org_model: org_model_root_claim_query_dependency):
|
||||||
status.HTTP_403_FORBIDDEN: {
|
status.HTTP_403_FORBIDDEN: {
|
||||||
"description": "Not authorised. Must be org root user."
|
"description": "Not authorised. Must be org root user."
|
||||||
},
|
},
|
||||||
status.HTTP_422_UNPROCESSABLE_CONTENT: {
|
status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "Invalid data in request."},
|
||||||
"description": "Invalid data in request."
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def remove_user_from_org(
|
async def remove_user_from_org(
|
||||||
|
|
@ -581,9 +559,7 @@ async def remove_user_from_org(
|
||||||
response_model=OrgGetContactResponse,
|
response_model=OrgGetContactResponse,
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_200_OK: {"description": "Successful retrieval of contact."},
|
status.HTTP_200_OK: {"description": "Successful retrieval of contact."},
|
||||||
status.HTTP_422_UNPROCESSABLE_CONTENT: {
|
status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "Invalid data in request."},
|
||||||
"description": "Invalid data in request."
|
|
||||||
},
|
|
||||||
status.HTTP_403_FORBIDDEN: {
|
status.HTTP_403_FORBIDDEN: {
|
||||||
"description": "Not authorised. Must be org root user."
|
"description": "Not authorised. Must be org root user."
|
||||||
},
|
},
|
||||||
|
|
@ -626,9 +602,7 @@ async def get_contact(
|
||||||
response_model=OrgPatchContactResponse,
|
response_model=OrgPatchContactResponse,
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_200_OK: {"description": "Successfully updated contact."},
|
status.HTTP_200_OK: {"description": "Successfully updated contact."},
|
||||||
status.HTTP_422_UNPROCESSABLE_CONTENT: {
|
status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "Invalid data in request."},
|
||||||
"description": "Invalid data in request."
|
|
||||||
},
|
|
||||||
status.HTTP_403_FORBIDDEN: {
|
status.HTTP_403_FORBIDDEN: {
|
||||||
"description": "Not authorised. Must be org root user."
|
"description": "Not authorised. Must be org root user."
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ Reusable business logic functions for the organisation module
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from src.iam.service import assign_default_group
|
from src.iam.service import assign_default_group
|
||||||
from src.organisation.models import Organisation as Org
|
from src.organisation.models import Organisation as Org
|
||||||
|
|
@ -50,9 +49,6 @@ async def assign_defaults(
|
||||||
print("User not found while adding defaults")
|
print("User not found while adding defaults")
|
||||||
return
|
return
|
||||||
|
|
||||||
org_model = cast(Org, org_model)
|
|
||||||
user_model = cast(User, user_model)
|
|
||||||
|
|
||||||
await add_default_org_permissions(db, org_model, default_org_permissions)
|
await add_default_org_permissions(db, org_model, default_org_permissions)
|
||||||
await assign_default_group(
|
await assign_default_group(
|
||||||
db=db,
|
db=db,
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,7 @@ async def get_service_model_query(
|
||||||
return service_model
|
return service_model
|
||||||
|
|
||||||
|
|
||||||
service_model_query_dependency = Annotated[
|
service_model_query_dependency = Annotated[Service, Depends(get_service_model_query)]
|
||||||
type[Service], Depends(get_service_model_query)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_service_model_body(db: db_dependency, request_model: ServiceIDMixin):
|
async def get_service_model_body(db: db_dependency, request_model: ServiceIDMixin):
|
||||||
|
|
@ -39,6 +37,4 @@ async def get_service_model_body(db: db_dependency, request_model: ServiceIDMixi
|
||||||
return service_model
|
return service_model
|
||||||
|
|
||||||
|
|
||||||
service_model_body_dependency = Annotated[
|
service_model_body_dependency = Annotated[Service, Depends(get_service_model_body)]
|
||||||
type[Service], Depends(get_service_model_body)
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,6 @@ class Service(CustomBase):
|
||||||
name: Mapped[str] = mapped_column(unique=True)
|
name: Mapped[str] = mapped_column(unique=True)
|
||||||
api_key: Mapped[str]
|
api_key: Mapped[str]
|
||||||
|
|
||||||
permission_rel = relationship("Permission", back_populates="service_rel")
|
permission_rel = relationship(
|
||||||
|
"Permission", back_populates="service_rel", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -95,9 +95,7 @@ async def get_all_services(
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_200_OK: {"description": "Successfully registered a new service"},
|
status.HTTP_200_OK: {"description": "Successfully registered a new service"},
|
||||||
status.HTTP_401_UNAUTHORIZED: {"description": "Unauthorized"},
|
status.HTTP_401_UNAUTHORIZED: {"description": "Unauthorized"},
|
||||||
status.HTTP_409_CONFLICT: {
|
status.HTTP_409_CONFLICT: {"description": "Service with this name already exists"},
|
||||||
"description": "Service with this name already exists"
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def register_service(
|
async def register_service(
|
||||||
|
|
@ -159,9 +157,7 @@ async def regenerate_api_key(
|
||||||
summary="Remove a service.",
|
summary="Remove a service.",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_204_NO_CONTENT: {
|
status.HTTP_204_NO_CONTENT: {"description": "Successfully removed service from db"},
|
||||||
"description": "Successfully removed service from db"
|
|
||||||
},
|
|
||||||
status.HTTP_401_UNAUTHORIZED: {"description": "Unauthorized"},
|
status.HTTP_401_UNAUTHORIZED: {"description": "Unauthorized"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ async def get_user_model_claims(claims: claims_dependency, db: db_dependency):
|
||||||
return user_model
|
return user_model
|
||||||
|
|
||||||
|
|
||||||
user_model_claims_dependency = Annotated[type[User], Depends(get_user_model_claims)]
|
user_model_claims_dependency = Annotated[User, Depends(get_user_model_claims)]
|
||||||
|
|
||||||
|
|
||||||
async def get_user_model_query(db: db_dependency, user_id: Annotated[int, Query(gt=0)]):
|
async def get_user_model_query(db: db_dependency, user_id: Annotated[int, Query(gt=0)]):
|
||||||
|
|
@ -41,7 +41,7 @@ async def get_user_model_query(db: db_dependency, user_id: Annotated[int, Query(
|
||||||
return user_model
|
return user_model
|
||||||
|
|
||||||
|
|
||||||
user_model_query_dependency = Annotated[type[User], Depends(get_user_model_query)]
|
user_model_query_dependency = Annotated[User, Depends(get_user_model_query)]
|
||||||
|
|
||||||
|
|
||||||
async def get_user_model_body(db: db_dependency, request_model: UserIDMixin):
|
async def get_user_model_body(db: db_dependency, request_model: UserIDMixin):
|
||||||
|
|
@ -52,4 +52,4 @@ async def get_user_model_body(db: db_dependency, request_model: UserIDMixin):
|
||||||
return user_model
|
return user_model
|
||||||
|
|
||||||
|
|
||||||
user_model_body_dependency = Annotated[type[User], Depends(get_user_model_body)]
|
user_model_body_dependency = Annotated[User, Depends(get_user_model_body)]
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,7 @@ class User(CustomBase):
|
||||||
"Organisation", secondary="orgusers", back_populates="user_rel"
|
"Organisation", secondary="orgusers", back_populates="user_rel"
|
||||||
)
|
)
|
||||||
|
|
||||||
group_rel = relationship(
|
group_rel = relationship("Group", secondary="user_groups", back_populates="user_rel")
|
||||||
"Group", secondary="user_groups", back_populates="user_rel"
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def organisations(self):
|
def organisations(self):
|
||||||
|
|
|
||||||
|
|
@ -190,9 +190,7 @@ async def accept_invitation(
|
||||||
user_model: user_model_claims_dependency,
|
user_model: user_model_claims_dependency,
|
||||||
request_model: UserPostInvitationAcceptRequest,
|
request_model: UserPostInvitationAcceptRequest,
|
||||||
):
|
):
|
||||||
email_claims = await verify_email_token(
|
email_claims = await verify_email_token(token=request_model.jwt, user_model=user_model)
|
||||||
token=request_model.jwt, user_model=user_model
|
|
||||||
)
|
|
||||||
|
|
||||||
org_model = db.get(Org, email_claims["org_id"])
|
org_model = db.get(Org, email_claims["org_id"])
|
||||||
if org_model is None:
|
if org_model is None:
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from src.auth.service import get_current_user, get_dev_user
|
||||||
from src.auth.dependencies import empty_su_list, get_super_admin_list, testing_su_list
|
from src.auth.dependencies import empty_su_list, get_super_admin_list, testing_su_list
|
||||||
from src.main import app # inited FastAPI app
|
from src.main import app # inited FastAPI app
|
||||||
from src.database import engine, get_db
|
from src.database import engine, get_db
|
||||||
from models import CustomBase
|
from src.models import CustomBase
|
||||||
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,9 +165,7 @@ async def test_post_user_invitation_auth_approval(no_su_client: AsyncClient):
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_delete_group_permissions_auth_approval(no_su_client: AsyncClient):
|
async def test_delete_group_permissions_auth_approval(no_su_client: AsyncClient):
|
||||||
resp = await no_su_client.delete(
|
resp = await no_su_client.delete("/iam/group/permission?org_id=3&group_id=1&perm_id=1")
|
||||||
"/iam/group/permission?org_id=3&group_id=1&perm_id=1"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert resp.status_code != 422
|
assert resp.status_code != 422
|
||||||
assert "has not been approved." in resp.json()["detail"]
|
assert "has not been approved." in resp.json()["detail"]
|
||||||
|
|
|
||||||
|
|
@ -69,9 +69,7 @@ async def test_post_perm_auth_su(no_su_client: AsyncClient):
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_post_org_user_auth_su(no_su_client: AsyncClient):
|
async def test_post_org_user_auth_su(no_su_client: AsyncClient):
|
||||||
resp = await no_su_client.post(
|
resp = await no_su_client.post("/org/user", json={"organisation_id": 1, "user_id": 2})
|
||||||
"/org/user", json={"organisation_id": 1, "user_id": 2}
|
|
||||||
)
|
|
||||||
assert resp.status_code != 422
|
assert resp.status_code != 422
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
assert "Must be super admin" in resp.json()["detail"]
|
assert "Must be super admin" in resp.json()["detail"]
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,7 @@ async def test_post_act_on_resource_endpoint_success(default_client: AsyncClient
|
||||||
"Authorization": "Bearer not_checked_when_auth_is_disabled",
|
"Authorization": "Bearer not_checked_when_auth_is_disabled",
|
||||||
"X-API-Key": "123456789",
|
"X-API-Key": "123456789",
|
||||||
}
|
}
|
||||||
resp = await default_client.post(
|
resp = await default_client.post("/iam/can_act_on_resource", json=body, headers=headers)
|
||||||
"/iam/can_act_on_resource", json=body, headers=headers
|
|
||||||
)
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
@ -56,9 +54,7 @@ async def test_act_on_resource_wrong_key(
|
||||||
"Authorization": "Bearer not_checked_when_auth_is_disabled",
|
"Authorization": "Bearer not_checked_when_auth_is_disabled",
|
||||||
"X-API-Key": api_key,
|
"X-API-Key": api_key,
|
||||||
}
|
}
|
||||||
resp = await default_client.post(
|
resp = await default_client.post("/iam/can_act_on_resource", json=body, headers=headers)
|
||||||
"/iam/can_act_on_resource", json=body, headers=headers
|
|
||||||
)
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
@ -110,9 +106,7 @@ async def test_act_on_resource_endpoint_status_checks(
|
||||||
"Authorization": "Bearer not_checked_when_auth_is_disabled",
|
"Authorization": "Bearer not_checked_when_auth_is_disabled",
|
||||||
"X-API-Key": "123456789",
|
"X-API-Key": "123456789",
|
||||||
}
|
}
|
||||||
resp = await default_client.post(
|
resp = await default_client.post("/iam/can_act_on_resource", json=body, headers=headers)
|
||||||
"/iam/can_act_on_resource", json=body, headers=headers
|
|
||||||
)
|
|
||||||
|
|
||||||
assert resp.status_code == expected_status
|
assert resp.status_code == expected_status
|
||||||
|
|
||||||
|
|
@ -143,9 +137,7 @@ async def test_act_on_resource_logic(
|
||||||
"Authorization": "Bearer not_checked_when_auth_is_disabled",
|
"Authorization": "Bearer not_checked_when_auth_is_disabled",
|
||||||
"X-API-Key": "123456789",
|
"X-API-Key": "123456789",
|
||||||
}
|
}
|
||||||
resp = await default_client.post(
|
resp = await default_client.post("/iam/can_act_on_resource", json=body, headers=headers)
|
||||||
"/iam/can_act_on_resource", json=body, headers=headers
|
|
||||||
)
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
@ -414,9 +406,7 @@ async def test_get_permissions_success(default_client: AsyncClient):
|
||||||
assert permission["action"] == "read"
|
assert permission["action"] == "read"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize("query, expected_status", generate_query_and_status(["org_id"]))
|
||||||
"query, expected_status", generate_query_and_status(["org_id"])
|
|
||||||
)
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_get_permissions_status_checks(
|
async def test_get_permissions_status_checks(
|
||||||
default_client: AsyncClient, query: str, expected_status: int
|
default_client: AsyncClient, query: str, expected_status: int
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,7 @@ async def test_get_org_success(default_client: AsyncClient):
|
||||||
assert org["security_contact"]["email"] == "security@orgone.com"
|
assert org["security_contact"]["email"] == "security@orgone.com"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize("query, expected_status", generate_query_and_status(["org_id"]))
|
||||||
"query, expected_status", generate_query_and_status(["org_id"])
|
|
||||||
)
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_get_org_status_checks(
|
async def test_get_org_status_checks(
|
||||||
default_client: AsyncClient, query: str, expected_status: int
|
default_client: AsyncClient, query: str, expected_status: int
|
||||||
|
|
@ -60,7 +58,6 @@ async def test_post_org_success(default_client: AsyncClient):
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"body, expected_status",
|
"body, expected_status",
|
||||||
[
|
[
|
||||||
({"name": "Org One"}, 409),
|
|
||||||
({"name": 42}, 422),
|
({"name": 42}, 422),
|
||||||
({}, 422),
|
({}, 422),
|
||||||
({"name": "New Test Org", "intake_questionnaire": {"question_one": 42}}, 422),
|
({"name": "New Test Org", "intake_questionnaire": {"question_one": 42}}, 422),
|
||||||
|
|
@ -229,9 +226,7 @@ async def test_get_org_users_success(default_client: AsyncClient):
|
||||||
assert data["organisation"]["id"] == 1
|
assert data["organisation"]["id"] == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize("query, expected_status", generate_query_and_status(["org_id"]))
|
||||||
"query, expected_status", generate_query_and_status(["org_id"])
|
|
||||||
)
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_get_org_users_status_checks(
|
async def test_get_org_users_status_checks(
|
||||||
default_client: AsyncClient, query: str, expected_status: int
|
default_client: AsyncClient, query: str, expected_status: int
|
||||||
|
|
@ -243,9 +238,7 @@ async def test_get_org_users_status_checks(
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_post_org_user_success(default_client: AsyncClient):
|
async def test_post_org_user_success(default_client: AsyncClient):
|
||||||
resp = await default_client.post(
|
resp = await default_client.post("/org/user", json={"organisation_id": 1, "user_id": 3})
|
||||||
"/org/user", json={"organisation_id": 1, "user_id": 3}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
@ -258,9 +251,7 @@ async def test_post_org_user_success(default_client: AsyncClient):
|
||||||
|
|
||||||
assert "users" in data
|
assert "users" in data
|
||||||
assert isinstance(data["users"], list)
|
assert isinstance(data["users"], list)
|
||||||
assert (
|
assert len([user for user in data["users"] if user["email"] == "root@orgtwo.com"]) == 1
|
||||||
len([user for user in data["users"] if user["email"] == "root@orgtwo.com"]) == 1
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|
@ -348,9 +339,7 @@ async def test_get_org_groups_success(default_client: AsyncClient):
|
||||||
assert group["name"] == "Org One Group"
|
assert group["name"] == "Org One Group"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize("query, expected_status", generate_query_and_status(["org_id"]))
|
||||||
"query, expected_status", generate_query_and_status(["org_id"])
|
|
||||||
)
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_get_org_groups_status_checks(
|
async def test_get_org_groups_status_checks(
|
||||||
default_client: AsyncClient, query: str, expected_status: int
|
default_client: AsyncClient, query: str, expected_status: int
|
||||||
|
|
@ -363,9 +352,7 @@ async def test_get_org_groups_status_checks(
|
||||||
@pytest.mark.parametrize("contact_type", ["billing", "security", "owner"])
|
@pytest.mark.parametrize("contact_type", ["billing", "security", "owner"])
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_get_org_contact_success(default_client: AsyncClient, contact_type: str):
|
async def test_get_org_contact_success(default_client: AsyncClient, contact_type: str):
|
||||||
resp = await default_client.get(
|
resp = await default_client.get(f"/org/contact?org_id=1&contact_type={contact_type}")
|
||||||
f"/org/contact?org_id=1&contact_type={contact_type}"
|
|
||||||
)
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
@ -437,9 +424,7 @@ async def test_get_org_contact_status_checks(
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_patch_org_contact_success(
|
async def test_patch_org_contact_success(default_client: AsyncClient, key: str, value: str):
|
||||||
default_client: AsyncClient, key: str, value: str
|
|
||||||
):
|
|
||||||
resp = await default_client.patch(
|
resp = await default_client.patch(
|
||||||
"/org/contact",
|
"/org/contact",
|
||||||
json={"organisation_id": 1, "contact_type": "billing", key: value},
|
json={"organisation_id": 1, "contact_type": "billing", key: value},
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,7 @@ async def test_get_services_success(default_client: AsyncClient):
|
||||||
assert data["services"][0]["name"] == "Test Service"
|
assert data["services"][0]["name"] == "Test Service"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize("query, expected_status", generate_query_and_status(["org_id"]))
|
||||||
"query, expected_status", generate_query_and_status(["org_id"])
|
|
||||||
)
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_get_services_status_checks(
|
async def test_get_services_status_checks(
|
||||||
default_client: AsyncClient, query: str, expected_status: int
|
default_client: AsyncClient, query: str, expected_status: int
|
||||||
|
|
@ -49,9 +47,7 @@ async def test_post_service_success(default_client: AsyncClient):
|
||||||
assert isinstance(data["service"]["api_key"], str)
|
assert isinstance(data["service"]["api_key"], str)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize("body, expected_status", generate_body_and_status({"name": "str"}))
|
||||||
"body, expected_status", generate_body_and_status({"name": "str"})
|
|
||||||
)
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_post_service_status_checks(
|
async def test_post_service_status_checks(
|
||||||
default_client: AsyncClient, body: dict[str, str], expected_status: int
|
default_client: AsyncClient, body: dict[str, str], expected_status: int
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,7 @@ async def test_get_user_success(default_client: AsyncClient):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize("query, expected_status", generate_query_and_status(["user_id"]))
|
||||||
"query, expected_status", generate_query_and_status(["user_id"])
|
|
||||||
)
|
|
||||||
async def test_get_user_status_checks(
|
async def test_get_user_status_checks(
|
||||||
default_client: AsyncClient, query: str, expected_status: int
|
default_client: AsyncClient, query: str, expected_status: int
|
||||||
):
|
):
|
||||||
|
|
@ -184,10 +182,8 @@ async def test_get_self_orgs_dynamic(default_client: AsyncClient):
|
||||||
|
|
||||||
route = next(
|
route = next(
|
||||||
route
|
route
|
||||||
for route in default_client._transport.app.routes
|
for route in default_client._transport.app.routes # ty:ignore[unresolved-attribute]
|
||||||
if isinstance(route, APIRoute)
|
if isinstance(route, APIRoute) and path in route.path and method in route.methods
|
||||||
and path in route.path
|
|
||||||
and method in route.methods
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert resp.status_code == route.status_code
|
assert resp.status_code == route.status_code
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue