From 587de563eb0464193a4667f29bdac92dd4f34460 Mon Sep 17 00:00:00 2001 From: signebedi Date: Sat, 23 Mar 2024 14:32:55 -0500 Subject: [PATCH] Modified: form config on API call, not at runtime (#37) --- libreforms_fastapi/app/__init__.py | 22 +++--- libreforms_fastapi/utils/document_database.py | 23 ++++-- libreforms_fastapi/utils/pydantic_models.py | 72 ++++++++++++++++--- 3 files changed, 90 insertions(+), 27 deletions(-) diff --git a/libreforms_fastapi/app/__init__.py b/libreforms_fastapi/app/__init__.py index 3f0ad5a..fe10736 100644 --- a/libreforms_fastapi/app/__init__.py +++ b/libreforms_fastapi/app/__init__.py @@ -67,9 +67,11 @@ ) from libreforms_fastapi.utils.pydantic_models import ( - example_form_config, - generate_html_form, - generate_pydantic_models, + # example_form_config, + # generate_html_form, + # generate_pydantic_models, + get_form_config, + get_form_names, CreateUserRequest, ) @@ -207,16 +209,12 @@ async def start_test_logger(): logger.info('Relational database has been initialized') -# Yield the pydantic form model -form_config = example_form_config -FormModels = generate_pydantic_models(form_config) - # Initialize the document database if config.MONGODB_ENABLED: - DocumentDatabase = ManageMongoDB(config=form_config, timezone=config.TIMEZONE, env=config.ENVIRONMENT) + DocumentDatabase = ManageMongoDB(form_names_callable=get_form_names, timezone=config.TIMEZONE, env=config.ENVIRONMENT) logger.info('MongoDB has been initialized') else: - DocumentDatabase = ManageTinyDB(config=form_config, timezone=config.TIMEZONE, env=config.ENVIRONMENT) + DocumentDatabase = ManageTinyDB(form_names_callable=get_form_names, timezone=config.TIMEZONE, env=config.ENVIRONMENT) logger.info('TinyDB has been initialized') # Here we define an API key header for the api view functions. @@ -359,11 +357,11 @@ async def verify_key_details(key: str = Depends(X_API_KEY)): @app.post("/api/form/create/{form_name}", dependencies=[Depends(api_key_auth)]) async def api_form_create(form_name: str, background_tasks: BackgroundTasks, request: Request, session: SessionLocal = Depends(get_db), key: str = Depends(X_API_KEY), body: Dict = Body(...)): - if form_name not in form_config: + if form_name not in get_form_names(): raise HTTPException(status_code=404, detail=f"Form '{form_name}' not found") - # Pull this form model from the list of available models - FormModel = FormModels[form_name] + # Yield the pydantic form model + FormModel = get_form_config(form_name=form_name) # # Here we validate and coerce data into its proper type form_data = FormModel.model_validate(body) diff --git a/libreforms_fastapi/utils/document_database.py b/libreforms_fastapi/utils/document_database.py index 87f0b5a..bfd254c 100644 --- a/libreforms_fastapi/utils/document_database.py +++ b/libreforms_fastapi/utils/document_database.py @@ -144,8 +144,8 @@ def __init__(self, form_name): super().__init__(message) class ManageDocumentDB(ABC): - def __init__(self, config: dict, timezone: ZoneInfo): - self.config = config + def __init__(self, form_names_callable, timezone: ZoneInfo): + self.form_names_callable = form_names_callable # Set default log_name if not already set by a subclass if not hasattr(self, 'log_name'): @@ -176,6 +176,11 @@ def _initialize_database_collections(self): """Establishes database instances / collections for each form.""" pass + # @abstractmethod + # def _update_database_collections(self, form_names_callable): + # """Idempotent method to update available collections.""" + # pass + @abstractmethod def _check_form_exists(self, form_name:str): """Checks if the form exists in the configuration.""" @@ -238,7 +243,7 @@ def restore_database_from_backup(self, form_name:str, backup_filename:str, backu class ManageTinyDB(ManageDocumentDB): - def __init__(self, config: dict, timezone: ZoneInfo, db_path: str = "instance/", use_logger=True, env="development"): + def __init__(self, form_names_callable, timezone: ZoneInfo, db_path: str = "instance/", use_logger=True, env="development"): self.db_path = db_path os.makedirs(self.db_path, exist_ok=True) @@ -252,7 +257,7 @@ def __init__(self, config: dict, timezone: ZoneInfo, db_path: str = "instance/", namespace=self.log_name ) - super().__init__(config, timezone) + super().__init__(form_names_callable, timezone) # Here we create a Query object to ship with the class self.Form = Query() @@ -262,7 +267,7 @@ def _initialize_database_collections(self): """Establishes database instances for each form.""" # Initialize databases self.databases = {} - for form_name in self.config.keys(): + for form_name in self.form_names_callable(): # self.databases[form_name] = TinyDB(self._get_db_path(form_name)) self.databases[form_name] = CustomTinyDB(self._get_db_path(form_name)) @@ -272,9 +277,15 @@ def _get_db_path(self, form_name:str): def _check_form_exists(self, form_name:str): """Checks if the form exists in the configuration.""" - if form_name not in self.config: + if form_name not in self.form_names_callable(): raise CollectionDoesNotExist(form_name) + # If a form name is found in the callable but not in the collections, reinitialize. + # This probably means there has been a change to the form config. This class should + # be able to work even when configuration data changes. + if form_name not in self.databases.keys(): + self._initialize_database_collections() + def create_document(self, form_name:str, json_data, metadata={}): """Adds json data to the specified form's database.""" self._check_form_exists(form_name) diff --git a/libreforms_fastapi/utils/pydantic_models.py b/libreforms_fastapi/utils/pydantic_models.py index 8affde6..967285a 100644 --- a/libreforms_fastapi/utils/pydantic_models.py +++ b/libreforms_fastapi/utils/pydantic_models.py @@ -34,9 +34,6 @@ class PasswordMatchException(Exception): class CreateUserRequest(BaseModel): - # username: constr(pattern=config.USERNAME_REGEX) - # password: constr(pattern=config.PASSWORD_REGEX) - username: str = Field(..., min_length=2, max_length=100) password: str = Field(..., min_length=8) verify_password: str = Field(..., min_length=8) @@ -64,12 +61,6 @@ def passwords_match(cls, data: Any) -> Any: raise ValueError('Passwords do not match') return data - # def _passwords_match(self): - # if password == verify_password: - # # raise ValueError("Passwords do not match") - # return True - # return False - # Example form configuration with default values set example_form_config = { "example_form": { @@ -282,6 +273,69 @@ class Config: return models + + +def get_form_names(config_path=config.FORM_CONFIG_PATH): + """ + Given a form config path, return a list of available forms, defaulting to the example + dictionary provided above. + """ + # Try to open config_path and if not existent or empty, use example config + form_config = example_form_config # Default to example_form_config + + if os.path.exists(config_path): + try: + with open(config_path, 'r') as file: + form_config = json.load(file) + except json.JSONDecodeError: + pass + # print("Failed to load the JSON file. Falling back to the default configuration.") + else: + pass + # print("Config file does not exist. Using the default configuration.") + return form_config.keys() + +def get_form_config(form_name, config_path=config.FORM_CONFIG_PATH): + + # Try to open config_path and if not existent or empty, use example config + form_config = example_form_config # Default to example_form_config + + if os.path.exists(config_path): + try: + with open(config_path, 'r') as file: + form_config = json.load(file) + except json.JSONDecodeError: + pass + # print("Failed to load the JSON file. Falling back to the default configuration.") + else: + pass + # print("Config file does not exist. Using the default configuration.") + + if form_name not in form_config: + raise Exception(f"Form '{form_name}' not found in") + + fields = form_config[form_name] + field_definitions = {} + + for field_name, field_info in fields.items(): + python_type: Type = field_info["output_type"] + default = field_info.get("default", ...) + + # Ensure Optional is always used with a specific type + if default is ... and python_type != Optional: + python_type = Optional[python_type] + + field_definitions[field_name] = (python_type, default) + + # Creating the model dynamically, allowing arbitrary types + class Config: + arbitrary_types_allowed = True + + model = create_model(form_name, __config__=Config, **field_definitions) + + return model + + def __reconstruct_form_data(request, form_fields): """ This repackages request data into a format that pydantic will be able to understand.