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

636 forms api backend #722

Merged
merged 8 commits into from
Aug 13, 2024
85 changes: 85 additions & 0 deletions api/alembic/versions/cfc4e41b69d3_initial_form_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""initial_form_api

Revision ID: cfc4e41b69d3
Revises: e4c8bb426528
Create Date: 2024-05-05 17:14:51.771328

"""
from alembic import op
import sqlalchemy as sa


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


def upgrade() -> None:
op.create_table('field_properties',
sa.Column('properties_id', sa.Integer(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('field_type', sa.String(length=50), nullable=False),
sa.Column('choices', sa.JSON(), nullable=True),
sa.CheckConstraint("field_type IN ('date', 'dropdown', 'multiple_choice', 'email', 'file_upload', 'group', 'long_text', 'number', 'short_text', 'yes_no')", name='chk_field_type'),
sa.PrimaryKeyConstraint('properties_id')
)
op.create_table('field_validations',
sa.Column('validations_id', sa.Integer(), nullable=False),
sa.Column('required', sa.Boolean(), nullable=False),
sa.Column('max_length', sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint('validations_id')
)
op.create_table('forms',
sa.Column('form_id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('form_id')
)
op.create_table('field_groups',
sa.Column('group_id', sa.Integer(), nullable=False),
sa.Column('form_id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['form_id'], ['forms.form_id'], ),
sa.PrimaryKeyConstraint('group_id')
)
op.create_table('fields',
sa.Column('field_id', sa.Integer(), nullable=False),
sa.Column('ref', sa.String(length=255), nullable=False),
sa.Column('properties_id', sa.Integer(), nullable=False),
sa.Column('validations_id', sa.Integer(), nullable=False),
sa.Column('group_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['group_id'], ['field_groups.group_id'], ),
sa.ForeignKeyConstraint(['properties_id'], ['field_properties.properties_id'], ),
sa.ForeignKeyConstraint(['validations_id'], ['field_validations.validations_id'], ),
sa.PrimaryKeyConstraint('field_id')
)
op.create_table('responses',
sa.Column('answer_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('field_id', sa.String(length=255), nullable=False),
sa.Column('answer_text', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['field_id'], ['fields.field_id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('answer_id')
)
with op.batch_alter_table('role', schema=None) as batch_op:
batch_op.create_unique_constraint('role', ['name'])
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.alter_column('lastName',
existing_type=sa.VARCHAR(length=255),
nullable=True,
existing_server_default=sa.text("'Unknown'"))

def downgrade() -> None:
with op.batch_alter_table('role', schema=None) as batch_op:
batch_op.drop_constraint('role', type_='unique')
op.drop_table('responses')
op.drop_table('fields')
op.drop_table('field_groups')
op.drop_table('forms')
op.drop_table('field_validations')
op.drop_table('field_properties')
11 changes: 10 additions & 1 deletion api/openapi_server/__main__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from openapi_server.app import create_app
from openapi_server.configs.mock_aws import AWSMockService
from openapi_server.configs.registry import HUUConfigRegistry
from openapi_server.repositories.user_repo import UserRepository
from openapi_server.models.database import DataAccessLayer

if __name__ == "__main__":
connexion_app = create_app()
Expand All @@ -15,7 +17,14 @@
match flask_app.environment:
case HUUConfigRegistry.DEVELOPMENT:
# Use mocked AWS Cognito service, and temporary user pool
with AWSMockService(flask_app):
with AWSMockService(flask_app) as service:
with DataAccessLayer.session() as session:
user_repo = UserRepository(session)
all_emails = [user.email for user in user_repo.get_all_users()]

for email in all_emails:
service.add_aws_userpool_user(email, "Test!123")

run_app()
case HUUConfigRegistry.STAGING:
# Use the real AWS Cognito service, and real user pool
Expand Down
35 changes: 31 additions & 4 deletions api/openapi_server/configs/mock_aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,12 @@ def destroy(self):
self.app.logger.info("Destroyed fake temporary userpool")

def __enter__(self):
self.create()
self.create()
return self

def __exit__(self, exc_type, exc_value, traceback):
self.destroy()
return self

class AWSMockService():
'''
Expand Down Expand Up @@ -138,6 +140,29 @@ def create_test_users(self):

self.test_users_created = True

def add_aws_userpool_user(self, email, password, attributes=None):
"""
Adds a new user to the temporary user pool with the given username, password, and attributes.
Attributes should be a list of dictionaries, each containing a 'Name' and 'Value' key.
"""
if attributes is None:
attributes = []

try:
response = self.app.boto_client.admin_create_user(
UserPoolId=self.app.config["COGNITO_USER_POOL_ID"],
Username=email,
TemporaryPassword=password,
UserAttributes=attributes,
MessageAction='SUPPRESS'
)
self._auto_signup_user(email)
self.app.logger.info(f"Added user {email} to the temporary user pool")
return response
except Exception as e:
self.app.logger.error(f"Failed to add user {email}: {str(e)}")
raise

def _auto_signup_user(self, email) -> bool:
'''
Auto-confirm a new user. Return True if successful and
Expand All @@ -164,7 +189,7 @@ def auto_signup_user_after_request(self, response):
# conditional login within our endpoint. The lambda approach
# requires more overhead, and conditional logic within the endpoint
# risks adding a bug to the production code.
if ('signup' in request.endpoint.lower()) and 200 <= response.status_code < 300:
if request.endpoint and ('signup' in request.endpoint.lower()) and 200 <= response.status_code < 300:
email = request.json['email']
if self._auto_signup_user(email):
new_response = response.get_json()
Expand Down Expand Up @@ -193,7 +218,9 @@ def stop(self):
self.app.logger.info("Stopped mock AWS Cognito service")

def __enter__(self):
self.start()
self.start()
return self

def __exit__(self, exc_type, exc_value, traceback):
self.stop()
self.stop()
return self
18 changes: 18 additions & 0 deletions api/openapi_server/controllers/forms_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from openapi_server.repositories.forms import FormsRepository
from openapi_server.models.database import DataAccessLayer

def create_form(body):
forms_repo = FormsRepository(DataAccessLayer.session())

form_id = forms_repo.add_form(body)
form = forms_repo.get_form_json(form_id)
if form:
return form, 200
return {}, 404

def get_form(form_id):
forms_repo = FormsRepository(DataAccessLayer.session())
form = forms_repo.get_form_json(form_id)
if form:
return form, 200
return f"Form with id {form_id} does not exist.", 404
38 changes: 38 additions & 0 deletions api/openapi_server/controllers/responses_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from openapi_server.repositories.forms import FormsRepository
from openapi_server.repositories.user_repo import UserRepository
from openapi_server.models.database import DataAccessLayer

def update_responses(body, form_id, token_info):
with DataAccessLayer.session() as session:
user_repo = UserRepository(session)
forms_repo = FormsRepository(session)
user = user_repo.get_user(token_info['Username'])

form = forms_repo.get_form(form_id)
if not form:
return f"Form with id {form_id} does not exist.", 404

valid_field_ids = form.get_field_ids()
for response in body:
response["user_id"] = user.id
if response["field_id"] not in valid_field_ids:
return f"Form {form_id} does not contain field id {response['field_id']}", 400

forms_repo.add_user_responses(user.id, body)

return {}, 204

def get_responses(form_id, token_info):
with DataAccessLayer.session() as session:
user_repo = UserRepository(session)
forms_repo = FormsRepository(session)

form = forms_repo.get_form_json(form_id)
if not form:
return f"Form with id {form_id} does not exist.", 404

user = user_repo.get_user(token_info['Username'])
responses = forms_repo.get_user_responses(user.id, form_id)
if responses:
return responses, 200
return [], 202
50 changes: 46 additions & 4 deletions api/openapi_server/models/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,36 @@ classDiagram
class alembic_version{
*VARCHAR<32> version_num NOT NULL
}
class field_groups{
*INTEGER group_id NOT NULL
TEXT description
INTEGER form_id NOT NULL
VARCHAR<255> title NOT NULL
}
class forms{
*INTEGER form_id NOT NULL
DATETIME created_at
TEXT description
VARCHAR<255> title NOT NULL
}
class field_properties{
*INTEGER properties_id NOT NULL
JSON choices
TEXT description
VARCHAR<50> field_type NOT NULL
}
class field_validations{
*INTEGER validations_id NOT NULL
INTEGER max_length
BOOLEAN required NOT NULL
}
class fields{
*INTEGER field_id NOT NULL
INTEGER group_id
INTEGER properties_id NOT NULL
VARCHAR<255> ref NOT NULL
INTEGER validations_id NOT NULL
}
class housing_program{
*INTEGER id NOT NULL
VARCHAR program_name NOT NULL
Expand All @@ -14,19 +44,31 @@ 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 responses{
*INTEGER answer_id NOT NULL
TEXT answer_text
VARCHAR<255> field_id NOT NULL
INTEGER user_id NOT NULL
}
class user{
*INTEGER id NOT NULL
VARCHAR email NOT NULL
VARCHAR<255> firstName NOT NULL
VARCHAR<255> lastName NOT NULL
VARCHAR<255> lastName
VARCHAR<255> middleName
INTEGER role_id NOT NULL
}
class role{
*INTEGER id NOT NULL
VARCHAR name NOT NULL
}
forms "1" -- "0..n" field_groups
field_groups "0..1" -- "0..n" fields
field_validations "1" -- "0..n" fields
field_properties "1" -- "0..n" fields
housing_program_service_provider "1" -- "0..n" housing_program
fields "1" -- "0..n" responses
user "1" -- "0..n" responses
role "1" -- "0..n" user
```

Expand Down
67 changes: 66 additions & 1 deletion api/openapi_server/models/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
from sqlalchemy.orm import Session, declarative_base, relationship
# Avoid naming conflict with marshmallow.validates
from sqlalchemy.orm import validates as validates_sqlachemy
from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy import Column, Integer, String, ForeignKey, Text, Boolean, DateTime
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.sql import func
from sqlalchemy.schema import CheckConstraint
from sqlalchemy.types import JSON
from typing import List

Base = declarative_base()

Expand Down Expand Up @@ -46,6 +50,67 @@ class HousingProgram(Base):
program_name = Column(String, nullable=False)
service_provider = Column(Integer, ForeignKey('housing_program_service_provider.id'), nullable=False)

class Form(Base):
__tablename__ = 'forms'
form_id = Column(Integer, primary_key=True)
title = Column(String(255), nullable=False)
description = Column(Text)
created_at = Column(DateTime, default=func.current_timestamp())

def get_field_ids(self) -> List[int]:
return [field.field_id for group in self.field_groups for field in group.fields]

class FieldProperties(Base):
__tablename__ = 'field_properties'
properties_id = Column(Integer, primary_key=True)
description = Column(Text)
field_type = Column(String(50), nullable=False)
choices = Column(JSON)

__table_args__ = (
CheckConstraint(
"field_type IN ('date', 'dropdown', 'multiple_choice', 'email', 'file_upload', 'group', 'long_text', 'number', 'short_text', 'yes_no')",
name='chk_field_type'
),
)

class FieldValidations(Base):
__tablename__ = 'field_validations'
validations_id = Column(Integer, primary_key=True)
required = Column(Boolean, nullable=False, default=False)
max_length = Column(Integer) # NULL if not applicable

class FieldGroup(Base):
__tablename__ = 'field_groups'
group_id = Column(Integer, primary_key=True)
form_id = Column(Integer, ForeignKey('forms.form_id'), nullable=False)
title = Column(String(255), nullable=False)
description = Column(Text)
form = relationship("Form", back_populates="field_groups")

class Field(Base):
__tablename__ = 'fields'
field_id = Column(Integer, primary_key=True)
ref = Column(String(255), nullable=False)
properties_id = Column(Integer, ForeignKey('field_properties.properties_id'), nullable=False)
validations_id = Column(Integer, ForeignKey('field_validations.validations_id'), nullable=False)
group_id = Column(Integer, ForeignKey('field_groups.group_id'))
properties = relationship("FieldProperties")
validations = relationship("FieldValidations")
group = relationship("FieldGroup", back_populates="fields")

class Response(Base):
__tablename__ = 'responses'
answer_id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('user.id'), nullable=False)
field_id = Column(Integer, ForeignKey('fields.field_id'), nullable=False)
answer_text = Column(Text)
user = relationship("User")
field = relationship("Field")

Form.field_groups = relationship("FieldGroup", order_by=FieldGroup.group_id, back_populates="form")
FieldGroup.fields = relationship("Field", order_by=Field.field_id, back_populates="group")

class DataAccessLayer:
_engine: Engine = None

Expand Down
Loading
Loading