From f6bb093184c8bcd0732bb0202922839a94c36287 Mon Sep 17 00:00:00 2001 From: aMetallurgist Date: Sun, 28 Apr 2024 20:46:04 -0700 Subject: [PATCH 1/7] Add initial forms api to store user-defined forms. --- .../versions/4f86de253491_first_forms_api.py | 79 +++++++++++++++++++ api/openapi_server/models/database.py | 69 +++++++++++++++- 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 api/alembic/versions/4f86de253491_first_forms_api.py diff --git a/api/alembic/versions/4f86de253491_first_forms_api.py b/api/alembic/versions/4f86de253491_first_forms_api.py new file mode 100644 index 00000000..88816165 --- /dev/null +++ b/api/alembic/versions/4f86de253491_first_forms_api.py @@ -0,0 +1,79 @@ +"""first_forms_api + +Revision ID: 4f86de253491 +Revises: e4c8bb426528 +Create Date: 2024-04-28 20:29:57.931911 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '4f86de253491' +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.Column('field_group', 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.String(length=255), nullable=False), + sa.Column('form_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(['form_id'], ['forms.form_id'], ), + 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') + ) + +def downgrade() -> None: + 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') diff --git a/api/openapi_server/models/database.py b/api/openapi_server/models/database.py index 36deaafd..1ab5fedd 100644 --- a/api/openapi_server/models/database.py +++ b/api/openapi_server/models/database.py @@ -3,8 +3,11 @@ 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 Base = declarative_base() @@ -17,6 +20,7 @@ class User(Base): lastName = Column(String(255), nullable=True) role_id = Column(Integer, ForeignKey('role.id'), nullable=False) role = relationship("Role", back_populates="users") + responses = relationship("Response", back_populates="user") @validates_sqlachemy('firstName') def validate_first_name(self, key, value): @@ -46,6 +50,69 @@ 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()) + +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) # Using native JSON support + field_group = Column(JSON) # Using native JSON support + + __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(String(255), primary_key=True) + form_id = Column(Integer, ForeignKey('forms.form_id'), nullable=False) + 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')) + form = relationship("Form", back_populates="fields") + properties = relationship("FieldProperties") + validations = relationship("FieldValidations") + group = relationship("FieldGroup", back_populates="fields") + responses = relationship("Response", back_populates="field") + +class Response(Base): + __tablename__ = 'responses' + answer_id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey('user.id'), nullable=False) + field_id = Column(String(255), ForeignKey('fields.field_id'), nullable=False) + answer_text = Column(Text) + user = relationship("User", back_populates="responses") + field = relationship("Field", back_populates="responses") + +Form.field_groups = relationship("FieldGroup", order_by=FieldGroup.group_id, back_populates="form") +Form.fields = relationship("Field", order_by=Field.field_id, back_populates="form") +FieldGroup.fields = relationship("Field", order_by=Field.field_id, back_populates="group") + class DataAccessLayer: _engine: Engine = None From 59c1ebdbffa3055e180993a51ea95702006464c9 Mon Sep 17 00:00:00 2001 From: aMetallurgist Date: Sun, 5 May 2024 22:18:12 -0700 Subject: [PATCH 2/7] Add basic forms endpoint logic, and marshmallow schema with tests of the forms retrieval --- ...pi.py => cfc4e41b69d3_initial_form_api.py} | 24 +- .../controllers/forms_controller.py | 49 ++++ api/openapi_server/models/database.py | 18 +- api/openapi_server/models/schema.py | 112 ++++++- api/tests/test_forms_schema.py | 274 ++++++++++++++++++ 5 files changed, 453 insertions(+), 24 deletions(-) rename api/alembic/versions/{4f86de253491_first_forms_api.py => cfc4e41b69d3_initial_form_api.py} (81%) create mode 100644 api/openapi_server/controllers/forms_controller.py create mode 100644 api/tests/test_forms_schema.py diff --git a/api/alembic/versions/4f86de253491_first_forms_api.py b/api/alembic/versions/cfc4e41b69d3_initial_form_api.py similarity index 81% rename from api/alembic/versions/4f86de253491_first_forms_api.py rename to api/alembic/versions/cfc4e41b69d3_initial_form_api.py index 88816165..8379e0d9 100644 --- a/api/alembic/versions/4f86de253491_first_forms_api.py +++ b/api/alembic/versions/cfc4e41b69d3_initial_form_api.py @@ -1,8 +1,8 @@ -"""first_forms_api +"""initial_form_api -Revision ID: 4f86de253491 +Revision ID: cfc4e41b69d3 Revises: e4c8bb426528 -Create Date: 2024-04-28 20:29:57.931911 +Create Date: 2024-05-05 17:14:51.771328 """ from alembic import op @@ -10,7 +10,7 @@ # revision identifiers, used by Alembic. -revision = '4f86de253491' +revision = 'cfc4e41b69d3' down_revision = 'e4c8bb426528' branch_labels = None depends_on = None @@ -22,7 +22,6 @@ def upgrade() -> None: 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.Column('field_group', 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') ) @@ -48,13 +47,11 @@ def upgrade() -> None: sa.PrimaryKeyConstraint('group_id') ) op.create_table('fields', - sa.Column('field_id', sa.String(length=255), nullable=False), - sa.Column('form_id', sa.Integer(), nullable=False), + 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(['form_id'], ['forms.form_id'], ), sa.ForeignKeyConstraint(['group_id'], ['field_groups.group_id'], ), sa.ForeignKeyConstraint(['properties_id'], ['field_properties.properties_id'], ), sa.ForeignKeyConstraint(['validations_id'], ['field_validations.validations_id'], ), @@ -69,8 +66,17 @@ def upgrade() -> None: 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') diff --git a/api/openapi_server/controllers/forms_controller.py b/api/openapi_server/controllers/forms_controller.py new file mode 100644 index 00000000..98413253 --- /dev/null +++ b/api/openapi_server/controllers/forms_controller.py @@ -0,0 +1,49 @@ +from sqlalchemy import func, select, delete + +from openapi_server.models.database import DataAccessLayer, Form, Response +from openapi_server.models.schema import form_schema, response_schema, ResponseSchema, Response + +def create_form(form_json): + new_form = form_schema.load(form_json) + with DataAccessLayer.session() as session: + session.add(new_form) + return form_schema.dump(new_form), 200 + +def get_form(form_id): + form = None + with DataAccessLayer.session() as session: + form = session.get(Form, form_id) + if form: + return form_schema.dump(form), 200 + return dict(), 404 + +def update_responses(response_json, user_id): + for response in response_json: + response["user_id"] = user_id + + with DataAccessLayer.session() as session: + new_responses = response_schema.load(response_json) + field_ids = [r.field_id for r in new_responses] + session.execute( + delete(Response)\ + .where(Response.user_id == user_id)\ + .where(Response.field_id.in_(field_ids)) + ) + for new_response in new_responses: + session.add(new_response) + +def get_responses(form_id, user_id): + with DataAccessLayer.session() as session: + form = session.get(Form, form_id) + field_ids = set() + for group in form.field_groups: + for field in group.fields: + field_ids.add(field.field_id) + + responses = session.execute( + select(Response).\ + where(Response.user_id == user_id).\ + where(Response.field_id.in_(field_ids)) + ) + + return response_schema.dump(responses) \ No newline at end of file diff --git a/api/openapi_server/models/database.py b/api/openapi_server/models/database.py index 1ab5fedd..639dac5a 100644 --- a/api/openapi_server/models/database.py +++ b/api/openapi_server/models/database.py @@ -20,7 +20,6 @@ class User(Base): lastName = Column(String(255), nullable=True) role_id = Column(Integer, ForeignKey('role.id'), nullable=False) role = relationship("Role", back_populates="users") - responses = relationship("Response", back_populates="user") @validates_sqlachemy('firstName') def validate_first_name(self, key, value): @@ -62,8 +61,7 @@ class FieldProperties(Base): properties_id = Column(Integer, primary_key=True) description = Column(Text) field_type = Column(String(50), nullable=False) - choices = Column(JSON) # Using native JSON support - field_group = Column(JSON) # Using native JSON support + choices = Column(JSON) __table_args__ = ( CheckConstraint( @@ -85,32 +83,28 @@ class FieldGroup(Base): title = Column(String(255), nullable=False) description = Column(Text) form = relationship("Form", back_populates="field_groups") - + class Field(Base): __tablename__ = 'fields' - field_id = Column(String(255), primary_key=True) - form_id = Column(Integer, ForeignKey('forms.form_id'), nullable=False) + 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')) - form = relationship("Form", back_populates="fields") properties = relationship("FieldProperties") validations = relationship("FieldValidations") group = relationship("FieldGroup", back_populates="fields") - responses = relationship("Response", back_populates="field") class Response(Base): __tablename__ = 'responses' answer_id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey('user.id'), nullable=False) - field_id = Column(String(255), ForeignKey('fields.field_id'), nullable=False) + field_id = Column(Integer, ForeignKey('fields.field_id'), nullable=False) answer_text = Column(Text) - user = relationship("User", back_populates="responses") - field = relationship("Field", back_populates="responses") + user = relationship("User") + field = relationship("Field") Form.field_groups = relationship("FieldGroup", order_by=FieldGroup.group_id, back_populates="form") -Form.fields = relationship("Field", order_by=Field.field_id, back_populates="form") FieldGroup.fields = relationship("Field", order_by=Field.field_id, back_populates="group") class DataAccessLayer: diff --git a/api/openapi_server/models/schema.py b/api/openapi_server/models/schema.py index 6c9911bd..2757db20 100644 --- a/api/openapi_server/models/schema.py +++ b/api/openapi_server/models/schema.py @@ -1,9 +1,30 @@ -from marshmallow_sqlalchemy import SQLAlchemyAutoSchema -from marshmallow import validates, ValidationError, EXCLUDE +from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, auto_field +from marshmallow import validates, ValidationError, EXCLUDE, post_load from marshmallow_sqlalchemy.fields import Nested from openapi_server.models.database import * from openapi_server.models.user_roles import UserRole +class SmartNested(Nested): + ''' + Schema attribute used to serialize nested attributes to + primary keys, unless they are already loaded. This + enables serialization of complex nested relationships. + + Modified from + https://marshmallow-sqlalchemy.readthedocs.io/en/latest/recipes.html#smart-nested-field + ''' + def serialize(self, attr, obj, accessor=None): + if hasattr(obj, attr): + value = getattr(obj, attr, None) + if value is None: + return None + elif hasattr(value, 'id'): + return {"id": value.id} + else: + return super(SmartNested, self).serialize(attr, obj, accessor) + else: + raise AttributeError(f"{obj.__class__.__name__} object has no attribute '{attr}'") + class RoleSchema(SQLAlchemyAutoSchema): class Meta: model = Role @@ -37,7 +58,92 @@ class Meta: include_relationships = True load_instance = True +class FieldValidationsSchema(SQLAlchemyAutoSchema): + class Meta: + model = FieldValidations + include_relationships = True + load_instance = True + exclude = ('validations_id',) + + required = auto_field() + max_length = auto_field() + +class FieldPropertiesSchema(SQLAlchemyAutoSchema): + class Meta: + model = FieldProperties + include_relationships = True + load_instance = True + exclude = ('properties_id',) + + description = auto_field() + field_type = auto_field() + choices = auto_field() + +class FieldSchema(SQLAlchemyAutoSchema): + class Meta: + model = Field + include_relationships = True + load_instance = True + exclude = ('properties_id','validations_id','group_id') + field_id = auto_field(dump_only=True) + ref = auto_field() + properties = SmartNested(FieldPropertiesSchema) + validations = SmartNested(FieldValidationsSchema) + +class FieldGroupSchema(SQLAlchemyAutoSchema): + class Meta: + model = FieldGroup + include_relationships = True + load_instance = True + exclude = ('group_id', 'form_id') + + title = auto_field() + description = auto_field() + fields = SmartNested(FieldSchema, many=True) + +class FormSchema(SQLAlchemyAutoSchema): + class Meta: + model = Form + include_relationships = True + load_instance = True + exclude = ('form_id',) + + title = auto_field() + description = auto_field() + field_groups = SmartNested(FieldGroupSchema, many=True) + +class ResponseSchema(SQLAlchemyAutoSchema): + class Meta: + model = Response + include_relationship = True + load_instance = True + exclude = ('answer_id',) + + user_id = auto_field(load_only=True) + field_id = auto_field(load_only=True) + answer_text = auto_field() + user = SmartNested(UserSchema, only=['name'], required=False, missing=None) + field = SmartNested(FieldSchema, only=['field_id', 'ref', 'properties'], required=False, missing=None) + + @post_load + def make_response(self, data, **kwargs): + if data.user is None: + user = self._session.query(User).get(data.user_id) + if not user: + raise ValidationError('User not found', 'user_id') + data.user = user + + if data.field is None: + field = self._session.query(Field).get(data.field_id) + if not field: + raise ValidationError('Field not found', 'field_id') + data.field = field + + return data + user_schema = UserSchema() users_schema = UserSchema(many=True) service_provider_schema = HousingProgramServiceProviderSchema() -service_provider_list_schema = HousingProgramServiceProviderSchema(many=True) \ No newline at end of file +service_provider_list_schema = HousingProgramServiceProviderSchema(many=True) +form_schema = FormSchema() +response_schema = ResponseSchema(many=True) \ No newline at end of file diff --git a/api/tests/test_forms_schema.py b/api/tests/test_forms_schema.py new file mode 100644 index 00000000..0c49a448 --- /dev/null +++ b/api/tests/test_forms_schema.py @@ -0,0 +1,274 @@ +import json + +from openapi_server.models.schema import ( + form_schema, + response_schema, + FieldSchema, + FieldValidationsSchema, + FieldPropertiesSchema, + FieldGroupSchema +) + +def test_serialize_form_no_questions(empty_db_session): + form_json = {"title": "mytitle", "description": "mydesc", "field_groups": []} + form = form_schema.load(form_json, session=empty_db_session) + + assert "mytitle" == form.title + assert "mydesc" == form.description + assert list() == form.field_groups + +def test_deserialize_field_validations(empty_db_session): + validation_json = { + "required": True, + "max_length": None + } + validation = FieldValidationsSchema().load(validation_json, session=empty_db_session) + assert validation.required + assert validation.max_length is None + +def test_deserialize_field_property(empty_db_session): + property_json = { + "description": "sample desc", + "field_type": "long_text", + "choices": ['one', 'two','three'] + } + property = FieldPropertiesSchema().load(property_json, session=empty_db_session) + assert property_json["field_type"] == property.field_type + assert property_json["description"] == property.description + +def test_deserialize_field(empty_db_session): + single_field_json = { + "ref": "position", + "properties": { + "description": "Position in the company", + "field_type": "dropdown", + "choices": ['Manager', 'Developer', 'Designer'], + }, + "validations": { + "required": True, + "max_length": 12 + } + } + field = FieldSchema().load(single_field_json, session=empty_db_session) + assert single_field_json["ref"] == field.ref + assert single_field_json["properties"]["description"] == field.properties.description + assert single_field_json["properties"]["choices"] == field.properties.choices + assert single_field_json["validations"]["max_length"] == field.validations.max_length + assert field.validations.required + +def test_deserialize_fields(empty_db_session): + multiple_fields = [ + { + "ref": "position", + "properties": { + "description": "Position in the company", + "field_type": "dropdown", + "choices": ['Manager', 'Developer', 'Designer'], + }, + "validations": { + "required": True, + "max_length": 12 + } + }, + { + "ref": "service_length", + "properties": { + "description": "Years in the company", + "field_type": "number", + "choices": None, + }, + "validations": { + "required": False, + "max_length": None + } + } + ] + fields = FieldSchema(many=True).load(multiple_fields, session=empty_db_session) + assert 2 == len(fields) + assert multiple_fields[0]["properties"]["description"] == fields[0].properties.description + assert multiple_fields[1]["properties"]["description"] == fields[1].properties.description + assert multiple_fields[1]["properties"]["field_type"] == fields[1].properties.field_type + +def test_deserialize_field_group(empty_db_session): + group_json = [ + { + "title": "Personal Details", + "description": "Please enter your personal details.", + "fields": [ + { + "ref": "position", + "properties": { + "description": "Position in the company", + "field_type": "dropdown", + "choices": ['Manager', 'Developer', 'Designer'], + }, + "validations": { + "required": True, + "max_length": 12 + } + }, + { + "ref": "service_length", + "properties": { + "description": "Years in the company", + "field_type": "number", + "choices": None, + }, + "validations": { + "required": False, + "max_length": None + } + } + ] + }, + { + "title": "Second Group", + "description": "A second field group.", + "fields": [ + { + "ref": "start date", + "properties": { + "description": "Start date", + "field_type": "date", + "choices": "11-22-2005", + }, + "validations": { + "required": True, + "max_length": 12 + } + } + ] + } + ] + group = FieldGroupSchema(many=True).load(group_json, session=empty_db_session) + pass + +def test_deserialize_form_happypath(empty_db_session): + form_json = { + "title": "Employee Onboarding", + "description": "Collect necessary employee data.", + "field_groups": [ + { + "title": "Personal Details", + "description": "Please enter your personal details.", + "fields": [ + { + "ref": "position", + "properties": { + "description": "Position in the company", + "field_type": "dropdown", + "choices": ['Manager', 'Developer', 'Designer'], + }, + "validations": { + "required": True, + "max_length": 12 + } + }, + { + "ref": "service_length", + "properties": { + "description": "Years in the company", + "field_type": "number", + "choices": None, + }, + "validations": { + "required": False, + "max_length": None + } + } + ] + }, + { + "title": "Second Group", + "description": "A second field group.", + "fields": [ + { + "ref": "start date", + "properties": { + "description": "Start date", + "field_type": "date", + "choices": "11-22-2005", + }, + "validations": { + "required": True, + "max_length": 12 + } + } + ] + } + ] + } + form = form_schema.load(form_json, session=empty_db_session) + assert form_json["title"] == form.title + assert form_json["description"] == form.description + assert 2 == len(form.field_groups) + for expected, actual in zip(form_json["field_groups"], form.field_groups): + assert expected["title"] == actual.title + assert expected["description"] == actual.description + assert len(expected["fields"]) == len(actual.fields) + + form_schema.dump(form) + empty_db_session.add(form) + empty_db_session.commit() + from openapi_server.models.database import Form + new_form = empty_db_session.get(Form, 1) + + from openapi_server.models.database import User, Field + from openapi_server.repositories.user_repo import UserRepository + from openapi_server.models.user_roles import UserRole + new_user = UserRepository(empty_db_session).add_user( + email="email@email.com", + firstName="firstName", + role=UserRole.HOST + ) + + response_json = [ + { + "user_id": new_user.id, + "field_id": new_form.field_groups[0].fields[0].field_id, + "answer_text": "Fancy JSON answer" + } + ] + from openapi_server.models.database import Response + responses = response_schema.load(response_json, session=empty_db_session) + empty_db_session.add(responses[0]) + empty_db_session.commit() + empty_db_session.get(Response, responses[0].answer_id) + + for group in new_form.field_groups: + for field in group.fields: + pass # field.response + + +def test_response_deserialization(empty_db_session): + response_json = [ + { + "user_id": 11, + "field_id": 1, + "answer_text": "answer stored as text" + }, + + ] + return + from openapi_server.models.database import User, Field + response_user = User( + email="email@email.com", + firstName="firstName", + role="Host" + ) + field_json = { + "ref": "start date", + "properties": { + "description": "Start date", + "field_type": "date", + "choices": "11-22-2005", + }, + "validations": { + "required": True, + "max_length": 12 + } + } + field = FieldSchema().load(field_json, session=empty_db_session) + empty_db_session.add(response_user) + empty_db_session.add(field_json) + pass From cbebad69302e633415e80afba24d3d78c895974e Mon Sep 17 00:00:00 2001 From: aMetallurgist Date: Sat, 18 May 2024 16:51:32 -0700 Subject: [PATCH 3/7] add forms repo. Last thing to do is register the endpoint. --- api/openapi_server/repositories/base.py | 10 ++++ api/openapi_server/repositories/forms.py | 23 ++++++++ api/tests/test_forms_repo.py | 68 ++++++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 api/openapi_server/repositories/base.py create mode 100644 api/openapi_server/repositories/forms.py create mode 100644 api/tests/test_forms_repo.py diff --git a/api/openapi_server/repositories/base.py b/api/openapi_server/repositories/base.py new file mode 100644 index 00000000..6ae2f946 --- /dev/null +++ b/api/openapi_server/repositories/base.py @@ -0,0 +1,10 @@ +from sqlalchemy.orm import Session + +from openapi_server.models.database import DataAccessLayer + +class BaseRepo: + + def __init__(self, session: Session = None): + if session is None: + session = DataAccessLayer.session() + self.session = session \ No newline at end of file diff --git a/api/openapi_server/repositories/forms.py b/api/openapi_server/repositories/forms.py new file mode 100644 index 00000000..87590212 --- /dev/null +++ b/api/openapi_server/repositories/forms.py @@ -0,0 +1,23 @@ +from typing import Optional + +from openapi_server.models.schema import form_schema, FormSchema +from openapi_server.models.database import Form +from openapi_server.repositories.base import BaseRepo + +class FormsRepository(BaseRepo): + + def add_form(self, form_json) -> int: + with self.session as session: + form = form_schema.load(form_json, session=session) + session.add(form) + session.commit() + return form.form_id + + def get_form(self, form_id) -> Optional[Form]: + return self.session.query(Form).get(form_id) + + def get_form_json(self, form_id) -> Optional[FormSchema]: + form = self.get_form(form_id) + if form is not None: + return form_schema.dump(form) + return None \ No newline at end of file diff --git a/api/tests/test_forms_repo.py b/api/tests/test_forms_repo.py new file mode 100644 index 00000000..91cc6f03 --- /dev/null +++ b/api/tests/test_forms_repo.py @@ -0,0 +1,68 @@ +from openapi_server.repositories.forms import FormsRepository + +def test_add_form_valid_json(empty_db_session_provider): + form_json = { + "title": "Employee Onboarding", + "description": "Collect necessary employee data.", + "field_groups": [ + { + "title": "Personal Details", + "description": "Please enter your personal details.", + "fields": [ + { + "ref": "position", + "properties": { + "description": "Position in the company", + "field_type": "dropdown", + "choices": ['Manager', 'Developer', 'Designer'], + }, + "validations": { + "required": True, + "max_length": 12 + } + }, + { + "ref": "service_length", + "properties": { + "description": "Years in the company", + "field_type": "number", + "choices": None, + }, + "validations": { + "required": False, + "max_length": None + } + } + ] + }, + { + "title": "Second Group", + "description": "A second field group.", + "fields": [ + { + "ref": "start date", + "properties": { + "description": "Start date", + "field_type": "date", + "choices": "11-22-2005", + }, + "validations": { + "required": True, + "max_length": 12 + } + } + ] + } + ] + } + + form_repo = FormsRepository(empty_db_session_provider.session()) + created_form_id = form_repo.add_form(form_json) + retrieved_form = form_repo.get_form_json(created_form_id) + + # the json won't match exactly because retrieved_form has ids associated with + # it, but we can do a spot check at least + assert retrieved_form['title'] == form_json['title'] + assert len(retrieved_form['field_groups']) == len(form_json['field_groups']) + assert len(retrieved_form['field_groups'][1]['fields']) == len(form_json['field_groups'][1]['fields']) + assert retrieved_form['field_groups'][1]['fields'][0]['validations'] == form_json['field_groups'][1]['fields'][0]['validations'] \ No newline at end of file From b64c04e56d3316148cff64b585908013b305a2c0 Mon Sep 17 00:00:00 2001 From: aMetallurgist Date: Sun, 16 Jun 2024 12:00:36 -0700 Subject: [PATCH 4/7] Update form repo to support adding and retrieving responses, with tests. --- api/openapi_server/repositories/forms.py | 33 ++- api/openapi_server/repositories/user_repo.py | 3 + api/tests/conftest.py | 7 + api/tests/test_forms_repo.py | 175 ++++++++++------ api/tests/test_forms_schema.py | 208 ++++++++----------- 5 files changed, 242 insertions(+), 184 deletions(-) diff --git a/api/openapi_server/repositories/forms.py b/api/openapi_server/repositories/forms.py index 87590212..f5e13c72 100644 --- a/api/openapi_server/repositories/forms.py +++ b/api/openapi_server/repositories/forms.py @@ -1,7 +1,7 @@ from typing import Optional -from openapi_server.models.schema import form_schema, FormSchema -from openapi_server.models.database import Form +from openapi_server.models.schema import form_schema, response_schema, FormSchema +from openapi_server.models.database import Form, Response from openapi_server.repositories.base import BaseRepo class FormsRepository(BaseRepo): @@ -13,11 +13,36 @@ def add_form(self, form_json) -> int: session.commit() return form.form_id - def get_form(self, form_id) -> Optional[Form]: + def get_form(self, form_id: int) -> Optional[Form]: return self.session.query(Form).get(form_id) def get_form_json(self, form_id) -> Optional[FormSchema]: form = self.get_form(form_id) if form is not None: return form_schema.dump(form) - return None \ No newline at end of file + return None + + def get_user_responses(self, user_id: int, form_id: int): + with self.session as session: + form = session.query(Form).get(form_id) + field_ids = [field.field_id for group in form.field_groups for field in group.fields] + responses = session.query(Response).filter( + Response.user_id == user_id, + Response.field_id.in_(field_ids) + ).all() + return response_schema.dump(responses) + + def add_user_responses(self, user_id: int, responses) -> None: + ''' + Add list of responses, from raw json. This function will + parse the json and overwrite any existing responses. + ''' + with self.session as session: + new_responses = response_schema.load(responses, session=session) + new_field_ids = [resp.field_id for resp in new_responses] + session.query(Response).filter( + Response.user_id == user_id, + Response.field_id.in_(new_field_ids) + ).delete(synchronize_session=False) + session.add_all(new_responses) + session.commit() \ No newline at end of file diff --git a/api/openapi_server/repositories/user_repo.py b/api/openapi_server/repositories/user_repo.py index 8e9e0588..25eb8466 100644 --- a/api/openapi_server/repositories/user_repo.py +++ b/api/openapi_server/repositories/user_repo.py @@ -33,5 +33,8 @@ def delete_user(self, user_id: int) -> bool: def get_user(self, email: str) -> User: return self.session.query(User).filter_by(email=email).first() + def get_user_id(self, email: str) -> int: + return self.session.query(User).filter_by(email=email).first().id + def get_users_with_role(self, role: UserRole) -> List[User]: return self.session.query(User).filter_by(role=self._get_role(role)) \ No newline at end of file diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 8da445df..8b811068 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -118,6 +118,13 @@ def empty_db_session(alembic_runner, alembic_engine) -> Generator[Session, None, test_engine, DataAccessLayer._engine = DataAccessLayer._engine, None test_engine.dispose() +@pytest.fixture() +def empty_db_session_provider(empty_db_session): + class _provider: + def session(): return empty_db_session + + return _provider + @pytest.fixture() def client(app): return app.test_client() diff --git a/api/tests/test_forms_repo.py b/api/tests/test_forms_repo.py index 91cc6f03..14f665a1 100644 --- a/api/tests/test_forms_repo.py +++ b/api/tests/test_forms_repo.py @@ -1,68 +1,127 @@ +from types import MappingProxyType + from openapi_server.repositories.forms import FormsRepository +from openapi_server.repositories.user_repo import UserRepository, UserRole + +TEST_FORM_READ_ONLY = MappingProxyType({ + "title": "Employee Onboarding", + "description": "Collect necessary employee data.", + "field_groups": [ + { + "title": "Personal Details", + "description": "Please enter your personal details.", + "fields": [ + { + "ref": "position", + "properties": { + "description": "Position in the company", + "field_type": "dropdown", + "choices": ['Manager', 'Developer', 'Designer'], + }, + "validations": { + "required": True, + "max_length": 12 + } + }, + { + "ref": "service_length", + "properties": { + "description": "Years in the company", + "field_type": "number", + "choices": None, + }, + "validations": { + "required": False, + "max_length": None + } + } + ] + }, + { + "title": "Second Group", + "description": "A second field group.", + "fields": [ + { + "ref": "start date", + "properties": { + "description": "Start date", + "field_type": "date", + "choices": "11-22-2005", + }, + "validations": { + "required": True, + "max_length": 12 + } + } + ] + } + ] +}) + +def assert_form_equal(actual_form: dict, expected_form: dict): + ''' + Do a deep equality check of a form, excluding dynamically + assigned values like timestamps and primary key ids. + ''' + actual_copy = actual_form.copy() + del actual_copy['created_at'] + for group in actual_copy['field_groups']: + del group['form'] + for field in group['fields']: + del field['field_id'] + del field['group'] + + assert actual_copy == expected_form def test_add_form_valid_json(empty_db_session_provider): - form_json = { - "title": "Employee Onboarding", - "description": "Collect necessary employee data.", - "field_groups": [ + form_json = dict(TEST_FORM_READ_ONLY) + + form_repo = FormsRepository(empty_db_session_provider.session()) + created_form_id = form_repo.add_form(form_json) + retrieved_form = form_repo.get_form_json(created_form_id) + + assert_form_equal(retrieved_form, form_json) + +def test_add_get_responses(empty_db_session_provider): + with empty_db_session_provider.session() as session: + user_repo = UserRepository(session) + form_repo = FormsRepository(session) + + user_repo.add_user('fake@email.com', UserRole.COORDINATOR, 'firstname') + user_id = user_repo.get_user_id('fake@email.com') + created_form_id = form_repo.add_form(TEST_FORM_READ_ONLY) + retrieved_form = form_repo.get_form_json(created_form_id) + + def _get_field_id(lcl_form, ref): + for group in lcl_form['field_groups']: + for field in group['fields']: + if field['ref'] == ref: + return int(field['field_id']) + raise ValueError(f'ref {ref} not found in test form') + + expected_responses = [ { - "title": "Personal Details", - "description": "Please enter your personal details.", - "fields": [ - { - "ref": "position", - "properties": { - "description": "Position in the company", - "field_type": "dropdown", - "choices": ['Manager', 'Developer', 'Designer'], - }, - "validations": { - "required": True, - "max_length": 12 - } - }, - { - "ref": "service_length", - "properties": { - "description": "Years in the company", - "field_type": "number", - "choices": None, - }, - "validations": { - "required": False, - "max_length": None - } - } - ] + "user_id": user_id, + "field_id": _get_field_id(retrieved_form, 'position'), + "answer_text": "Designer" }, { - "title": "Second Group", - "description": "A second field group.", - "fields": [ - { - "ref": "start date", - "properties": { - "description": "Start date", - "field_type": "date", - "choices": "11-22-2005", - }, - "validations": { - "required": True, - "max_length": 12 - } - } - ] + "user_id": user_id, + "field_id": _get_field_id(retrieved_form, 'service_length'), + "answer_text": "5" + }, + { + "user_id": user_id, + "field_id": _get_field_id(retrieved_form, 'start date'), + "answer_text": '2024-05-19' } ] - } + form_repo.add_user_responses(user_id, expected_responses) - form_repo = FormsRepository(empty_db_session_provider.session()) - created_form_id = form_repo.add_form(form_json) - retrieved_form = form_repo.get_form_json(created_form_id) + retrieved_answers = form_repo.get_user_responses(user_id, created_form_id) - # the json won't match exactly because retrieved_form has ids associated with - # it, but we can do a spot check at least - assert retrieved_form['title'] == form_json['title'] - assert len(retrieved_form['field_groups']) == len(form_json['field_groups']) - assert len(retrieved_form['field_groups'][1]['fields']) == len(form_json['field_groups'][1]['fields']) - assert retrieved_form['field_groups'][1]['fields'][0]['validations'] == form_json['field_groups'][1]['fields'][0]['validations'] \ No newline at end of file + assert len(retrieved_answers) == 3 + for expected, actual in zip(expected_responses, retrieved_answers): + assert expected['answer_text'] == actual['answer_text'] + assert expected['user_id'] == actual['user']['id'] + assert expected['field_id'] == actual['field']['field_id'] \ No newline at end of file diff --git a/api/tests/test_forms_schema.py b/api/tests/test_forms_schema.py index 0c49a448..66aef7b3 100644 --- a/api/tests/test_forms_schema.py +++ b/api/tests/test_forms_schema.py @@ -1,14 +1,72 @@ -import json +from types import MappingProxyType +import pytest +from marshmallow import ValidationError from openapi_server.models.schema import ( form_schema, - response_schema, FieldSchema, FieldValidationsSchema, FieldPropertiesSchema, FieldGroupSchema ) +VALID_FORM_JSON = MappingProxyType({ + "title": "Employee Onboarding", + "description": "Collect necessary employee data.", + "field_groups": [ + { + "title": "Personal Details", + "description": "Please enter your personal details.", + "fields": [ + { + "ref": "position", + "properties": { + "description": "Position in the company", + "field_type": "dropdown", + "choices": ['Manager', 'Developer', 'Designer'], + }, + "validations": { + "required": True, + "max_length": 12 + } + }, + { + "ref": "service_length", + "properties": { + "description": "Years in the company", + "field_type": "number", + "choices": None, + }, + "validations": { + "required": False, + "max_length": None + } + } + ] + }, + { + "title": "Second Group", + "description": "A second field group.", + "fields": [ + { + "ref": "start date", + "properties": { + "description": "Start date", + "field_type": "date", + "choices": "11-22-2005", + }, + "validations": { + "required": True, + "max_length": 12 + } + } + ] + } + ] + } + ) + + def test_serialize_form_no_questions(empty_db_session): form_json = {"title": "mytitle", "description": "mydesc", "field_groups": []} form = form_schema.load(form_json, session=empty_db_session) @@ -85,9 +143,9 @@ def test_deserialize_fields(empty_db_session): ] fields = FieldSchema(many=True).load(multiple_fields, session=empty_db_session) assert 2 == len(fields) - assert multiple_fields[0]["properties"]["description"] == fields[0].properties.description - assert multiple_fields[1]["properties"]["description"] == fields[1].properties.description - assert multiple_fields[1]["properties"]["field_type"] == fields[1].properties.field_type + for expected, actual in zip(multiple_fields, fields): + assert expected['properties']['description'] == actual.properties.description + assert expected['properties']['field_type'] == actual.properties.field_type def test_deserialize_field_group(empty_db_session): group_json = [ @@ -140,64 +198,21 @@ def test_deserialize_field_group(empty_db_session): ] } ] - group = FieldGroupSchema(many=True).load(group_json, session=empty_db_session) - pass + groups = FieldGroupSchema(many=True).load(group_json, session=empty_db_session) + assert len(group_json) == len(groups) + for expected_group, actual_group in zip(group_json, groups): + assert expected_group['title'] == actual_group.title + assert expected_group['description'] == actual_group.description + for expected_fields, actual_fields in zip(expected_group['fields'], actual_group.fields): + assert expected_fields['ref'] == actual_fields.ref + assert expected_fields['validations']['required'] == actual_fields.validations.required + assert expected_fields['validations']['max_length'] == actual_fields.validations.max_length + assert expected_fields['properties']['description'] == actual_fields.properties.description + assert expected_fields['properties']['field_type'] == actual_fields.properties.field_type + assert expected_fields['properties']['choices'] == actual_fields.properties.choices def test_deserialize_form_happypath(empty_db_session): - form_json = { - "title": "Employee Onboarding", - "description": "Collect necessary employee data.", - "field_groups": [ - { - "title": "Personal Details", - "description": "Please enter your personal details.", - "fields": [ - { - "ref": "position", - "properties": { - "description": "Position in the company", - "field_type": "dropdown", - "choices": ['Manager', 'Developer', 'Designer'], - }, - "validations": { - "required": True, - "max_length": 12 - } - }, - { - "ref": "service_length", - "properties": { - "description": "Years in the company", - "field_type": "number", - "choices": None, - }, - "validations": { - "required": False, - "max_length": None - } - } - ] - }, - { - "title": "Second Group", - "description": "A second field group.", - "fields": [ - { - "ref": "start date", - "properties": { - "description": "Start date", - "field_type": "date", - "choices": "11-22-2005", - }, - "validations": { - "required": True, - "max_length": 12 - } - } - ] - } - ] - } + form_json = dict(VALID_FORM_JSON) form = form_schema.load(form_json, session=empty_db_session) assert form_json["title"] == form.title assert form_json["description"] == form.description @@ -207,68 +222,17 @@ def test_deserialize_form_happypath(empty_db_session): assert expected["description"] == actual.description assert len(expected["fields"]) == len(actual.fields) - form_schema.dump(form) - empty_db_session.add(form) - empty_db_session.commit() - from openapi_server.models.database import Form - new_form = empty_db_session.get(Form, 1) - from openapi_server.models.database import User, Field - from openapi_server.repositories.user_repo import UserRepository - from openapi_server.models.user_roles import UserRole - new_user = UserRepository(empty_db_session).add_user( - email="email@email.com", - firstName="firstName", - role=UserRole.HOST - ) +def test_deserialize_form_extra_key(empty_db_session): + invalid_form_json = dict(VALID_FORM_JSON) + invalid_form_json['extra_key'] = 'extra_value' - response_json = [ - { - "user_id": new_user.id, - "field_id": new_form.field_groups[0].fields[0].field_id, - "answer_text": "Fancy JSON answer" - } - ] - from openapi_server.models.database import Response - responses = response_schema.load(response_json, session=empty_db_session) - empty_db_session.add(responses[0]) - empty_db_session.commit() - empty_db_session.get(Response, responses[0].answer_id) - - for group in new_form.field_groups: - for field in group.fields: - pass # field.response + with pytest.raises(ValidationError, match=r"Unknown field"): + form_schema.load(invalid_form_json, session=empty_db_session) +def test_deserialize_form_missing_key(empty_db_session): + invalid_form_json = dict(VALID_FORM_JSON) + del invalid_form_json['title'] -def test_response_deserialization(empty_db_session): - response_json = [ - { - "user_id": 11, - "field_id": 1, - "answer_text": "answer stored as text" - }, - - ] - return - from openapi_server.models.database import User, Field - response_user = User( - email="email@email.com", - firstName="firstName", - role="Host" - ) - field_json = { - "ref": "start date", - "properties": { - "description": "Start date", - "field_type": "date", - "choices": "11-22-2005", - }, - "validations": { - "required": True, - "max_length": 12 - } - } - field = FieldSchema().load(field_json, session=empty_db_session) - empty_db_session.add(response_user) - empty_db_session.add(field_json) - pass + with pytest.raises(ValidationError, match=r"Missing data for required field"): + form_schema.load(invalid_form_json, session=empty_db_session) \ No newline at end of file From 8d5ce36806ba3727e4c96b89c49a5422fd70b482 Mon Sep 17 00:00:00 2001 From: aMetallurgist Date: Sun, 16 Jun 2024 16:33:54 -0700 Subject: [PATCH 5/7] Implement forms API endpoints, complete with API schema, and simplify controller logic. Also fix small NoneType bug in AWSMockService. --- api/openapi_server/configs/mock_aws.py | 2 +- .../controllers/forms_controller.py | 57 ++++---------- .../controllers/responses_controller.py | 38 ++++++++++ api/openapi_server/models/database.py | 4 + api/openapi_server/openapi/openapi.yaml | 6 ++ api/openapi_server/openapi/paths/forms.yaml | 23 ++++++ .../openapi/paths/post-form.yaml | 23 ++++++ .../openapi/paths/responses.yaml | 46 ++++++++++++ .../openapi/schemas/_index.yaml | 74 ++++++++++++++++++- api/tests/test_alembic_migration.py | 2 +- 10 files changed, 228 insertions(+), 47 deletions(-) create mode 100644 api/openapi_server/controllers/responses_controller.py create mode 100644 api/openapi_server/openapi/paths/forms.yaml create mode 100644 api/openapi_server/openapi/paths/post-form.yaml create mode 100644 api/openapi_server/openapi/paths/responses.yaml diff --git a/api/openapi_server/configs/mock_aws.py b/api/openapi_server/configs/mock_aws.py index d65c226a..387080a3 100644 --- a/api/openapi_server/configs/mock_aws.py +++ b/api/openapi_server/configs/mock_aws.py @@ -164,7 +164,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() diff --git a/api/openapi_server/controllers/forms_controller.py b/api/openapi_server/controllers/forms_controller.py index 98413253..51fbb8b4 100644 --- a/api/openapi_server/controllers/forms_controller.py +++ b/api/openapi_server/controllers/forms_controller.py @@ -1,49 +1,18 @@ -from sqlalchemy import func, select, delete +from openapi_server.repositories.forms import FormsRepository +from openapi_server.models.database import DataAccessLayer -from openapi_server.models.database import DataAccessLayer, Form, Response -from openapi_server.models.schema import form_schema, response_schema, ResponseSchema, Response +def create_form(body): + forms_repo = FormsRepository(DataAccessLayer.session()) -def create_form(form_json): - new_form = form_schema.load(form_json) - with DataAccessLayer.session() as session: - session.add(new_form) - return form_schema.dump(new_form), 200 + 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): - form = None - with DataAccessLayer.session() as session: - form = session.get(Form, form_id) + forms_repo = FormsRepository(DataAccessLayer.session()) + form = forms_repo.get_form_json(form_id) if form: - return form_schema.dump(form), 200 - return dict(), 404 - -def update_responses(response_json, user_id): - for response in response_json: - response["user_id"] = user_id - - with DataAccessLayer.session() as session: - new_responses = response_schema.load(response_json) - field_ids = [r.field_id for r in new_responses] - session.execute( - delete(Response)\ - .where(Response.user_id == user_id)\ - .where(Response.field_id.in_(field_ids)) - ) - for new_response in new_responses: - session.add(new_response) - -def get_responses(form_id, user_id): - with DataAccessLayer.session() as session: - form = session.get(Form, form_id) - field_ids = set() - for group in form.field_groups: - for field in group.fields: - field_ids.add(field.field_id) - - responses = session.execute( - select(Response).\ - where(Response.user_id == user_id).\ - where(Response.field_id.in_(field_ids)) - ) - - return response_schema.dump(responses) \ No newline at end of file + return form, 200 + return f"Form with id {form_id} does not exist.", 404 \ No newline at end of file diff --git a/api/openapi_server/controllers/responses_controller.py b/api/openapi_server/controllers/responses_controller.py new file mode 100644 index 00000000..4dcb1af1 --- /dev/null +++ b/api/openapi_server/controllers/responses_controller.py @@ -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 \ No newline at end of file diff --git a/api/openapi_server/models/database.py b/api/openapi_server/models/database.py index 639dac5a..4395a5f4 100644 --- a/api/openapi_server/models/database.py +++ b/api/openapi_server/models/database.py @@ -8,6 +8,7 @@ from sqlalchemy.sql import func from sqlalchemy.schema import CheckConstraint from sqlalchemy.types import JSON +from typing import List Base = declarative_base() @@ -56,6 +57,9 @@ class Form(Base): 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) diff --git a/api/openapi_server/openapi/openapi.yaml b/api/openapi_server/openapi/openapi.yaml index 260dc986..672460fb 100644 --- a/api/openapi_server/openapi/openapi.yaml +++ b/api/openapi_server/openapi/openapi.yaml @@ -49,6 +49,12 @@ paths: $ref: "./paths/auth/authInvite.yaml" /auth/confirmInvite: $ref: "./paths/auth/authConfirmInvite.yaml" + /forms: + $ref: "./paths/post-form.yaml" + /forms/{form_id}: + $ref: "./paths/forms.yaml" + /responses/{form_id}: + $ref: "./paths/responses.yaml" components: securitySchemes: jwt: diff --git a/api/openapi_server/openapi/paths/forms.yaml b/api/openapi_server/openapi/paths/forms.yaml new file mode 100644 index 00000000..8684790c --- /dev/null +++ b/api/openapi_server/openapi/paths/forms.yaml @@ -0,0 +1,23 @@ +get: + summary: Get a form by ID + operationId: get_form + parameters: + - name: form_id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Form data retrieved successfully + content: + application/json: + schema: + $ref: '../schemas/_index.yaml#/Form' + '404': + description: Form not found + '401': + description: Authentication error + x-openapi-router-controller: openapi_server.controllers.forms_controller + security: + - jwt: ["secret"] \ No newline at end of file diff --git a/api/openapi_server/openapi/paths/post-form.yaml b/api/openapi_server/openapi/paths/post-form.yaml new file mode 100644 index 00000000..993c71ce --- /dev/null +++ b/api/openapi_server/openapi/paths/post-form.yaml @@ -0,0 +1,23 @@ +post: + summary: Create a new form + operationId: create_form + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/_index.yaml#/Form' + responses: + '200': + description: Successfully created a form + content: + application/json: + schema: + $ref: '../schemas/_index.yaml#/Form' + '401': + description: Authentication error + '404': + description: Not found + x-openapi-router-controller: openapi_server.controllers.forms_controller + security: + - jwt: ["secret"] \ No newline at end of file diff --git a/api/openapi_server/openapi/paths/responses.yaml b/api/openapi_server/openapi/paths/responses.yaml new file mode 100644 index 00000000..3f1a6a02 --- /dev/null +++ b/api/openapi_server/openapi/paths/responses.yaml @@ -0,0 +1,46 @@ +put: + summary: Update user responses for a specific form + operationId: update_responses + parameters: + - name: form_id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/_index.yaml#/FormResponses' + responses: + '401': + description: Authentication error + '204': + description: Responses updated successfully + x-openapi-router-controller: openapi_server.controllers.responses_controller + security: + - jwt: ["secret"] +get: + summary: Get responses for a specific form and user + operationId: get_responses + parameters: + - name: form_id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Successfully retrieved responses + content: + application/json: + schema: + $ref: '../schemas/_index.yaml#/FormResponses' + '401': + description: Authentication error + '404': + description: Responses not found + x-openapi-router-controller: openapi_server.controllers.responses_controller + security: + - jwt: ["secret"] \ No newline at end of file diff --git a/api/openapi_server/openapi/schemas/_index.yaml b/api/openapi_server/openapi/schemas/_index.yaml index 84a6c2bc..5f7185ae 100644 --- a/api/openapi_server/openapi/schemas/_index.yaml +++ b/api/openapi_server/openapi/schemas/_index.yaml @@ -82,4 +82,76 @@ UserSignupSchema: password: type: string required: - - password \ No newline at end of file + - password +FormResponse: + type: object + properties: + field_id: + type: integer + answer_text: + type: string +FormResponses: + type: array + items: + $ref: '#/FormResponse' +FieldValidations: + type: object + properties: + required: + type: boolean + max_length: + type: integer + nullable: true +FieldProperties: + type: object + properties: + description: + type: string + field_type: + type: string + enum: + - date + - dropdown + - multiple_choice + - email + - file_upload + - group + - long_text + - number + - short_text + - yes_no + choices: + type: array + items: + type: string +Field: + type: object + properties: + ref: + type: string + properties: + $ref: '#/FieldProperties' + validations: + $ref: '#/FieldValidations' +FieldGroup: + type: object + properties: + title: + type: string + description: + type: string + fields: + type: array + items: + $ref: '#/Field' +Form: + type: object + properties: + title: + type: string + description: + type: string + field_groups: + type: array + items: + $ref: '#/FieldGroup' \ No newline at end of file diff --git a/api/tests/test_alembic_migration.py b/api/tests/test_alembic_migration.py index 30436d22..500fa552 100644 --- a/api/tests/test_alembic_migration.py +++ b/api/tests/test_alembic_migration.py @@ -21,7 +21,7 @@ def test_db_session_version(empty_db_session): # Before updating to the new revision please add additional # test cases below that check the integrity of your new migration - assert DataAccessLayer.revision_id() == 'e4c8bb426528' + assert DataAccessLayer.revision_id() == 'cfc4e41b69d3' def test_user_roles_available(empty_db_session): ''' From 6802a272ce74f8ed0c51a102911c0b2d86b358f3 Mon Sep 17 00:00:00 2001 From: aMetallurgist Date: Sun, 16 Jun 2024 16:35:45 -0700 Subject: [PATCH 6/7] Enhance mocking configuration to all existing local users to the mock userpool, to allow persisting changes across multiple development sessions. Before this change the mock configuration would only include the 3 test users, but now all existing users will be assigned a test password. --- api/openapi_server/__main__.py | 11 ++++++- api/openapi_server/configs/mock_aws.py | 33 ++++++++++++++++++-- api/openapi_server/repositories/user_repo.py | 3 ++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/api/openapi_server/__main__.py b/api/openapi_server/__main__.py index 37b24196..de1e1dbf 100644 --- a/api/openapi_server/__main__.py +++ b/api/openapi_server/__main__.py @@ -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() @@ -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 diff --git a/api/openapi_server/configs/mock_aws.py b/api/openapi_server/configs/mock_aws.py index 387080a3..e1d7d7f9 100644 --- a/api/openapi_server/configs/mock_aws.py +++ b/api/openapi_server/configs/mock_aws.py @@ -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(): ''' @@ -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 @@ -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() \ No newline at end of file + self.stop() + return self \ No newline at end of file diff --git a/api/openapi_server/repositories/user_repo.py b/api/openapi_server/repositories/user_repo.py index 25eb8466..5904396e 100644 --- a/api/openapi_server/repositories/user_repo.py +++ b/api/openapi_server/repositories/user_repo.py @@ -33,6 +33,9 @@ def delete_user(self, user_id: int) -> bool: def get_user(self, email: str) -> User: return self.session.query(User).filter_by(email=email).first() + def get_all_users(self) -> List[User]: + return self.session.query(User).all() + def get_user_id(self, email: str) -> int: return self.session.query(User).filter_by(email=email).first().id From 5ed5252146cdd4f342d74dda6c841d9ecdd37b0e Mon Sep 17 00:00:00 2001 From: aMetallurgist Date: Sun, 16 Jun 2024 16:40:08 -0700 Subject: [PATCH 7/7] Update model README with new data model. --- api/openapi_server/models/README.md | 50 ++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/api/openapi_server/models/README.md b/api/openapi_server/models/README.md index 7ddac734..3a1ddd75 100644 --- a/api/openapi_server/models/README.md +++ b/api/openapi_server/models/README.md @@ -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 @@ -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 ```