From f8ae000841b558f72d4702857b4d2b24d8c123de Mon Sep 17 00:00:00 2001 From: Tyler Zale Date: Wed, 22 May 2024 23:48:30 -0700 Subject: [PATCH 1/4] add POST account functionality --- .../versions/20240522_dbdabde037cc_.py | 54 +++++ .../versions/20240523_3d7b4953b1ee_.py | 34 +++ strr-api/poetry.lock | 123 +++++++++- strr-api/pyproject.toml | 1 + strr-api/src/strr_api/enums/enum.py | 27 +++ strr-api/src/strr_api/exceptions/__init__.py | 1 + .../src/strr_api/exceptions/exceptions.py | 17 +- strr-api/src/strr_api/models/__init__.py | 11 +- strr-api/src/strr_api/models/rental.py | 20 +- strr-api/src/strr_api/models/user.py | 9 +- .../strr_api/requests/RegistrationRequest.py | 111 +++++++++ strr-api/src/strr_api/requests/__init__.py | 4 + strr-api/src/strr_api/resources/__init__.py | 6 + strr-api/src/strr_api/resources/account.py | 81 ++++++- .../src/strr_api/resources/registrations.py | 80 +++++++ .../responses/RegistrationResponse.py | 180 ++++++++++++++ strr-api/src/strr_api/responses/__init__.py | 4 + .../strr_api/schemas/schemas/new_account.json | 54 ----- .../schemas/schemas/registration.json | 226 ++++++++++++++++++ strr-api/src/strr_api/schemas/utils.py | 3 + strr-api/src/strr_api/services/__init__.py | 1 + .../strr_api/services/registration_service.py | 140 +++++++++++ strr-api/tests/mocks/json/registration.json | 80 +++++++ strr-api/tests/unit/resources/test_account.py | 30 ++- .../unit/resources/test_registrations.py | 17 ++ strr-api/tests/unit/schemas/test_utils.py | 28 ++- strr-api/tests/unit/utils/mocks.py | 11 + 27 files changed, 1259 insertions(+), 94 deletions(-) create mode 100644 strr-api/migrations/versions/20240522_dbdabde037cc_.py create mode 100644 strr-api/migrations/versions/20240523_3d7b4953b1ee_.py create mode 100644 strr-api/src/strr_api/requests/RegistrationRequest.py create mode 100644 strr-api/src/strr_api/requests/__init__.py create mode 100644 strr-api/src/strr_api/resources/registrations.py create mode 100644 strr-api/src/strr_api/responses/RegistrationResponse.py create mode 100644 strr-api/src/strr_api/responses/__init__.py delete mode 100644 strr-api/src/strr_api/schemas/schemas/new_account.json create mode 100644 strr-api/src/strr_api/schemas/schemas/registration.json create mode 100644 strr-api/src/strr_api/services/registration_service.py create mode 100644 strr-api/tests/mocks/json/registration.json create mode 100644 strr-api/tests/unit/resources/test_registrations.py diff --git a/strr-api/migrations/versions/20240522_dbdabde037cc_.py b/strr-api/migrations/versions/20240522_dbdabde037cc_.py new file mode 100644 index 00000000..1b970bde --- /dev/null +++ b/strr-api/migrations/versions/20240522_dbdabde037cc_.py @@ -0,0 +1,54 @@ +"""empty message + +Revision ID: dbdabde037cc +Revises: 7026f9b26d3f +Create Date: 2024-05-22 23:25:46.897711 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'dbdabde037cc' +down_revision = '7026f9b26d3f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('preferredname', sa.VARCHAR(), autoincrement=False, nullable=True)) + batch_op.add_column(sa.Column('phone_extension', sa.VARCHAR(), autoincrement=False, nullable=True)) + batch_op.add_column(sa.Column('fax_number', sa.VARCHAR(), autoincrement=False, nullable=True)) + batch_op.add_column(sa.Column('phone_number', sa.VARCHAR(), autoincrement=False, nullable=True)) + batch_op.add_column(sa.Column('date_of_birth', sa.DATE(), autoincrement=False, nullable=True)) + + with op.batch_alter_table('property_managers', schema=None) as batch_op: + batch_op.add_column(sa.Column('secondary_contact_user_id', sa.INTEGER(), autoincrement=False, nullable=True)) + batch_op.create_foreign_key('property_managers_secondary_contact_user_id_fkey', 'users', ['secondary_contact_user_id'], ['id']) + + with op.batch_alter_table('addresses', schema=None) as batch_op: + batch_op.add_column(sa.Column('street_address_additional', sa.VARCHAR(), autoincrement=False, nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('addresses', schema=None) as batch_op: + batch_op.drop_column('street_address_additional') + + with op.batch_alter_table('property_managers', schema=None) as batch_op: + batch_op.drop_constraint('property_managers_secondary_contact_user_id_fkey', type_='foreignkey') + batch_op.drop_column('secondary_contact_user_id') + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('date_of_birth') + batch_op.drop_column('phone_number') + batch_op.drop_column('fax_number') + batch_op.drop_column('phone_extension') + batch_op.drop_column('preferredname') + + # ### end Alembic commands ### diff --git a/strr-api/migrations/versions/20240523_3d7b4953b1ee_.py b/strr-api/migrations/versions/20240523_3d7b4953b1ee_.py new file mode 100644 index 00000000..35da62b2 --- /dev/null +++ b/strr-api/migrations/versions/20240523_3d7b4953b1ee_.py @@ -0,0 +1,34 @@ +"""empty message + +Revision ID: 3d7b4953b1ee +Revises: dbdabde037cc +Create Date: 2024-05-23 22:32:30.579174 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3d7b4953b1ee' +down_revision = 'dbdabde037cc' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('rental_platforms', schema=None) as batch_op: + batch_op.alter_column('name', + existing_nullable=False, + nullable=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('rental_platforms', schema=None) as batch_op: + batch_op.alter_column('name', + existing_nullable=True, + nullable=False) + # ### end Alembic commands ### diff --git a/strr-api/poetry.lock b/strr-api/poetry.lock index 7ab94e78..892e9d32 100644 --- a/strr-api/poetry.lock +++ b/strr-api/poetry.lock @@ -19,6 +19,17 @@ typing-extensions = ">=4" [package.extras] tz = ["backports.zoneinfo"] +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + [[package]] name = "arrow" version = "1.3.0" @@ -1390,6 +1401,116 @@ files = [ {file = "pycountry-23.12.11.tar.gz", hash = "sha256:00569d82eaefbc6a490a311bfa84a9c571cff9ddbf8b0a4f4e7b4f868b4ad925"}, ] +[[package]] +name = "pydantic" +version = "2.7.1" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.7.1-py3-none-any.whl", hash = "sha256:e029badca45266732a9a79898a15ae2e8b14840b1eabbb25844be28f0b33f3d5"}, + {file = "pydantic-2.7.1.tar.gz", hash = "sha256:e9dbb5eada8abe4d9ae5f46b9939aead650cd2b68f249bb3a8139dbe125803cc"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.18.2" +typing-extensions = ">=4.6.1" + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.18.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.18.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9e08e867b306f525802df7cd16c44ff5ebbe747ff0ca6cf3fde7f36c05a59a81"}, + {file = "pydantic_core-2.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f0a21cbaa69900cbe1a2e7cad2aa74ac3cf21b10c3efb0fa0b80305274c0e8a2"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0680b1f1f11fda801397de52c36ce38ef1c1dc841a0927a94f226dea29c3ae3d"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:95b9d5e72481d3780ba3442eac863eae92ae43a5f3adb5b4d0a1de89d42bb250"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fcf5cd9c4b655ad666ca332b9a081112cd7a58a8b5a6ca7a3104bc950f2038"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b5155ff768083cb1d62f3e143b49a8a3432e6789a3abee8acd005c3c7af1c74"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553ef617b6836fc7e4df130bb851e32fe357ce36336d897fd6646d6058d980af"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89ed9eb7d616ef5714e5590e6cf7f23b02d0d539767d33561e3675d6f9e3857"}, + {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:75f7e9488238e920ab6204399ded280dc4c307d034f3924cd7f90a38b1829563"}, + {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ef26c9e94a8c04a1b2924149a9cb081836913818e55681722d7f29af88fe7b38"}, + {file = "pydantic_core-2.18.2-cp310-none-win32.whl", hash = "sha256:182245ff6b0039e82b6bb585ed55a64d7c81c560715d1bad0cbad6dfa07b4027"}, + {file = "pydantic_core-2.18.2-cp310-none-win_amd64.whl", hash = "sha256:e23ec367a948b6d812301afc1b13f8094ab7b2c280af66ef450efc357d2ae543"}, + {file = "pydantic_core-2.18.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:219da3f096d50a157f33645a1cf31c0ad1fe829a92181dd1311022f986e5fbe3"}, + {file = "pydantic_core-2.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc1cfd88a64e012b74e94cd00bbe0f9c6df57049c97f02bb07d39e9c852e19a4"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05b7133a6e6aeb8df37d6f413f7705a37ab4031597f64ab56384c94d98fa0e90"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:224c421235f6102e8737032483f43c1a8cfb1d2f45740c44166219599358c2cd"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b14d82cdb934e99dda6d9d60dc84a24379820176cc4a0d123f88df319ae9c150"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2728b01246a3bba6de144f9e3115b532ee44bd6cf39795194fb75491824a1413"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:470b94480bb5ee929f5acba6995251ada5e059a5ef3e0dfc63cca287283ebfa6"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:997abc4df705d1295a42f95b4eec4950a37ad8ae46d913caeee117b6b198811c"}, + {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75250dbc5290e3f1a0f4618db35e51a165186f9034eff158f3d490b3fed9f8a0"}, + {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4456f2dca97c425231d7315737d45239b2b51a50dc2b6f0c2bb181fce6207664"}, + {file = "pydantic_core-2.18.2-cp311-none-win32.whl", hash = "sha256:269322dcc3d8bdb69f054681edff86276b2ff972447863cf34c8b860f5188e2e"}, + {file = "pydantic_core-2.18.2-cp311-none-win_amd64.whl", hash = "sha256:800d60565aec896f25bc3cfa56d2277d52d5182af08162f7954f938c06dc4ee3"}, + {file = "pydantic_core-2.18.2-cp311-none-win_arm64.whl", hash = "sha256:1404c69d6a676245199767ba4f633cce5f4ad4181f9d0ccb0577e1f66cf4c46d"}, + {file = "pydantic_core-2.18.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:fb2bd7be70c0fe4dfd32c951bc813d9fe6ebcbfdd15a07527796c8204bd36242"}, + {file = "pydantic_core-2.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6132dd3bd52838acddca05a72aafb6eab6536aa145e923bb50f45e78b7251043"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d904828195733c183d20a54230c0df0eb46ec746ea1a666730787353e87182"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9bd70772c720142be1020eac55f8143a34ec9f82d75a8e7a07852023e46617f"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8ed04b3582771764538f7ee7001b02e1170223cf9b75dff0bc698fadb00cf3"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6dac87ddb34aaec85f873d737e9d06a3555a1cc1a8e0c44b7f8d5daeb89d86f"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ca4ae5a27ad7a4ee5170aebce1574b375de390bc01284f87b18d43a3984df72"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:886eec03591b7cf058467a70a87733b35f44707bd86cf64a615584fd72488b7c"}, + {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ca7b0c1f1c983e064caa85f3792dd2fe3526b3505378874afa84baf662e12241"}, + {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b4356d3538c3649337df4074e81b85f0616b79731fe22dd11b99499b2ebbdf3"}, + {file = "pydantic_core-2.18.2-cp312-none-win32.whl", hash = "sha256:8b172601454f2d7701121bbec3425dd71efcb787a027edf49724c9cefc14c038"}, + {file = "pydantic_core-2.18.2-cp312-none-win_amd64.whl", hash = "sha256:b1bd7e47b1558ea872bd16c8502c414f9e90dcf12f1395129d7bb42a09a95438"}, + {file = "pydantic_core-2.18.2-cp312-none-win_arm64.whl", hash = "sha256:98758d627ff397e752bc339272c14c98199c613f922d4a384ddc07526c86a2ec"}, + {file = "pydantic_core-2.18.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:9fdad8e35f278b2c3eb77cbdc5c0a49dada440657bf738d6905ce106dc1de439"}, + {file = "pydantic_core-2.18.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1d90c3265ae107f91a4f279f4d6f6f1d4907ac76c6868b27dc7fb33688cfb347"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390193c770399861d8df9670fb0d1874f330c79caaca4642332df7c682bf6b91"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:82d5d4d78e4448683cb467897fe24e2b74bb7b973a541ea1dcfec1d3cbce39fb"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4774f3184d2ef3e14e8693194f661dea5a4d6ca4e3dc8e39786d33a94865cefd"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4d938ec0adf5167cb335acb25a4ee69a8107e4984f8fbd2e897021d9e4ca21b"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0e8b1be28239fc64a88a8189d1df7fad8be8c1ae47fcc33e43d4be15f99cc70"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:868649da93e5a3d5eacc2b5b3b9235c98ccdbfd443832f31e075f54419e1b96b"}, + {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:78363590ef93d5d226ba21a90a03ea89a20738ee5b7da83d771d283fd8a56761"}, + {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:852e966fbd035a6468fc0a3496589b45e2208ec7ca95c26470a54daed82a0788"}, + {file = "pydantic_core-2.18.2-cp38-none-win32.whl", hash = "sha256:6a46e22a707e7ad4484ac9ee9f290f9d501df45954184e23fc29408dfad61350"}, + {file = "pydantic_core-2.18.2-cp38-none-win_amd64.whl", hash = "sha256:d91cb5ea8b11607cc757675051f61b3d93f15eca3cefb3e6c704a5d6e8440f4e"}, + {file = "pydantic_core-2.18.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ae0a8a797a5e56c053610fa7be147993fe50960fa43609ff2a9552b0e07013e8"}, + {file = "pydantic_core-2.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:042473b6280246b1dbf530559246f6842b56119c2926d1e52b631bdc46075f2a"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a388a77e629b9ec814c1b1e6b3b595fe521d2cdc625fcca26fbc2d44c816804"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25add29b8f3b233ae90ccef2d902d0ae0432eb0d45370fe315d1a5cf231004b"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f459a5ce8434614dfd39bbebf1041952ae01da6bed9855008cb33b875cb024c0"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eff2de745698eb46eeb51193a9f41d67d834d50e424aef27df2fcdee1b153845"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8309f67285bdfe65c372ea3722b7a5642680f3dba538566340a9d36e920b5f0"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f93a8a2e3938ff656a7c1bc57193b1319960ac015b6e87d76c76bf14fe0244b4"}, + {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:22057013c8c1e272eb8d0eebc796701167d8377441ec894a8fed1af64a0bf399"}, + {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cfeecd1ac6cc1fb2692c3d5110781c965aabd4ec5d32799773ca7b1456ac636b"}, + {file = "pydantic_core-2.18.2-cp39-none-win32.whl", hash = "sha256:0d69b4c2f6bb3e130dba60d34c0845ba31b69babdd3f78f7c0c8fae5021a253e"}, + {file = "pydantic_core-2.18.2-cp39-none-win_amd64.whl", hash = "sha256:d9319e499827271b09b4e411905b24a426b8fb69464dfa1696258f53a3334641"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a1874c6dd4113308bd0eb568418e6114b252afe44319ead2b4081e9b9521fe75"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:ccdd111c03bfd3666bd2472b674c6899550e09e9f298954cfc896ab92b5b0e6d"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e18609ceaa6eed63753037fc06ebb16041d17d28199ae5aba0052c51449650a9"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e5c584d357c4e2baf0ff7baf44f4994be121e16a2c88918a5817331fc7599d7"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43f0f463cf89ace478de71a318b1b4f05ebc456a9b9300d027b4b57c1a2064fb"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e1b395e58b10b73b07b7cf740d728dd4ff9365ac46c18751bf8b3d8cca8f625a"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0098300eebb1c837271d3d1a2cd2911e7c11b396eac9661655ee524a7f10587b"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:36789b70d613fbac0a25bb07ab3d9dba4d2e38af609c020cf4d888d165ee0bf3"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3f9a801e7c8f1ef8718da265bba008fa121243dfe37c1cea17840b0944dfd72c"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3a6515ebc6e69d85502b4951d89131ca4e036078ea35533bb76327f8424531ce"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20aca1e2298c56ececfd8ed159ae4dde2df0781988c97ef77d5c16ff4bd5b400"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:223ee893d77a310a0391dca6df00f70bbc2f36a71a895cecd9a0e762dc37b349"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2334ce8c673ee93a1d6a65bd90327588387ba073c17e61bf19b4fd97d688d63c"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cbca948f2d14b09d20268cda7b0367723d79063f26c4ffc523af9042cad95592"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b3ef08e20ec49e02d5c6717a91bb5af9b20f1805583cb0adfe9ba2c6b505b5ae"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c6fdc8627910eed0c01aed6a390a252fe3ea6d472ee70fdde56273f198938374"}, + {file = "pydantic_core-2.18.2.tar.gz", hash = "sha256:2e29d20810dfc3043ee13ac7d9e25105799817683348823f305ab3f349b9386e"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + [[package]] name = "pyflakes" version = "3.2.0" @@ -2210,4 +2331,4 @@ watchdog = ["watchdog (>=2.3)"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "f9557d88d33ce00028f97332c924540ba0863d101efdc67a5dc4bd19f6eba33e" +content-hash = "98e9df695e0b01ad2f83b0ad8e0d366313768ee98c0fe4e42d2c442dab11f0de" diff --git a/strr-api/pyproject.toml b/strr-api/pyproject.toml index a8ba53dd..e4bac997 100644 --- a/strr-api/pyproject.toml +++ b/strr-api/pyproject.toml @@ -27,6 +27,7 @@ pytest-env = "^1.1.3" coloredlogs = "^15.0.1" flask-httpauth = "^4.8.0" flasgger = "^0.9.7.1" +pydantic = "^2.7.1" [tool.poetry.group.test.dependencies] freezegun = "^1.2.2" diff --git a/strr-api/src/strr_api/enums/enum.py b/strr-api/src/strr_api/enums/enum.py index 6f14fc7a..6e720c7b 100644 --- a/strr-api/src/strr_api/enums/enum.py +++ b/strr-api/src/strr_api/enums/enum.py @@ -65,3 +65,30 @@ class Role(Enum): STAFF_CREATE_ACCOUNTS = "create_accounts" STAFF_MANAGE_BUSINESS = "manage_business" STAFF_SUSPEND_ACCOUNTS = "suspend_accounts" + + +class RegistrationStatus(Enum): + """STRR Registration Status.""" + + PENDING = "pending" + APPROVED = "approved" + MORE_INFO_NEEDED = "more info needed" + DENIED = "denied" + + +class PropertyType(Enum): + """STRR Property Type.""" + + PRIMARY = "All or part of primary dwelling" + SECONDARY = "Secondary suite" + ACCESSORY = "Accessory dwelling unit" + FLOAT_HOME = "Float home" + OTHER = "Other" + + +class OwnershipType(Enum): + """STRR Ownership Type.""" + + OWN = "own" + RENT = "rent" + CO_OWN = "co-own" diff --git a/strr-api/src/strr_api/exceptions/__init__.py b/strr-api/src/strr_api/exceptions/__init__.py index f50a7f86..a9ea64f4 100644 --- a/strr-api/src/strr_api/exceptions/__init__.py +++ b/strr-api/src/strr_api/exceptions/__init__.py @@ -14,4 +14,5 @@ """Application Specific Exceptions/Responses, to manage handled errors.""" from .exceptions import AuthException # noqa: F401 from .exceptions import ExternalServiceException # noqa: F401 +from .exceptions import ValidationException # noqa: F401 from .responses import error_response, exception_response # noqa: F401 diff --git a/strr-api/src/strr_api/exceptions/exceptions.py b/strr-api/src/strr_api/exceptions/exceptions.py index 3ed2987c..e185ec2f 100644 --- a/strr-api/src/strr_api/exceptions/exceptions.py +++ b/strr-api/src/strr_api/exceptions/exceptions.py @@ -40,11 +40,26 @@ class BaseExceptionE(Exception): """Base exception class for custom exceptions.""" - error: str + error: str = None message: str = None status_code: HTTPStatus = None +@dataclass +class ValidationException(BaseExceptionE): + """Request validation exception.""" + + error = None + + def __post_init__(self): + """Return a valid ValidationException.""" + self.error = "Validation Error" + if not self.message: + self.message = "Invalid request." + if not self.status_code: + self.status_code = HTTPStatus.BAD_REQUEST + + @dataclass class AuthException(BaseExceptionE): """Authorization/Authentication exception.""" diff --git a/strr-api/src/strr_api/models/__init__.py b/strr-api/src/strr_api/models/__init__.py index d08b8e03..b983eceb 100644 --- a/strr-api/src/strr_api/models/__init__.py +++ b/strr-api/src/strr_api/models/__init__.py @@ -33,14 +33,7 @@ # POSSIBILITY OF SUCH DAMAGE. """This exports all of the models and schemas used by the application.""" from .db import db # noqa: I001 -from .rental import Address, PropertyManager, RentalPlatform, RentalProperty +from .rental import Address, PropertyManager, Registration, RentalPlatform, RentalProperty from .user import User -__all__ = ( - "db", - "User", - "RentalProperty", - "Address", - "PropertyManager", - "RentalPlatform", -) +__all__ = ("db", "User", "RentalProperty", "Address", "PropertyManager", "RentalPlatform", "Registration") diff --git a/strr-api/src/strr_api/models/rental.py b/strr-api/src/strr_api/models/rental.py index d12a5025..8e463075 100644 --- a/strr-api/src/strr_api/models/rental.py +++ b/strr-api/src/strr_api/models/rental.py @@ -5,8 +5,11 @@ from datetime import datetime +from sqlalchemy import Enum from sqlalchemy.orm import relationship +from strr_api.enums.enum import OwnershipType, PropertyType, RegistrationStatus + from .db import db @@ -22,12 +25,13 @@ class RentalProperty(db.Model): parcel_identifier = db.Column(db.String, nullable=True) local_business_licence = db.Column(db.String, nullable=True) # Enum: All or part of primary dwelling; Secondary suite; Accessory dwelling unit; Float home; Other - property_type = db.Column(db.String, nullable=False) - ownership_type = db.Column(db.String, nullable=False) # Enum: own, rent, co-own + property_type = db.Column(Enum(PropertyType), nullable=False) + ownership_type = db.Column(Enum(OwnershipType), nullable=False) # Enum: own, rent, co-own property_manager = relationship("PropertyManager") rental_platforms = relationship("RentalPlatform") registrations = relationship("Registration") + address = relationship("Address", foreign_keys=[address_id], back_populates="rental_properties_address") class Address(db.Model): @@ -38,6 +42,7 @@ class Address(db.Model): id = db.Column(db.Integer, primary_key=True, autoincrement=True) country = db.Column(db.String, nullable=False) street_address = db.Column(db.String, nullable=False) + street_address_additional = db.Column(db.String, nullable=True) city = db.Column(db.String, nullable=False) province = db.Column(db.String, nullable=False) postal_code = db.Column(db.String, nullable=False) @@ -48,6 +53,9 @@ class Address(db.Model): property_managers_secondary = relationship( "PropertyManager", back_populates="secondary_address", foreign_keys="PropertyManager.secondary_address_id" ) + rental_properties_address = relationship( + "RentalProperty", back_populates="address", foreign_keys="RentalProperty.address_id" + ) class PropertyManager(db.Model): @@ -57,10 +65,12 @@ class PropertyManager(db.Model): id = db.Column(db.Integer, primary_key=True, autoincrement=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + secondary_contact_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) primary_address_id = db.Column(db.Integer, db.ForeignKey("addresses.id"), nullable=False) secondary_address_id = db.Column(db.Integer, db.ForeignKey("addresses.id"), nullable=True) - user = relationship("User") + primary_contact_user = relationship("User", foreign_keys=[user_id]) + secondary_contact_user = relationship("User", foreign_keys=[secondary_contact_user_id]) primary_address = relationship( "Address", foreign_keys=[primary_address_id], back_populates="property_managers_primary" ) @@ -75,7 +85,7 @@ class RentalPlatform(db.Model): __tablename__ = "rental_platforms" id = db.Column(db.Integer, primary_key=True, autoincrement=True) - name = db.Column(db.String, nullable=False) + name = db.Column(db.String, nullable=True) property_id = db.Column(db.Integer, db.ForeignKey("rental_properties.id"), nullable=False) url = db.Column(db.String, nullable=False) type = db.Column(db.String, nullable=True) @@ -92,6 +102,6 @@ class Registration(db.Model): rental_property_id = db.Column(db.Integer, db.ForeignKey("rental_properties.id"), nullable=False) submission_date = db.Column(db.DateTime, default=datetime.now, nullable=False) updated_date = db.Column(db.DateTime, default=datetime.now, nullable=False) - status = db.Column(db.String, nullable=False) # Enum: Pending, Approved, MoreInfoNeeded, Denied + status = db.Column(Enum(RegistrationStatus), nullable=False) # Enum: pending, approved, more info needed, denied rental_property = relationship("RentalProperty", back_populates="registrations") diff --git a/strr-api/src/strr_api/models/user.py b/strr-api/src/strr_api/models/user.py index 074f21a3..fcd289c7 100644 --- a/strr-api/src/strr_api/models/user.py +++ b/strr-api/src/strr_api/models/user.py @@ -38,6 +38,8 @@ """ from __future__ import annotations +from datetime import datetime + from flask import current_app from .db import db @@ -58,7 +60,12 @@ class User(db.Model): iss = db.Column(db.String(1024)) idp_userid = db.Column(db.String(256), index=True) login_source = db.Column(db.String(200), nullable=True) - creation_date = db.Column(db.DateTime(timezone=True)) + creation_date = db.Column(db.DateTime(timezone=True), default=datetime.now) + preferredname = db.Column(db.String, nullable=True) + phone_extension = db.Column(db.String, nullable=True) + fax_number = db.Column(db.String, nullable=True) + phone_number = db.Column(db.String, nullable=True) + date_of_birth = db.Column(db.Date, nullable=True) @property def display_name(self): diff --git a/strr-api/src/strr_api/requests/RegistrationRequest.py b/strr-api/src/strr_api/requests/RegistrationRequest.py new file mode 100644 index 00000000..41b20dd6 --- /dev/null +++ b/strr-api/src/strr_api/requests/RegistrationRequest.py @@ -0,0 +1,111 @@ +# pylint: disable=C0103 +# pylint: disable=R0913 +""" +Registration request payload objects. +""" + + +class RegistrationRequest: + """RegistrationRequest payload object.""" + + def __init__(self, selectedAccount, registration): + self.selectedAccount = SelectedAccount(**selectedAccount) + self.registration = Registration(**registration) + + +class SBCMailingAddress: + """SBCMailingAddress payload object.""" + + def __init__(self, street, city, region, postalCode, country, streetAdditional=None): + self.street = street + self.streetAdditional = streetAdditional + self.city = city + self.region = region + self.postalCode = postalCode + self.country = country + + +class SelectedAccount: + """SelectedAccount payload object.""" + + def __init__(self, name, mailingAddress): + self.name = name + self.mailingAddress = SBCMailingAddress(**mailingAddress) + + +class Registration: + """Registration payload object.""" + + def __init__(self, primaryContact, secondaryContact, unitAddress, unitDetails, listingDetails): + self.primaryContact = Contact(**primaryContact) + self.secondaryContact = Contact(**secondaryContact) + self.unitAddress = UnitAddress(**unitAddress) + self.unitDetails = UnitDetails(**unitDetails) + self.listingDetails = [ListingDetails(**item) for item in listingDetails] + + +class ListingDetails: + """ListingDetails payload object.""" + + def __init__(self, url): + self.url = url + + +class UnitDetails: + """UnitDetails payload object.""" + + def __init__(self, parcelIdentifier, businessLicense, propertyType, ownershipType): + self.parcelIdentifier = parcelIdentifier + self.businessLicense = businessLicense + self.propertyType = propertyType + self.ownershipType = ownershipType + + +class MailingAddress: + """MailingAddress payload object.""" + + def __init__(self, address, addressLineTwo, city, postalCode, province, country): + self.address = address + self.addressLineTwo = addressLineTwo + self.city = city + self.postalCode = postalCode + self.province = province + self.country = country + + +class UnitAddress(MailingAddress): + """UnitAddress payload object.""" + + def __init__(self, nickname, address, addressLineTwo, city, postalCode, province, country): + super().__init__(address, addressLineTwo, city, postalCode, province, country) + self.nickname = nickname + + +class ContactName: + """ContactName payload object.""" + + def __init__(self, firstName, middleName, lastName): + self.firstName = firstName + self.middleName = middleName + self.lastName = lastName + + +class ContactDetails: + """ContactDetails payload object.""" + + def __init__(self, preferredName, phoneNumber, extension, faxNumber, emailAddress): + self.preferredName = preferredName + self.phoneNumber = phoneNumber + self.extension = extension + self.faxNumber = faxNumber + self.emailAddress = emailAddress + + +class Contact: + """Contact payload object.""" + + def __init__(self, name, dateOfBirth, details, mailingAddress): + self.name = ContactName(**name) + self.dateOfBirth = dateOfBirth + self.details = ContactDetails(**details) + self.mailingAddress = MailingAddress(**mailingAddress) diff --git a/strr-api/src/strr_api/requests/__init__.py b/strr-api/src/strr_api/requests/__init__.py new file mode 100644 index 00000000..d7b6446d --- /dev/null +++ b/strr-api/src/strr_api/requests/__init__.py @@ -0,0 +1,4 @@ +""" +This module is for the requests used in the API. +""" +from .RegistrationRequest import Registration, RegistrationRequest diff --git a/strr-api/src/strr_api/resources/__init__.py b/strr-api/src/strr_api/resources/__init__.py index 47fbd10f..db00203d 100644 --- a/strr-api/src/strr_api/resources/__init__.py +++ b/strr-api/src/strr_api/resources/__init__.py @@ -43,6 +43,7 @@ from .base import bp as base_endpoint from .ops import bp as ops_endpoint from .payment import bp as payment_endpoint +from .registrations import bp as registrations_endpoint def register_endpoints(app: Flask): @@ -77,6 +78,11 @@ def register_endpoints(app: Flask): blueprint=payment_endpoint, ) + app.register_blueprint( + url_prefix="/registrations", + blueprint=registrations_endpoint, + ) + app.config["SWAGGER"] = { "title": "STRR API", "specs_route": "/", diff --git a/strr-api/src/strr_api/resources/account.py b/strr-api/src/strr_api/resources/account.py index dac1666c..7e8e24ee 100644 --- a/strr-api/src/strr_api/resources/account.py +++ b/strr-api/src/strr_api/resources/account.py @@ -37,6 +37,7 @@ """ import logging +import re from http import HTTPStatus from flasgger import swag_from @@ -44,11 +45,13 @@ from flask_cors import cross_origin from strr_api.common.auth import jwt -from strr_api.exceptions import AuthException, ExternalServiceException, error_response, exception_response +from strr_api.exceptions import AuthException, ExternalServiceException, ValidationException, exception_response +from strr_api.requests import RegistrationRequest +from strr_api.responses import Registration from strr_api.schemas.utils import validate # from strr_api.schemas import utils as schema_utils -from strr_api.services import AuthService +from strr_api.services import AuthService, RegistrationService logger = logging.getLogger("api") bp = Blueprint("account", __name__) @@ -115,20 +118,82 @@ def create_account(): try: token = jwt.get_token_auth_header() json_input = request.get_json() - [valid, errors] = validate(json_input, "new-account") + [valid, errors] = validate(json_input, "registration") if not valid: - return error_response("Invalid request", HTTPStatus.BAD_REQUEST, errors) + raise ValidationException(message=errors) - name = json_input.get("name") - mailing_address = json_input.get("mailingAddress") - new_user_account = AuthService.create_user_account(token, name, mailing_address) - return jsonify(new_user_account), HTTPStatus.CREATED + registration_request = RegistrationRequest(**json_input) + selected_account = registration_request.selectedAccount + + # TODO: link SBC account to User account + AuthService.create_user_account(token, selected_account.name, selected_account.mailingAddress) + + # DO POSTAL CODE VALIDATION IF COUNTRY IS CANADA + selected_account.mailingAddress.postalCode = validate_and_format_canadian_postal_code( + selected_account.mailingAddress.country, + selected_account.mailingAddress.region, + selected_account.mailingAddress.postalCode, + ) + + registration_request.registration.unitAddress.postalCode = validate_and_format_canadian_postal_code( + registration_request.registration.unitAddress.country, + registration_request.registration.unitAddress.province, + registration_request.registration.unitAddress.postalCode, + ) + + if ( + registration_request.registration.unitAddress.country != "CA" + or registration_request.registration.unitAddress.province != "BC" + ): + raise ValidationException(message="Invalid Rental Unit Address. Location must be in British Columbia.") + + registration_request.registration.primaryContact.mailingAddress.postalCode = ( + validate_and_format_canadian_postal_code( + registration_request.registration.primaryContact.mailingAddress.country, + registration_request.registration.primaryContact.mailingAddress.province, + registration_request.registration.primaryContact.mailingAddress.postalCode, + ) + ) + + if registration_request.registration.secondaryContact: + registration_request.registration.secondaryContact.mailingAddress.postalCode = ( + validate_and_format_canadian_postal_code( + registration_request.registration.secondaryContact.mailingAddress.country, + registration_request.registration.secondaryContact.mailingAddress.province, + registration_request.registration.secondaryContact.mailingAddress.postalCode, + ) + ) + + registration = RegistrationService.save_registration(token, registration_request.registration) + return jsonify(Registration.from_db(registration).model_dump(mode="json")), HTTPStatus.CREATED + except ValidationException as auth_exception: + return exception_response(auth_exception) except AuthException as auth_exception: return exception_response(auth_exception) except ExternalServiceException as service_exception: return exception_response(service_exception) +def validate_and_format_canadian_postal_code(country: str, province: str, postal_code: str): + """Validate and format a Canadian postal code.""" + + if country == "CA": + if province not in ["BC", "AB", "SK", "MB", "ON", "QC", "NB", "PE", "NS", "NL", "YT", "NT", "NU"]: + raise ValidationException( + message="Invalid province. Must be one of 'BC', 'AB', 'SK', 'MB', 'ON', 'QC', " + + "'NB', 'PE', 'NS', 'NL', 'YT', 'NT', 'NU'" + ) + + regex = r"^[A-Za-z]\d[A-Za-z][ -]?\d[A-Za-z]\d$" + match = re.match(regex, postal_code) + if match: + return postal_code.upper().replace(" ", "") + else: + raise ValidationException(message="Invalid postal code. Must be in the format 'A1A 1A1' or 'A1A1A1'") + + return postal_code + + # @bp.route("/search_accounts", methods=("GET",)) # @cross_origin(origin="*") # def search_accounts(): diff --git a/strr-api/src/strr_api/resources/registrations.py b/strr-api/src/strr_api/resources/registrations.py new file mode 100644 index 00000000..a3170a17 --- /dev/null +++ b/strr-api/src/strr_api/resources/registrations.py @@ -0,0 +1,80 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the BSD 3 Clause License, (the "License"); +# you may not use this file except in compliance with the License. +# The template for the license can be found here +# https://opensource.org/license/bsd-3-clause/ +# +# Redistribution and use in source and binary forms, +# with or without modification, are permitted provided that the +# following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +""" +This module provides a simple flask blueprint with a single 'home' route that returns a JSON response. +""" + +import logging +from http import HTTPStatus + +from flasgger import swag_from +from flask import Blueprint, jsonify +from flask_cors import cross_origin + +from strr_api.common.auth import jwt +from strr_api.exceptions import AuthException, exception_response +from strr_api.responses import Registration +from strr_api.services import RegistrationService + +logger = logging.getLogger("api") +bp = Blueprint("registrations", __name__) + + +@bp.route("", methods=("GET",)) +@swag_from({"security": [{"Bearer": []}]}) +@cross_origin(origin="*") +@jwt.requires_auth +def get_registrations(): + """ + Get Registrations for current user. + --- + tags: + - registration + responses: + 201: + description: + 401: + description: + """ + + try: + token = jwt.get_token_auth_header() + registrations = RegistrationService.list_registrations(token) + return ( + jsonify([Registration.from_db(registration).model_dump(mode="json") for registration in registrations]), + HTTPStatus.OK, + ) + except AuthException as auth_exception: + return exception_response(auth_exception) diff --git a/strr-api/src/strr_api/responses/RegistrationResponse.py b/strr-api/src/strr_api/responses/RegistrationResponse.py new file mode 100644 index 00000000..58845f86 --- /dev/null +++ b/strr-api/src/strr_api/responses/RegistrationResponse.py @@ -0,0 +1,180 @@ +""" +Registration response objects. +""" +from datetime import date, datetime +from typing import List, Optional + +from pydantic import BaseModel + +from strr_api import models + + +class SBCMailingAddress(BaseModel): + """SBCMailingAddress response object.""" + + street: str + streetAdditional: Optional[str] = None + city: str + region: str + postalCode: str + country: str + + +class SelectedAccount(BaseModel): + """SelectedAccount response object.""" + + name: str + mailingAddress: SBCMailingAddress + + +class ListingDetails(BaseModel): + """ListingDetails response object.""" + + url: str + + +class UnitDetails(BaseModel): + """UnitDetails response object.""" + + parcelIdentifier: Optional[str] = None + businessLicense: Optional[str] = None + propertyType: str + ownershipType: str + + +class MailingAddress(BaseModel): + """MailingAddress response object.""" + + address: str + addressLineTwo: Optional[str] = None + city: str + postalCode: str + province: str + country: str + + +class UnitAddress(MailingAddress): + """UnitAddress response object.""" + + nickname: Optional[str] = None + + def __init__(self, address_dict, nickname: Optional[str] = None): + super().__init__(**address_dict) + self.nickname = nickname + + +class ContactName(BaseModel): + """ContactName response object.""" + + firstName: str + middleName: Optional[str] = None + lastName: str + + +class ContactDetails(BaseModel): + """ContactDetails response object.""" + + preferredName: Optional[str] = None + phoneNumber: str + extension: Optional[str] = None + faxNumber: Optional[str] = None + emailAddress: str + + +class Contact(BaseModel): + """Contact response object.""" + + name: ContactName + dateOfBirth: date + details: ContactDetails + mailingAddress: MailingAddress + + +class Registration(BaseModel): + """Registration response object.""" + + id: int + submissionDate: datetime + updatedDate: datetime + status: str + primaryContact: Optional[Contact] = None + secondaryContact: Optional[Contact] = None + unitAddress: UnitAddress + unitDetails: UnitDetails + listingDetails: List[ListingDetails] + + @classmethod + def from_db(cls, source: models.Registration): + """Return a Registration object from a database model.""" + return cls( + id=source.id, + submissionDate=source.submission_date, + updatedDate=source.updated_date, + status=source.status.name, + primaryContact=Contact( + name=ContactName( + firstName=source.rental_property.property_manager.primary_contact_user.firstname, + middleName=source.rental_property.property_manager.primary_contact_user.middlename, + lastName=source.rental_property.property_manager.primary_contact_user.lastname, + ), + dateOfBirth=source.rental_property.property_manager.primary_contact_user.date_of_birth, + details=ContactDetails( + preferredName=source.rental_property.property_manager.primary_contact_user.preferredname, + phoneNumber=source.rental_property.property_manager.primary_contact_user.phone_number, + extension=source.rental_property.property_manager.primary_contact_user.phone_extension, + faxNumber=source.rental_property.property_manager.primary_contact_user.fax_number, + emailAddress=source.rental_property.property_manager.primary_contact_user.email, + ), + mailingAddress=MailingAddress( + address=source.rental_property.property_manager.primary_address.street_address, + addressLineTwo=source.rental_property.property_manager.primary_address.street_address_additional, + city=source.rental_property.property_manager.primary_address.city, + postalCode=source.rental_property.property_manager.primary_address.postal_code, + province=source.rental_property.property_manager.primary_address.province, + country=source.rental_property.property_manager.primary_address.country, + ), + ), + secondaryContact=Contact( + name=ContactName( + firstName=source.rental_property.property_manager.secondary_contact_user.firstname, + middleName=source.rental_property.property_manager.secondary_contact_user.middlename, + lastName=source.rental_property.property_manager.secondary_contact_user.lastname, + ), + dateOfBirth=source.rental_property.property_manager.secondary_contact_user.date_of_birth, + details=ContactDetails( + preferredName=source.rental_property.property_manager.secondary_contact_user.preferredname, + phoneNumber=source.rental_property.property_manager.secondary_contact_user.phone_number, + extension=source.rental_property.property_manager.secondary_contact_user.phone_extension, + faxNumber=source.rental_property.property_manager.secondary_contact_user.fax_number, + emailAddress=source.rental_property.property_manager.secondary_contact_user.email, + ), + mailingAddress=MailingAddress( + address=source.rental_property.property_manager.secondary_address.street_address, + addressLineTwo=source.rental_property.property_manager.secondary_address.street_address_additional, + city=source.rental_property.property_manager.secondary_address.city, + postalCode=source.rental_property.property_manager.secondary_address.postal_code, + province=source.rental_property.property_manager.secondary_address.province, + country=source.rental_property.property_manager.secondary_address.country, + ), + ) + if source.rental_property.property_manager.secondary_contact_user + else None, + unitAddress=UnitAddress( + { + "address": source.rental_property.address.street_address, + "addressLineTwo": source.rental_property.address.street_address_additional, + "city": source.rental_property.address.city, + "postalCode": source.rental_property.address.postal_code, + "province": source.rental_property.address.province, + "country": source.rental_property.address.country, + }, + nickname=source.rental_property.nickname, + ), + unitDetails=UnitDetails( + parcelIdentifier=source.rental_property.parcel_identifier, + businessLicense=source.rental_property.local_business_licence, + propertyType=source.rental_property.property_type.name, + ownershipType=source.rental_property.ownership_type.name, + ), + listingDetails=[ListingDetails(url=platform.url) for platform in source.rental_property.rental_platforms], + ) diff --git a/strr-api/src/strr_api/responses/__init__.py b/strr-api/src/strr_api/responses/__init__.py new file mode 100644 index 00000000..25f2da60 --- /dev/null +++ b/strr-api/src/strr_api/responses/__init__.py @@ -0,0 +1,4 @@ +""" +This module is for the responses used in the API. +""" +from .RegistrationResponse import Registration diff --git a/strr-api/src/strr_api/schemas/schemas/new_account.json b/strr-api/src/strr_api/schemas/schemas/new_account.json deleted file mode 100644 index b5a47e9c..00000000 --- a/strr-api/src/strr_api/schemas/schemas/new_account.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://strr.gov.bc.ca/.well_known/schemas/new_account", - "type": "object", - "title": "New Account", - "required": [ - "name" - ], - "properties": { - "name": { - "description": "Account Name", - "type": "string" - }, - "mailingAddress": { - "title": "Address", - "description": "A free text address string, providing as much address data as is relevant, suitable for processing using address parsing algorithms. For some uses (for example, Place of Birth) only a town and country are required.", - "type": "object", - "properties": { - "street": { - "type": "string", - "maxLength": 50, - "description": "Street address and name." - }, - "streetAdditional": { - "type": "string", - "maxLength": 50, - "description": "Additional street address information." - }, - "city": { - "type": "string", - "maxLength": 40, - "description": "City, Town, or Village." - }, - "region": { - "type": [ - "string", - "null" - ], - "description": "For Canada or USA, 2 character province or state code." - }, - "postalCode": { - "type": "string", - "maxLength": 15, - "description": "Postal Code in A1A 1A1 format for Canada, or zip code for US addresses." - }, - "country": { - "type": "string", - "maxLength": 2, - "description": "The 2-letter country code (ISO 3166-1) for this address." - } - } - } - } -} \ No newline at end of file diff --git a/strr-api/src/strr_api/schemas/schemas/registration.json b/strr-api/src/strr_api/schemas/schemas/registration.json new file mode 100644 index 00000000..6e994c45 --- /dev/null +++ b/strr-api/src/strr_api/schemas/schemas/registration.json @@ -0,0 +1,226 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://strr.gov.bc.ca/.well_known/schemas/registration", + "type": "object", + "title": "STRR Registration", + "definitions": { + "mailingAddress": { + "title": "Address", + "description": "A free text address string, providing as much address data as is relevant, suitable for processing using address parsing algorithms. For some uses (for example, Place of Birth) only a town and country are required.", + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "Street address and name." + }, + "addressLineTwo": { + "type": "string", + "description": "Additional street address information." + }, + "city": { + "type": "string", + "description": "City, Town, or Village." + }, + "postalCode": { + "type": "string", + "maxLength": 15, + "description": "Postal Code in A1A 1A1 format for Canada, or zip code for US addresses." + }, + "province": { + "type": "string", + "maxLength": 2, + "description": "The 2-letter province code (ISO 3166-2) for this address." + }, + "country": { + "type": "string", + "maxLength": 2, + "description": "The 2-letter country code (ISO 3166-1) for this address." + } + }, + "required": [ + "address", + "city", + "postalCode", + "province", + "country" + ] + }, + "contactName": { + "type": "object", + "properties": { + "firstName": { + "type": "string" + }, + "middleName": { + "type": "string" + }, + "lastName": { + "type": "string" + } + }, + "required": [ + "firstName", + "lastName" + ] + }, + "contact": { + "type": "object", + "properties": { + "name": { + "$ref": "#/definitions/contactName" + }, + "dateOfBirth": { + "type": "string", + "format": "date", + "description": "YYYY-MM-DD" + }, + "details": { + "type": "object", + "properties": { + "preferredName": { + "type": "string" + }, + "phoneNumber": { + "type": "string" + }, + "extension": { + "type": "string" + }, + "faxNumber": { + "type": "string" + }, + "emailAddress": { + "type": "string", + "format": "email" + } + }, + "required": [ + "phoneNumber", + "emailAddress" + ] + }, + "mailingAddress": { + "$ref": "#/definitions/mailingAddress" + } + }, + "required": [ + "name", + "dateOfBirth", + "details", + "mailingAddress" + ] + } + }, + "properties": { + "selectedAccount": { + "type": "object", + "name": { + "description": "Account Name", + "type": "string" + }, + "mailingAddress": { + "title": "Address", + "description": "A free text address string, providing as much address data as is relevant, suitable for processing using address parsing algorithms. For some uses (for example, Place of Birth) only a town and country are required.", + "type": "object", + "properties": { + "street": { + "type": "string", + "maxLength": 50, + "description": "Street address and name." + }, + "streetAdditional": { + "type": "string", + "maxLength": 50, + "description": "Additional street address information." + }, + "city": { + "type": "string", + "maxLength": 40, + "description": "City, Town, or Village." + }, + "region": { + "type": [ + "string", + "null" + ], + "description": "For Canada or USA, 2 character province or state code." + }, + "postalCode": { + "type": "string", + "maxLength": 15, + "description": "Postal Code in A1A 1A1 format for Canada, or zip code for US addresses." + }, + "country": { + "type": "string", + "maxLength": 2, + "description": "The 2-letter country code (ISO 3166-1) for this address." + } + } + }, + "required": [ + "name" + ] + }, + "registration": { + "type": "object", + "properties": { + "primaryContact": { + "$ref": "#/definitions/contact" + }, + "secondaryContact": { + "$ref": "#/definitions/contact" + }, + "unitAddress": { + "nickname": { + "type": "string" + }, + "$ref": "#/definitions/mailingAddress" + }, + "unitDetails": { + "type": "object", + "properties": { + "parcelIdentifier": { + "type": "string" + }, + "businessLicense": { + "type": "string" + }, + "propertyType": { + "type": "string" + }, + "ownershipType": { + "type": "string" + } + }, + "required": [ + "propertyType", + "ownershipType" + ] + }, + "listingDetails": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + } + } + } + }, + "required": [ + "primaryContact", + "unitAddress", + "unitDetails", + "listingDetails" + ] + } + }, + "required": [ + "selectedAccount", + "registration" + ] +} \ No newline at end of file diff --git a/strr-api/src/strr_api/schemas/utils.py b/strr-api/src/strr_api/schemas/utils.py index b7533259..13865c8a 100644 --- a/strr-api/src/strr_api/schemas/utils.py +++ b/strr-api/src/strr_api/schemas/utils.py @@ -76,6 +76,9 @@ def validate_schema( schema_uri = f"{BASE_URI}/{schema_id}" schema = schema_store.get(schema_uri) + if schema is None: + raise ValueError(f"No schema found for URI {schema_uri}") + def retrieve_resource(uri): contents = schema_store.get(uri) return Resource.from_contents(contents) diff --git a/strr-api/src/strr_api/services/__init__.py b/strr-api/src/strr_api/services/__init__.py index ffe53e6c..4786c4b7 100644 --- a/strr-api/src/strr_api/services/__init__.py +++ b/strr-api/src/strr_api/services/__init__.py @@ -34,6 +34,7 @@ """This module wraps helper services used by the API.""" from .auth_service import AuthService from .payment_service import PayService +from .registration_service import RegistrationService from .rest_service import RestService PAYMENT_REQUEST_TEMPLATE = { diff --git a/strr-api/src/strr_api/services/registration_service.py b/strr-api/src/strr_api/services/registration_service.py new file mode 100644 index 00000000..7b4f543a --- /dev/null +++ b/strr-api/src/strr_api/services/registration_service.py @@ -0,0 +1,140 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the BSD 3 Clause License, (the "License"); +# you may not use this file except in compliance with the License. +# The template for the license can be found here +# https://opensource.org/license/bsd-3-clause/ +# +# Redistribution and use in source and binary forms, +# with or without modification, are permitted provided that the +# following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +"""Manages Auth service interactions.""" +from strr_api import models, requests +from strr_api.enums.enum import RegistrationStatus +from strr_api.models import db + + +class RegistrationService: + """Service to save and load regristration details from the database.""" + + @classmethod + def save_registration(cls, token, registration_request: requests.Registration): + """Save STRR property registration to database.""" + + # TODO: FUTURE SPRINT - handle the other cases where jwt doesn't have the info + user = models.User.get_or_create_user_by_jwt(token) + user.preferredname = (registration_request.primaryContact.details.preferredName,) + user.phone_extension = registration_request.primaryContact.details.extension + user.fax_number = registration_request.primaryContact.details.faxNumber + user.phone_number = registration_request.primaryContact.details.phoneNumber + user.date_of_birth = registration_request.primaryContact.dateOfBirth + + primary_contact = user + db.session.add(primary_contact) + db.session.commit() + db.session.refresh(primary_contact) + + if registration_request.secondaryContact: + secondary_contact = models.User( + firstname=registration_request.secondaryContact.name.firstName, + lastname=registration_request.secondaryContact.name.lastName, + middlename=registration_request.secondaryContact.name.middleName, + email=registration_request.secondaryContact.details.emailAddress, + preferredname=registration_request.secondaryContact.details.preferredName, + phone_extension=registration_request.secondaryContact.details.extension, + fax_number=registration_request.secondaryContact.details.faxNumber, + phone_number=registration_request.secondaryContact.details.phoneNumber, + date_of_birth=registration_request.secondaryContact.dateOfBirth, + ) + db.session.add(secondary_contact) + db.session.commit() + db.session.refresh(secondary_contact) + + property_manager = models.PropertyManager( + user_id=primary_contact.id, + secondary_contact_user_id=secondary_contact.id if secondary_contact else None, + primary_address=models.Address( + country=registration_request.primaryContact.mailingAddress.country, + street_address=registration_request.primaryContact.mailingAddress.address, + street_address_additional=registration_request.primaryContact.mailingAddress.addressLineTwo, + city=registration_request.primaryContact.mailingAddress.city, + province=registration_request.primaryContact.mailingAddress.province, + postal_code=registration_request.primaryContact.mailingAddress.postalCode, + ), + secondary_address=models.Address( + country=registration_request.secondaryContact.mailingAddress.country, + street_address=registration_request.secondaryContact.mailingAddress.address, + street_address_additional=registration_request.secondaryContact.mailingAddress.addressLineTwo, + city=registration_request.secondaryContact.mailingAddress.city, + province=registration_request.secondaryContact.mailingAddress.province, + postal_code=registration_request.secondaryContact.mailingAddress.postalCode, + ) + if secondary_contact + else None, + ) + db.session.add(property_manager) + db.session.commit() + db.session.refresh(property_manager) + + registration = models.Registration( + status=RegistrationStatus.PENDING, + rental_property=models.RentalProperty( + property_manager_id=property_manager.id, + address=models.Address( + country=registration_request.unitAddress.country, + street_address=registration_request.unitAddress.address, + street_address_additional=registration_request.unitAddress.addressLineTwo, + city=registration_request.unitAddress.city, + province=registration_request.unitAddress.province, + postal_code=registration_request.unitAddress.postalCode, + ), + nickname=registration_request.unitAddress.nickname, + parcel_identifier=registration_request.unitDetails.parcelIdentifier, + local_business_licence=registration_request.unitDetails.businessLicense, + property_type=registration_request.unitDetails.propertyType, + ownership_type=registration_request.unitDetails.ownershipType, + rental_platforms=[ + models.RentalPlatform(url=listing.url) for listing in registration_request.listingDetails + ], + ), + ) + + db.session.add(registration) + db.session.commit() + db.session.refresh(registration) + return registration + + @classmethod + def list_registrations(cls, token): + """List all registrations for current user.""" + user = models.User.find_by_jwt_token(token) + return ( + models.Registration.query.join( + models.PropertyManager, models.PropertyManager.id == models.Registration.rental_property_id + ) + .filter_by(user_id=user.id) + .all() + ) diff --git a/strr-api/tests/mocks/json/registration.json b/strr-api/tests/mocks/json/registration.json new file mode 100644 index 00000000..adc7ae37 --- /dev/null +++ b/strr-api/tests/mocks/json/registration.json @@ -0,0 +1,80 @@ +{ + "selectedAccount": { + "name": "Test Account", + "mailingAddress": { + "country": "CA", + "street": "12766 227st", + "city": "MAPLE RIDGE", + "region": "BC", + "postalCode": "V2X 6K6" + } + }, + "registration": { + "primaryContact": { + "name": { + "firstName": "The", + "middleName": "First", + "lastName": "Guy" + }, + "dateOfBirth": "1986-10-23", + "details": { + "preferredName": "Mickey", + "phoneNumber": "604-999-9999", + "extension": "x64", + "faxNumber": "604-777-7777", + "emailAddress": "test@test.test" + }, + "mailingAddress": { + "country": "CA", + "address": "12766 227st", + "addressLineTwo": "", + "city": "MAPLE RIDGE", + "province": "BC", + "postalCode": "V2X 6K6" + } + }, + "secondaryContact": { + "name": { + "firstName": "The", + "middleName": "Other", + "lastName": "Guy" + }, + "dateOfBirth": "1986-10-23", + "details": { + "preferredName": "Mouse", + "phoneNumber": "604-888-8888", + "extension": "", + "faxNumber": "", + "emailAddress": "test2@test.test" + }, + "mailingAddress": { + "country": "CA", + "address": "12766 227st", + "addressLineTwo": "", + "city": "MAPLE RIDGE", + "province": "BC", + "postalCode": "V2X 6K6" + } + }, + "unitDetails": { + "parcelIdentifier": "000-460-991", + "businessLicense": "", + "propertyType": "PRIMARY", + "ownershipType": "OWN" + }, + "unitAddress": { + "nickname": "My Rental Property", + "country": "CA", + "address": "12166 GREENWELL ST MAPLE RIDGE", + "addressLineTwo": "", + "city": "MAPLE RIDGE", + "province": "BC", + "postalCode": "V2X 7N1" + }, + "listingDetails": [ + { + "url": "https://www.airbnb.ca/rooms/26359027" + } + ] + } +} \ No newline at end of file diff --git a/strr-api/tests/unit/resources/test_account.py b/strr-api/tests/unit/resources/test_account.py index 22c9ae0b..1b06db08 100644 --- a/strr-api/tests/unit/resources/test_account.py +++ b/strr-api/tests/unit/resources/test_account.py @@ -1,7 +1,20 @@ +import json +import os from http import HTTPStatus from unittest.mock import patch -from tests.unit.utils.mocks import empty_json, fake_get_token_auth_header, keycloak_profile_json, no_op +from tests.unit.utils.mocks import ( + empty_json, + fake_get_token_auth_header, + fake_user_from_token, + keycloak_profile_json, + no_op, +) + +REGISTRATION = "registration" +MOCK_ACCOUNT_REQUEST = os.path.join( + os.path.dirname(os.path.realpath(__file__)), f"../../mocks/json/{REGISTRATION}.json" +) @patch("strr_api.services.AuthService.get_user_profile", new=keycloak_profile_json) @@ -26,12 +39,15 @@ def test_me_401(client): assert rv.status_code == HTTPStatus.UNAUTHORIZED -# @patch("strr_api.services.AuthService.create_user_account", new=empty_json) -# @patch("flask_jwt_oidc.JwtManager.get_token_auth_header", new=fake_get_token_auth_header) -# @patch("flask_jwt_oidc.JwtManager._validate_token", new=no_op) -# def test_create_account_201(client): -# rv = client.post("/account", json={"name": "test"}) -# assert rv.status_code == HTTPStatus.CREATED +@patch("strr_api.services.AuthService.create_user_account", new=empty_json) +@patch("strr_api.models.user.User.get_or_create_user_by_jwt", new=fake_user_from_token) +@patch("flask_jwt_oidc.JwtManager.get_token_auth_header", new=fake_get_token_auth_header) +@patch("flask_jwt_oidc.JwtManager._validate_token", new=no_op) +def test_create_account_201(client): + with open(MOCK_ACCOUNT_REQUEST) as f: + data = json.load(f) + rv = client.post("/account", json=data) + assert rv.status_code == HTTPStatus.CREATED @patch("flask_jwt_oidc.JwtManager.get_token_auth_header", new=fake_get_token_auth_header) diff --git a/strr-api/tests/unit/resources/test_registrations.py b/strr-api/tests/unit/resources/test_registrations.py new file mode 100644 index 00000000..2d7e4e6f --- /dev/null +++ b/strr-api/tests/unit/resources/test_registrations.py @@ -0,0 +1,17 @@ +from http import HTTPStatus +from unittest.mock import patch + +from tests.unit.utils.mocks import fake_get_token_auth_header, fake_user_from_token, no_op + + +@patch("strr_api.models.user.User.find_by_jwt_token", new=fake_user_from_token) +@patch("flask_jwt_oidc.JwtManager.get_token_auth_header", new=fake_get_token_auth_header) +@patch("flask_jwt_oidc.JwtManager._validate_token", new=no_op) +def test_registrations_200(client): + rv = client.get("/registrations") + assert rv.status_code == HTTPStatus.OK + + +def test_registrations_401(client): + rv = client.get("/registrations") + assert rv.status_code == HTTPStatus.UNAUTHORIZED diff --git a/strr-api/tests/unit/schemas/test_utils.py b/strr-api/tests/unit/schemas/test_utils.py index 26bf2b9f..3d8203cd 100644 --- a/strr-api/tests/unit/schemas/test_utils.py +++ b/strr-api/tests/unit/schemas/test_utils.py @@ -1,24 +1,36 @@ +import json +import os + from strr_api.schemas import utils +REGISTRATION = "registration" +MOCK_ACCOUNT_REQUEST = os.path.join( + os.path.dirname(os.path.realpath(__file__)), f"../../mocks/json/{REGISTRATION}.json" +) + def test_get_schema(): - schema_store = utils.get_schema("new_account.json") + schema_store = utils.get_schema(f"{REGISTRATION}.json") assert schema_store is not None def test_validate_schema(): - valid, error = utils.validate_schema({"name": "a"}, "new_account") - assert valid - assert not error + with open(MOCK_ACCOUNT_REQUEST) as f: + data = json.load(f) + valid, error = utils.validate_schema(data, f"{REGISTRATION}") + assert valid + assert not error def test_validate_schema_error(): - valid, error = utils.validate_schema({"a": "b"}, "new_account") + valid, error = utils.validate_schema({"a": "b"}, f"{REGISTRATION}") assert not valid assert error def test_validate(): - valid, error = utils.validate({"name": "a"}, "new_account") - assert valid - assert not error + with open(MOCK_ACCOUNT_REQUEST) as f: + data = json.load(f) + valid, error = utils.validate(data, f"{REGISTRATION}") + assert valid + assert not error diff --git a/strr-api/tests/unit/utils/mocks.py b/strr-api/tests/unit/utils/mocks.py index a3bf3209..fd2d3101 100644 --- a/strr-api/tests/unit/utils/mocks.py +++ b/strr-api/tests/unit/utils/mocks.py @@ -1,3 +1,6 @@ +from strr_api.models import User + + def disable_jwt_requires_auth(f): return f @@ -6,6 +9,14 @@ def fake_get_token_auth_header(cls): return "fake_jwt_token" +def fake_user_from_token(cls): + return User( + firstname="First", + lastname="last", + email="test@test.test", + ) + + def keycloak_profile_json(*args, **kwargs): return {"keycloakGuid": "ecb1ef8f2fee443eb14a414321bbc1f2"} From b3bb2abf1a98457a6253dfec602f355221e9bd3e Mon Sep 17 00:00:00 2001 From: Tyler Zale Date: Fri, 24 May 2024 10:24:11 -0700 Subject: [PATCH 2/4] fix new vs reuse sbc account flow --- .../versions/20240524_894fe4847de6_.py | 30 ++++++++ strr-api/src/strr_api/models/rental.py | 1 + .../strr_api/requests/RegistrationRequest.py | 29 +++++++- strr-api/src/strr_api/resources/account.py | 28 ++++--- .../src/strr_api/resources/registrations.py | 5 +- .../responses/RegistrationResponse.py | 11 +-- .../schemas/schemas/registration.json | 17 ++++- .../strr_api/services/registration_service.py | 12 +-- ...json => registration_new_sbc_account.json} | 0 .../json/registration_use_sbc_account.json | 73 +++++++++++++++++++ strr-api/tests/unit/resources/test_account.py | 5 +- .../unit/resources/test_registrations.py | 3 + strr-api/tests/unit/schemas/test_utils.py | 11 +-- 13 files changed, 189 insertions(+), 36 deletions(-) create mode 100644 strr-api/migrations/versions/20240524_894fe4847de6_.py rename strr-api/tests/mocks/json/{registration.json => registration_new_sbc_account.json} (100%) create mode 100644 strr-api/tests/mocks/json/registration_use_sbc_account.json diff --git a/strr-api/migrations/versions/20240524_894fe4847de6_.py b/strr-api/migrations/versions/20240524_894fe4847de6_.py new file mode 100644 index 00000000..2055144d --- /dev/null +++ b/strr-api/migrations/versions/20240524_894fe4847de6_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: 894fe4847de6 +Revises: 3d7b4953b1ee +Create Date: 2024-05-24 09:34:10.209903 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '894fe4847de6' +down_revision = '3d7b4953b1ee' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('registrations', schema=None) as batch_op: + batch_op.add_column(sa.Column('sbc_account_id', sa.INTEGER(), autoincrement=False, nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('registrations', schema=None) as batch_op: + batch_op.drop_column('sbc_account_id') + # ### end Alembic commands ### diff --git a/strr-api/src/strr_api/models/rental.py b/strr-api/src/strr_api/models/rental.py index 8e463075..d74e3ac2 100644 --- a/strr-api/src/strr_api/models/rental.py +++ b/strr-api/src/strr_api/models/rental.py @@ -99,6 +99,7 @@ class Registration(db.Model): __tablename__ = "registrations" id = db.Column(db.Integer, primary_key=True, autoincrement=True) + sbc_account_id = db.Column(db.Integer, nullable=True) rental_property_id = db.Column(db.Integer, db.ForeignKey("rental_properties.id"), nullable=False) submission_date = db.Column(db.DateTime, default=datetime.now, nullable=False) updated_date = db.Column(db.DateTime, default=datetime.now, nullable=False) diff --git a/strr-api/src/strr_api/requests/RegistrationRequest.py b/strr-api/src/strr_api/requests/RegistrationRequest.py index 41b20dd6..20909263 100644 --- a/strr-api/src/strr_api/requests/RegistrationRequest.py +++ b/strr-api/src/strr_api/requests/RegistrationRequest.py @@ -24,13 +24,36 @@ def __init__(self, street, city, region, postalCode, country, streetAdditional=N self.postalCode = postalCode self.country = country + def to_dict(self): + """Convert object to dictionary for json serialization.""" + return { + key: value + for key, value in { + "street": self.street, + "streetAdditional": self.streetAdditional, + "city": self.city, + "region": self.region, + "postalCode": self.postalCode, + "country": self.country, + }.items() + if value is not None + } + class SelectedAccount: """SelectedAccount payload object.""" - def __init__(self, name, mailingAddress): - self.name = name - self.mailingAddress = SBCMailingAddress(**mailingAddress) + def __init__(self, sbc_account_id=None, name=None, mailingAddress=None): + self.sbc_account_id = None + self.name = None + self.mailingAddress = None + + if sbc_account_id: + self.sbc_account_id = sbc_account_id + if name: + self.name = name + if mailingAddress: + self.mailingAddress = SBCMailingAddress(**mailingAddress) class Registration: diff --git a/strr-api/src/strr_api/resources/account.py b/strr-api/src/strr_api/resources/account.py index 7e8e24ee..78905002 100644 --- a/strr-api/src/strr_api/resources/account.py +++ b/strr-api/src/strr_api/resources/account.py @@ -41,7 +41,7 @@ from http import HTTPStatus from flasgger import swag_from -from flask import Blueprint, jsonify, request +from flask import Blueprint, g, jsonify, request from flask_cors import cross_origin from strr_api.common.auth import jwt @@ -125,15 +125,23 @@ def create_account(): registration_request = RegistrationRequest(**json_input) selected_account = registration_request.selectedAccount - # TODO: link SBC account to User account - AuthService.create_user_account(token, selected_account.name, selected_account.mailingAddress) + # SBC Account lookup or creation + sbc_account_id = None + if selected_account.sbc_account_id: + sbc_account_id = selected_account.sbc_account_id + else: + new_account = AuthService.create_user_account( + token, selected_account.name, selected_account.mailingAddress.to_dict() + ) + sbc_account_id = new_account.get("id") # DO POSTAL CODE VALIDATION IF COUNTRY IS CANADA - selected_account.mailingAddress.postalCode = validate_and_format_canadian_postal_code( - selected_account.mailingAddress.country, - selected_account.mailingAddress.region, - selected_account.mailingAddress.postalCode, - ) + if selected_account.mailingAddress: + selected_account.mailingAddress.postalCode = validate_and_format_canadian_postal_code( + selected_account.mailingAddress.country, + selected_account.mailingAddress.region, + selected_account.mailingAddress.postalCode, + ) registration_request.registration.unitAddress.postalCode = validate_and_format_canadian_postal_code( registration_request.registration.unitAddress.country, @@ -164,7 +172,9 @@ def create_account(): ) ) - registration = RegistrationService.save_registration(token, registration_request.registration) + registration = RegistrationService.save_registration( + g.jwt_oidc_token_info, sbc_account_id, registration_request.registration + ) return jsonify(Registration.from_db(registration).model_dump(mode="json")), HTTPStatus.CREATED except ValidationException as auth_exception: return exception_response(auth_exception) diff --git a/strr-api/src/strr_api/resources/registrations.py b/strr-api/src/strr_api/resources/registrations.py index a3170a17..7bf16524 100644 --- a/strr-api/src/strr_api/resources/registrations.py +++ b/strr-api/src/strr_api/resources/registrations.py @@ -40,7 +40,7 @@ from http import HTTPStatus from flasgger import swag_from -from flask import Blueprint, jsonify +from flask import Blueprint, g, jsonify from flask_cors import cross_origin from strr_api.common.auth import jwt @@ -70,8 +70,7 @@ def get_registrations(): """ try: - token = jwt.get_token_auth_header() - registrations = RegistrationService.list_registrations(token) + registrations = RegistrationService.list_registrations(g.jwt_oidc_token_info) return ( jsonify([Registration.from_db(registration).model_dump(mode="json") for registration in registrations]), HTTPStatus.OK, diff --git a/strr-api/src/strr_api/responses/RegistrationResponse.py b/strr-api/src/strr_api/responses/RegistrationResponse.py index 58845f86..1b8831d1 100644 --- a/strr-api/src/strr_api/responses/RegistrationResponse.py +++ b/strr-api/src/strr_api/responses/RegistrationResponse.py @@ -20,13 +20,6 @@ class SBCMailingAddress(BaseModel): country: str -class SelectedAccount(BaseModel): - """SelectedAccount response object.""" - - name: str - mailingAddress: SBCMailingAddress - - class ListingDetails(BaseModel): """ListingDetails response object.""" @@ -78,7 +71,7 @@ class ContactDetails(BaseModel): phoneNumber: str extension: Optional[str] = None faxNumber: Optional[str] = None - emailAddress: str + emailAddress: Optional[str] = None class Contact(BaseModel): @@ -94,6 +87,7 @@ class Registration(BaseModel): """Registration response object.""" id: int + sbc_account_id: Optional[int] = None submissionDate: datetime updatedDate: datetime status: str @@ -108,6 +102,7 @@ def from_db(cls, source: models.Registration): """Return a Registration object from a database model.""" return cls( id=source.id, + sbc_account_id=source.sbc_account_id, submissionDate=source.submission_date, updatedDate=source.updated_date, status=source.status.name, diff --git a/strr-api/src/strr_api/schemas/schemas/registration.json b/strr-api/src/strr_api/schemas/schemas/registration.json index 6e994c45..e54042b7 100644 --- a/strr-api/src/strr_api/schemas/schemas/registration.json +++ b/strr-api/src/strr_api/schemas/schemas/registration.json @@ -114,6 +114,9 @@ "properties": { "selectedAccount": { "type": "object", + "sbc_account_id": { + "type": "integer" + }, "name": { "description": "Account Name", "type": "string" @@ -157,8 +160,18 @@ } } }, - "required": [ - "name" + "oneOf": [ + { + "required": [ + "sbc_account_id" + ] + }, + { + "required": [ + "name", + "mailingAddress" + ] + } ] }, "registration": { diff --git a/strr-api/src/strr_api/services/registration_service.py b/strr-api/src/strr_api/services/registration_service.py index 7b4f543a..b1c873ba 100644 --- a/strr-api/src/strr_api/services/registration_service.py +++ b/strr-api/src/strr_api/services/registration_service.py @@ -41,12 +41,13 @@ class RegistrationService: """Service to save and load regristration details from the database.""" @classmethod - def save_registration(cls, token, registration_request: requests.Registration): + def save_registration(cls, jwt_oidc_token_info, sbc_account_id, registration_request: requests.Registration): """Save STRR property registration to database.""" # TODO: FUTURE SPRINT - handle the other cases where jwt doesn't have the info - user = models.User.get_or_create_user_by_jwt(token) - user.preferredname = (registration_request.primaryContact.details.preferredName,) + user = models.User.get_or_create_user_by_jwt(jwt_oidc_token_info) + user.email = registration_request.primaryContact.details.emailAddress + user.preferredname = registration_request.primaryContact.details.preferredName user.phone_extension = registration_request.primaryContact.details.extension user.fax_number = registration_request.primaryContact.details.faxNumber user.phone_number = registration_request.primaryContact.details.phoneNumber @@ -100,6 +101,7 @@ def save_registration(cls, token, registration_request: requests.Registration): db.session.refresh(property_manager) registration = models.Registration( + sbc_account_id=sbc_account_id, status=RegistrationStatus.PENDING, rental_property=models.RentalProperty( property_manager_id=property_manager.id, @@ -128,9 +130,9 @@ def save_registration(cls, token, registration_request: requests.Registration): return registration @classmethod - def list_registrations(cls, token): + def list_registrations(cls, jwt_oidc_token_info): """List all registrations for current user.""" - user = models.User.find_by_jwt_token(token) + user = models.User.find_by_jwt_token(jwt_oidc_token_info) return ( models.Registration.query.join( models.PropertyManager, models.PropertyManager.id == models.Registration.rental_property_id diff --git a/strr-api/tests/mocks/json/registration.json b/strr-api/tests/mocks/json/registration_new_sbc_account.json similarity index 100% rename from strr-api/tests/mocks/json/registration.json rename to strr-api/tests/mocks/json/registration_new_sbc_account.json diff --git a/strr-api/tests/mocks/json/registration_use_sbc_account.json b/strr-api/tests/mocks/json/registration_use_sbc_account.json new file mode 100644 index 00000000..3acf5c4c --- /dev/null +++ b/strr-api/tests/mocks/json/registration_use_sbc_account.json @@ -0,0 +1,73 @@ +{ + "selectedAccount": { + "sbc_account_id": 3299 + }, + "registration": { + "primaryContact": { + "name": { + "firstName": "The", + "middleName": "First", + "lastName": "Guy" + }, + "dateOfBirth": "1986-10-23", + "details": { + "preferredName": "Mickey", + "phoneNumber": "604-999-9999", + "extension": "x64", + "faxNumber": "604-777-7777", + "emailAddress": "test@test.test" + }, + "mailingAddress": { + "country": "CA", + "address": "12766 227st", + "addressLineTwo": "", + "city": "MAPLE RIDGE", + "province": "BC", + "postalCode": "V2X 6K6" + } + }, + "secondaryContact": { + "name": { + "firstName": "The", + "middleName": "Other", + "lastName": "Guy" + }, + "dateOfBirth": "1986-10-23", + "details": { + "preferredName": "Mouse", + "phoneNumber": "604-888-8888", + "extension": "", + "faxNumber": "", + "emailAddress": "test2@test.test" + }, + "mailingAddress": { + "country": "CA", + "address": "12766 227st", + "addressLineTwo": "", + "city": "MAPLE RIDGE", + "province": "BC", + "postalCode": "V2X 6K6" + } + }, + "unitDetails": { + "parcelIdentifier": "000-460-991", + "businessLicense": "", + "propertyType": "PRIMARY", + "ownershipType": "OWN" + }, + "unitAddress": { + "nickname": "My Rental Property", + "country": "CA", + "address": "12166 GREENWELL ST MAPLE RIDGE", + "addressLineTwo": "", + "city": "MAPLE RIDGE", + "province": "BC", + "postalCode": "V2X 7N1" + }, + "listingDetails": [ + { + "url": "https://www.airbnb.ca/rooms/26359027" + } + ] + } +} \ No newline at end of file diff --git a/strr-api/tests/unit/resources/test_account.py b/strr-api/tests/unit/resources/test_account.py index 1b06db08..45d83ab2 100644 --- a/strr-api/tests/unit/resources/test_account.py +++ b/strr-api/tests/unit/resources/test_account.py @@ -3,6 +3,8 @@ from http import HTTPStatus from unittest.mock import patch +from flask import g + from tests.unit.utils.mocks import ( empty_json, fake_get_token_auth_header, @@ -11,7 +13,7 @@ no_op, ) -REGISTRATION = "registration" +REGISTRATION = "registration_new_sbc_account" MOCK_ACCOUNT_REQUEST = os.path.join( os.path.dirname(os.path.realpath(__file__)), f"../../mocks/json/{REGISTRATION}.json" ) @@ -45,6 +47,7 @@ def test_me_401(client): @patch("flask_jwt_oidc.JwtManager._validate_token", new=no_op) def test_create_account_201(client): with open(MOCK_ACCOUNT_REQUEST) as f: + g.jwt_oidc_token_info = None data = json.load(f) rv = client.post("/account", json=data) assert rv.status_code == HTTPStatus.CREATED diff --git a/strr-api/tests/unit/resources/test_registrations.py b/strr-api/tests/unit/resources/test_registrations.py index 2d7e4e6f..e151dc6b 100644 --- a/strr-api/tests/unit/resources/test_registrations.py +++ b/strr-api/tests/unit/resources/test_registrations.py @@ -1,6 +1,8 @@ from http import HTTPStatus from unittest.mock import patch +from flask import g + from tests.unit.utils.mocks import fake_get_token_auth_header, fake_user_from_token, no_op @@ -8,6 +10,7 @@ @patch("flask_jwt_oidc.JwtManager.get_token_auth_header", new=fake_get_token_auth_header) @patch("flask_jwt_oidc.JwtManager._validate_token", new=no_op) def test_registrations_200(client): + g.jwt_oidc_token_info = None rv = client.get("/registrations") assert rv.status_code == HTTPStatus.OK diff --git a/strr-api/tests/unit/schemas/test_utils.py b/strr-api/tests/unit/schemas/test_utils.py index 3d8203cd..84f2fed5 100644 --- a/strr-api/tests/unit/schemas/test_utils.py +++ b/strr-api/tests/unit/schemas/test_utils.py @@ -3,27 +3,28 @@ from strr_api.schemas import utils -REGISTRATION = "registration" +REGISTRATION = "registration_new_sbc_account" +REGISTRATION_SCHEMA = "registration" MOCK_ACCOUNT_REQUEST = os.path.join( os.path.dirname(os.path.realpath(__file__)), f"../../mocks/json/{REGISTRATION}.json" ) def test_get_schema(): - schema_store = utils.get_schema(f"{REGISTRATION}.json") + schema_store = utils.get_schema(f"{REGISTRATION_SCHEMA}.json") assert schema_store is not None def test_validate_schema(): with open(MOCK_ACCOUNT_REQUEST) as f: data = json.load(f) - valid, error = utils.validate_schema(data, f"{REGISTRATION}") + valid, error = utils.validate_schema(data, f"{REGISTRATION_SCHEMA}") assert valid assert not error def test_validate_schema_error(): - valid, error = utils.validate_schema({"a": "b"}, f"{REGISTRATION}") + valid, error = utils.validate_schema({"a": "b"}, f"{REGISTRATION_SCHEMA}") assert not valid assert error @@ -31,6 +32,6 @@ def test_validate_schema_error(): def test_validate(): with open(MOCK_ACCOUNT_REQUEST) as f: data = json.load(f) - valid, error = utils.validate(data, f"{REGISTRATION}") + valid, error = utils.validate(data, f"{REGISTRATION_SCHEMA}") assert valid assert not error From 807ef24f6ed3896f4d313b310a165b8464678b23 Mon Sep 17 00:00:00 2001 From: Tyler Zale Date: Mon, 27 May 2024 13:51:43 -0700 Subject: [PATCH 3/4] PR changes based on code review --- strr-api/src/strr_api/resources/account.py | 127 +----------------- .../src/strr_api/resources/registrations.py | 71 +++++++++- .../responses/RegistrationResponse.py | 2 +- .../strr_api/services/registration_service.py | 6 +- .../RegistrationRequestValidator.py | 64 +++++++++ strr-api/src/strr_api/validators/__init__.py | 3 + strr-api/tests/unit/resources/test_account.py | 35 +---- .../unit/resources/test_registrations.py | 33 ++++- 8 files changed, 175 insertions(+), 166 deletions(-) create mode 100644 strr-api/src/strr_api/validators/RegistrationRequestValidator.py create mode 100644 strr-api/src/strr_api/validators/__init__.py diff --git a/strr-api/src/strr_api/resources/account.py b/strr-api/src/strr_api/resources/account.py index 78905002..b646e460 100644 --- a/strr-api/src/strr_api/resources/account.py +++ b/strr-api/src/strr_api/resources/account.py @@ -37,21 +37,17 @@ """ import logging -import re from http import HTTPStatus from flasgger import swag_from -from flask import Blueprint, g, jsonify, request +from flask import Blueprint, jsonify from flask_cors import cross_origin from strr_api.common.auth import jwt -from strr_api.exceptions import AuthException, ExternalServiceException, ValidationException, exception_response -from strr_api.requests import RegistrationRequest -from strr_api.responses import Registration -from strr_api.schemas.utils import validate +from strr_api.exceptions import AuthException, ExternalServiceException, exception_response # from strr_api.schemas import utils as schema_utils -from strr_api.services import AuthService, RegistrationService +from strr_api.services import AuthService logger = logging.getLogger("api") bp = Blueprint("account", __name__) @@ -87,123 +83,6 @@ def me(): return exception_response(service_exception) -@bp.route("", methods=("POST",)) -@swag_from({"security": [{"Bearer": []}]}) -@cross_origin(origin="*") -@jwt.requires_auth -def create_account(): - """ - Create a new account for the user. - --- - tags: - - users - parameters: - - in: body - name: body - schema: - type: object - required: - - name - properties: - name: - type: string - description: The name of the new user account. - responses: - 201: - description: - 401: - description: - """ - - try: - token = jwt.get_token_auth_header() - json_input = request.get_json() - [valid, errors] = validate(json_input, "registration") - if not valid: - raise ValidationException(message=errors) - - registration_request = RegistrationRequest(**json_input) - selected_account = registration_request.selectedAccount - - # SBC Account lookup or creation - sbc_account_id = None - if selected_account.sbc_account_id: - sbc_account_id = selected_account.sbc_account_id - else: - new_account = AuthService.create_user_account( - token, selected_account.name, selected_account.mailingAddress.to_dict() - ) - sbc_account_id = new_account.get("id") - - # DO POSTAL CODE VALIDATION IF COUNTRY IS CANADA - if selected_account.mailingAddress: - selected_account.mailingAddress.postalCode = validate_and_format_canadian_postal_code( - selected_account.mailingAddress.country, - selected_account.mailingAddress.region, - selected_account.mailingAddress.postalCode, - ) - - registration_request.registration.unitAddress.postalCode = validate_and_format_canadian_postal_code( - registration_request.registration.unitAddress.country, - registration_request.registration.unitAddress.province, - registration_request.registration.unitAddress.postalCode, - ) - - if ( - registration_request.registration.unitAddress.country != "CA" - or registration_request.registration.unitAddress.province != "BC" - ): - raise ValidationException(message="Invalid Rental Unit Address. Location must be in British Columbia.") - - registration_request.registration.primaryContact.mailingAddress.postalCode = ( - validate_and_format_canadian_postal_code( - registration_request.registration.primaryContact.mailingAddress.country, - registration_request.registration.primaryContact.mailingAddress.province, - registration_request.registration.primaryContact.mailingAddress.postalCode, - ) - ) - - if registration_request.registration.secondaryContact: - registration_request.registration.secondaryContact.mailingAddress.postalCode = ( - validate_and_format_canadian_postal_code( - registration_request.registration.secondaryContact.mailingAddress.country, - registration_request.registration.secondaryContact.mailingAddress.province, - registration_request.registration.secondaryContact.mailingAddress.postalCode, - ) - ) - - registration = RegistrationService.save_registration( - g.jwt_oidc_token_info, sbc_account_id, registration_request.registration - ) - return jsonify(Registration.from_db(registration).model_dump(mode="json")), HTTPStatus.CREATED - except ValidationException as auth_exception: - return exception_response(auth_exception) - except AuthException as auth_exception: - return exception_response(auth_exception) - except ExternalServiceException as service_exception: - return exception_response(service_exception) - - -def validate_and_format_canadian_postal_code(country: str, province: str, postal_code: str): - """Validate and format a Canadian postal code.""" - - if country == "CA": - if province not in ["BC", "AB", "SK", "MB", "ON", "QC", "NB", "PE", "NS", "NL", "YT", "NT", "NU"]: - raise ValidationException( - message="Invalid province. Must be one of 'BC', 'AB', 'SK', 'MB', 'ON', 'QC', " - + "'NB', 'PE', 'NS', 'NL', 'YT', 'NT', 'NU'" - ) - - regex = r"^[A-Za-z]\d[A-Za-z][ -]?\d[A-Za-z]\d$" - match = re.match(regex, postal_code) - if match: - return postal_code.upper().replace(" ", "") - else: - raise ValidationException(message="Invalid postal code. Must be in the format 'A1A 1A1' or 'A1A1A1'") - - return postal_code - - # @bp.route("/search_accounts", methods=("GET",)) # @cross_origin(origin="*") # def search_accounts(): diff --git a/strr-api/src/strr_api/resources/registrations.py b/strr-api/src/strr_api/resources/registrations.py index 7bf16524..7b2ad643 100644 --- a/strr-api/src/strr_api/resources/registrations.py +++ b/strr-api/src/strr_api/resources/registrations.py @@ -40,13 +40,16 @@ from http import HTTPStatus from flasgger import swag_from -from flask import Blueprint, g, jsonify +from flask import Blueprint, g, jsonify, request from flask_cors import cross_origin from strr_api.common.auth import jwt -from strr_api.exceptions import AuthException, exception_response +from strr_api.exceptions import AuthException, ExternalServiceException, ValidationException, exception_response +from strr_api.requests import RegistrationRequest from strr_api.responses import Registration -from strr_api.services import RegistrationService +from strr_api.schemas.utils import validate +from strr_api.services import AuthService, RegistrationService +from strr_api.validators.RegistrationRequestValidator import validate_registration_request logger = logging.getLogger("api") bp = Blueprint("registrations", __name__) @@ -77,3 +80,65 @@ def get_registrations(): ) except AuthException as auth_exception: return exception_response(auth_exception) + + +@bp.route("", methods=("POST",)) +@swag_from({"security": [{"Bearer": []}]}) +@cross_origin(origin="*") +@jwt.requires_auth +def create_registration(): + """ + Create a STRR registration. + --- + tags: + - registration + parameters: + - in: body + name: body + schema: + type: object + required: + - name + properties: + name: + type: string + description: The name of the new user account. + responses: + 201: + description: + 401: + description: + """ + + try: + token = jwt.get_token_auth_header() + json_input = request.get_json() + [valid, errors] = validate(json_input, "registration") + if not valid: + raise ValidationException(message=errors) + + registration_request = RegistrationRequest(**json_input) + selected_account = registration_request.selectedAccount + + # SBC Account lookup or creation + sbc_account_id = None + if selected_account.sbc_account_id: + sbc_account_id = selected_account.sbc_account_id + else: + new_account = AuthService.create_user_account( + token, selected_account.name, selected_account.mailingAddress.to_dict() + ) + sbc_account_id = new_account.get("id") + + validate_registration_request(selected_account, registration_request) + + registration = RegistrationService.save_registration( + g.jwt_oidc_token_info, sbc_account_id, registration_request.registration + ) + return jsonify(Registration.from_db(registration).model_dump(mode="json")), HTTPStatus.CREATED + except ValidationException as auth_exception: + return exception_response(auth_exception) + except AuthException as auth_exception: + return exception_response(auth_exception) + except ExternalServiceException as service_exception: + return exception_response(service_exception) diff --git a/strr-api/src/strr_api/responses/RegistrationResponse.py b/strr-api/src/strr_api/responses/RegistrationResponse.py index 1b8831d1..5d66ad5f 100644 --- a/strr-api/src/strr_api/responses/RegistrationResponse.py +++ b/strr-api/src/strr_api/responses/RegistrationResponse.py @@ -91,7 +91,7 @@ class Registration(BaseModel): submissionDate: datetime updatedDate: datetime status: str - primaryContact: Optional[Contact] = None + primaryContact: Contact secondaryContact: Optional[Contact] = None unitAddress: UnitAddress unitDetails: UnitDetails diff --git a/strr-api/src/strr_api/services/registration_service.py b/strr-api/src/strr_api/services/registration_service.py index b1c873ba..783de0aa 100644 --- a/strr-api/src/strr_api/services/registration_service.py +++ b/strr-api/src/strr_api/services/registration_service.py @@ -55,7 +55,7 @@ def save_registration(cls, jwt_oidc_token_info, sbc_account_id, registration_req primary_contact = user db.session.add(primary_contact) - db.session.commit() + db.session.flush() db.session.refresh(primary_contact) if registration_request.secondaryContact: @@ -71,7 +71,7 @@ def save_registration(cls, jwt_oidc_token_info, sbc_account_id, registration_req date_of_birth=registration_request.secondaryContact.dateOfBirth, ) db.session.add(secondary_contact) - db.session.commit() + db.session.flush() db.session.refresh(secondary_contact) property_manager = models.PropertyManager( @@ -97,7 +97,7 @@ def save_registration(cls, jwt_oidc_token_info, sbc_account_id, registration_req else None, ) db.session.add(property_manager) - db.session.commit() + db.session.flush() db.session.refresh(property_manager) registration = models.Registration( diff --git a/strr-api/src/strr_api/validators/RegistrationRequestValidator.py b/strr-api/src/strr_api/validators/RegistrationRequestValidator.py new file mode 100644 index 00000000..f5d89ac7 --- /dev/null +++ b/strr-api/src/strr_api/validators/RegistrationRequestValidator.py @@ -0,0 +1,64 @@ +"""Validator for registration requests.""" +import re + +from strr_api.exceptions import ValidationException + + +def validate_registration_request(selected_account, registration_request): + """Validate the registration request.""" + # DO POSTAL CODE VALIDATION IF COUNTRY IS CANADA + if selected_account.mailingAddress: + selected_account.mailingAddress.postalCode = validate_and_format_canadian_postal_code( + selected_account.mailingAddress.country, + selected_account.mailingAddress.region, + selected_account.mailingAddress.postalCode, + ) + + registration_request.registration.unitAddress.postalCode = validate_and_format_canadian_postal_code( + registration_request.registration.unitAddress.country, + registration_request.registration.unitAddress.province, + registration_request.registration.unitAddress.postalCode, + ) + + if ( + registration_request.registration.unitAddress.country != "CA" + or registration_request.registration.unitAddress.province != "BC" + ): + raise ValidationException(message="Invalid Rental Unit Address. Location must be in British Columbia.") + + registration_request.registration.primaryContact.mailingAddress.postalCode = ( + validate_and_format_canadian_postal_code( + registration_request.registration.primaryContact.mailingAddress.country, + registration_request.registration.primaryContact.mailingAddress.province, + registration_request.registration.primaryContact.mailingAddress.postalCode, + ) + ) + + if registration_request.registration.secondaryContact: + registration_request.registration.secondaryContact.mailingAddress.postalCode = ( + validate_and_format_canadian_postal_code( + registration_request.registration.secondaryContact.mailingAddress.country, + registration_request.registration.secondaryContact.mailingAddress.province, + registration_request.registration.secondaryContact.mailingAddress.postalCode, + ) + ) + + +def validate_and_format_canadian_postal_code(country: str, province: str, postal_code: str): + """Validate and format a Canadian postal code.""" + + if country == "CA": + if province not in ["BC", "AB", "SK", "MB", "ON", "QC", "NB", "PE", "NS", "NL", "YT", "NT", "NU"]: + raise ValidationException( + message="Invalid province. Must be one of 'BC', 'AB', 'SK', 'MB', 'ON', 'QC', " + + "'NB', 'PE', 'NS', 'NL', 'YT', 'NT', 'NU'" + ) + + regex = r"^[A-Za-z]\d[A-Za-z][ -]?\d[A-Za-z]\d$" + match = re.match(regex, postal_code) + if match: + return postal_code.upper().replace(" ", "") + else: + raise ValidationException(message="Invalid postal code. Must be in the format 'A1A 1A1' or 'A1A1A1'") + + return postal_code diff --git a/strr-api/src/strr_api/validators/__init__.py b/strr-api/src/strr_api/validators/__init__.py new file mode 100644 index 00000000..e4fe91f0 --- /dev/null +++ b/strr-api/src/strr_api/validators/__init__.py @@ -0,0 +1,3 @@ +""" +This module is for business logic validation. +""" diff --git a/strr-api/tests/unit/resources/test_account.py b/strr-api/tests/unit/resources/test_account.py index 45d83ab2..2d64b647 100644 --- a/strr-api/tests/unit/resources/test_account.py +++ b/strr-api/tests/unit/resources/test_account.py @@ -1,17 +1,8 @@ -import json import os from http import HTTPStatus from unittest.mock import patch -from flask import g - -from tests.unit.utils.mocks import ( - empty_json, - fake_get_token_auth_header, - fake_user_from_token, - keycloak_profile_json, - no_op, -) +from tests.unit.utils.mocks import empty_json, fake_get_token_auth_header, keycloak_profile_json, no_op REGISTRATION = "registration_new_sbc_account" MOCK_ACCOUNT_REQUEST = os.path.join( @@ -39,27 +30,3 @@ def test_me_502(client): def test_me_401(client): rv = client.get("/account/me") assert rv.status_code == HTTPStatus.UNAUTHORIZED - - -@patch("strr_api.services.AuthService.create_user_account", new=empty_json) -@patch("strr_api.models.user.User.get_or_create_user_by_jwt", new=fake_user_from_token) -@patch("flask_jwt_oidc.JwtManager.get_token_auth_header", new=fake_get_token_auth_header) -@patch("flask_jwt_oidc.JwtManager._validate_token", new=no_op) -def test_create_account_201(client): - with open(MOCK_ACCOUNT_REQUEST) as f: - g.jwt_oidc_token_info = None - data = json.load(f) - rv = client.post("/account", json=data) - assert rv.status_code == HTTPStatus.CREATED - - -@patch("flask_jwt_oidc.JwtManager.get_token_auth_header", new=fake_get_token_auth_header) -@patch("flask_jwt_oidc.JwtManager._validate_token", new=no_op) -def test_create_account_400(client): - rv = client.post("/account", json={}) - assert rv.status_code == HTTPStatus.BAD_REQUEST - - -def test_create_account_401(client): - rv = client.post("/account", json={"name": "test"}) - assert rv.status_code == HTTPStatus.UNAUTHORIZED diff --git a/strr-api/tests/unit/resources/test_registrations.py b/strr-api/tests/unit/resources/test_registrations.py index e151dc6b..a72c7994 100644 --- a/strr-api/tests/unit/resources/test_registrations.py +++ b/strr-api/tests/unit/resources/test_registrations.py @@ -1,9 +1,16 @@ +import json +import os from http import HTTPStatus from unittest.mock import patch from flask import g -from tests.unit.utils.mocks import fake_get_token_auth_header, fake_user_from_token, no_op +from tests.unit.utils.mocks import empty_json, fake_get_token_auth_header, fake_user_from_token, no_op + +REGISTRATION = "registration_new_sbc_account" +MOCK_ACCOUNT_REQUEST = os.path.join( + os.path.dirname(os.path.realpath(__file__)), f"../../mocks/json/{REGISTRATION}.json" +) @patch("strr_api.models.user.User.find_by_jwt_token", new=fake_user_from_token) @@ -18,3 +25,27 @@ def test_registrations_200(client): def test_registrations_401(client): rv = client.get("/registrations") assert rv.status_code == HTTPStatus.UNAUTHORIZED + + +@patch("strr_api.services.AuthService.create_user_account", new=empty_json) +@patch("strr_api.models.user.User.get_or_create_user_by_jwt", new=fake_user_from_token) +@patch("flask_jwt_oidc.JwtManager.get_token_auth_header", new=fake_get_token_auth_header) +@patch("flask_jwt_oidc.JwtManager._validate_token", new=no_op) +def test_create_account_201(client): + with open(MOCK_ACCOUNT_REQUEST) as f: + g.jwt_oidc_token_info = None + data = json.load(f) + rv = client.post("/registrations", json=data) + assert rv.status_code == HTTPStatus.CREATED + + +@patch("flask_jwt_oidc.JwtManager.get_token_auth_header", new=fake_get_token_auth_header) +@patch("flask_jwt_oidc.JwtManager._validate_token", new=no_op) +def test_create_account_400(client): + rv = client.post("/registrations", json={}) + assert rv.status_code == HTTPStatus.BAD_REQUEST + + +def test_create_account_401(client): + rv = client.post("/registrations", json={"name": "test"}) + assert rv.status_code == HTTPStatus.UNAUTHORIZED From 05a2737a51a20030722174f2cac11efc6767f988 Mon Sep 17 00:00:00 2001 From: Tyler Zale Date: Mon, 27 May 2024 15:42:01 -0700 Subject: [PATCH 4/4] fix model support for minimum provided fields on registration request --- .../strr_api/requests/RegistrationRequest.py | 30 +++++++------- .../strr_api/services/registration_service.py | 12 +++--- .../registration_use_sbc_account_minimum.json | 41 +++++++++++++++++++ .../unit/resources/test_registrations.py | 16 ++++++++ 4 files changed, 80 insertions(+), 19 deletions(-) create mode 100644 strr-api/tests/mocks/json/registration_use_sbc_account_minimum.json diff --git a/strr-api/src/strr_api/requests/RegistrationRequest.py b/strr-api/src/strr_api/requests/RegistrationRequest.py index 20909263..463a203d 100644 --- a/strr-api/src/strr_api/requests/RegistrationRequest.py +++ b/strr-api/src/strr_api/requests/RegistrationRequest.py @@ -59,9 +59,11 @@ def __init__(self, sbc_account_id=None, name=None, mailingAddress=None): class Registration: """Registration payload object.""" - def __init__(self, primaryContact, secondaryContact, unitAddress, unitDetails, listingDetails): + def __init__(self, primaryContact, unitAddress, unitDetails, listingDetails, secondaryContact=None): self.primaryContact = Contact(**primaryContact) - self.secondaryContact = Contact(**secondaryContact) + self.secondaryContact = None + if secondaryContact: + self.secondaryContact = Contact(**secondaryContact) self.unitAddress = UnitAddress(**unitAddress) self.unitDetails = UnitDetails(**unitDetails) self.listingDetails = [ListingDetails(**item) for item in listingDetails] @@ -77,51 +79,51 @@ def __init__(self, url): class UnitDetails: """UnitDetails payload object.""" - def __init__(self, parcelIdentifier, businessLicense, propertyType, ownershipType): - self.parcelIdentifier = parcelIdentifier - self.businessLicense = businessLicense + def __init__(self, propertyType, ownershipType, parcelIdentifier=None, businessLicense=None): self.propertyType = propertyType self.ownershipType = ownershipType + self.parcelIdentifier = parcelIdentifier + self.businessLicense = businessLicense class MailingAddress: """MailingAddress payload object.""" - def __init__(self, address, addressLineTwo, city, postalCode, province, country): + def __init__(self, address, city, postalCode, province, country, addressLineTwo=None): self.address = address - self.addressLineTwo = addressLineTwo self.city = city self.postalCode = postalCode self.province = province self.country = country + self.addressLineTwo = addressLineTwo class UnitAddress(MailingAddress): """UnitAddress payload object.""" - def __init__(self, nickname, address, addressLineTwo, city, postalCode, province, country): - super().__init__(address, addressLineTwo, city, postalCode, province, country) + def __init__(self, address, city, postalCode, province, country, addressLineTwo=None, nickname=None): + super().__init__(address, city, postalCode, province, country, addressLineTwo) self.nickname = nickname class ContactName: """ContactName payload object.""" - def __init__(self, firstName, middleName, lastName): + def __init__(self, firstName, lastName, middleName=None): self.firstName = firstName - self.middleName = middleName self.lastName = lastName + self.middleName = middleName class ContactDetails: """ContactDetails payload object.""" - def __init__(self, preferredName, phoneNumber, extension, faxNumber, emailAddress): - self.preferredName = preferredName + def __init__(self, phoneNumber, emailAddress, preferredName=None, extension=None, faxNumber=None): self.phoneNumber = phoneNumber + self.emailAddress = emailAddress + self.preferredName = preferredName self.extension = extension self.faxNumber = faxNumber - self.emailAddress = emailAddress class Contact: diff --git a/strr-api/src/strr_api/services/registration_service.py b/strr-api/src/strr_api/services/registration_service.py index 783de0aa..c4ccda47 100644 --- a/strr-api/src/strr_api/services/registration_service.py +++ b/strr-api/src/strr_api/services/registration_service.py @@ -58,6 +58,7 @@ def save_registration(cls, jwt_oidc_token_info, sbc_account_id, registration_req db.session.flush() db.session.refresh(primary_contact) + secondary_contact = None if registration_request.secondaryContact: secondary_contact = models.User( firstname=registration_request.secondaryContact.name.firstName, @@ -76,7 +77,6 @@ def save_registration(cls, jwt_oidc_token_info, sbc_account_id, registration_req property_manager = models.PropertyManager( user_id=primary_contact.id, - secondary_contact_user_id=secondary_contact.id if secondary_contact else None, primary_address=models.Address( country=registration_request.primaryContact.mailingAddress.country, street_address=registration_request.primaryContact.mailingAddress.address, @@ -85,7 +85,11 @@ def save_registration(cls, jwt_oidc_token_info, sbc_account_id, registration_req province=registration_request.primaryContact.mailingAddress.province, postal_code=registration_request.primaryContact.mailingAddress.postalCode, ), - secondary_address=models.Address( + ) + + if secondary_contact: + property_manager.secondary_contact_user_id = (secondary_contact.id,) + property_manager.secondary_address = models.Address( country=registration_request.secondaryContact.mailingAddress.country, street_address=registration_request.secondaryContact.mailingAddress.address, street_address_additional=registration_request.secondaryContact.mailingAddress.addressLineTwo, @@ -93,9 +97,7 @@ def save_registration(cls, jwt_oidc_token_info, sbc_account_id, registration_req province=registration_request.secondaryContact.mailingAddress.province, postal_code=registration_request.secondaryContact.mailingAddress.postalCode, ) - if secondary_contact - else None, - ) + db.session.add(property_manager) db.session.flush() db.session.refresh(property_manager) diff --git a/strr-api/tests/mocks/json/registration_use_sbc_account_minimum.json b/strr-api/tests/mocks/json/registration_use_sbc_account_minimum.json new file mode 100644 index 00000000..85ad4b72 --- /dev/null +++ b/strr-api/tests/mocks/json/registration_use_sbc_account_minimum.json @@ -0,0 +1,41 @@ +{ + "selectedAccount": { + "sbc_account_id": 3299 + }, + "registration": { + "primaryContact": { + "name": { + "firstName": "The", + "lastName": "Guy" + }, + "dateOfBirth": "1986-10-23", + "details": { + "phoneNumber": "604-999-9999", + "emailAddress": "test@test.test" + }, + "mailingAddress": { + "country": "CA", + "address": "12766 227st", + "city": "MAPLE RIDGE", + "province": "BC", + "postalCode": "V2X 6K6" + } + }, + "unitDetails": { + "propertyType": "PRIMARY", + "ownershipType": "OWN" + }, + "unitAddress": { + "country": "CA", + "address": "12166 GREENWELL ST MAPLE RIDGE", + "city": "MAPLE RIDGE", + "province": "BC", + "postalCode": "V2X 7N1" + }, + "listingDetails": [ + { + "url": "https://www.airbnb.ca/rooms/26359027" + } + ] + } +} \ No newline at end of file diff --git a/strr-api/tests/unit/resources/test_registrations.py b/strr-api/tests/unit/resources/test_registrations.py index a72c7994..53842365 100644 --- a/strr-api/tests/unit/resources/test_registrations.py +++ b/strr-api/tests/unit/resources/test_registrations.py @@ -8,9 +8,13 @@ from tests.unit.utils.mocks import empty_json, fake_get_token_auth_header, fake_user_from_token, no_op REGISTRATION = "registration_new_sbc_account" +REGISTRATION_MINIMUM_FIELDS = "registration_use_sbc_account_minimum" MOCK_ACCOUNT_REQUEST = os.path.join( os.path.dirname(os.path.realpath(__file__)), f"../../mocks/json/{REGISTRATION}.json" ) +MOCK_ACCOUNT_MINIMUM_FIELDS_REQUEST = os.path.join( + os.path.dirname(os.path.realpath(__file__)), f"../../mocks/json/{REGISTRATION_MINIMUM_FIELDS}.json" +) @patch("strr_api.models.user.User.find_by_jwt_token", new=fake_user_from_token) @@ -39,6 +43,18 @@ def test_create_account_201(client): assert rv.status_code == HTTPStatus.CREATED +@patch("strr_api.services.AuthService.create_user_account", new=empty_json) +@patch("strr_api.models.user.User.get_or_create_user_by_jwt", new=fake_user_from_token) +@patch("flask_jwt_oidc.JwtManager.get_token_auth_header", new=fake_get_token_auth_header) +@patch("flask_jwt_oidc.JwtManager._validate_token", new=no_op) +def test_create_account_minimum_fields_201(client): + with open(MOCK_ACCOUNT_MINIMUM_FIELDS_REQUEST) as f: + g.jwt_oidc_token_info = None + data = json.load(f) + rv = client.post("/registrations", json=data) + assert rv.status_code == HTTPStatus.CREATED + + @patch("flask_jwt_oidc.JwtManager.get_token_auth_header", new=fake_get_token_auth_header) @patch("flask_jwt_oidc.JwtManager._validate_token", new=no_op) def test_create_account_400(client):