Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

655 Add User Roles and Basic User Data to Db #668

Merged
merged 32 commits into from
Apr 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
b6d612d
Add ER Diagram, remove unused db models
Joshua-Douglas Feb 11, 2024
b308c8f
Merge branch 'main' into 655-remove-unused-db-models
Joshua-Douglas Mar 10, 2024
7fb329f
Removed schema associated with removed data models
Joshua-Douglas Mar 10, 2024
e874eeb
Upgrade marshmallow-sqlalchemy from 0.3 to 1.0
Joshua-Douglas Mar 10, 2024
353a545
Expand sqlalchemy test cases to show how to serialize/deserialize
Joshua-Douglas Mar 10, 2024
a7f06ab
Add eralchemy2 linux install instructions
Joshua-Douglas Mar 10, 2024
8657569
Add migration script to drop unused tables
Joshua-Douglas Mar 10, 2024
d76be3d
Fix v 3ceec db migration script to allow us to migrate databases that…
Joshua-Douglas Mar 10, 2024
aacbeb4
Update project ER Diagram to include missing db version number
Joshua-Douglas Mar 10, 2024
18b427a
Add user types. Define types during migration script. Remove old Host…
Joshua-Douglas Mar 11, 2024
773b6b0
Add pytest-alembic to allow migration tests, and to provide fixtures …
Joshua-Douglas Mar 17, 2024
1519028
Update test infrastructure to properly alembic migrate the test datab…
Joshua-Douglas Mar 17, 2024
5d14a91
Add user repo. Simplify user role to 1:1 relationship. Add user schem…
Joshua-Douglas Mar 18, 2024
356798c
Fix host controller by refactoring to use new user_repo, and by addin…
Joshua-Douglas Mar 19, 2024
a99d61f
Fix authentication failed tests by temporarily disabling the user fir…
Joshua-Douglas Mar 19, 2024
59bd09f
Fix alembic migration script to pass the pytest_alembic tests by maki…
Joshua-Douglas Mar 23, 2024
40f0834
Fix remaining test cases and add a user_repo test
Joshua-Douglas Mar 23, 2024
7513df7
Rename user model names to use camel casing to match the frontend req…
Joshua-Douglas Mar 23, 2024
2c5a7dc
1. Update Host & Coordinator endpoints to require first and last name.
Joshua-Douglas Mar 23, 2024
440424d
Merge branch 'main' into 655-add-user-models
Joshua-Douglas Mar 23, 2024
cf7c862
Fix test_configs cases by including the database alembic upgrade. Tes…
Joshua-Douglas Mar 23, 2024
e6383e0
Update data model diagram and add instructions on how to update the d…
Joshua-Douglas Mar 23, 2024
5a67602
Update user endpoints to store first/last name from request, and retu…
Joshua-Douglas Mar 23, 2024
91eb3cb
Remove unnecessary create host endpoint. This is now replaced by sign…
Joshua-Douglas Mar 23, 2024
86addbe
Display user initials on settings menu avatar
Joshua-Douglas Mar 24, 2024
b11fed9
Merge branch 'main' into 655-add-user-models
Joshua-Douglas Mar 24, 2024
6ad02dd
Enforce unique user roles, require first names to be at least one cha…
Joshua-Douglas Mar 31, 2024
95cb5d2
Auto-increment the API database version whenever a new migration scri…
Joshua-Douglas Mar 31, 2024
996fd14
Remove last name validation check. The last name is no longer required.
Joshua-Douglas Mar 31, 2024
7bd721d
Merge branch 'main' of https://github.com/hackforla/HomeUniteUs into …
Joshua-Douglas Mar 31, 2024
74f861e
Fix errors in header.tsx from PR #602
Joshua-Douglas Mar 31, 2024
4a1ac90
Remove lastName required parameter from the OpenAPI user schema
Joshua-Douglas Apr 1, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
branches: [main]
# This is also a reusable workflow that can be called from other workflows
workflow_call:
workflow_dispatch:
Joshua-Douglas marked this conversation as resolved.
Show resolved Hide resolved
jobs:
test-api:
runs-on: ubuntu-latest
Expand Down
9 changes: 6 additions & 3 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,13 @@ To launch the tests, run `tox`.

