Skip to content

Commit

Permalink
Modified: form config on API call, not at runtime (#37)
Browse files Browse the repository at this point in the history
  • Loading branch information
signebedi committed Mar 23, 2024
1 parent ef899bc commit 587de56
Show file tree
Hide file tree
Showing 3 changed files with 90 additions and 27 deletions.
22 changes: 10 additions & 12 deletions libreforms_fastapi/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 17 additions & 6 deletions libreforms_fastapi/utils/document_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'):
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)

Expand All @@ -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()
Expand All @@ -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))

Expand All @@ -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)
Expand Down
72 changes: 63 additions & 9 deletions libreforms_fastapi/utils/pydantic_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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.
Expand Down

0 comments on commit 587de56

Please sign in to comment.