`tox` will run the tests for this (`api`) project using `pytest` in an isolated virtual environment that is distinct from the virtual environment that you created following the instructions from [Usage - Development](#usage---development). By default `tox` will invoke `pytest` in `debug` mode, which uses 3rd party API mocking. The test cases can optionally be run without mocking by testing the application in `release` mode, using `tox -e releasetest` or `pytest --mode=release`.

### Alembic migrations
### Database Management

When changing the database, you can automatically generate an alembic migration. Simply change the model however you want in `database.py`, run `alembic revision --autogenerate -m <name_of_migration>` to generate a new migration, and then run `alembic upgrade head` to upgrade your database to the latest revision.
In the case of someone else adding a revision to the database, simply pull their changes to your repo and run `alembic upgrade head` to upgrade your local database to the latest revision.
Running the API requires an up-to-date version of the HomeUniteUs database. You can generate an empty database by running this command from the `api` directory with a virtual environment enabled.

`alembic upgrade head`

The current database version can be viewed using the `alembic current` command, or by inspecting the database `alembic_version` table. See the `model` [readme](./openapi_server/models/) for more information about the database project.

### Dependency Management

Expand Down
1 change: 0 additions & 1 deletion api/alembic/README

This file was deleted.

15 changes: 10 additions & 5 deletions api/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,16 @@ def run_migrations_online() -> None:

"""
print("ONLINE")
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
# Check for an existing connection before creating a new engine.
# pytest-alembic will hook into alembic by creating a connection
# with the test engine configuration.
connectable = context.config.attributes.get("connection", None)
if connectable is None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)

with connectable.connect() as connection:
context.configure(
Expand Down
72 changes: 72 additions & 0 deletions api/alembic/versions/e4c8bb426528_add_user_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Add user types

Revision ID: e4c8bb426528
Revises: ec8b1c17739a
Create Date: 2024-03-10 21:47:13.942845

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
from openapi_server.models.user_roles import UserRole

# revision identifiers, used by Alembic.
revision = 'e4c8bb426528'
down_revision = 'ec8b1c17739a'
branch_labels = None
depends_on = None

def upgrade() -> None:
'''
1. Add one table:
1. role - Store available application user roles
2. Prepopulate the role table with four role types: Admin, Host, Guest, Coordinator
3. Update the user table to add the first, middle, last name, and role_id columns.
* All existing users will be given the first, last name "UNKNOWN"
* Assign all existing users to the Guest role.
4. Drop the host table.
* There is no way to map host users back to the user table. We would need a user id foreign
key, or at least an email address.
'''
role_table = op.create_table('role',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means there can be duplicate role names. The id column isn't strictly necessary. The name column can be the primary key.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, the id isn't strictly necessary, but it does provide us the flexibility to rename the roles at any time. I think being able to easily rename our roles is worth storing 4-10 extra integers in the db.

To your point about duplicate names, I added a safeguard against this in 6ad02dd by adding the unique=True constraint to the role.name field.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using on update cascade can also be considered easy when defining a table. What other reasons are there to use a surrogate key?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using surrogate keys like an id field offers several advantages beyond the ease of updates provided by ON UPDATE CASCADE. Surrogate keys are unique and stable, providing an abstraction layer that decouples database structure from business logic, thus reducing the need for changes due to business modifications. They enhance performance with quicker indexing and simplify relational management by avoiding the complexities and potential performance issues of cascading updates. Additionally, surrogate keys maintain data integrity by minimizing risks associated with data duplication and erroneous updates, making them ideal for robust, scalable database architectures.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that a chatgpt response? 😄 Do these reasons apply to this data model?

sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.bulk_insert(role_table,
[{'name': UserRole.ADMIN.value},
{'name': UserRole.HOST.value},
{'name': UserRole.GUEST.value},
{'name': UserRole.COORDINATOR.value}])
op.create_index(op.f('ix_role_id'), 'role', ['id'])

conn = op.get_bind()
guest_role_id = conn.execute(text("SELECT id FROM role WHERE name = 'Guest'")).fetchone()[0]

with op.batch_alter_table('user', schema=None) as batch_op:
# Each existing user will get the first and last names "Unknown" by default
# and they will be assigned to the "Guest" user role.
batch_op.add_column(sa.Column('firstName', sa.String(length=255), nullable=False, server_default='Unknown'))
batch_op.add_column(sa.Column('middleName', sa.String(length=255), nullable=True))
batch_op.add_column(sa.Column('lastName', sa.String(length=255), nullable=True))
batch_op.add_column(sa.Column('role_id', sa.Integer, nullable=False, server_default=str(guest_role_id)))
batch_op.create_foreign_key('fk_user_role_id', 'role', ['role_id'], ['id'])

op.drop_table('host')

def downgrade() -> None:
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_constraint('fk_user_role_id', type_='foreignkey')
batch_op.drop_column('lastName')
batch_op.drop_column('middleName')
batch_op.drop_column('firstName')

op.drop_index(op.f('ix_role_id'), table_name='role')
op.drop_table('role')
op.create_table('host',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_host_id'), 'host', ['id'])
8 changes: 7 additions & 1 deletion api/openapi_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
IntegerConverter
)

import openapi_server.models
from openapi_server.models.database import DataAccessLayer
from openapi_server.exceptions import AuthError, handle_auth_error
from openapi_server.configs.registry import HUUConfigRegistry, HUUConfig
Expand Down Expand Up @@ -142,6 +143,11 @@ def create_app(test_config: HUUConfig = None):
flask_app = connexion_app.app
flask_app.config.from_object(config)

DataAccessLayer.db_init(flask_app.config["DATABASE_URL"])
current_db_version = openapi_server.models.version()
DataAccessLayer.db_init(flask_app.config["DATABASE_URL"])
if DataAccessLayer.revision_id() != current_db_version:
raise Exception(f"Database is out of date. Expected {current_db_version} but "
f"found {DataAccessLayer.revision_id()}. "
"Please run 'alembic upgrade head' to upgrade.")

return connexion_app
8 changes: 5 additions & 3 deletions api/openapi_server/configs/mock_aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from openapi_server.exceptions import AuthError
from openapi_server.controllers.admin_controller import removeUser
from openapi_server.controllers.auth_controller import signUpHost
from openapi_server.controllers.auth_controller import signUpAdmin

class AWSTemporaryUserpool():
'''
Expand Down Expand Up @@ -125,9 +125,11 @@ def create_test_users(self):
pass

try:
signUpHost({
signUpAdmin({
"email": email,
"password": user["password"]
"password": user["password"],
"firstName": "testuser_firstname",
"lastName": "testuser_lastname"
})
self._auto_signup_user(email)
self.app.logger.info(f"Created test user: {email}")
Expand Down
58 changes: 34 additions & 24 deletions api/openapi_server/controllers/auth_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
)
from openapi_server.exceptions import AuthError
from openapi_server.models.database import DataAccessLayer, User
from sqlalchemy.exc import IntegrityError
from openapi_server.repositories.user_repo import UserRepository
from openapi_server.models.user_roles import UserRole
from openapi_server.models.schema import user_schema
from sqlalchemy import select

from botocore.exceptions import ClientError
Expand Down Expand Up @@ -59,19 +61,21 @@ def get_token_auth_header():
token = parts[1]
return token

def sign_up(body: dict):
def sign_up(body: dict, role: UserRole):
secret_hash = current_app.calc_secret_hash(body['email'])

with DataAccessLayer.session() as session:
user = User(email=body['email'])
session.add(user)
try:
session.commit()
except IntegrityError:
session.rollback()
raise AuthError({
"message": "A user with this email already exists."
}, 422)
try:
with DataAccessLayer.session() as db_session:
user_repo = UserRepository(db_session)
user_repo.add_user(
email=body['email'],
role=role,
firstName=body['firstName'],
middleName=body.get('middleName', ''),
lastName=body.get('lastName', '')
)
except Exception as error:
raise AuthError({"message": str(error)}, 400)

try:
response = current_app.boto_client.sign_up(
Expand Down Expand Up @@ -107,13 +111,16 @@ def sign_up(body: dict):
msg = f"The parameters you provided are incorrect: {error}"
raise AuthError({"message": msg}, 500)

def signUpAdmin(body: dict):
return sign_up(body, UserRole.ADMIN)

def signUpHost(body: dict):
"""Signup a new Host"""
return sign_up(body)
return sign_up(body, UserRole.HOST)

def signUpCoordinator(body: dict): # noqa: E501
"""Signup a new Coordinator"""
return sign_up(body)
return sign_up(body, UserRole.COORDINATOR)

def sign_in(body: dict):
secret_hash = current_app.calc_secret_hash(body['email'])
Expand All @@ -140,22 +147,22 @@ def sign_in(body: dict):
access_token = response['AuthenticationResult']['AccessToken']
refresh_token = response['AuthenticationResult']['RefreshToken']

# retrieve user data
user_data = current_app.boto_client.get_user(AccessToken=access_token)
user_data = None
with DataAccessLayer.session() as db_session:
user_repo = UserRepository(db_session)
signed_in_user = user_repo.get_user(body['email'])
user_data = user_schema.dump(signed_in_user)

# set refresh token cookie
session['refresh_token'] = refresh_token
session['username'] = user_data['Username']
session['username'] = body['email']

# return user data json
return {
'token': access_token,
'user': {
'email': body['email']
}
'user': user_data
}


def resend_confirmation_code():
'''
Resends the registration confirmation code to the specified user (identified by email).
Expand Down Expand Up @@ -380,10 +387,13 @@ def confirm_forgot_password():
return response

def user(token_info):
user_data = None
with DataAccessLayer.session() as db_session:
user_repo = UserRepository(db_session)
signed_in_user = user_repo.get_user(token_info["Username"])
user_data = user_schema.dump(signed_in_user)
return {
"user": {
"email": token_info["Username"]
}
"user": user_data
}

def private(token_info):
Expand Down
20 changes: 7 additions & 13 deletions api/openapi_server/controllers/host_controller.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
from flask import Response

from openapi_server.models.database import Host, DataAccessLayer
from openapi_server.models.schema import host_schema, hosts_schema
from sqlalchemy import select

# create host in database
def create_host(body: dict) -> Response:
with DataAccessLayer.session() as session:
new_host = Host(name = body["name"])
session.add(new_host)
session.commit()
return host_schema.dump(new_host), 201
from openapi_server.models.database import DataAccessLayer
from openapi_server.models.schema import users_schema
from openapi_server.models.user_roles import UserRole
from openapi_server.repositories.user_repo import UserRepository

def get_hosts() -> Response:
with DataAccessLayer.session() as session:
all_hosts = session.scalars(select(Host)).all()
return hosts_schema.dump(all_hosts), 200
user_repo = UserRepository(session)
all_users = user_repo.get_users_with_role(UserRole.HOST)
return users_schema.dump(all_users), 200
65 changes: 61 additions & 4 deletions api/openapi_server/models/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ classDiagram
class alembic_version{
*VARCHAR<32> version_num NOT NULL
}
class host{
*INTEGER id NOT NULL
VARCHAR name NOT NULL
}
class housing_program{
*INTEGER id NOT NULL
VARCHAR program_name NOT NULL
Expand All @@ -18,13 +14,74 @@ class housing_program_service_provider{
*INTEGER id NOT NULL
VARCHAR provider_name NOT NULL
}
class role{
*INTEGER id NOT NULL
VARCHAR name NOT NULL
}
class user{
*INTEGER id NOT NULL
VARCHAR email NOT NULL
VARCHAR<255> firstName NOT NULL
VARCHAR<255> lastName NOT NULL
VARCHAR<255> middleName
INTEGER role_id NOT NULL
}
housing_program_service_provider "1" -- "0..n" housing_program
role "1" -- "0..n" user
```

Notes:

* The `role` table is pre-populated with four roles by the `alembic` migration script:
* Guest, Host, Coordinator, Admin

## How to update the Data Model

The HomeUniteUs database is versioned, using `alembic`. Every change to the database model increments the database version number, and must be submitted with a database migration script that is capable of upgrading and downgrading the database.

When changing the database, you can automatically generate an alembic migration. The autogenerated migration scripts can [fail to detect a number of changes](https://alembic.sqlalchemy.org/en/latest/autogenerate.html#what-does-autogenerate-detect-and-what-does-it-not-detect), so you should always review, test, and possibly edit the migration scripts before committing the update.

The recommended steps for developing the database model are as follows:

### 1. Backup your local database

Errors in the `alembic` migration script can corrupt your local data. Take a copy of your database before applying new migration scripts.

### 2. Update the python `Model` objects

Implement your model changes by updating the `sqlalchemy` model objects within the `models` project. Most models are within `database.py`, but they may be placed elsewhere in the future.

### 3. Autogenerate a migration script

Run `alembic revision --autogenerate -m <name_of_migration>` to generate a new migration. The autogenerated scripts often times miss important migration changes, but they provide an excellent start.

### 4. Test and manually edit migration script

Our test suite includes `alembic` migration tests that cycle up and down through the entire database history to check for common migration errors. A great way to test your new migration script is to run:

`pytest -k test_alembic_migration`

Fix any errors. Once the script passes our common tests, please add additional tests to `test_alembic_migration` that capture the newly introduced model requirements.

### 5. Apply the new script to your local db copy

Apply the final migration script to a copy of your local database by:

1. Name your local copy `api\homeuniteus.db`
2. Upgrade the database `alembic upgrade head`

You can verify your change was successful by viewing the database objects directly, and querying the database. If you are using `SQLite` then you can view the database objects using tools like [DB Browser](https://sqlitebrowser.org/).

### 6. [Optional] Update & test the model schema

If your model objects need to be (de)serialized (to)from json then add a schema object using `marshmallow_sqlalchemy.SQLAlchemyAutoSchema`. The schema object should contain all of the serialization related validation logic.

Each submitted schema change should include test cases to verify the serialization rules and validation. See `tests/test_schema.py` for examples.

### 7. Update the data model diagram

Follow the steps in [Data Model Generation](#data-model-generation) to create a new mermaid diagram of the updated datamodel. Add the new model to the [Data Model section](#data-model).

## Data Model Generation

Our data model is autogenerated using `eralchemy2`. The `eralchemy2` tool relies on `pygraphviz` and `graphviz` which are not easily installed using pip. The tool is easier to install on linux than windows, but instructions are provided for both operating systems.
Expand Down
Loading
Loading