From 340aa7e3ff613192cc6c9d1140138edae1e51d27 Mon Sep 17 00:00:00 2001 From: Steve Chen Date: Wed, 19 Mar 2025 20:34:52 -0700 Subject: [PATCH 001/109] Story2_temp --- app/clients/service/ml_models.py | 21 +++++++++++++++++++++ app/clients/service/ml_models_router.py | 21 +++++++++++++++++++++ app/main.py | 2 ++ 3 files changed, 44 insertions(+) create mode 100644 app/clients/service/ml_models.py create mode 100644 app/clients/service/ml_models_router.py diff --git a/app/clients/service/ml_models.py b/app/clients/service/ml_models.py new file mode 100644 index 00000000..074a56bf --- /dev/null +++ b/app/clients/service/ml_models.py @@ -0,0 +1,21 @@ +class MLModels: + current_model = "Random Forest" + """List of available ml models""" + available_models = ["Random Forest"] + + @staticmethod + def get_current_model(): + """Get the current active ml model""" + return MLModels.current_model + + @staticmethod + def list_available_models(): + return MLModels.available_models + + @staticmethod + def switch_model(model_name: str): + """Switch the current ml model""" + if model_name in MLModels.available_models: + MLModels.current_model = model_name + return True + return False \ No newline at end of file diff --git a/app/clients/service/ml_models_router.py b/app/clients/service/ml_models_router.py new file mode 100644 index 00000000..e96de76d --- /dev/null +++ b/app/clients/service/ml_models_router.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, HTTPException +from app.clients.service.ml_models import MLModels + +router = APIRouter(prefix="/ml_models") + +@router.get("/list") +def list_models(): + models = MLModels.list_available_models() + return {"models": models} + +@router.post("/switch/{model_name}") +def switch_models(model_name: str): + success = MLModels.switch_model(model_name) + if not success: + raise HTTPException(status_code=400, detail="Model switch failed") + return {"message": f"Model switched to {model_name}"} + +@router.get("/current") +def current_model(): + model = MLModels.get_current_model() + return {"current_model": model} diff --git a/app/main.py b/app/main.py index a8e8fa7f..adf1af76 100644 --- a/app/main.py +++ b/app/main.py @@ -9,6 +9,7 @@ from app.database import engine from app.clients.router import router as clients_router from app.auth.router import router as auth_router +from app.clients.service.ml_models_router import router as ml_models_router from fastapi.middleware.cors import CORSMiddleware # Initialize database tables @@ -20,6 +21,7 @@ # Include routers app.include_router(auth_router) app.include_router(clients_router) +app.include_router(ml_models_router) # Configure CORS middleware app.add_middleware( From f41a9e0673c1c433dbb9c9d70397adf8f1b09d43 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 20 Mar 2025 12:27:51 -0700 Subject: [PATCH 002/109] Add ability to train/save/load three different model types --- app/clients/service/ml_models.py | 2 +- app/clients/service/model.py | 97 +++++++++++++++--- .../model_Linear_Regression.pkl | Bin 0 -> 899 bytes .../model_Random_Forest_Regressor.pkl | Bin 0 -> 749557 bytes .../model_Support_Vector_Machine.pkl | Bin 0 -> 29888 bytes 5 files changed, 82 insertions(+), 17 deletions(-) create mode 100644 app/clients/service/pretrained_models/model_Linear_Regression.pkl create mode 100644 app/clients/service/pretrained_models/model_Random_Forest_Regressor.pkl create mode 100644 app/clients/service/pretrained_models/model_Support_Vector_Machine.pkl diff --git a/app/clients/service/ml_models.py b/app/clients/service/ml_models.py index 074a56bf..129ab6e2 100644 --- a/app/clients/service/ml_models.py +++ b/app/clients/service/ml_models.py @@ -1,7 +1,7 @@ class MLModels: current_model = "Random Forest" """List of available ml models""" - available_models = ["Random Forest"] + available_models = ["Linear Regression", "Random Forest Regressor", "Support Vector Machine"] @staticmethod def get_current_model(): diff --git a/app/clients/service/model.py b/app/clients/service/model.py index b2406370..16097276 100644 --- a/app/clients/service/model.py +++ b/app/clients/service/model.py @@ -1,6 +1,7 @@ """ Model training module for the Common Assessment Tool. Handles the preparation, training, and saving of the prediction model. +Pass in model name via command line """ # Standard library imports @@ -9,12 +10,24 @@ # Third-party imports import numpy as np import pandas as pd +import sys +import os from sklearn.model_selection import train_test_split +from sklearn.linear_model import LinearRegression from sklearn.ensemble import RandomForestRegressor +from sklearn import svm -def prepare_models(): +# Local imports +from ml_models import MLModels + +default_unformatted_model_path = "pretrained_models" + os.sep + "model_{}.pkl" + +def prepare_model_data(test_size=0.2, random_state=42): """ Prepare and train the Random Forest model using the dataset. + Args: + test_size: The percent of the dataset to use as test data (rest will be used as train data) + random_state: The random state to generate train/test split with Returns: RandomForestRegressor: Trained model for predicting success rates @@ -64,47 +77,99 @@ def prepare_models(): features = np.array(data[all_features]) # Changed from X to features targets = np.array(data['success_rate']) # Changed from y to targets # Split the dataset - features_train, _, targets_train, _ = train_test_split( # Removed unused variables + X_train, x_test, Y_train, y_test = train_test_split( # Removed unused variables features, targets, - test_size=0.2, - random_state=42 + test_size=test_size, + random_state=random_state ) - # Initialize and train the model - model = RandomForestRegressor(n_estimators=100, random_state=42) - model.fit(features_train, targets_train) + + return X_train, x_test, Y_train, y_test + + +def train_model(X_train, Y_train, model_type, n_estimators=100, random_state=42): + """ + Trains the model + Args: + X_train: Training features + targets_train: Target features + Y_train: Which model to create + n_estimators: Number estimators (for random forest) + random_state: Random state to train with (for random forest) + + Returns: A trained model of the type specified + + """ + # TODO the way model is selected should be improved + # Instantiate machine learning model + if model_type == MLModels.available_models[0]: # Linear regression + model = LinearRegression() + + elif model_type == MLModels.available_models[1]: # Random forest + model = RandomForestRegressor(n_estimators=n_estimators, random_state=random_state) + + elif model_type == MLModels.available_models[2]: # Support Vector Machine + model = svm.SVR() + + else: # Invalid model passed in + print("ERROR! Invalid model passed in. Exiting...") + sys.exit(-1) + + # Fit model with training data + model.fit(X_train, Y_train) return model -def save_model(model, filename="model.pkl"): +def get_true_file_name(model_type, filename): + """ + Takes a model type and file name, formats model type, and replaces spaces with underscores + Args: + model_type: The model type as a String + filename: The file name (should follow 'model_{}.pkl' format) + + Returns: The clean file name + """ + return filename.format(model_type).replace(" ", "_") + + +def save_model(model, model_type, filename=default_unformatted_model_path): """ Save the trained model to a file. Args: model: Trained model to save + model_type: The type of model being saved filename (str): Name of the file to save the model to """ - with open(filename, "wb") as model_file: + true_file_name = get_true_file_name(model_type, filename) + with open(true_file_name, "wb") as model_file: pickle.dump(model, model_file) -def load_model(filename="model.pkl"): +def load_model(model_type, filename=default_unformatted_model_path): """ Load a trained model from a file. Args: + model_type: The type of model being loaded filename (str): Name of the file to load the model from Returns: The loaded model """ - with open(filename, "rb") as model_file: + true_file_name = get_true_file_name(model_type, filename) + with open(true_file_name, "rb") as model_file: return pickle.load(model_file) -def main(): +def main(argv): """Main function to train and save the model.""" - print("Starting model training...") - model = prepare_models() - save_model(model) + # Get the model type from the command line arguments + model_type = argv[1] + + # Train and save the model + print("Starting model training for {} model...".format(model_type)) + X_train, x_test, Y_train, y_test = prepare_model_data() + model = train_model(X_train, Y_train, model_type) + save_model(model, model_type) print("Model training completed and saved successfully.") if __name__ == "__main__": - main() + main(sys.argv) diff --git a/app/clients/service/pretrained_models/model_Linear_Regression.pkl b/app/clients/service/pretrained_models/model_Linear_Regression.pkl new file mode 100644 index 0000000000000000000000000000000000000000..d264f1b9ca1b479c78f1402a65ee1b1c7988be85 GIT binary patch literal 899 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 OckQ0Kd)kp53;qvv3hZ$J literal 0 HcmV?d00001 diff --git a/app/clients/service/pretrained_models/model_Support_Vector_Machine.pkl b/app/clients/service/pretrained_models/model_Support_Vector_Machine.pkl new file mode 100644 index 0000000000000000000000000000000000000000..999b824c1113e91c153c6333a6813dd438685c89 GIT binary patch literal 29888 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 P)2ALi_3-S*#Z&(aZOEUp literal 0 HcmV?d00001 From 36445eeb8205cd4166345f068dc4e241b1d0ecc4 Mon Sep 17 00:00:00 2001 From: Fran Date: Sun, 23 Mar 2025 11:19:24 -0700 Subject: [PATCH 003/109] fixed client service --- app/clients/service/client_service.py | 193 ++++++++++++++++++++---- app/clients/service/ml_models.py | 39 ++--- app/clients/service/ml_models_router.py | 19 ++- 3 files changed, 197 insertions(+), 54 deletions(-) diff --git a/app/clients/service/client_service.py b/app/clients/service/client_service.py index 86c3ef4a..c743acb4 100644 --- a/app/clients/service/client_service.py +++ b/app/clients/service/client_service.py @@ -3,6 +3,7 @@ Provides CRUD operations and business logic for client management. """ +from abc import ABC, abstractmethod from sqlalchemy.orm import Session from sqlalchemy import and_ from fastapi import HTTPException, status @@ -10,7 +11,76 @@ from app.models import Client, ClientCase, User from app.clients.schema import ClientUpdate, ServiceUpdate, ServiceResponse -class ClientService: + +class InterfaceClientQueryService(ABC): + """Interface for client query operations""" + @abstractmethod + def get_client(self, db: Session, client_id: int) -> Client: + """Get a specific client by ID""" + pass + + @abstractmethod + def get_clients(self, db: Session, skip: int, limit: int) -> Dict[str, Any]: + """ Get clients with optional pagination.""" + pass + + @abstractmethod + def get_clients_by_criteria(self, db: Session, **criteria) -> List[Client]: + """Get clients filtered by any combination of criteria""" + pass + + @abstractmethod + def get_clients_by_services(self, db: Session, **service_filters) -> \ + List[Client]: + """ Get clients filtered by multiple service statuses.""" + + pass + + @abstractmethod + def get_client_services(self, db: Session, client_id: int) -> List[ + ClientCase]: + pass + + @abstractmethod + def get_clients_by_success_rate(self, db: Session, min_rate: int) -> List[ + Client]: + pass + + @abstractmethod + def get_clients_by_case_worker(self, db: Session, case_worker_id: int) -> \ + List[Client]: + pass + + +class InterfaceClientManagementService(ABC): + """Interface for client management operations""" + @abstractmethod + def update_client(self, db: Session, client_id: int, + client_update: ClientUpdate) -> ClientUpdate: + """Update a client's information""" + pass + + @abstractmethod + def update_client_services(self, db: Session, client_id: int, user_id: int, + service_update: ServiceUpdate) -> ClientCase: + """Update a client's services and outcomes for a specific caseworker""" + pass + + @abstractmethod + def create_case_assignment(self, db: Session, client_id: int, + case_worker_id: int) -> ClientCase: + """Create a new case assignment""" + pass + + @abstractmethod + def delete_client(self, db: Session, client_id: int) -> None: + """Delete a client and their associated records""" + pass + + +class ClientQueryService(InterfaceClientQueryService): + """Implementation of client query service""" + @staticmethod def get_client(db: Session, client_id: int): """Get a specific client by ID""" @@ -38,7 +108,7 @@ def get_clients(db: Session, skip: int = 0, limit: int = 50): status_code=status.HTTP_400_BAD_REQUEST, detail="Limit must be greater than 0" ) - + clients = db.query(Client).offset(skip).limit(limit).all() total = db.query(Client).count() return {"clients": clients, "total": total} @@ -73,13 +143,13 @@ def get_clients_by_criteria( ): """Get clients filtered by any combination of criteria""" query = db.query(Client) - + if education_level is not None and not (1 <= education_level <= 14): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Education level must be between 1 and 14" ) - + if age_min is not None and age_min < 18: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -114,17 +184,21 @@ def get_clients_by_criteria( if fluent_english is not None: query = query.filter(Client.fluent_english == fluent_english) if reading_english_scale is not None: - query = query.filter(Client.reading_english_scale == reading_english_scale) + query = query.filter( + Client.reading_english_scale == reading_english_scale) if speaking_english_scale is not None: - query = query.filter(Client.speaking_english_scale == speaking_english_scale) + query = query.filter( + Client.speaking_english_scale == speaking_english_scale) if writing_english_scale is not None: - query = query.filter(Client.writing_english_scale == writing_english_scale) + query = query.filter( + Client.writing_english_scale == writing_english_scale) if numeracy_scale is not None: query = query.filter(Client.numeracy_scale == numeracy_scale) if computer_scale is not None: query = query.filter(Client.computer_scale == computer_scale) if transportation_bool is not None: - query = query.filter(Client.transportation_bool == transportation_bool) + query = query.filter( + Client.transportation_bool == transportation_bool) if caregiver_bool is not None: query = query.filter(Client.caregiver_bool == caregiver_bool) if housing is not None: @@ -140,7 +214,8 @@ def get_clients_by_criteria( if time_unemployed is not None: query = query.filter(Client.time_unemployed == time_unemployed) if need_mental_health_support_bool is not None: - query = query.filter(Client.need_mental_health_support_bool == need_mental_health_support_bool) + query = query.filter( + Client.need_mental_health_support_bool == need_mental_health_support_bool) try: return query.all() @@ -159,12 +234,12 @@ def get_clients_by_services( Get clients filtered by multiple service statuses. """ query = db.query(Client).join(ClientCase) - + for service_name, status in service_filters.items(): if status is not None: filter_criteria = getattr(ClientCase, service_name) == status query = query.filter(filter_criteria) - + try: return query.all() except Exception as e: @@ -175,8 +250,9 @@ def get_clients_by_services( @staticmethod def get_client_services(db: Session, client_id: int): - """Get all services for a specific client with case worker info""" - client_cases = db.query(ClientCase).filter(ClientCase.client_id == client_id).all() + """Get all services for a specific client with caseworker info""" + client_cases = db.query(ClientCase).filter( + ClientCase.client_id == client_id).all() if not client_cases: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -192,7 +268,7 @@ def get_clients_by_success_rate(db: Session, min_rate: int = 70): status_code=status.HTTP_400_BAD_REQUEST, detail="Success rate must be between 0 and 100" ) - + return db.query(Client).join(ClientCase).filter( ClientCase.success_rate >= min_rate ).all() @@ -206,11 +282,15 @@ def get_clients_by_case_worker(db: Session, case_worker_id: int): status_code=status.HTTP_404_NOT_FOUND, detail=f"Case worker with id {case_worker_id} not found" ) - + return db.query(Client).join(ClientCase).filter( ClientCase.user_id == case_worker_id ).all() + +class ClientManagementService(InterfaceClientManagementService): + """Implementation of client management service""" + @staticmethod def update_client(db: Session, client_id: int, client_update: ClientUpdate): """Update a client's information""" @@ -235,10 +315,10 @@ def update_client(db: Session, client_id: int, client_update: ClientUpdate): status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update client: {str(e)}" ) - + @staticmethod def update_client_services( - db: Session, + db: Session, client_id: int, user_id: int, service_update: ServiceUpdate @@ -248,12 +328,12 @@ def update_client_services( ClientCase.client_id == client_id, ClientCase.user_id == user_id ).first() - + if not client_case: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"No case found for client {client_id} with case worker {user_id}. " - f"Cannot update services for a non-existent case assignment." + f"Cannot update services for a non-existent case assignment." ) update_data = service_update.dict(exclude_unset=True) @@ -270,10 +350,10 @@ def update_client_services( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update client services: {str(e)}" ) - + @staticmethod def create_case_assignment( - db: Session, + db: Session, client_id: int, case_worker_id: int ): @@ -286,7 +366,7 @@ def create_case_assignment( detail=f"Client with id {client_id} not found" ) - # Check if case worker exists + # Check if caseworker exists case_worker = db.query(User).filter(User.id == case_worker_id).first() if not case_worker: raise HTTPException( @@ -299,12 +379,12 @@ def create_case_assignment( ClientCase.client_id == client_id, ClientCase.user_id == case_worker_id ).first() - + if existing_case: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Client {client_id} already has a case assigned to case worker {case_worker_id}" - ) + ) try: # Create new case assignment with default service values @@ -331,7 +411,7 @@ def create_case_assignment( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to create case assignment: {str(e)}" ) - + @staticmethod def delete_client(db: Session, client_id: int): """Delete a client and their associated records""" @@ -348,14 +428,73 @@ def delete_client(db: Session, client_id: int): db.query(ClientCase).filter( ClientCase.client_id == client_id ).delete() - + # Delete the client db.delete(client) db.commit() - + except Exception as e: db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to delete client: {str(e)}" ) + + +class ClientService: + """ + Facade that maintains backward compatibility with the existing router. + Delegates to specialized service classes. + """ + + # Query methods + @staticmethod + def get_client(db: Session, client_id: int): + return ClientQueryService.get_client(db, client_id) + + @staticmethod + def get_clients(db: Session, skip: int = 0, limit: int = 50): + return ClientQueryService.get_clients(db, skip, limit) + + @staticmethod + def get_clients_by_criteria(db: Session, **criteria): + return ClientQueryService.get_clients_by_criteria(db, **criteria) + + @staticmethod + def get_clients_by_services(db: Session, **service_filters): + return ClientQueryService.get_clients_by_services(db, **service_filters) + + @staticmethod + def get_client_services(db: Session, client_id: int): + return ClientQueryService.get_client_services(db, client_id) + + @staticmethod + def get_clients_by_success_rate(db: Session, min_rate: int = 70): + return ClientQueryService.get_clients_by_success_rate(db, min_rate) + + @staticmethod + def get_clients_by_case_worker(db: Session, case_worker_id: int): + return ClientQueryService.get_clients_by_case_worker(db, case_worker_id) + + # Modification methods + @staticmethod + def update_client(db: Session, client_id: int, client_update: ClientUpdate): + return ClientManagementService.update_client(db, client_id, + client_update) + + @staticmethod + def update_client_services(db: Session, client_id: int, user_id: int, + service_update: ServiceUpdate): + return ClientManagementService.update_client_services(db, client_id, + user_id, + service_update) + + @staticmethod + def create_case_assignment(db: Session, client_id: int, + case_worker_id: int): + return ClientManagementService.create_case_assignment(db, client_id, + case_worker_id) + + @staticmethod + def delete_client(db: Session, client_id: int): + return ClientManagementService.delete_client(db, client_id) diff --git a/app/clients/service/ml_models.py b/app/clients/service/ml_models.py index 129ab6e2..7fb16fa2 100644 --- a/app/clients/service/ml_models.py +++ b/app/clients/service/ml_models.py @@ -1,21 +1,22 @@ class MLModels: - current_model = "Random Forest" - """List of available ml models""" - available_models = ["Linear Regression", "Random Forest Regressor", "Support Vector Machine"] + current_model = "Random Forest" + """List of available ml models""" + available_models = ["Linear Regression", "Random Forest Regressor", + "Support Vector Machine"] - @staticmethod - def get_current_model(): - """Get the current active ml model""" - return MLModels.current_model - - @staticmethod - def list_available_models(): - return MLModels.available_models - - @staticmethod - def switch_model(model_name: str): - """Switch the current ml model""" - if model_name in MLModels.available_models: - MLModels.current_model = model_name - return True - return False \ No newline at end of file + @staticmethod + def get_current_model(): + """Get the current active ml model""" + return MLModels.current_model + + @staticmethod + def list_available_models(): + return MLModels.available_models + + @staticmethod + def switch_model(model_name: str): + """Switch the current ml model""" + if model_name in MLModels.available_models: + MLModels.current_model = model_name + return True + return False diff --git a/app/clients/service/ml_models_router.py b/app/clients/service/ml_models_router.py index e96de76d..476f2b1e 100644 --- a/app/clients/service/ml_models_router.py +++ b/app/clients/service/ml_models_router.py @@ -3,19 +3,22 @@ router = APIRouter(prefix="/ml_models") + @router.get("/list") def list_models(): - models = MLModels.list_available_models() - return {"models": models} + models = MLModels.list_available_models() + return {"models": models} + @router.post("/switch/{model_name}") def switch_models(model_name: str): - success = MLModels.switch_model(model_name) - if not success: - raise HTTPException(status_code=400, detail="Model switch failed") - return {"message": f"Model switched to {model_name}"} + success = MLModels.switch_model(model_name) + if not success: + raise HTTPException(status_code=400, detail="Model switch failed") + return {"message": f"Model switched to {model_name}"} + @router.get("/current") def current_model(): - model = MLModels.get_current_model() - return {"current_model": model} + model = MLModels.get_current_model() + return {"current_model": model} From 5afc84e0f0d8953c6f4a72a71c8e333909a362c9 Mon Sep 17 00:00:00 2001 From: Fran Date: Sun, 23 Mar 2025 11:47:42 -0700 Subject: [PATCH 004/109] fixed ml model solid principle --- app/clients/service/ml_models.py | 72 +++++++++++++++++++------ app/clients/service/ml_models_router.py | 17 +++--- 2 files changed, 64 insertions(+), 25 deletions(-) diff --git a/app/clients/service/ml_models.py b/app/clients/service/ml_models.py index 7fb16fa2..7b19d661 100644 --- a/app/clients/service/ml_models.py +++ b/app/clients/service/ml_models.py @@ -1,22 +1,60 @@ -class MLModels: - current_model = "Random Forest" - """List of available ml models""" - available_models = ["Linear Regression", "Random Forest Regressor", - "Support Vector Machine"] - - @staticmethod - def get_current_model(): +from abc import ABC, abstractmethod +from typing import List + + +class InterfaceMLModelRepository(ABC): + """Interface for ML Models storage""" + + @abstractmethod + def list_models(self) -> List[str]: + """Get list of all available models""" + pass + + @abstractmethod + def is_model_available(self, model_name: str) -> bool: + """Check if a model is valid""" + pass + + +class InterfaceMLModelManager(ABC): + """Interface for ML model management""" + + @abstractmethod + def get_current_model(self) -> str: """Get the current active ml model""" - return MLModels.current_model + pass - @staticmethod - def list_available_models(): - return MLModels.available_models + @abstractmethod + def switch_model(self, model_name: str) -> bool: + """Switch between models""" + pass - @staticmethod - def switch_model(model_name: str): - """Switch the current ml model""" - if model_name in MLModels.available_models: - MLModels.current_model = model_name + +class MLModelRepository(InterfaceMLModelRepository): + def __init__(self): + self._available_models = [ + "Linear Regression", + "Random Forest Regressor", + "Support Vector Machine" + ] + + def list_models(self) -> List[str]: + return self._available_models + + def is_model_available(self, model_name: str) -> bool: + return model_name in self._available_models + +class MLModelManager(InterfaceMLModelManager): + def __init__(self, repository: InterfaceMLModelRepository): + self._repository = repository + self._current_model = "Random Forest Regressor" + + def get_current_model(self) -> str: + return self._current_model + + def switch_model(self, model_name: str) -> bool: + if self._repository.is_model_available(model_name): + self._current_model = model_name return True return False + diff --git a/app/clients/service/ml_models_router.py b/app/clients/service/ml_models_router.py index 476f2b1e..aa3ec3ec 100644 --- a/app/clients/service/ml_models_router.py +++ b/app/clients/service/ml_models_router.py @@ -1,18 +1,19 @@ from fastapi import APIRouter, HTTPException -from app.clients.service.ml_models import MLModels +from app.clients.service.ml_models import MLModelRepository, MLModelManager router = APIRouter(prefix="/ml_models") - - +model_repository = MLModelRepository() +model_manager = MLModelManager(model_repository) @router.get("/list") def list_models(): - models = MLModels.list_available_models() - return {"models": models} + """List all available ML models""" + return {"models": model_repository.list_models()} @router.post("/switch/{model_name}") def switch_models(model_name: str): - success = MLModels.switch_model(model_name) + """Switch between ML models""" + success = model_manager.switch_model(model_name) if not success: raise HTTPException(status_code=400, detail="Model switch failed") return {"message": f"Model switched to {model_name}"} @@ -20,5 +21,5 @@ def switch_models(model_name: str): @router.get("/current") def current_model(): - model = MLModels.get_current_model() - return {"current_model": model} + """Get the current ML model""" + return {"current_model": model_manager.get_current_model()} From 32cbfbc224df0a049027017e3ffe521ffe11c1dc Mon Sep 17 00:00:00 2001 From: Fran Date: Sun, 23 Mar 2025 12:26:32 -0700 Subject: [PATCH 005/109] fixed ml base models --- app/clients/service/ml_models.py | 115 +++++++++++++++++++++--- app/clients/service/ml_models_router.py | 7 +- app/clients/service/model.py | 97 +++++++++++--------- requirements.txt | 2 +- 4 files changed, 161 insertions(+), 60 deletions(-) diff --git a/app/clients/service/ml_models.py b/app/clients/service/ml_models.py index 7b19d661..4f75209c 100644 --- a/app/clients/service/ml_models.py +++ b/app/clients/service/ml_models.py @@ -1,13 +1,88 @@ from abc import ABC, abstractmethod from typing import List +import numpy as np +from sklearn.linear_model import LinearRegression +from sklearn.ensemble import RandomForestRegressor +from sklearn.svm import SVR + + +class InterfaceBaseMLModel(ABC): + """Interface of a base ML Model""" + + @abstractmethod + def fit(self, X: np.ndarray, y: np.ndarray): + pass + + @abstractmethod + def predict(self, X: np.ndarray) -> np.ndarray: + pass + + def save(self, path: str): + import pickle + with open(path, "wb") as f: + pickle.dump(self, f) + + @staticmethod + def load(path: str): + import pickle + with open(path, "rb") as f: + return pickle.load(f) + + @abstractmethod + def __str__(self) -> str: + """Return the name of the model""" + pass + + +class LinearRegressionModel(InterfaceBaseMLModel): + def __init__(self): + self.model = LinearRegression() + + def fit(self, X, y): + self.model.fit(X, y) + + def predict(self, X): + return self.model.predict(X) + + def __str__(self): + return "Linear Regression" + + +class RandomForestModel(InterfaceBaseMLModel): + def __init__(self, n_estimators=100, random_state=42): + self.model = RandomForestRegressor(n_estimators=n_estimators, + random_state=random_state) + + def fit(self, X, y): + self.model.fit(X, y) + + def predict(self, X): + return self.model.predict(X) + + def __str__(self): + return "Random Forest Regressor" + + +class SVMModel(InterfaceBaseMLModel): + def __init__(self): + self.model = SVR() + + def fit(self, X, y): + self.model.fit(X, y) + + def predict(self, X): + return self.model.predict(X) + + def __str__(self): + return "Support Vector Machine" class InterfaceMLModelRepository(ABC): """Interface for ML Models storage""" @abstractmethod - def list_models(self) -> List[str]: - """Get list of all available models""" + def list_models(self) -> List[InterfaceBaseMLModel]: + """Get list of all available models instances""" pass @abstractmethod @@ -15,12 +90,17 @@ def is_model_available(self, model_name: str) -> bool: """Check if a model is valid""" pass + @abstractmethod + def get_model_instance(self, model_name: str) -> InterfaceBaseMLModel: + """Return an instance of the requested model""" + pass + class InterfaceMLModelManager(ABC): """Interface for ML model management""" @abstractmethod - def get_current_model(self) -> str: + def get_current_model(self) -> InterfaceBaseMLModel: """Get the current active ml model""" pass @@ -32,29 +112,36 @@ def switch_model(self, model_name: str) -> bool: class MLModelRepository(InterfaceMLModelRepository): def __init__(self): - self._available_models = [ - "Linear Regression", - "Random Forest Regressor", - "Support Vector Machine" - ] + self._model_map = { + "Linear Regression": LinearRegressionModel, + "Random Forest Regressor": RandomForestModel, + "Support Vector Machine": SVMModel + } - def list_models(self) -> List[str]: - return self._available_models + def list_models(self) -> List[InterfaceBaseMLModel]: + return [model_class() for model_class in self._model_map.values()] def is_model_available(self, model_name: str) -> bool: - return model_name in self._available_models + return model_name in self._model_map + + def get_model_instance(self, model_name: str) -> InterfaceBaseMLModel: + if not self.is_model_available(model_name): + raise ValueError(f"Model '{model_name}' is not available.") + return self._model_map[model_name]() + class MLModelManager(InterfaceMLModelManager): def __init__(self, repository: InterfaceMLModelRepository): self._repository = repository - self._current_model = "Random Forest Regressor" + self._current_model = repository.get_model_instance( + "Random Forest Regressor") def get_current_model(self) -> str: return self._current_model def switch_model(self, model_name: str) -> bool: if self._repository.is_model_available(model_name): - self._current_model = model_name + self._current_model = self._repository.get_model_instance( + model_name) return True return False - diff --git a/app/clients/service/ml_models_router.py b/app/clients/service/ml_models_router.py index aa3ec3ec..f9ffe659 100644 --- a/app/clients/service/ml_models_router.py +++ b/app/clients/service/ml_models_router.py @@ -7,8 +7,8 @@ @router.get("/list") def list_models(): """List all available ML models""" - return {"models": model_repository.list_models()} - + # return {"models": model_repository.list_models()} + return {"models": [str(model) for model in model_repository.list_models()]} @router.post("/switch/{model_name}") def switch_models(model_name: str): @@ -22,4 +22,5 @@ def switch_models(model_name: str): @router.get("/current") def current_model(): """Get the current ML model""" - return {"current_model": model_manager.get_current_model()} + # return {"current_model": model_manager.get_current_model()} + return {"current_model": str(model_manager.get_current_model())} \ No newline at end of file diff --git a/app/clients/service/model.py b/app/clients/service/model.py index 16097276..973c03d5 100644 --- a/app/clients/service/model.py +++ b/app/clients/service/model.py @@ -18,10 +18,32 @@ from sklearn import svm # Local imports -from ml_models import MLModels +from ml_models import InterfaceBaseMLModel, MLModelRepository, \ + LinearRegressionModel, RandomForestModel, SVMModel + +repo = MLModelRepository() default_unformatted_model_path = "pretrained_models" + os.sep + "model_{}.pkl" + +def get_model_by_name(model_type: str, n_estimators=100, + random_state=42) -> InterfaceBaseMLModel: + model_map = { + "Linear Regression": LinearRegressionModel, + "Random Forest Regressor": lambda: RandomForestModel(n_estimators, + random_state), + "Support Vector Machine": SVMModel + } + + if model_type not in model_map: + print(f"ERROR! Invalid model type '{model_type}' passed in.") + print(f"Available models: {repo.list_models()}") + sys.exit(-1) + + constructor = model_map[model_type] + return constructor() if callable(constructor) else constructor() + + def prepare_model_data(test_size=0.2, random_state=42): """ Prepare and train the Random Forest model using the dataset. @@ -36,29 +58,29 @@ def prepare_model_data(test_size=0.2, random_state=42): data = pd.read_csv('data_commontool.csv') # Define feature columns feature_columns = [ - 'age', # Client's age - 'gender', # Client's gender (bool) - 'work_experience', # Years of work experience - 'canada_workex', # Years of work experience in Canada - 'dep_num', # Number of dependents - 'canada_born', # Born in Canada - 'citizen_status', # Citizenship status - 'level_of_schooling', # Highest level achieved (1-14) - 'fluent_english', # English fluency scale (1-10) - 'reading_english_scale', # Reading ability scale (1-10) - 'speaking_english_scale',# Speaking ability scale (1-10) - 'writing_english_scale', # Writing ability scale (1-10) - 'numeracy_scale', # Numeracy ability scale (1-10) - 'computer_scale', # Computer proficiency scale (1-10) - 'transportation_bool', # Needs transportation support (bool) - 'caregiver_bool', # Is primary caregiver (bool) - 'housing', # Housing situation (1-10) - 'income_source', # Source of income (1-10) - 'felony_bool', # Has a felony (bool) - 'attending_school', # Currently a student (bool) - 'currently_employed', # Currently employed (bool) - 'substance_use', # Substance use disorder (bool) - 'time_unemployed', # Years unemployed + 'age', # Client's age + 'gender', # Client's gender (bool) + 'work_experience', # Years of work experience + 'canada_workex', # Years of work experience in Canada + 'dep_num', # Number of dependents + 'canada_born', # Born in Canada + 'citizen_status', # Citizenship status + 'level_of_schooling', # Highest level achieved (1-14) + 'fluent_english', # English fluency scale (1-10) + 'reading_english_scale', # Reading ability scale (1-10) + 'speaking_english_scale', # Speaking ability scale (1-10) + 'writing_english_scale', # Writing ability scale (1-10) + 'numeracy_scale', # Numeracy ability scale (1-10) + 'computer_scale', # Computer proficiency scale (1-10) + 'transportation_bool', # Needs transportation support (bool) + 'caregiver_bool', # Is primary caregiver (bool) + 'housing', # Housing situation (1-10) + 'income_source', # Source of income (1-10) + 'felony_bool', # Has a felony (bool) + 'attending_school', # Currently a student (bool) + 'currently_employed', # Currently employed (bool) + 'substance_use', # Substance use disorder (bool) + 'time_unemployed', # Years unemployed 'need_mental_health_support_bool' # Needs mental health support (bool) ] # Define intervention columns @@ -77,7 +99,8 @@ def prepare_model_data(test_size=0.2, random_state=42): features = np.array(data[all_features]) # Changed from X to features targets = np.array(data['success_rate']) # Changed from y to targets # Split the dataset - X_train, x_test, Y_train, y_test = train_test_split( # Removed unused variables + X_train, x_test, Y_train, y_test = train_test_split( + # Removed unused variables features, targets, test_size=test_size, @@ -87,7 +110,8 @@ def prepare_model_data(test_size=0.2, random_state=42): return X_train, x_test, Y_train, y_test -def train_model(X_train, Y_train, model_type, n_estimators=100, random_state=42): +def train_model(X_train, Y_train, model_type, n_estimators=100, + random_state=42) -> InterfaceBaseMLModel: """ Trains the model Args: @@ -100,25 +124,11 @@ def train_model(X_train, Y_train, model_type, n_estimators=100, random_state=42) Returns: A trained model of the type specified """ - # TODO the way model is selected should be improved - # Instantiate machine learning model - if model_type == MLModels.available_models[0]: # Linear regression - model = LinearRegression() - - elif model_type == MLModels.available_models[1]: # Random forest - model = RandomForestRegressor(n_estimators=n_estimators, random_state=random_state) - - elif model_type == MLModels.available_models[2]: # Support Vector Machine - model = svm.SVR() - - else: # Invalid model passed in - print("ERROR! Invalid model passed in. Exiting...") - sys.exit(-1) - - # Fit model with training data + model = get_model_by_name(model_type, n_estimators, random_state) model.fit(X_train, Y_train) return model + def get_true_file_name(model_type, filename): """ Takes a model type and file name, formats model type, and replaces spaces with underscores @@ -144,6 +154,7 @@ def save_model(model, model_type, filename=default_unformatted_model_path): with open(true_file_name, "wb") as model_file: pickle.dump(model, model_file) + def load_model(model_type, filename=default_unformatted_model_path): """ Load a trained model from a file. @@ -159,6 +170,7 @@ def load_model(model_type, filename=default_unformatted_model_path): with open(true_file_name, "rb") as model_file: return pickle.load(model_file) + def main(argv): """Main function to train and save the model.""" # Get the model type from the command line arguments @@ -171,5 +183,6 @@ def main(argv): save_model(model, model_type) print("Model training completed and saved successfully.") + if __name__ == "__main__": main(sys.argv) diff --git a/requirements.txt b/requirements.txt index 93d35fbf..c5910a3b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -141,4 +141,4 @@ webencodings==0.5.1 websocket-client==1.5.1 websockets==11.0.3 widgetsnbextension==4.0.7 - +mypy=1.15.1 From 9e3567f081f8148dc92ef7b78bbb1d7ed4ed0222 Mon Sep 17 00:00:00 2001 From: Fran Date: Sun, 23 Mar 2025 12:29:50 -0700 Subject: [PATCH 006/109] added black, isort, mypy, pylint --- .pylintrc | 637 ++++++++++++++++++++++++ app/auth/router.py | 50 +- app/clients/router.py | 68 +-- app/clients/schema.py | 17 +- app/clients/service/client_service.py | 177 +++---- app/clients/service/logic.py | 159 ++++-- app/clients/service/ml_models.py | 16 +- app/clients/service/ml_models_router.py | 8 +- app/clients/service/model.py | 111 +++-- app/database.py | 13 +- app/main.py | 17 +- app/models.py | 54 +- mypy.ini | 40 ++ pyproject.toml | 18 + 14 files changed, 1081 insertions(+), 304 deletions(-) create mode 100644 .pylintrc create mode 100644 mypy.ini create mode 100644 pyproject.toml diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 00000000..dee81e31 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,637 @@ +[MAIN] + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Clear in-memory caches upon conclusion of linting. Useful if running pylint +# in a server-like mode. +clear-cache-post-run=no + +# Load and enable all available extensions. Use --list-extensions to see a list +# all available extensions. +#enable-all-extensions= + +# In error mode, messages with a category besides ERROR or FATAL are +# suppressed, and no reports are done by default. Error mode is compatible with +# disabling specific errors. +#errors-only= + +# Always return a 0 (non-error) status code, even if lint errors are found. +# This is primarily useful in continuous integration scripts. +#exit-zero= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-allow-list= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +extension-pkg-whitelist= + +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +fail-on= + +# Specify a score threshold under which the program will exit with error. +fail-under=10 + +# Interpret the stdin as a python script, whose filename needs to be passed as +# the module_or_package argument. +#from-stdin= + +# Files or directories to be skipped. They should be base names, not paths. +ignore=CVS + +# Add files or directories matching the regular expressions patterns to the +# ignore-list. The regex matches against paths and can be in Posix or Windows +# format. Because '\\' represents the directory delimiter on Windows systems, +# it can't be used as an escape character. +ignore-paths= + +# Files or directories matching the regular expression patterns are skipped. +# The regex matches against base names, not paths. The default value ignores +# Emacs file locks +ignore-patterns=^\.# + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use, and will cap the count on Windows to +# avoid hangs. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Minimum Python version to use for version dependent checks. Will default to +# the version used to run pylint. +py-version=3.10 + +# Discover python modules and packages in the file system subtree. +recursive=no + +# Add paths to the list of the source roots. Supports globbing patterns. The +# source root is an absolute path or a path relative to the current working +# directory used to determine a package namespace for modules located under the +# source root. +source-roots= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + +# In verbose mode, extra non-checker-related info will be displayed. +#verbose= + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. If left empty, argument names will be checked with the set +# naming style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. If left empty, attribute names will be checked with the set naming +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. If left empty, class attribute names will be checked +# with the set naming style. +#class-attribute-rgx= + +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE + +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. If left empty, class constant names will be checked with +# the set naming style. +#class-const-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. If left empty, class names will be checked with the set naming style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. If left empty, constant names will be checked with the set naming +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. If left empty, function names will be checked with the set +# naming style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. If left empty, inline iteration names will be checked +# with the set naming style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. If left empty, method names will be checked with the set naming style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. If left empty, module names will be checked with the set naming style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Regular expression matching correct type alias names. If left empty, type +# alias names will be checked with the set naming style. +#typealias-rgx= + +# Regular expression matching correct type variable names. If left empty, type +# variable names will be checked with the set naming style. +#typevar-rgx= + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. If left empty, variable names will be checked with the set +# naming style. +#variable-rgx= + + +[CLASSES] + +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + asyncSetUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[DESIGN] + +# List of regular expressions of class ancestor names to ignore when counting +# public methods (see R0903) +exclude-too-few-public-methods= + +# List of qualified class names to ignore when counting class parents (see +# R0901) +ignored-parents= + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when caught. +overgeneral-exceptions=builtins.BaseException,builtins.Exception + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow explicit reexports by alias from a package __init__. +allow-reexport-from-package=no + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules= + +# Output a graph (.gv or any supported image format) of external dependencies +# to the given file (report RP0402 must not be disabled). +ext-import-graph= + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be +# disabled). +import-graph= + +# Output a graph (.gv or any supported image format) of internal dependencies +# to the given file (report RP0402 must not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, +# UNDEFINED. +confidence=HIGH, + CONTROL_FLOW, + INFERENCE, + INFERENCE_FAILURE, + UNDEFINED + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then re-enable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + use-implicit-booleaness-not-comparison-to-string, + use-implicit-booleaness-not-comparison-to-zero, + missing-module-docstring, + missing-class-docstring, + missing-function-docstring + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable= + + +[METHOD_ARGS] + +# List of qualified names (i.e., library.method) which require a timeout +# parameter e.g. 'requests.api.get,requests.api.post' +timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +notes-rgx= + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'fatal', 'error', 'warning', 'refactor', +# 'convention', and 'info' which contain the number of messages in each +# category, as well as 'statement' which is the total number of statements +# analyzed. This score is used by the global evaluation report (RP0004). +evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +msg-template= + +# Set the output format. Available formats are: text, parseable, colorized, +# json2 (improved json format), json (old json format) and msvs (visual +# studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +#output-format=colorized + +# Tells whether to display a full report or only the messages. +reports=yes + +# Activate the evaluation score. +score=yes + + +[SIMILARITIES] + +# Comments are removed from the similarity computation +ignore-comments=yes + +# Docstrings are removed from the similarity computation +ignore-docstrings=yes + +# Imports are removed from the similarity computation +ignore-imports=yes + +# Signatures are removed from the similarity computation +ignore-signatures=yes + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. No available dictionaries : You need to install +# both the python package and the system dependency for enchant to work. +spelling-dict= + +# List of comma separated words that should be considered directives if they +# appear at the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of symbolic message names to ignore for Mixin members. +ignored-checks-for-mixins=no-member, + not-async-context-manager, + not-context-manager, + attribute-defined-outside-init + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + +# Regex pattern to define which classes are considered mixins. +mixin-class-rgx=.*[Mm]ixin + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of names allowed to shadow builtins +allowed-redefined-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io diff --git a/app/auth/router.py b/app/auth/router.py index 229ee71d..3f5455ee 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -1,28 +1,32 @@ from datetime import datetime, timedelta from typing import Optional + from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from jose import JWTError, jwt +from passlib.context import CryptContext +from pydantic import BaseModel, Field, validator from sqlalchemy.orm import Session + from app.database import get_db from app.models import User, UserRole -from passlib.context import CryptContext -from pydantic import BaseModel, Field, validator router = APIRouter(prefix="/auth", tags=["authentication"]) + class UserCreate(BaseModel): username: str = Field(..., min_length=3, max_length=50) email: str password: str role: UserRole - @validator('role') + @validator("role") def validate_role(cls, v): if v not in [UserRole.admin, UserRole.case_worker]: - raise ValueError('Role must be either admin or case_worker') + raise ValueError("Role must be either admin or case_worker") return v + class UserResponse(BaseModel): username: str email: str @@ -31,6 +35,7 @@ class UserResponse(BaseModel): class Config: from_attributes = True + # Configuration SECRET_KEY = "your-secret-key-here" ALGORITHM = "HS256" @@ -39,18 +44,22 @@ class Config: pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") + def verify_password(plain_password: str, hashed_password: str) -> bool: return pwd_context.verify(plain_password, hashed_password) + def get_password_hash(password: str) -> str: return pwd_context.hash(password) + def authenticate_user(db: Session, username: str, password: str) -> Optional[User]: user = db.query(User).filter(User.username == username).first() if not user or not verify_password(password, user.hashed_password): return None return user + def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): to_encode = data.copy() if expires_delta: @@ -61,9 +70,9 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt + async def get_current_user( - token: str = Depends(oauth2_scheme), - db: Session = Depends(get_db) + token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) ) -> User: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -77,24 +86,25 @@ async def get_current_user( raise credentials_exception except JWTError: raise credentials_exception - + user = db.query(User).filter(User.username == username).first() if user is None: raise credentials_exception return user + def get_admin_user(current_user: User = Depends(get_current_user)): if current_user.role != UserRole.admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Only admin users can perform this operation" + detail="Only admin users can perform this operation", ) return current_user + @router.post("/token") async def login_for_access_token( - form_data: OAuth2PasswordRequestForm = Depends(), - db: Session = Depends(get_db) + form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db) ): user = authenticate_user(db, form_data.username, form_data.password) if not user: @@ -109,25 +119,24 @@ async def login_for_access_token( ) return {"access_token": access_token, "token_type": "bearer"} + @router.post("/users", response_model=UserResponse) async def create_user( user_data: UserCreate, current_user: User = Depends(get_admin_user), - db: Session = Depends(get_db) + db: Session = Depends(get_db), ): """Create a new user (admin only)""" # Check if username exists if db.query(User).filter(User.username == user_data.username).first(): raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Username already registered" + status_code=status.HTTP_400_BAD_REQUEST, detail="Username already registered" ) - + # Check if email exists if db.query(User).filter(User.email == user_data.email).first(): raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Email already registered" + status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered" ) # Create new user @@ -135,9 +144,9 @@ async def create_user( username=user_data.username, email=user_data.email, hashed_password=get_password_hash(user_data.password), - role=user_data.role + role=user_data.role, ) - + try: db.add(db_user) db.commit() @@ -145,7 +154,4 @@ async def create_user( return db_user except Exception as e: db.rollback() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=str(e) - ) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) diff --git a/app/clients/router.py b/app/clients/router.py index 4ecc83e4..45c385e5 100644 --- a/app/clients/router.py +++ b/app/clients/router.py @@ -3,42 +3,44 @@ Handles all HTTP requests for client operations including create, read, update, and delete. """ -from fastapi import APIRouter, Depends, HTTPException, status, Query -from sqlalchemy.orm import Session from typing import List, Optional -from app.auth.router import get_current_user, get_admin_user -from app.models import User, UserRole -from app.database import get_db -from app.clients.service.client_service import ClientService +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.auth.router import get_admin_user, get_current_user from app.clients.schema import ( - ClientResponse, - ClientUpdate, ClientListResponse, + ClientResponse, + ClientUpdate, ServiceResponse, - ServiceUpdate + ServiceUpdate, ) +from app.clients.service.client_service import ClientService +from app.database import get_db +from app.models import User, UserRole router = APIRouter(prefix="/clients", tags=["clients"]) + @router.get("/", response_model=ClientListResponse) async def get_clients( - current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_admin_user), skip: int = Query(default=0, ge=0, description="Number of records to skip"), limit: int = Query(default=50, ge=1, le=150, description="Maximum number of records to return"), - db: Session = Depends(get_db) + db: Session = Depends(get_db), ): return ClientService.get_clients(db, skip, limit) + @router.get("/{client_id}", response_model=ClientResponse) async def get_client( - client_id: int, - current_user: User = Depends(get_admin_user), - db: Session = Depends(get_db) + client_id: int, current_user: User = Depends(get_admin_user), db: Session = Depends(get_db) ): """Get a specific client by ID""" return ClientService.get_client(db, client_id) + @router.get("/search/by-criteria", response_model=List[ClientResponse]) async def get_clients_by_criteria( employment_status: Optional[bool] = None, @@ -66,7 +68,7 @@ async def get_clients_by_criteria( time_unemployed: Optional[int] = Query(None, ge=0), need_mental_health_support_bool: Optional[bool] = None, current_user: User = Depends(get_admin_user), - db: Session = Depends(get_db) + db: Session = Depends(get_db), ): """Search clients by any combination of criteria""" return ClientService.get_clients_by_criteria( @@ -94,9 +96,10 @@ async def get_clients_by_criteria( attending_school=attending_school, substance_use=substance_use, time_unemployed=time_unemployed, - need_mental_health_support_bool=need_mental_health_support_bool + need_mental_health_support_bool=need_mental_health_support_bool, ) + @router.get("/search/by-services", response_model=List[ClientResponse]) async def get_clients_by_services( employment_assistance: Optional[bool] = None, @@ -107,7 +110,7 @@ async def get_clients_by_services( employer_financial_supports: Optional[bool] = None, enhanced_referrals: Optional[bool] = None, current_user: User = Depends(get_admin_user), - db: Session = Depends(get_db) + db: Session = Depends(get_db), ): """Get clients filtered by multiple service statuses""" return ClientService.get_clients_by_services( @@ -118,70 +121,73 @@ async def get_clients_by_services( specialized_services=specialized_services, employment_related_financial_supports=employment_related_financial_supports, employer_financial_supports=employer_financial_supports, - enhanced_referrals=enhanced_referrals + enhanced_referrals=enhanced_referrals, ) + @router.get("/{client_id}/services", response_model=List[ServiceResponse]) async def get_client_services( - client_id: int, - current_user: User = Depends(get_admin_user), - db: Session = Depends(get_db) + client_id: int, current_user: User = Depends(get_admin_user), db: Session = Depends(get_db) ): """Get all services and their status for a specific client, including case worker info""" return ClientService.get_client_services(db, client_id) + @router.get("/search/success-rate", response_model=List[ClientResponse]) async def get_clients_by_success_rate( min_rate: int = Query(70, ge=0, le=100, description="Minimum success rate percentage"), current_user: User = Depends(get_admin_user), - db: Session = Depends(get_db) + db: Session = Depends(get_db), ): """Get clients with success rate above specified threshold""" return ClientService.get_clients_by_success_rate(db, min_rate) + @router.get("/case-worker/{case_worker_id}", response_model=List[ClientResponse]) async def get_clients_by_case_worker( case_worker_id: int, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), ): return ClientService.get_clients_by_case_worker(db, case_worker_id) + @router.put("/{client_id}", response_model=ClientResponse) async def update_client( client_id: int, client_data: ClientUpdate, current_user: User = Depends(get_admin_user), - db: Session = Depends(get_db) + db: Session = Depends(get_db), ): """Update a client's information""" return ClientService.update_client(db, client_id, client_data) + @router.put("/{client_id}/services/{user_id}", response_model=ServiceResponse) async def update_client_services( client_id: int, user_id: int, service_update: ServiceUpdate, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), ): return ClientService.update_client_services(db, client_id, user_id, service_update) + @router.post("/{client_id}/case-assignment", response_model=ServiceResponse) async def create_case_assignment( client_id: int, case_worker_id: int = Query(..., description="Case worker ID to assign"), current_user: User = Depends(get_admin_user), - db: Session = Depends(get_db) + db: Session = Depends(get_db), ): """Create a new case assignment for a client with a case worker""" return ClientService.create_case_assignment(db, client_id, case_worker_id) + @router.delete("/{client_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_client( - client_id: int, - current_user: User = Depends(get_admin_user), - db: Session = Depends(get_db) + client_id: int, current_user: User = Depends(get_admin_user), db: Session = Depends(get_db) ): """Delete a client""" ClientService.delete_client(db, client_id) diff --git a/app/clients/schema.py b/app/clients/schema.py index cff28897..0120d1cf 100644 --- a/app/clients/schema.py +++ b/app/clients/schema.py @@ -3,22 +3,27 @@ Defines schemas for client data, predictions, and API responses. """ +from enum import IntEnum +from typing import List, Optional + # Standard library imports from pydantic import BaseModel, Field, validator -from typing import Optional, List -from enum import IntEnum + from app.models import UserRole + # Enums for validation class Gender(IntEnum): MALE = 1 FEMALE = 2 + class PredictionInput(BaseModel): """ Schema for prediction input data containing all client assessment fields. Used for making predictions about client outcomes. """ + age: int gender: str work_experience: int @@ -44,6 +49,7 @@ class PredictionInput(BaseModel): time_unemployed: int need_mental_health_support_bool: str + class ClientBase(BaseModel): age: int = Field(ge=18, description="Age of client, must be 18 or older") gender: Gender = Field(description="Gender: 1 for male, 2 for female") @@ -96,16 +102,18 @@ class Config: "currently_employed": False, "substance_use": False, "time_unemployed": 6, - "need_mental_health_support_bool": False + "need_mental_health_support_bool": False, } } + class ClientResponse(ClientBase): id: int class Config: from_attributes = True + class ClientUpdate(BaseModel): age: Optional[int] = Field(None, ge=18) gender: Optional[Gender] = None @@ -132,6 +140,7 @@ class ClientUpdate(BaseModel): time_unemployed: Optional[int] = Field(None, ge=0) need_mental_health_support_bool: Optional[bool] = None + class ServiceResponse(BaseModel): client_id: int user_id: int @@ -147,6 +156,7 @@ class ServiceResponse(BaseModel): class Config: from_attributes = True + class ServiceUpdate(BaseModel): employment_assistance: Optional[bool] = None life_stabilization: Optional[bool] = None @@ -157,6 +167,7 @@ class ServiceUpdate(BaseModel): enhanced_referrals: Optional[bool] = None success_rate: Optional[int] = Field(None, ge=0, le=100) + class ClientListResponse(BaseModel): clients: List[ClientResponse] total: int diff --git a/app/clients/service/client_service.py b/app/clients/service/client_service.py index c743acb4..16115ec1 100644 --- a/app/clients/service/client_service.py +++ b/app/clients/service/client_service.py @@ -4,16 +4,19 @@ """ from abc import ABC, abstractmethod -from sqlalchemy.orm import Session -from sqlalchemy import and_ +from typing import Any, Dict, List, Optional + from fastapi import HTTPException, status -from typing import List, Optional, Dict, Any +from sqlalchemy import and_ +from sqlalchemy.orm import Session + +from app.clients.schema import ClientUpdate, ServiceResponse, ServiceUpdate from app.models import Client, ClientCase, User -from app.clients.schema import ClientUpdate, ServiceUpdate, ServiceResponse class InterfaceClientQueryService(ABC): """Interface for client query operations""" + @abstractmethod def get_client(self, db: Session, client_id: int) -> Client: """Get a specific client by ID""" @@ -21,7 +24,7 @@ def get_client(self, db: Session, client_id: int) -> Client: @abstractmethod def get_clients(self, db: Session, skip: int, limit: int) -> Dict[str, Any]: - """ Get clients with optional pagination.""" + """Get clients with optional pagination.""" pass @abstractmethod @@ -30,45 +33,45 @@ def get_clients_by_criteria(self, db: Session, **criteria) -> List[Client]: pass @abstractmethod - def get_clients_by_services(self, db: Session, **service_filters) -> \ - List[Client]: - """ Get clients filtered by multiple service statuses.""" + def get_clients_by_services(self, db: Session, **service_filters) -> List[Client]: + """Get clients filtered by multiple service statuses.""" pass @abstractmethod - def get_client_services(self, db: Session, client_id: int) -> List[ - ClientCase]: + def get_client_services(self, db: Session, client_id: int) -> List[ClientCase]: pass @abstractmethod - def get_clients_by_success_rate(self, db: Session, min_rate: int) -> List[ - Client]: + def get_clients_by_success_rate(self, db: Session, min_rate: int) -> List[Client]: pass @abstractmethod - def get_clients_by_case_worker(self, db: Session, case_worker_id: int) -> \ - List[Client]: + def get_clients_by_case_worker(self, db: Session, case_worker_id: int) -> List[Client]: pass class InterfaceClientManagementService(ABC): """Interface for client management operations""" + @abstractmethod - def update_client(self, db: Session, client_id: int, - client_update: ClientUpdate) -> ClientUpdate: + def update_client( + self, db: Session, client_id: int, client_update: ClientUpdate + ) -> ClientUpdate: """Update a client's information""" pass @abstractmethod - def update_client_services(self, db: Session, client_id: int, user_id: int, - service_update: ServiceUpdate) -> ClientCase: + def update_client_services( + self, db: Session, client_id: int, user_id: int, service_update: ServiceUpdate + ) -> ClientCase: """Update a client's services and outcomes for a specific caseworker""" pass @abstractmethod - def create_case_assignment(self, db: Session, client_id: int, - case_worker_id: int) -> ClientCase: + def create_case_assignment( + self, db: Session, client_id: int, case_worker_id: int + ) -> ClientCase: """Create a new case assignment""" pass @@ -88,7 +91,7 @@ def get_client(db: Session, client_id: int): if not client: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Client with id {client_id} not found" + detail=f"Client with id {client_id} not found", ) return client @@ -100,13 +103,11 @@ def get_clients(db: Session, skip: int = 0, limit: int = 50): """ if skip < 0: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Skip value cannot be negative" + status_code=status.HTTP_400_BAD_REQUEST, detail="Skip value cannot be negative" ) if limit < 1: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Limit must be greater than 0" + status_code=status.HTTP_400_BAD_REQUEST, detail="Limit must be greater than 0" ) clients = db.query(Client).offset(skip).limit(limit).all() @@ -139,7 +140,7 @@ def get_clients_by_criteria( attending_school: Optional[bool] = None, substance_use: Optional[bool] = None, time_unemployed: Optional[int] = None, - need_mental_health_support_bool: Optional[bool] = None + need_mental_health_support_bool: Optional[bool] = None, ): """Get clients filtered by any combination of criteria""" query = db.query(Client) @@ -147,19 +148,17 @@ def get_clients_by_criteria( if education_level is not None and not (1 <= education_level <= 14): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Education level must be between 1 and 14" + detail="Education level must be between 1 and 14", ) if age_min is not None and age_min < 18: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Minimum age must be at least 18" + status_code=status.HTTP_400_BAD_REQUEST, detail="Minimum age must be at least 18" ) if gender is not None and gender not in [1, 2]: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Gender must be 1 or 2" + status_code=status.HTTP_400_BAD_REQUEST, detail="Gender must be 1 or 2" ) # Apply filters for non-None values @@ -184,21 +183,17 @@ def get_clients_by_criteria( if fluent_english is not None: query = query.filter(Client.fluent_english == fluent_english) if reading_english_scale is not None: - query = query.filter( - Client.reading_english_scale == reading_english_scale) + query = query.filter(Client.reading_english_scale == reading_english_scale) if speaking_english_scale is not None: - query = query.filter( - Client.speaking_english_scale == speaking_english_scale) + query = query.filter(Client.speaking_english_scale == speaking_english_scale) if writing_english_scale is not None: - query = query.filter( - Client.writing_english_scale == writing_english_scale) + query = query.filter(Client.writing_english_scale == writing_english_scale) if numeracy_scale is not None: query = query.filter(Client.numeracy_scale == numeracy_scale) if computer_scale is not None: query = query.filter(Client.computer_scale == computer_scale) if transportation_bool is not None: - query = query.filter( - Client.transportation_bool == transportation_bool) + query = query.filter(Client.transportation_bool == transportation_bool) if caregiver_bool is not None: query = query.filter(Client.caregiver_bool == caregiver_bool) if housing is not None: @@ -215,21 +210,19 @@ def get_clients_by_criteria( query = query.filter(Client.time_unemployed == time_unemployed) if need_mental_health_support_bool is not None: query = query.filter( - Client.need_mental_health_support_bool == need_mental_health_support_bool) + Client.need_mental_health_support_bool == need_mental_health_support_bool + ) try: return query.all() except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error retrieving clients: {str(e)}" + detail=f"Error retrieving clients: {str(e)}", ) @staticmethod - def get_clients_by_services( - db: Session, - **service_filters: Optional[bool] - ): + def get_clients_by_services(db: Session, **service_filters: Optional[bool]): """ Get clients filtered by multiple service statuses. """ @@ -245,18 +238,17 @@ def get_clients_by_services( except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error retrieving clients: {str(e)}" + detail=f"Error retrieving clients: {str(e)}", ) @staticmethod def get_client_services(db: Session, client_id: int): """Get all services for a specific client with caseworker info""" - client_cases = db.query(ClientCase).filter( - ClientCase.client_id == client_id).all() + client_cases = db.query(ClientCase).filter(ClientCase.client_id == client_id).all() if not client_cases: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"No services found for client with id {client_id}" + detail=f"No services found for client with id {client_id}", ) return client_cases @@ -266,12 +258,10 @@ def get_clients_by_success_rate(db: Session, min_rate: int = 70): if not (0 <= min_rate <= 100): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Success rate must be between 0 and 100" + detail="Success rate must be between 0 and 100", ) - return db.query(Client).join(ClientCase).filter( - ClientCase.success_rate >= min_rate - ).all() + return db.query(Client).join(ClientCase).filter(ClientCase.success_rate >= min_rate).all() @staticmethod def get_clients_by_case_worker(db: Session, case_worker_id: int): @@ -280,12 +270,10 @@ def get_clients_by_case_worker(db: Session, case_worker_id: int): if not case_worker: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Case worker with id {case_worker_id} not found" + detail=f"Case worker with id {case_worker_id} not found", ) - return db.query(Client).join(ClientCase).filter( - ClientCase.user_id == case_worker_id - ).all() + return db.query(Client).join(ClientCase).filter(ClientCase.user_id == case_worker_id).all() class ClientManagementService(InterfaceClientManagementService): @@ -298,7 +286,7 @@ def update_client(db: Session, client_id: int, client_update: ClientUpdate): if not client: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Client with id {client_id} not found" + detail=f"Client with id {client_id} not found", ) update_data = client_update.dict(exclude_unset=True) @@ -313,27 +301,25 @@ def update_client(db: Session, client_id: int, client_update: ClientUpdate): db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to update client: {str(e)}" + detail=f"Failed to update client: {str(e)}", ) @staticmethod def update_client_services( - db: Session, - client_id: int, - user_id: int, - service_update: ServiceUpdate + db: Session, client_id: int, user_id: int, service_update: ServiceUpdate ): """Update a client's services and outcomes for a specific case worker""" - client_case = db.query(ClientCase).filter( - ClientCase.client_id == client_id, - ClientCase.user_id == user_id - ).first() + client_case = ( + db.query(ClientCase) + .filter(ClientCase.client_id == client_id, ClientCase.user_id == user_id) + .first() + ) if not client_case: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"No case found for client {client_id} with case worker {user_id}. " - f"Cannot update services for a non-existent case assignment." + f"Cannot update services for a non-existent case assignment.", ) update_data = service_update.dict(exclude_unset=True) @@ -348,22 +334,18 @@ def update_client_services( db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to update client services: {str(e)}" + detail=f"Failed to update client services: {str(e)}", ) @staticmethod - def create_case_assignment( - db: Session, - client_id: int, - case_worker_id: int - ): + def create_case_assignment(db: Session, client_id: int, case_worker_id: int): """Create a new case assignment""" # Check if client exists client = db.query(Client).filter(Client.id == client_id).first() if not client: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Client with id {client_id} not found" + detail=f"Client with id {client_id} not found", ) # Check if caseworker exists @@ -371,19 +353,20 @@ def create_case_assignment( if not case_worker: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Case worker with id {case_worker_id} not found" + detail=f"Case worker with id {case_worker_id} not found", ) # Check if assignment already exists - existing_case = db.query(ClientCase).filter( - ClientCase.client_id == client_id, - ClientCase.user_id == case_worker_id - ).first() + existing_case = ( + db.query(ClientCase) + .filter(ClientCase.client_id == client_id, ClientCase.user_id == case_worker_id) + .first() + ) if existing_case: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Client {client_id} already has a case assigned to case worker {case_worker_id}" + detail=f"Client {client_id} already has a case assigned to case worker {case_worker_id}", ) try: @@ -398,7 +381,7 @@ def create_case_assignment( employment_related_financial_supports=False, employer_financial_supports=False, enhanced_referrals=False, - success_rate=0 + success_rate=0, ) db.add(new_case) db.commit() @@ -409,7 +392,7 @@ def create_case_assignment( db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to create case assignment: {str(e)}" + detail=f"Failed to create case assignment: {str(e)}", ) @staticmethod @@ -420,14 +403,12 @@ def delete_client(db: Session, client_id: int): if not client: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Client with id {client_id} not found" + detail=f"Client with id {client_id} not found", ) try: # Delete associated client_cases - db.query(ClientCase).filter( - ClientCase.client_id == client_id - ).delete() + db.query(ClientCase).filter(ClientCase.client_id == client_id).delete() # Delete the client db.delete(client) @@ -437,7 +418,7 @@ def delete_client(db: Session, client_id: int): db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to delete client: {str(e)}" + detail=f"Failed to delete client: {str(e)}", ) @@ -479,21 +460,19 @@ def get_clients_by_case_worker(db: Session, case_worker_id: int): # Modification methods @staticmethod def update_client(db: Session, client_id: int, client_update: ClientUpdate): - return ClientManagementService.update_client(db, client_id, - client_update) + return ClientManagementService.update_client(db, client_id, client_update) @staticmethod - def update_client_services(db: Session, client_id: int, user_id: int, - service_update: ServiceUpdate): - return ClientManagementService.update_client_services(db, client_id, - user_id, - service_update) + def update_client_services( + db: Session, client_id: int, user_id: int, service_update: ServiceUpdate + ): + return ClientManagementService.update_client_services( + db, client_id, user_id, service_update + ) @staticmethod - def create_case_assignment(db: Session, client_id: int, - case_worker_id: int): - return ClientManagementService.create_case_assignment(db, client_id, - case_worker_id) + def create_case_assignment(db: Session, client_id: int, case_worker_id: int): + return ClientManagementService.create_case_assignment(db, client_id, case_worker_id) @staticmethod def delete_client(db: Session, client_id: int): diff --git a/app/clients/service/logic.py b/app/clients/service/logic.py index c25b4217..290e1dd9 100644 --- a/app/clients/service/logic.py +++ b/app/clients/service/logic.py @@ -5,30 +5,33 @@ # Standard library imports import os -#import json -from itertools import product # Third-party imports import pickle + +# import json +from itertools import product + import numpy as np # Constants COLUMN_INTERVENTIONS = [ - 'Life Stabilization', - 'General Employment Assistance Services', - 'Retention Services', - 'Specialized Services', - 'Employment-Related Financial Supports for Job Seekers and Employers', - 'Employer Financial Supports', - 'Enhanced Referrals for Skills Development' + "Life Stabilization", + "General Employment Assistance Services", + "Retention Services", + "Specialized Services", + "Employment-Related Financial Supports for Job Seekers and Employers", + "Employer Financial Supports", + "Enhanced Referrals for Skills Development", ] # Load model CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) -MODEL_PATH = os.path.join(CURRENT_DIR, 'model.pkl') +MODEL_PATH = os.path.join(CURRENT_DIR, "model.pkl") with open(MODEL_PATH, "rb") as model_file: MODEL = pickle.load(model_file) + def clean_input_data(input_data): """ Clean and transform input data into model-compatible format. @@ -40,13 +43,30 @@ def clean_input_data(input_data): list: Cleaned and formatted data ready for model input """ columns = [ - "age", "gender", "work_experience", "canada_workex", "dep_num", - "canada_born", "citizen_status", "level_of_schooling", "fluent_english", - "reading_english_scale", "speaking_english_scale", "writing_english_scale", - "numeracy_scale", "computer_scale", "transportation_bool", "caregiver_bool", - "housing", "income_source", "felony_bool", "attending_school", - "currently_employed", "substance_use", "time_unemployed", - "need_mental_health_support_bool" + "age", + "gender", + "work_experience", + "canada_workex", + "dep_num", + "canada_born", + "citizen_status", + "level_of_schooling", + "fluent_english", + "reading_english_scale", + "speaking_english_scale", + "writing_english_scale", + "numeracy_scale", + "computer_scale", + "transportation_bool", + "caregiver_bool", + "housing", + "income_source", + "felony_bool", + "attending_school", + "currently_employed", + "substance_use", + "time_unemployed", + "need_mental_health_support_bool", ] demographics = {key: input_data[key] for key in columns} output = [] @@ -57,6 +77,7 @@ def clean_input_data(input_data): output.append(value) return output + def convert_text(text_data: str): """ Convert text answers from front end into numerical values. @@ -68,33 +89,47 @@ def convert_text(text_data: str): int: Converted numerical value """ categorical_mappings = [ + {"": 0, "true": 1, "false": 0, "no": 0, "yes": 1, "No": 0, "Yes": 1}, { - "": 0, "true": 1, "false": 0, "no": 0, "yes": 1, - "No": 0, "Yes": 1 - }, - { - "Grade 0-8": 1, "Grade 9": 2, "Grade 10": 3, "Grade 11": 4, - "Grade 12 or equivalent": 5, "OAC or Grade 13": 6, - "Some college": 7, "Some university": 8, "Some apprenticeship": 9, - "Certificate of Apprenticeship": 10, "Journeyperson": 11, - "Certificate/Diploma": 12, "Bachelor's degree": 13, - "Post graduate": 14 + "Grade 0-8": 1, + "Grade 9": 2, + "Grade 10": 3, + "Grade 11": 4, + "Grade 12 or equivalent": 5, + "OAC or Grade 13": 6, + "Some college": 7, + "Some university": 8, + "Some apprenticeship": 9, + "Certificate of Apprenticeship": 10, + "Journeyperson": 11, + "Certificate/Diploma": 12, + "Bachelor's degree": 13, + "Post graduate": 14, }, { - "Renting-private": 1, "Renting-subsidized": 2, - "Boarding or lodging": 3, "Homeowner": 4, - "Living with family/friend": 5, "Institution": 6, - "Temporary second residence": 7, "Band-owned home": 8, - "Homeless or transient": 9, "Emergency hostel": 10 + "Renting-private": 1, + "Renting-subsidized": 2, + "Boarding or lodging": 3, + "Homeowner": 4, + "Living with family/friend": 5, + "Institution": 6, + "Temporary second residence": 7, + "Band-owned home": 8, + "Homeless or transient": 9, + "Emergency hostel": 10, }, { - "No Source of Income": 1, "Employment Insurance": 2, + "No Source of Income": 1, + "Employment Insurance": 2, "Workplace Safety and Insurance Board": 3, "Ontario Works applied or receiving": 4, "Ontario Disability Support Program applied or receiving": 5, - "Dependent of someone receiving OW or ODSP": 6, "Crown Ward": 7, - "Employment": 8, "Self-Employment": 9, "Other (specify)": 10 - } + "Dependent of someone receiving OW or ODSP": 6, + "Crown Ward": 7, + "Employment": 8, + "Self-Employment": 9, + "Other (specify)": 10, + }, ] for category in categorical_mappings: if text_data in category: @@ -102,6 +137,7 @@ def convert_text(text_data: str): return int(text_data) if text_data.isnumeric() else text_data + def create_matrix(row_data): """ Create matrix of all possible intervention combinations. @@ -116,6 +152,7 @@ def create_matrix(row_data): perms = intervention_permutations(7) return np.concatenate((np.array(data), np.array(perms)), axis=1) + def intervention_permutations(num): """ Generate all possible intervention combinations. @@ -128,6 +165,7 @@ def intervention_permutations(num): """ return np.array(list(product([0, 1], repeat=num))) + def get_baseline_row(row_data): """ Create baseline row with no interventions. @@ -141,6 +179,7 @@ def get_baseline_row(row_data): base_interventions = np.zeros(7) return np.concatenate((np.array(row_data), base_interventions)) + def intervention_row_to_names(row_data): """ Convert intervention row to list of intervention names. @@ -153,6 +192,7 @@ def intervention_row_to_names(row_data): """ return [COLUMN_INTERVENTIONS[i] for i, value in enumerate(row_data) if value == 1] + def process_results(baseline_pred, results_matrix): """ Process model results into structured output. @@ -164,14 +204,9 @@ def process_results(baseline_pred, results_matrix): Returns: dict: Processed results with baseline and interventions """ - result_list = [ - (row[-1], intervention_row_to_names(row[:-1])) - for row in results_matrix - ] - return { - "baseline": baseline_pred[-1], - "interventions": result_list - } + result_list = [(row[-1], intervention_row_to_names(row[:-1])) for row in results_matrix] + return {"baseline": baseline_pred[-1], "interventions": result_list} + def interpret_and_calculate(input_data): """ @@ -194,19 +229,33 @@ def interpret_and_calculate(input_data): top_results = result_matrix[-3:, -8:] return process_results(baseline_prediction, top_results) + if __name__ == "__main__": test_data = { - "age": "23", "gender": "1", "work_experience": "1", - "canada_workex": "1", "dep_num": "0", "canada_born": "1", - "citizen_status": "2", "level_of_schooling": "2", - "fluent_english": "3", "reading_english_scale": "2", - "speaking_english_scale": "2", "writing_english_scale": "3", - "numeracy_scale": "2", "computer_scale": "3", - "transportation_bool": "2", "caregiver_bool": "1", - "housing": "1", "income_source": "5", "felony_bool": "1", - "attending_school": "0", "currently_employed": "1", - "substance_use": "1", "time_unemployed": "1", - "need_mental_health_support_bool": "1" + "age": "23", + "gender": "1", + "work_experience": "1", + "canada_workex": "1", + "dep_num": "0", + "canada_born": "1", + "citizen_status": "2", + "level_of_schooling": "2", + "fluent_english": "3", + "reading_english_scale": "2", + "speaking_english_scale": "2", + "writing_english_scale": "3", + "numeracy_scale": "2", + "computer_scale": "3", + "transportation_bool": "2", + "caregiver_bool": "1", + "housing": "1", + "income_source": "5", + "felony_bool": "1", + "attending_school": "0", + "currently_employed": "1", + "substance_use": "1", + "time_unemployed": "1", + "need_mental_health_support_bool": "1", } results = interpret_and_calculate(test_data) print(results) diff --git a/app/clients/service/ml_models.py b/app/clients/service/ml_models.py index 4f75209c..50957926 100644 --- a/app/clients/service/ml_models.py +++ b/app/clients/service/ml_models.py @@ -1,8 +1,9 @@ from abc import ABC, abstractmethod from typing import List + import numpy as np -from sklearn.linear_model import LinearRegression from sklearn.ensemble import RandomForestRegressor +from sklearn.linear_model import LinearRegression from sklearn.svm import SVR @@ -19,12 +20,14 @@ def predict(self, X: np.ndarray) -> np.ndarray: def save(self, path: str): import pickle + with open(path, "wb") as f: pickle.dump(self, f) @staticmethod def load(path: str): import pickle + with open(path, "rb") as f: return pickle.load(f) @@ -50,8 +53,7 @@ def __str__(self): class RandomForestModel(InterfaceBaseMLModel): def __init__(self, n_estimators=100, random_state=42): - self.model = RandomForestRegressor(n_estimators=n_estimators, - random_state=random_state) + self.model = RandomForestRegressor(n_estimators=n_estimators, random_state=random_state) def fit(self, X, y): self.model.fit(X, y) @@ -115,7 +117,7 @@ def __init__(self): self._model_map = { "Linear Regression": LinearRegressionModel, "Random Forest Regressor": RandomForestModel, - "Support Vector Machine": SVMModel + "Support Vector Machine": SVMModel, } def list_models(self) -> List[InterfaceBaseMLModel]: @@ -133,15 +135,13 @@ def get_model_instance(self, model_name: str) -> InterfaceBaseMLModel: class MLModelManager(InterfaceMLModelManager): def __init__(self, repository: InterfaceMLModelRepository): self._repository = repository - self._current_model = repository.get_model_instance( - "Random Forest Regressor") + self._current_model = repository.get_model_instance("Random Forest Regressor") def get_current_model(self) -> str: return self._current_model def switch_model(self, model_name: str) -> bool: if self._repository.is_model_available(model_name): - self._current_model = self._repository.get_model_instance( - model_name) + self._current_model = self._repository.get_model_instance(model_name) return True return False diff --git a/app/clients/service/ml_models_router.py b/app/clients/service/ml_models_router.py index f9ffe659..d1070153 100644 --- a/app/clients/service/ml_models_router.py +++ b/app/clients/service/ml_models_router.py @@ -1,15 +1,19 @@ from fastapi import APIRouter, HTTPException -from app.clients.service.ml_models import MLModelRepository, MLModelManager + +from app.clients.service.ml_models import MLModelManager, MLModelRepository router = APIRouter(prefix="/ml_models") model_repository = MLModelRepository() model_manager = MLModelManager(model_repository) + + @router.get("/list") def list_models(): """List all available ML models""" # return {"models": model_repository.list_models()} return {"models": [str(model) for model in model_repository.list_models()]} + @router.post("/switch/{model_name}") def switch_models(model_name: str): """Switch between ML models""" @@ -23,4 +27,4 @@ def switch_models(model_name: str): def current_model(): """Get the current ML model""" # return {"current_model": model_manager.get_current_model()} - return {"current_model": str(model_manager.get_current_model())} \ No newline at end of file + return {"current_model": str(model_manager.get_current_model())} diff --git a/app/clients/service/model.py b/app/clients/service/model.py index 973c03d5..cba85686 100644 --- a/app/clients/service/model.py +++ b/app/clients/service/model.py @@ -4,35 +4,39 @@ Pass in model name via command line """ +import os + # Standard library imports import pickle +import sys # Third-party imports import numpy as np import pandas as pd -import sys -import os -from sklearn.model_selection import train_test_split -from sklearn.linear_model import LinearRegression -from sklearn.ensemble import RandomForestRegressor -from sklearn import svm # Local imports -from ml_models import InterfaceBaseMLModel, MLModelRepository, \ - LinearRegressionModel, RandomForestModel, SVMModel +from ml_models import ( + InterfaceBaseMLModel, + LinearRegressionModel, + MLModelRepository, + RandomForestModel, + SVMModel, +) +from sklearn import svm +from sklearn.ensemble import RandomForestRegressor +from sklearn.linear_model import LinearRegression +from sklearn.model_selection import train_test_split repo = MLModelRepository() default_unformatted_model_path = "pretrained_models" + os.sep + "model_{}.pkl" -def get_model_by_name(model_type: str, n_estimators=100, - random_state=42) -> InterfaceBaseMLModel: +def get_model_by_name(model_type: str, n_estimators=100, random_state=42) -> InterfaceBaseMLModel: model_map = { "Linear Regression": LinearRegressionModel, - "Random Forest Regressor": lambda: RandomForestModel(n_estimators, - random_state), - "Support Vector Machine": SVMModel + "Random Forest Regressor": lambda: RandomForestModel(n_estimators, random_state), + "Support Vector Machine": SVMModel, } if model_type not in model_map: @@ -50,68 +54,69 @@ def prepare_model_data(test_size=0.2, random_state=42): Args: test_size: The percent of the dataset to use as test data (rest will be used as train data) random_state: The random state to generate train/test split with - + Returns: RandomForestRegressor: Trained model for predicting success rates """ # Load dataset - data = pd.read_csv('data_commontool.csv') + data = pd.read_csv("data_commontool.csv") # Define feature columns feature_columns = [ - 'age', # Client's age - 'gender', # Client's gender (bool) - 'work_experience', # Years of work experience - 'canada_workex', # Years of work experience in Canada - 'dep_num', # Number of dependents - 'canada_born', # Born in Canada - 'citizen_status', # Citizenship status - 'level_of_schooling', # Highest level achieved (1-14) - 'fluent_english', # English fluency scale (1-10) - 'reading_english_scale', # Reading ability scale (1-10) - 'speaking_english_scale', # Speaking ability scale (1-10) - 'writing_english_scale', # Writing ability scale (1-10) - 'numeracy_scale', # Numeracy ability scale (1-10) - 'computer_scale', # Computer proficiency scale (1-10) - 'transportation_bool', # Needs transportation support (bool) - 'caregiver_bool', # Is primary caregiver (bool) - 'housing', # Housing situation (1-10) - 'income_source', # Source of income (1-10) - 'felony_bool', # Has a felony (bool) - 'attending_school', # Currently a student (bool) - 'currently_employed', # Currently employed (bool) - 'substance_use', # Substance use disorder (bool) - 'time_unemployed', # Years unemployed - 'need_mental_health_support_bool' # Needs mental health support (bool) + "age", # Client's age + "gender", # Client's gender (bool) + "work_experience", # Years of work experience + "canada_workex", # Years of work experience in Canada + "dep_num", # Number of dependents + "canada_born", # Born in Canada + "citizen_status", # Citizenship status + "level_of_schooling", # Highest level achieved (1-14) + "fluent_english", # English fluency scale (1-10) + "reading_english_scale", # Reading ability scale (1-10) + "speaking_english_scale", # Speaking ability scale (1-10) + "writing_english_scale", # Writing ability scale (1-10) + "numeracy_scale", # Numeracy ability scale (1-10) + "computer_scale", # Computer proficiency scale (1-10) + "transportation_bool", # Needs transportation support (bool) + "caregiver_bool", # Is primary caregiver (bool) + "housing", # Housing situation (1-10) + "income_source", # Source of income (1-10) + "felony_bool", # Has a felony (bool) + "attending_school", # Currently a student (bool) + "currently_employed", # Currently employed (bool) + "substance_use", # Substance use disorder (bool) + "time_unemployed", # Years unemployed + "need_mental_health_support_bool", # Needs mental health support (bool) ] # Define intervention columns intervention_columns = [ - 'employment_assistance', - 'life_stabilization', - 'retention_services', - 'specialized_services', - 'employment_related_financial_supports', - 'employer_financial_supports', - 'enhanced_referrals' + "employment_assistance", + "life_stabilization", + "retention_services", + "specialized_services", + "employment_related_financial_supports", + "employer_financial_supports", + "enhanced_referrals", ] # Combine all feature columns all_features = feature_columns + intervention_columns # Prepare training data features = np.array(data[all_features]) # Changed from X to features - targets = np.array(data['success_rate']) # Changed from y to targets + targets = np.array(data["success_rate"]) # Changed from y to targets # Split the dataset X_train, x_test, Y_train, y_test = train_test_split( # Removed unused variables features, targets, test_size=test_size, - random_state=random_state + random_state=random_state, ) return X_train, x_test, Y_train, y_test -def train_model(X_train, Y_train, model_type, n_estimators=100, - random_state=42) -> InterfaceBaseMLModel: +def train_model( + X_train, Y_train, model_type, n_estimators=100, random_state=42 +) -> InterfaceBaseMLModel: """ Trains the model Args: @@ -144,7 +149,7 @@ def get_true_file_name(model_type, filename): def save_model(model, model_type, filename=default_unformatted_model_path): """ Save the trained model to a file. - + Args: model: Trained model to save model_type: The type of model being saved @@ -158,11 +163,11 @@ def save_model(model, model_type, filename=default_unformatted_model_path): def load_model(model_type, filename=default_unformatted_model_path): """ Load a trained model from a file. - + Args: model_type: The type of model being loaded filename (str): Name of the file to load the model from - + Returns: The loaded model """ diff --git a/app/database.py b/app/database.py index 3a489f54..b5c8b948 100644 --- a/app/database.py +++ b/app/database.py @@ -7,22 +7,23 @@ from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker -#Here is where the database is located -SQLALCHEMY_DATABASE_URL = "sqlite:///./sql_app.db" +# Here is where the database is located +SQLALCHEMY_DATABASE_URL = "sqlite:///./sql_app.db" -#Open up a connection so that we are able to use the database +# Open up a connection so that we are able to use the database engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) -#Bind the engine just created +# Bind the engine just created SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) -#Create an object of our database so as to control the database +# Create an object of our database so as to control the database Base = declarative_base() + def get_db(): """ Creates a database session and ensures it's closed after use. - + Yields: Session: SQLAlchemy database session """ diff --git a/app/main.py b/app/main.py index adf1af76..66746785 100644 --- a/app/main.py +++ b/app/main.py @@ -5,18 +5,21 @@ """ from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + from app import models -from app.database import engine -from app.clients.router import router as clients_router from app.auth.router import router as auth_router +from app.clients.router import router as clients_router from app.clients.service.ml_models_router import router as ml_models_router -from fastapi.middleware.cors import CORSMiddleware +from app.database import engine # Initialize database tables models.Base.metadata.create_all(bind=engine) # Create FastAPI application -app = FastAPI(title="Case Management API", description="API for managing client cases", version="1.0.0") +app = FastAPI( + title="Case Management API", description="API for managing client cases", version="1.0.0" +) # Include routers app.include_router(auth_router) @@ -26,8 +29,8 @@ # Configure CORS middleware app.add_middleware( CORSMiddleware, - allow_origins=["*"], # Allows all origins - allow_methods=["*"], # Allows all methods - allow_headers=["*"], # Allows all headers + allow_origins=["*"], # Allows all origins + allow_methods=["*"], # Allows all methods + allow_headers=["*"], # Allows all headers allow_credentials=True, ) diff --git a/app/models.py b/app/models.py index df778348..870210e5 100644 --- a/app/models.py +++ b/app/models.py @@ -3,11 +3,14 @@ Contains the Client model for storing client information in the database. """ -from app.database import Base -from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, CheckConstraint, Enum -from sqlalchemy.orm import relationship import enum +from sqlalchemy import Boolean, CheckConstraint, Column, Enum, ForeignKey, Integer, String +from sqlalchemy.orm import relationship + +from app.database import Base + + class UserRole(str, enum.Enum): admin = "admin" case_worker = "case_worker" @@ -24,46 +27,61 @@ class User(Base): cases = relationship("ClientCase", back_populates="user") + class Client(Base): """ Client model representing client data in the database. """ + __tablename__ = "clients" id = Column(Integer, primary_key=True, autoincrement=True) - age = Column(Integer, CheckConstraint('age >= 18')) + age = Column(Integer, CheckConstraint("age >= 18")) gender = Column(Integer, CheckConstraint("gender = 1 OR gender = 2")) - work_experience = Column(Integer, CheckConstraint('work_experience >= 0')) - canada_workex = Column(Integer, CheckConstraint('canada_workex >= 0')) - dep_num = Column(Integer, CheckConstraint('dep_num >= 0')) + work_experience = Column(Integer, CheckConstraint("work_experience >= 0")) + canada_workex = Column(Integer, CheckConstraint("canada_workex >= 0")) + dep_num = Column(Integer, CheckConstraint("dep_num >= 0")) canada_born = Column(Boolean) citizen_status = Column(Boolean) - level_of_schooling = Column(Integer, CheckConstraint('level_of_schooling >= 1 AND level_of_schooling <= 14')) + level_of_schooling = Column( + Integer, CheckConstraint("level_of_schooling >= 1 AND level_of_schooling <= 14") + ) fluent_english = Column(Boolean) - reading_english_scale = Column(Integer, CheckConstraint('reading_english_scale >= 0 AND reading_english_scale <= 10')) - speaking_english_scale = Column(Integer, CheckConstraint('speaking_english_scale >= 0 AND speaking_english_scale <= 10')) - writing_english_scale = Column(Integer, CheckConstraint('writing_english_scale >= 0 AND writing_english_scale <= 10')) - numeracy_scale = Column(Integer, CheckConstraint('numeracy_scale >= 0 AND numeracy_scale <= 10')) - computer_scale = Column(Integer, CheckConstraint('computer_scale >= 0 AND computer_scale <= 10')) + reading_english_scale = Column( + Integer, CheckConstraint("reading_english_scale >= 0 AND reading_english_scale <= 10") + ) + speaking_english_scale = Column( + Integer, CheckConstraint("speaking_english_scale >= 0 AND speaking_english_scale <= 10") + ) + writing_english_scale = Column( + Integer, CheckConstraint("writing_english_scale >= 0 AND writing_english_scale <= 10") + ) + numeracy_scale = Column( + Integer, CheckConstraint("numeracy_scale >= 0 AND numeracy_scale <= 10") + ) + computer_scale = Column( + Integer, CheckConstraint("computer_scale >= 0 AND computer_scale <= 10") + ) transportation_bool = Column(Boolean) caregiver_bool = Column(Boolean) - housing = Column(Integer, CheckConstraint('housing >= 1 AND housing <= 10')) - income_source = Column(Integer, CheckConstraint('income_source >= 1 AND income_source <= 11')) + housing = Column(Integer, CheckConstraint("housing >= 1 AND housing <= 10")) + income_source = Column(Integer, CheckConstraint("income_source >= 1 AND income_source <= 11")) felony_bool = Column(Boolean) attending_school = Column(Boolean) currently_employed = Column(Boolean) substance_use = Column(Boolean) - time_unemployed = Column(Integer, CheckConstraint('time_unemployed >= 0')) + time_unemployed = Column(Integer, CheckConstraint("time_unemployed >= 0")) need_mental_health_support_bool = Column(Boolean) cases = relationship("ClientCase", back_populates="client") + class ClientCase(Base): __tablename__ = "client_cases" client_id = Column(Integer, ForeignKey("clients.id"), primary_key=True) user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) - + employment_assistance = Column(Boolean) life_stabilization = Column(Boolean) retention_services = Column(Boolean) @@ -71,7 +89,7 @@ class ClientCase(Base): employment_related_financial_supports = Column(Boolean) employer_financial_supports = Column(Boolean) enhanced_referrals = Column(Boolean) - success_rate = Column(Integer, CheckConstraint('success_rate >= 0 AND success_rate <= 100')) + success_rate = Column(Integer, CheckConstraint("success_rate >= 0 AND success_rate <= 100")) client = relationship("Client", back_populates="cases") user = relationship("User", back_populates="cases") diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..6dd3a8a7 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,40 @@ +# Global options: +[mypy] +# Enable strict mode for better type checking +strict = false + +# Encourage but don't require type annotations +disallow_untyped_defs = false +# At least annotate return types +disallow_incomplete_defs = true +# Type check the body of functions without annotations +check_untyped_defs = true + +# Allow calling functions without type hints +disallow_untyped_calls = false + +# Be more permissive with 'Any' usage +disallow_any_unimported = false +disallow_any_explicit = false +disallow_any_generics = false +# Still warn about returning Any +warn_return_any = true + +# Don't enforce strict subclassing +disallow_subclassing_any = false + +# Still catch obvious issues +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = false +warn_unreachable = true + +# Allow redefinitions in some contexts +allow_redefinition = true + +# Module import settings +ignore_missing_imports = true +follow_imports = silent + +# Performance improvements +cache_dir = .mypy_cache \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..0d0e2efa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +# Black Configuration +[tool.black] +line-length = 100 +include = '\.pyi?$' +skip-magic-trailing-comma = true +target-version = ['py310'] + +# isort Configuration +[tool.isort] +profile = "black" +line_length = 100 +known_first_party = ["app"] +multi_line_output = 3 +force_grid_wrap = 0 +combine_as_imports = true +include_trailing_comma = true +force_single_line = false +skip = ["venv", ".venv", "migrations"] \ No newline at end of file From fc36fa89832a3d4fd839c1d415aa6885aa95c402 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 24 Mar 2025 17:43:51 -0700 Subject: [PATCH 007/109] Start on .toml file --- pyproject.toml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 0d0e2efa..3e7a4c41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,28 @@ +# Overall Project Configuration +[project] +name = "Common Assessment Tool" +version = "1.0.0" +authors = ["David Treadwell ", +"Steve Chen "] +readme = "README.md" +repository = "https://github.com/dtread4/CommonAssessmentTool" +license = "MIT" +dependencies = {file = ["requirements.txt"]} +requires-python = ">=3.10" + +# Build system +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +# Packages finder +[tool.setuptools.packages.find] +where = ["."] + +# Optional dependency configuration +[project.optional-dependencies] +dev = ["black", "isort"] + # Black Configuration [tool.black] line-length = 100 From 4251c688156a8849bf463975e5c2b5c941c6da8a Mon Sep 17 00:00:00 2001 From: David Date: Tue, 25 Mar 2025 08:46:36 -0700 Subject: [PATCH 008/109] Fixes to toml file --- pyproject.toml | 56 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3e7a4c41..755e2239 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,60 @@ # Overall Project Configuration [project] -name = "Common Assessment Tool" +name = "Common_Assessment_Tool" version = "1.0.0" -authors = ["David Treadwell ", -"Steve Chen "] +authors = [{name = "David Treadwell", email = "treadwell.d@northeastern.edu"}, {name = "Fran Li", email = "li.fengr@northeastern.edu"}, {name = "Steve Chen", email = "chen.steve2@northeastern.edu"}] readme = "README.md" -repository = "https://github.com/dtread4/CommonAssessmentTool" license = "MIT" -dependencies = {file = ["requirements.txt"]} +dependencies = [ + 'annotated-types==0.7.0', + 'anyio==4.4.0', + 'certifi==2024.7.4', + 'click==8.1.7', + 'dnspython==2.6.1', + 'email_validator==2.2.0', + 'fastapi==0.112.2', + 'fastapi-cli==0.0.5', + 'h11==0.14.0', + 'httpcore==1.0.5', + 'httptools==0.6.1', + 'httpx==0.27.2', + 'idna==3.8', + 'Jinja2==3.1.4', + 'joblib==1.4.2', + 'markdown-it-py==3.0.0', + 'MarkupSafe==2.1.5', + 'mdurl==0.1.2', + 'numpy==2.1.0', + 'pandas==2.2.2', + 'pydantic==2.8.2', + 'pydantic_core==2.20.1', + 'Pygments==2.18.0', + 'python-dateutil==2.9.0.post0', + 'python-dotenv==1.0.1', + 'python-multipart==0.0.9', + 'pytz==2024.1', + 'PyYAML==6.0.2', + 'rich==13.8.0', + 'scikit-learn==1.5.1', + 'scipy==1.14.1', + 'shellingham==1.5.4', + 'six==1.16.0', + 'sniffio==1.3.1', + 'starlette==0.38.2', + 'threadpoolctl==3.5.0', + 'typer==0.12.5', + 'typing_extensions==4.12.2', + 'tzdata==2024.1', + 'uvicorn==0.30.6', + 'watchfiles==0.23.0', + 'websockets==13.0' +] requires-python = ">=3.10" +# Project urls +[project.urls] +Repository = "https://github.com/dtread4/CommonAssessmentTool" + # Build system [build-system] requires = ["setuptools"] @@ -22,6 +67,7 @@ where = ["."] # Optional dependency configuration [project.optional-dependencies] dev = ["black", "isort"] +extra = ['uvloop==0.20.0'] # Black Configuration [tool.black] From 6be82558f599af46c6dd852d5f80affa916561d8 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 25 Mar 2025 09:06:36 -0700 Subject: [PATCH 009/109] Update to use .env file --- README.md | 9 ++++++++- app/auth/router.py | 12 ++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b34d6d6b..69070348 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Team TicTech +Team SuperSonics via TicTech Project -- Feature Development Backend: Create CRUD API's for Client @@ -22,6 +22,13 @@ This also has an API file to interact with the front end, and logic in order to -------------------------How to Use------------------------- 1. In the virtual environment you've created for this project, install all dependencies in requirements.txt (pip install -r requirements.txt) +2. Create a .env file with the following fields: +```markdown +SECRET_KEY = "your-secret-key-here" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 +``` + 2. Run the app (uvicorn app.main:app --reload) 3. Load data into database (python initialize_data.py) diff --git a/app/auth/router.py b/app/auth/router.py index 3f5455ee..edc48165 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -11,6 +11,9 @@ from app.database import get_db from app.models import User, UserRole +from dotenv import load_dotenv +import os + router = APIRouter(prefix="/auth", tags=["authentication"]) @@ -36,10 +39,11 @@ class Config: from_attributes = True -# Configuration -SECRET_KEY = "your-secret-key-here" -ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 30 +# Load configuration from .env +load_dotenv() +SECRET_KEY = os.getenv("SECRET_KEY") +ALGORITHM = os.getenv("ALGORITHM") +ACCESS_TOKEN_EXPIRE_MINUTES = os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") From e8cf6e7aba2c66a527143140d32aae3e969be3c9 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 25 Mar 2025 09:06:54 -0700 Subject: [PATCH 010/109] Update README --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 69070348..9f3af03f 100644 --- a/README.md +++ b/README.md @@ -29,15 +29,15 @@ ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 ``` -2. Run the app (uvicorn app.main:app --reload) +3. Run the app (uvicorn app.main:app --reload) -3. Load data into database (python initialize_data.py) +4. Load data into database (python initialize_data.py) -4. Go to SwaggerUI (http://127.0.0.1:8000/docs) +5. Go to SwaggerUI (http://127.0.0.1:8000/docs) -4. Log in as admin (username: admin password: admin123) +6. Log in as admin (username: admin password: admin123) -5. Click on each endpoint to use +7. Click on each endpoint to use -Create User (Only users in admin role can create new users. The role field needs to be either "admin" or "case_worker") -Get clients (Display all the clients that are in the database) From 223da652b55e68dbfb5e099d309e1cb6ccdbc926 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 25 Mar 2025 09:18:35 -0700 Subject: [PATCH 011/109] Add .env file per requirements --- .env | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 00000000..f292cb8d --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +SECRET_KEY = "your-secret-key-here" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 \ No newline at end of file From 9e1bd774feab4d03dbc8763c3bc745bcfd8c7fc8 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 28 Mar 2025 09:44:18 -0700 Subject: [PATCH 012/109] Create Docker compose file --- docker-compose.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..e69de29b From 862d44542776a8265895f46e94cfce3e64cbedc7 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 28 Mar 2025 09:44:57 -0700 Subject: [PATCH 013/109] Bug fix with .env loading --- app/auth/router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/auth/router.py b/app/auth/router.py index edc48165..50e07890 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -117,7 +117,7 @@ async def login_for_access_token( detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}, ) - access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token_expires = timedelta(minutes=int(ACCESS_TOKEN_EXPIRE_MINUTES)) access_token = create_access_token( data={"sub": user.username}, expires_delta=access_token_expires ) From 0ab65c9cc270d2b1bba84d6d59eef66dd8f475ed Mon Sep 17 00:00:00 2001 From: David Date: Fri, 28 Mar 2025 10:55:53 -0700 Subject: [PATCH 014/109] Update requirements and toml file --- app/requirements.txt | 1 - pyproject.toml | 49 +++++--------------------------------------- requirements.txt | 2 -- 3 files changed, 5 insertions(+), 47 deletions(-) diff --git a/app/requirements.txt b/app/requirements.txt index 57fc8e6e..ddcf118a 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -38,6 +38,5 @@ typer==0.12.5 typing_extensions==4.12.2 tzdata==2024.1 uvicorn==0.30.6 -uvloop==0.20.0 watchfiles==0.23.0 websockets==13.0 diff --git a/pyproject.toml b/pyproject.toml index 755e2239..820b4f6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,52 +5,13 @@ version = "1.0.0" authors = [{name = "David Treadwell", email = "treadwell.d@northeastern.edu"}, {name = "Fran Li", email = "li.fengr@northeastern.edu"}, {name = "Steve Chen", email = "chen.steve2@northeastern.edu"}] readme = "README.md" license = "MIT" -dependencies = [ - 'annotated-types==0.7.0', - 'anyio==4.4.0', - 'certifi==2024.7.4', - 'click==8.1.7', - 'dnspython==2.6.1', - 'email_validator==2.2.0', - 'fastapi==0.112.2', - 'fastapi-cli==0.0.5', - 'h11==0.14.0', - 'httpcore==1.0.5', - 'httptools==0.6.1', - 'httpx==0.27.2', - 'idna==3.8', - 'Jinja2==3.1.4', - 'joblib==1.4.2', - 'markdown-it-py==3.0.0', - 'MarkupSafe==2.1.5', - 'mdurl==0.1.2', - 'numpy==2.1.0', - 'pandas==2.2.2', - 'pydantic==2.8.2', - 'pydantic_core==2.20.1', - 'Pygments==2.18.0', - 'python-dateutil==2.9.0.post0', - 'python-dotenv==1.0.1', - 'python-multipart==0.0.9', - 'pytz==2024.1', - 'PyYAML==6.0.2', - 'rich==13.8.0', - 'scikit-learn==1.5.1', - 'scipy==1.14.1', - 'shellingham==1.5.4', - 'six==1.16.0', - 'sniffio==1.3.1', - 'starlette==0.38.2', - 'threadpoolctl==3.5.0', - 'typer==0.12.5', - 'typing_extensions==4.12.2', - 'tzdata==2024.1', - 'uvicorn==0.30.6', - 'watchfiles==0.23.0', - 'websockets==13.0' -] +dynamic = ["dependencies"] requires-python = ">=3.10" +# Set up dependencies from requirements.txt file +[tool.setuptools.dynamic] +dependencies = {file = ["requirements.txt"]} + # Project urls [project.urls] Repository = "https://github.com/dtread4/CommonAssessmentTool" diff --git a/requirements.txt b/requirements.txt index c5910a3b..d098b41c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -133,7 +133,6 @@ tzdata==2023.3 uri-template==1.2.0 urllib3==2.0.7 uvicorn==0.23.2 -uvloop==0.17.0 watchfiles==0.20.0 wcwidth==0.2.8 webcolors==1.13 @@ -141,4 +140,3 @@ webencodings==0.5.1 websocket-client==1.5.1 websockets==11.0.3 widgetsnbextension==4.0.7 -mypy=1.15.1 From 4a902b926ace0c326c81a47ef50fb99624c9a5ee Mon Sep 17 00:00:00 2001 From: David Date: Fri, 28 Mar 2025 10:57:31 -0700 Subject: [PATCH 015/109] Add Docker compose file --- docker-compose.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index e69de29b..d3b893b6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + web: + build: . + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] + ports: + - "8000:8000" + develop: + watch: + - action: sync + path: . + target: /code \ No newline at end of file From 3d751165b7c231b5da3c6e52c8be8f62bd424dbb Mon Sep 17 00:00:00 2001 From: David Date: Fri, 28 Mar 2025 11:02:50 -0700 Subject: [PATCH 016/109] Update README to include Docker instructions --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 9f3af03f..791b889c 100644 --- a/README.md +++ b/README.md @@ -62,3 +62,17 @@ ACCESS_TOKEN_EXPIRE_MINUTES = 30 -Create case assignment (Allow authorized users to create a new case assignment.) +## Docker Instructions +1. Follow installation guide from Docker: https://www.docker.com/blog/how-to-dockerize-your-python-applications/ +2. WINDOWS-SPECIFIC: Ensure virtualization is enabled in your system BIOS, or Docker cannot run +3. Open the Docker Desktop application +4. In a command prompt, navigate to the CommonAssessmentTool repo's directory on your machine (assumes you already cloned from GitHub) and run the command below (make sure the period at the end is included!): +``` +docker build -t common_assessment_tool . +``` +5. Now run docker run --rm -p 8000:8000 common_assessment_tool +6. Follow the steps to run the Swagger UI as described above (clicking link in step 5 should take you to the UI) +7. To run using Docker-Compose, run the command below in the CommonAssessmentTool repo's directory +``` +docker compose up +``` \ No newline at end of file From 74b1e0018184135b117c3278484cf972c88b1900 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 28 Mar 2025 11:23:08 -0700 Subject: [PATCH 017/109] Better README --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 791b889c..3e831058 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,10 @@ ACCESS_TOKEN_EXPIRE_MINUTES = 30 ``` docker build -t common_assessment_tool . ``` -5. Now run docker run --rm -p 8000:8000 common_assessment_tool +5. Now run with the following Docker command: +``` +docker run --rm -p 8000:8000 common_assessment_tool +``` 6. Follow the steps to run the Swagger UI as described above (clicking link in step 5 should take you to the UI) 7. To run using Docker-Compose, run the command below in the CommonAssessmentTool repo's directory ``` From b4b9ef7938c7910763f7429d628f893bcb1f2f19 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 31 Mar 2025 11:56:00 -0700 Subject: [PATCH 018/109] Update README to prevent possible DB error --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3e831058..70b0c190 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,9 @@ ACCESS_TOKEN_EXPIRE_MINUTES = 30 3. Run the app (uvicorn app.main:app --reload) -4. Load data into database (python initialize_data.py) +4. Go to SwaggerUI (http://127.0.0.1:8000/docs) -5. Go to SwaggerUI (http://127.0.0.1:8000/docs) +5. Load data into database (python initialize_data.py) (if receiving an error, make sure the app is running and open, then try again) 6. Log in as admin (username: admin password: admin123) From 06f99ee6b0d0c10a3d9cf11eb21e4e04010dc0fc Mon Sep 17 00:00:00 2001 From: Fran Date: Wed, 2 Apr 2025 18:39:55 -0700 Subject: [PATCH 019/109] added prediction endpoint --- app/auth/router.py | 5 +- app/clients/service/ml_models.py | 40 +++++++++ app/clients/service/ml_models_router.py | 38 ++++++++- app/clients/service/model.py | 1 + app/clients/service/model_helper.py | 51 +++++++++++ app/clients/service/models.py | 107 ++++++++++++++++++++++++ 6 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 app/clients/service/model_helper.py create mode 100644 app/clients/service/models.py diff --git a/app/auth/router.py b/app/auth/router.py index 50e07890..74ca5a8d 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -1,6 +1,8 @@ +import os from datetime import datetime, timedelta from typing import Optional +from dotenv import load_dotenv from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from jose import JWTError, jwt @@ -11,9 +13,6 @@ from app.database import get_db from app.models import User, UserRole -from dotenv import load_dotenv -import os - router = APIRouter(prefix="/auth", tags=["authentication"]) diff --git a/app/clients/service/ml_models.py b/app/clients/service/ml_models.py index 50957926..b69fef59 100644 --- a/app/clients/service/ml_models.py +++ b/app/clients/service/ml_models.py @@ -1,3 +1,4 @@ +import os from abc import ABC, abstractmethod from typing import List @@ -6,10 +7,19 @@ from sklearn.linear_model import LinearRegression from sklearn.svm import SVR +from app.clients.service.model_helper import get_all_feature_columns, get_true_file_name + +default_unformatted_model_path = os.path.join( + os.path.dirname(__file__), "pretrained_models", "model_{}.pkl" +) + class InterfaceBaseMLModel(ABC): """Interface of a base ML Model""" + def __init__(self): + self.feature_columns = get_all_feature_columns() + @abstractmethod def fit(self, X: np.ndarray, y: np.ndarray): pass @@ -39,6 +49,7 @@ def __str__(self) -> str: class LinearRegressionModel(InterfaceBaseMLModel): def __init__(self): + super().__init__() self.model = LinearRegression() def fit(self, X, y): @@ -50,9 +61,19 @@ def predict(self, X): def __str__(self): return "Linear Regression" + def _load_if_trained(self): + path = get_true_file_name(str(self), default_unformatted_model_path) + print(f"Attempting to load model from: {path}") + if os.path.exists(path): + print(f"Model file exists, loading...") + self.model = InterfaceBaseMLModel.load(path) + else: + print(f"Model file not found at {path}") + class RandomForestModel(InterfaceBaseMLModel): def __init__(self, n_estimators=100, random_state=42): + super().__init__() self.model = RandomForestRegressor(n_estimators=n_estimators, random_state=random_state) def fit(self, X, y): @@ -64,9 +85,19 @@ def predict(self, X): def __str__(self): return "Random Forest Regressor" + def _load_if_trained(self): + path = get_true_file_name(str(self), default_unformatted_model_path) + print(f"Attempting to load model from: {path}") + if os.path.exists(path): + print(f"Model file exists, loading...") + self.model = InterfaceBaseMLModel.load(path) + else: + print(f"Model file not found at {path}") + class SVMModel(InterfaceBaseMLModel): def __init__(self): + super().__init__() self.model = SVR() def fit(self, X, y): @@ -78,6 +109,15 @@ def predict(self, X): def __str__(self): return "Support Vector Machine" + def _load_if_trained(self): + path = get_true_file_name(str(self), default_unformatted_model_path) + print(f"Attempting to load model from: {path}") + if os.path.exists(path): + print(f"Model file exists, loading...") + self.model = InterfaceBaseMLModel.load(path) + else: + print(f"Model file not found at {path}") + class InterfaceMLModelRepository(ABC): """Interface for ML Models storage""" diff --git a/app/clients/service/ml_models_router.py b/app/clients/service/ml_models_router.py index d1070153..f875c419 100644 --- a/app/clients/service/ml_models_router.py +++ b/app/clients/service/ml_models_router.py @@ -1,8 +1,10 @@ +import numpy as np from fastapi import APIRouter, HTTPException from app.clients.service.ml_models import MLModelManager, MLModelRepository +from app.clients.service.models import PredictionFeatures, PredictionRequest -router = APIRouter(prefix="/ml_models") +router = APIRouter(prefix="/ml_models", tags=["model"]) model_repository = MLModelRepository() model_manager = MLModelManager(model_repository) @@ -28,3 +30,37 @@ def current_model(): """Get the current ML model""" # return {"current_model": model_manager.get_current_model()} return {"current_model": str(model_manager.get_current_model())} + + +@router.post("/predict/{model_name}") +def predict_with_model_name(features: PredictionFeatures, model_name: str): + """Predict based on a given ML model name""" + prediction_request = PredictionRequest.from_structured_features(features) + model = model_repository.get_model_instance(model_name) + model._load_if_trained() + try: + prediction = model.predict(np.array([prediction_request.features])) + return { + "model": str(model), + "input": prediction_request.features, + "prediction": prediction.tolist(), + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Prediction failed: {str(e)}") + + +@router.post("/predict") +def predict_with_current_model(features: PredictionFeatures): + """Predict based on current ML model""" + prediction_request = PredictionRequest.from_structured_features(features) + model = model_manager.get_current_model() + model._load_if_trained() + try: + prediction = model.predict(np.array([prediction_request.features])) + return { + "model": str(model), + "input": prediction_request.features, + "prediction": prediction.tolist(), + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Prediction failed: {str(e)}") diff --git a/app/clients/service/model.py b/app/clients/service/model.py index cba85686..2e830fc8 100644 --- a/app/clients/service/model.py +++ b/app/clients/service/model.py @@ -13,6 +13,7 @@ # Third-party imports import numpy as np import pandas as pd +from fastapi import HTTPException # Local imports from ml_models import ( diff --git a/app/clients/service/model_helper.py b/app/clients/service/model_helper.py new file mode 100644 index 00000000..0fdd8722 --- /dev/null +++ b/app/clients/service/model_helper.py @@ -0,0 +1,51 @@ +def get_feature_columns(): + """Get all feature columns""" + return [ + "age", + "gender", + "work_experience", + "canada_workex", + "dep_num", + "canada_born", + "citizen_status", + "level_of_schooling", + "fluent_english", + "reading_english_scale", + "speaking_english_scale", + "writing_english_scale", + "numeracy_scale", + "computer_scale", + "transportation_bool", + "caregiver_bool", + "housing", + "income_source", + "felony_bool", + "attending_school", + "currently_employed", + "substance_use", + "time_unemployed", + "need_mental_health_support_bool", + ] + + +def get_intervention_columns(): + """Get all intervention columns""" + return [ + "employment_assistance", + "life_stabilization", + "retention_services", + "specialized_services", + "employment_related_financial_supports", + "employer_financial_supports", + "enhanced_referrals", + ] + + +def get_all_feature_columns(): + """Get all feature columns""" + return get_feature_columns() + get_intervention_columns() + + +def get_true_file_name(model_type, filename): + """Format pikle file name""" + return filename.format(model_type).replace(" ", "_") diff --git a/app/clients/service/models.py b/app/clients/service/models.py new file mode 100644 index 00000000..904ec86d --- /dev/null +++ b/app/clients/service/models.py @@ -0,0 +1,107 @@ +from typing import List + +from pydantic import BaseModel, Field + + +class PredictionFeatures(BaseModel): + """Template class prediction class""" + + age: float = Field(..., description="Client's age", example=30) + gender: float = Field(..., description="Client's gender (1 for male, 0 for female)", example=1) + work_experience: float = Field(..., description="Years of work experience", example=5) + canada_workex: float = Field(..., description="Years of work experience in Canada", example=2) + dep_num: float = Field(..., description="Number of dependents", example=1) + canada_born: float = Field(..., description="Born in Canada (1 for yes, 0 for no)", example=0) + citizen_status: float = Field(..., description="Citizenship status", example=1) + level_of_schooling: float = Field(..., description="Highest level achieved (1-14)", example=8) + fluent_english: float = Field(..., description="English fluency scale (1-10)", example=7) + reading_english_scale: float = Field(..., description="Reading ability scale (1-10)", example=6) + speaking_english_scale: float = Field( + ..., description="Speaking ability scale (1-10)", example=6 + ) + writing_english_scale: float = Field(..., description="Writing ability scale (1-10)", example=5) + numeracy_scale: float = Field(..., description="Numeracy ability scale (1-10)", example=7) + computer_scale: float = Field(..., description="Computer proficiency scale (1-10)", example=6) + transportation_bool: float = Field( + ..., description="Needs transportation support (1 for yes, 0 for no)", example=0 + ) + caregiver_bool: float = Field( + ..., description="Is primary caregiver (1 for yes, 0 for no)", example=0 + ) + housing: float = Field(..., description="Housing situation (1-10)", example=3) + income_source: float = Field(..., description="Source of income (1-10)", example=2) + felony_bool: float = Field(..., description="Has a felony (1 for yes, 0 for no)", example=0) + attending_school: float = Field( + ..., description="Currently a student (1 for yes, 0 for no)", example=0 + ) + currently_employed: float = Field( + ..., description="Currently employed (1 for yes, 0 for no)", example=0 + ) + substance_use: float = Field( + ..., description="Substance use disorder (1 for yes, 0 for no)", example=0 + ) + time_unemployed: float = Field(..., description="Years unemployed", example=1) + need_mental_health_support_bool: float = Field( + ..., description="Needs mental health support (1 for yes, 0 for no)", example=0 + ) + # Intervention columns + employment_assistance: float = Field( + ..., description="Employment assistance intervention", example=1 + ) + life_stabilization: float = Field(..., description="Life stabilization intervention", example=1) + retention_services: float = Field(..., description="Retention services intervention", example=0) + specialized_services: float = Field( + ..., description="Specialized services intervention", example=0 + ) + employment_related_financial_supports: float = Field( + ..., description="Employment related financial supports", example=1 + ) + employer_financial_supports: float = Field( + ..., description="Employer financial supports", example=0 + ) + enhanced_referrals: float = Field(..., description="Enhanced referrals", example=0) + + +class PredictionRequest(BaseModel): + """Template class for prediction request""" + + features: List[float] = Field( + ..., description="List of 31 features in specific order for model prediction" + ) + + @classmethod + def from_structured_features(cls, structured_features: PredictionFeatures): + features = [ + structured_features.age, + structured_features.gender, + structured_features.work_experience, + structured_features.canada_workex, + structured_features.dep_num, + structured_features.canada_born, + structured_features.citizen_status, + structured_features.level_of_schooling, + structured_features.fluent_english, + structured_features.reading_english_scale, + structured_features.speaking_english_scale, + structured_features.writing_english_scale, + structured_features.numeracy_scale, + structured_features.computer_scale, + structured_features.transportation_bool, + structured_features.caregiver_bool, + structured_features.housing, + structured_features.income_source, + structured_features.felony_bool, + structured_features.attending_school, + structured_features.currently_employed, + structured_features.substance_use, + structured_features.time_unemployed, + structured_features.need_mental_health_support_bool, + structured_features.employment_assistance, + structured_features.life_stabilization, + structured_features.retention_services, + structured_features.specialized_services, + structured_features.employment_related_financial_supports, + structured_features.employer_financial_supports, + structured_features.enhanced_referrals, + ] + return cls(features=features) From 86737deb76f17384d53eae9ea8b5717f56c8e7f9 Mon Sep 17 00:00:00 2001 From: Steve Chen Date: Thu, 3 Apr 2025 16:12:46 -0700 Subject: [PATCH 020/109] linter and formatter --- .github/workflows/ci.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30c81bdb..88fe389b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,6 @@ name: Python CI Pipeline on: push: - branches: [master, main] pull_request: branches: [master, main] @@ -24,13 +23,22 @@ jobs: python -m pip install --upgrade pip # Upgrade pip to the latest version pip install setuptools wheel pip install -r requirements.txt # Install dependencies from requirements.txt - pip install pylint pytest + pip install pylint pytest black - name: Run Tests run: | python -m pytest tests/ + + - name: Run Linter + run: | + pylint . + + - name: Run Code Formatting with Black + run: | + black --check . # Check the format of entire repo - name: Print Success Message + if: success() run: | echo "CI Pipeline completed successfully!" echo "========================" @@ -39,4 +47,5 @@ jobs: echo "✓ Dependencies installed" echo "✓ Tests executed" echo "✓ Linting completed" + echo "✓ Formatting checked" echo "========================" From 3bd8d8a8c13e5714177d773649a74ce758ffca83 Mon Sep 17 00:00:00 2001 From: Steve Chen Date: Thu, 3 Apr 2025 16:19:36 -0700 Subject: [PATCH 021/109] linter error --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88fe389b..7382cdc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - name: Run Linter run: | - pylint . + pylint app tests - name: Run Code Formatting with Black run: | From f676d8a1c8ea8deec83ec31392ede00f8a36c327 Mon Sep 17 00:00:00 2001 From: Steve Chen Date: Thu, 3 Apr 2025 19:27:51 -0700 Subject: [PATCH 022/109] pylint adjustments --- .github/workflows/ci.yml | 10 +-- app/auth/router.py | 18 ++--- app/clients/router.py | 5 +- app/clients/schema.py | 4 +- app/clients/service/client_service.py | 33 ++++----- app/clients/service/ml_models.py | 15 +--- app/clients/service/model.py | 8 +-- app/models.py | 4 +- initialize_data.py | 99 +++++++++++++++------------ requirements.txt | 4 +- tests/conftest.py | 46 +++++++------ tests/test_auth.py | 76 ++++++++------------ tests/test_clients.py | 63 +++++++---------- 13 files changed, 177 insertions(+), 208 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7382cdc9..dafee754 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,13 +29,13 @@ jobs: run: | python -m pytest tests/ - - name: Run Linter - run: | - pylint app tests - - name: Run Code Formatting with Black run: | - black --check . # Check the format of entire repo + black . # Format the entire repo + + - name: Run Linter + run: | + pylint ./app ./tests - name: Print Success Message if: success() diff --git a/app/auth/router.py b/app/auth/router.py index 50e07890..9138445a 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -1,6 +1,9 @@ +# pylint: disable=unused-argument from datetime import datetime, timedelta from typing import Optional +import os + from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from jose import JWTError, jwt @@ -8,12 +11,11 @@ from pydantic import BaseModel, Field, validator from sqlalchemy.orm import Session +from dotenv import load_dotenv + from app.database import get_db from app.models import User, UserRole -from dotenv import load_dotenv -import os - router = APIRouter(prefix="/auth", tags=["authentication"]) @@ -25,7 +27,7 @@ class UserCreate(BaseModel): @validator("role") def validate_role(cls, v): - if v not in [UserRole.admin, UserRole.case_worker]: + if v not in [UserRole.ADMIN, UserRole.CASE_WORKER]: raise ValueError("Role must be either admin or case_worker") return v @@ -88,8 +90,8 @@ async def get_current_user( username: str = payload.get("sub") if username is None: raise credentials_exception - except JWTError: - raise credentials_exception + except JWTError as exc: + raise credentials_exception from exc user = db.query(User).filter(User.username == username).first() if user is None: @@ -98,7 +100,7 @@ async def get_current_user( def get_admin_user(current_user: User = Depends(get_current_user)): - if current_user.role != UserRole.admin: + if current_user.role != UserRole.ADMIN: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only admin users can perform this operation", @@ -158,4 +160,4 @@ async def create_user( return db_user except Exception as e: db.rollback() - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) from e diff --git a/app/clients/router.py b/app/clients/router.py index 45c385e5..1ab042a6 100644 --- a/app/clients/router.py +++ b/app/clients/router.py @@ -2,10 +2,11 @@ Router module for client-related endpoints. Handles all HTTP requests for client operations including create, read, update, and delete. """ +# pylint: disable=unused-argument from typing import List, Optional -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, Query, status from sqlalchemy.orm import Session from app.auth.router import get_admin_user, get_current_user @@ -18,7 +19,7 @@ ) from app.clients.service.client_service import ClientService from app.database import get_db -from app.models import User, UserRole +from app.models import User router = APIRouter(prefix="/clients", tags=["clients"]) diff --git a/app/clients/schema.py b/app/clients/schema.py index 0120d1cf..d47bc982 100644 --- a/app/clients/schema.py +++ b/app/clients/schema.py @@ -7,9 +7,7 @@ from typing import List, Optional # Standard library imports -from pydantic import BaseModel, Field, validator - -from app.models import UserRole +from pydantic import BaseModel, Field # Enums for validation diff --git a/app/clients/service/client_service.py b/app/clients/service/client_service.py index 16115ec1..63dfd269 100644 --- a/app/clients/service/client_service.py +++ b/app/clients/service/client_service.py @@ -10,7 +10,7 @@ from sqlalchemy import and_ from sqlalchemy.orm import Session -from app.clients.schema import ClientUpdate, ServiceResponse, ServiceUpdate +from app.clients.schema import ClientUpdate, ServiceUpdate from app.models import Client, ClientCase, User @@ -20,35 +20,30 @@ class InterfaceClientQueryService(ABC): @abstractmethod def get_client(self, db: Session, client_id: int) -> Client: """Get a specific client by ID""" - pass @abstractmethod def get_clients(self, db: Session, skip: int, limit: int) -> Dict[str, Any]: """Get clients with optional pagination.""" - pass @abstractmethod def get_clients_by_criteria(self, db: Session, **criteria) -> List[Client]: """Get clients filtered by any combination of criteria""" - pass @abstractmethod def get_clients_by_services(self, db: Session, **service_filters) -> List[Client]: """Get clients filtered by multiple service statuses.""" - pass - @abstractmethod def get_client_services(self, db: Session, client_id: int) -> List[ClientCase]: - pass + """Get client's services""" @abstractmethod def get_clients_by_success_rate(self, db: Session, min_rate: int) -> List[Client]: - pass + "Get clients filtered by success rate" @abstractmethod def get_clients_by_case_worker(self, db: Session, case_worker_id: int) -> List[Client]: - pass + "Get clients filtered by case worker" class InterfaceClientManagementService(ABC): @@ -59,26 +54,22 @@ def update_client( self, db: Session, client_id: int, client_update: ClientUpdate ) -> ClientUpdate: """Update a client's information""" - pass @abstractmethod def update_client_services( self, db: Session, client_id: int, user_id: int, service_update: ServiceUpdate ) -> ClientCase: """Update a client's services and outcomes for a specific caseworker""" - pass @abstractmethod def create_case_assignment( self, db: Session, client_id: int, case_worker_id: int ) -> ClientCase: """Create a new case assignment""" - pass @abstractmethod def delete_client(self, db: Session, client_id: int) -> None: """Delete a client and their associated records""" - pass class ClientQueryService(InterfaceClientQueryService): @@ -145,7 +136,7 @@ def get_clients_by_criteria( """Get clients filtered by any combination of criteria""" query = db.query(Client) - if education_level is not None and not (1 <= education_level <= 14): + if education_level is not None and not 1 <= education_level <= 14: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Education level must be between 1 and 14", @@ -219,7 +210,7 @@ def get_clients_by_criteria( raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error retrieving clients: {str(e)}", - ) + ) from e @staticmethod def get_clients_by_services(db: Session, **service_filters: Optional[bool]): @@ -239,7 +230,7 @@ def get_clients_by_services(db: Session, **service_filters: Optional[bool]): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error retrieving clients: {str(e)}", - ) + ) from e @staticmethod def get_client_services(db: Session, client_id: int): @@ -255,7 +246,7 @@ def get_client_services(db: Session, client_id: int): @staticmethod def get_clients_by_success_rate(db: Session, min_rate: int = 70): """Get clients with success rate at or above the specified percentage""" - if not (0 <= min_rate <= 100): + if not 0 <= min_rate <= 100: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Success rate must be between 0 and 100", @@ -302,7 +293,7 @@ def update_client(db: Session, client_id: int, client_update: ClientUpdate): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update client: {str(e)}", - ) + ) from e @staticmethod def update_client_services( @@ -335,7 +326,7 @@ def update_client_services( raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update client services: {str(e)}", - ) + ) from e @staticmethod def create_case_assignment(db: Session, client_id: int, case_worker_id: int): @@ -393,7 +384,7 @@ def create_case_assignment(db: Session, client_id: int, case_worker_id: int): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to create case assignment: {str(e)}", - ) + ) from e @staticmethod def delete_client(db: Session, client_id: int): @@ -419,7 +410,7 @@ def delete_client(db: Session, client_id: int): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to delete client: {str(e)}", - ) + ) from e class ClientService: diff --git a/app/clients/service/ml_models.py b/app/clients/service/ml_models.py index 50957926..bb620ec5 100644 --- a/app/clients/service/ml_models.py +++ b/app/clients/service/ml_models.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from typing import List +import pickle import numpy as np from sklearn.ensemble import RandomForestRegressor from sklearn.linear_model import LinearRegression @@ -12,29 +13,24 @@ class InterfaceBaseMLModel(ABC): @abstractmethod def fit(self, X: np.ndarray, y: np.ndarray): - pass + """Fit the model to provided data""" @abstractmethod def predict(self, X: np.ndarray) -> np.ndarray: - pass + """Predict using the fitted model""" def save(self, path: str): - import pickle - with open(path, "wb") as f: pickle.dump(self, f) @staticmethod def load(path: str): - import pickle - with open(path, "rb") as f: return pickle.load(f) @abstractmethod def __str__(self) -> str: """Return the name of the model""" - pass class LinearRegressionModel(InterfaceBaseMLModel): @@ -85,17 +81,14 @@ class InterfaceMLModelRepository(ABC): @abstractmethod def list_models(self) -> List[InterfaceBaseMLModel]: """Get list of all available models instances""" - pass @abstractmethod def is_model_available(self, model_name: str) -> bool: """Check if a model is valid""" - pass @abstractmethod def get_model_instance(self, model_name: str) -> InterfaceBaseMLModel: """Return an instance of the requested model""" - pass class InterfaceMLModelManager(ABC): @@ -104,12 +97,10 @@ class InterfaceMLModelManager(ABC): @abstractmethod def get_current_model(self) -> InterfaceBaseMLModel: """Get the current active ml model""" - pass @abstractmethod def switch_model(self, model_name: str) -> bool: """Switch between models""" - pass class MLModelRepository(InterfaceMLModelRepository): diff --git a/app/clients/service/model.py b/app/clients/service/model.py index cba85686..49110b95 100644 --- a/app/clients/service/model.py +++ b/app/clients/service/model.py @@ -29,7 +29,7 @@ repo = MLModelRepository() -default_unformatted_model_path = "pretrained_models" + os.sep + "model_{}.pkl" +DEFAULT_UNFORMATTED_MODEL_PATH = "pretrained_models" + os.sep + "model_{}.pkl" def get_model_by_name(model_type: str, n_estimators=100, random_state=42) -> InterfaceBaseMLModel: @@ -146,7 +146,7 @@ def get_true_file_name(model_type, filename): return filename.format(model_type).replace(" ", "_") -def save_model(model, model_type, filename=default_unformatted_model_path): +def save_model(model, model_type, filename=DEFAULT_UNFORMATTED_MODEL_PATH): """ Save the trained model to a file. @@ -160,7 +160,7 @@ def save_model(model, model_type, filename=default_unformatted_model_path): pickle.dump(model, model_file) -def load_model(model_type, filename=default_unformatted_model_path): +def load_model(model_type, filename=DEFAULT_UNFORMATTED_MODEL_PATH): """ Load a trained model from a file. @@ -182,7 +182,7 @@ def main(argv): model_type = argv[1] # Train and save the model - print("Starting model training for {} model...".format(model_type)) + print(f"Starting model training for {model_type} model...") X_train, x_test, Y_train, y_test = prepare_model_data() model = train_model(X_train, Y_train, model_type) save_model(model, model_type) diff --git a/app/models.py b/app/models.py index 870210e5..c824e432 100644 --- a/app/models.py +++ b/app/models.py @@ -12,8 +12,8 @@ class UserRole(str, enum.Enum): - admin = "admin" - case_worker = "case_worker" + ADMIN = "admin" + CASE_WORKER = "case_worker" class User(Base): diff --git a/initialize_data.py b/initialize_data.py index 1444bf41..34c77a68 100644 --- a/initialize_data.py +++ b/initialize_data.py @@ -4,6 +4,7 @@ from app.models import Client, User, ClientCase, UserRole from app.auth.router import get_password_hash + def initialize_database(): print("Starting database initialization...") db = SessionLocal() @@ -15,7 +16,7 @@ def initialize_database(): username="admin", email="admin@example.com", hashed_password=get_password_hash("admin123"), - role=UserRole.admin + role=UserRole.ADMIN, ) db.add(admin_user) db.commit() @@ -30,7 +31,7 @@ def initialize_database(): username="case_worker1", email="caseworker1@example.com", hashed_password=get_password_hash("worker123"), - role=UserRole.case_worker + role=UserRole.CASE_WORKER, ) db.add(case_worker) db.commit() @@ -40,46 +41,57 @@ def initialize_database(): # Load CSV data print("Loading CSV data...") - df = pd.read_csv('app/clients/service/data_commontool.csv') - + df = pd.read_csv("app/clients/service/data_commontool.csv") + # Convert data types integer_columns = [ - 'age', 'gender', 'work_experience', 'canada_workex', 'dep_num', - 'level_of_schooling', 'reading_english_scale', 'speaking_english_scale', - 'writing_english_scale', 'numeracy_scale', 'computer_scale', - 'housing', 'income_source', 'time_unemployed', 'success_rate' + "age", + "gender", + "work_experience", + "canada_workex", + "dep_num", + "level_of_schooling", + "reading_english_scale", + "speaking_english_scale", + "writing_english_scale", + "numeracy_scale", + "computer_scale", + "housing", + "income_source", + "time_unemployed", + "success_rate", ] for col in integer_columns: - df[col] = pd.to_numeric(df[col], errors='raise') + df[col] = pd.to_numeric(df[col], errors="raise") # Process each row in CSV for index, row in df.iterrows(): # Create client client = Client( - age=int(row['age']), - gender=int(row['gender']), - work_experience=int(row['work_experience']), - canada_workex=int(row['canada_workex']), - dep_num=int(row['dep_num']), - canada_born=bool(row['canada_born']), - citizen_status=bool(row['citizen_status']), - level_of_schooling=int(row['level_of_schooling']), - fluent_english=bool(row['fluent_english']), - reading_english_scale=int(row['reading_english_scale']), - speaking_english_scale=int(row['speaking_english_scale']), - writing_english_scale=int(row['writing_english_scale']), - numeracy_scale=int(row['numeracy_scale']), - computer_scale=int(row['computer_scale']), - transportation_bool=bool(row['transportation_bool']), - caregiver_bool=bool(row['caregiver_bool']), - housing=int(row['housing']), - income_source=int(row['income_source']), - felony_bool=bool(row['felony_bool']), - attending_school=bool(row['attending_school']), - currently_employed=bool(row['currently_employed']), - substance_use=bool(row['substance_use']), - time_unemployed=int(row['time_unemployed']), - need_mental_health_support_bool=bool(row['need_mental_health_support_bool']) + age=int(row["age"]), + gender=int(row["gender"]), + work_experience=int(row["work_experience"]), + canada_workex=int(row["canada_workex"]), + dep_num=int(row["dep_num"]), + canada_born=bool(row["canada_born"]), + citizen_status=bool(row["citizen_status"]), + level_of_schooling=int(row["level_of_schooling"]), + fluent_english=bool(row["fluent_english"]), + reading_english_scale=int(row["reading_english_scale"]), + speaking_english_scale=int(row["speaking_english_scale"]), + writing_english_scale=int(row["writing_english_scale"]), + numeracy_scale=int(row["numeracy_scale"]), + computer_scale=int(row["computer_scale"]), + transportation_bool=bool(row["transportation_bool"]), + caregiver_bool=bool(row["caregiver_bool"]), + housing=int(row["housing"]), + income_source=int(row["income_source"]), + felony_bool=bool(row["felony_bool"]), + attending_school=bool(row["attending_school"]), + currently_employed=bool(row["currently_employed"]), + substance_use=bool(row["substance_use"]), + time_unemployed=int(row["time_unemployed"]), + need_mental_health_support_bool=bool(row["need_mental_health_support_bool"]), ) db.add(client) db.commit() @@ -88,14 +100,16 @@ def initialize_database(): client_case = ClientCase( client_id=client.id, user_id=admin_user.id, # Assign to admin - employment_assistance=bool(row['employment_assistance']), - life_stabilization=bool(row['life_stabilization']), - retention_services=bool(row['retention_services']), - specialized_services=bool(row['specialized_services']), - employment_related_financial_supports=bool(row['employment_related_financial_supports']), - employer_financial_supports=bool(row['employer_financial_supports']), - enhanced_referrals=bool(row['enhanced_referrals']), - success_rate=int(row['success_rate']) + employment_assistance=bool(row["employment_assistance"]), + life_stabilization=bool(row["life_stabilization"]), + retention_services=bool(row["retention_services"]), + specialized_services=bool(row["specialized_services"]), + employment_related_financial_supports=bool( + row["employment_related_financial_supports"] + ), + employer_financial_supports=bool(row["employer_financial_supports"]), + enhanced_referrals=bool(row["enhanced_referrals"]), + success_rate=int(row["success_rate"]), ) db.add(client_case) db.commit() @@ -108,5 +122,6 @@ def initialize_database(): finally: db.close() + if __name__ == "__main__": - initialize_database() \ No newline at end of file + initialize_database() diff --git a/requirements.txt b/requirements.txt index d098b41c..b09bed08 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ appnope==0.1.3 argon2-cffi==21.3.0 argon2-cffi-bindings==21.2.0 arrow==1.2.3 -astroid==3.0.1 +astroid==3.3.9 asttokens==2.4.0 attrs==22.1.0 backcall==0.2.0 @@ -95,7 +95,7 @@ pydantic==2.4.2 pydantic-settings==2.0.3 pydantic_core==2.10.1 Pygments==2.16.1 -pylint==3.0.1 +pylint==3.3.6 pyrsistent==0.19.3 pytest==7.2.0 python-dateutil==2.8.2 diff --git a/tests/conftest.py b/tests/conftest.py index aa30d094..fffaf232 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +# pylint: disable=redefined-outer-name import pytest from fastapi.testclient import TestClient from sqlalchemy import create_engine @@ -12,11 +13,12 @@ engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + @pytest.fixture def test_db(): # Create tables Base.metadata.create_all(bind=engine) - + db = TestingSessionLocal() try: # Create test admin user @@ -24,7 +26,7 @@ def test_db(): username="testadmin", email="testadmin@example.com", hashed_password=get_password_hash("testpass123"), - role=UserRole.admin + role=UserRole.ADMIN, ) db.add(admin_user) @@ -33,10 +35,10 @@ def test_db(): username="testworker", email="worker@example.com", hashed_password=get_password_hash("workerpass123"), - role=UserRole.case_worker + role=UserRole.CASE_WORKER, ) db.add(case_worker) - + # Create test clients client1 = Client( age=25, @@ -62,9 +64,9 @@ def test_db(): currently_employed=False, substance_use=False, time_unemployed=6, - need_mental_health_support_bool=False + need_mental_health_support_bool=False, ) - + client2 = Client( age=30, gender=2, @@ -89,13 +91,13 @@ def test_db(): currently_employed=True, substance_use=False, time_unemployed=0, - need_mental_health_support_bool=False + need_mental_health_support_bool=False, ) - + db.add(client1) db.add(client2) db.commit() - + # Create test client cases client_case1 = ClientCase( client_id=1, @@ -107,9 +109,9 @@ def test_db(): employment_related_financial_supports=True, employer_financial_supports=False, enhanced_referrals=True, - success_rate=75 + success_rate=75, ) - + client_case2 = ClientCase( client_id=2, user_id=2, # Assigned to case worker @@ -120,18 +122,19 @@ def test_db(): employment_related_financial_supports=False, employer_financial_supports=True, enhanced_referrals=False, - success_rate=85 + success_rate=85, ) - + db.add(client_case1) db.add(client_case2) db.commit() - + yield db finally: db.close() Base.metadata.drop_all(bind=engine) + @pytest.fixture def client(test_db): def override_get_db(): @@ -139,32 +142,31 @@ def override_get_db(): yield test_db finally: test_db.close() - + app.dependency_overrides[get_db] = override_get_db yield TestClient(app) app.dependency_overrides.clear() + @pytest.fixture def admin_token(client): - response = client.post( - "/auth/token", - data={"username": "testadmin", "password": "testpass123"} - ) + response = client.post("/auth/token", data={"username": "testadmin", "password": "testpass123"}) return response.json()["access_token"] + @pytest.fixture def case_worker_token(client): response = client.post( - "/auth/token", - data={"username": "testworker", "password": "workerpass123"} + "/auth/token", data={"username": "testworker", "password": "workerpass123"} ) return response.json()["access_token"] + @pytest.fixture def admin_headers(admin_token): return {"Authorization": f"Bearer {admin_token}"} + @pytest.fixture def case_worker_headers(case_worker_token): return {"Authorization": f"Bearer {case_worker_token}"} - \ No newline at end of file diff --git a/tests/test_auth.py b/tests/test_auth.py index 1d4692e4..1c24a497 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,123 +1,110 @@ -import pytest from fastapi import status + def test_create_user_success(client, admin_headers): """Test successful user creation by admin""" user_data = { "username": "newuser", "email": "new@test.com", "password": "testpass123", - "role": "case_worker" + "role": "case_worker", } - response = client.post( - "/auth/users", - headers=admin_headers, - json=user_data - ) + response = client.post("/auth/users", headers=admin_headers, json=user_data) assert response.status_code == status.HTTP_200_OK data = response.json() assert data["username"] == "newuser" assert data["role"] == "case_worker" assert "password" not in data # Password should not be in response + def test_create_user_duplicate_username(client, admin_headers): """Test creating user with existing username""" user_data = { "username": "testadmin", # This username exists in test database "email": "another@test.com", "password": "testpass123", - "role": "case_worker" + "role": "case_worker", } - response = client.post( - "/auth/users", - headers=admin_headers, - json=user_data - ) + response = client.post("/auth/users", headers=admin_headers, json=user_data) assert response.status_code == status.HTTP_400_BAD_REQUEST assert "Username already registered" in response.json()["detail"] + def test_create_user_duplicate_email(client, admin_headers): """Test creating user with existing email""" user_data = { "username": "uniqueuser", "email": "testadmin@example.com", # This email exists in test database "password": "testpass123", - "role": "case_worker" + "role": "case_worker", } - response = client.post( - "/auth/users", - headers=admin_headers, - json=user_data - ) + response = client.post("/auth/users", headers=admin_headers, json=user_data) assert response.status_code == status.HTTP_400_BAD_REQUEST assert "Email already registered" in response.json()["detail"] + def test_create_user_invalid_role(client, admin_headers): """Test creating user with invalid role""" user_data = { "username": "newuser", "email": "new@test.com", "password": "testpass123", - "role": "invalid_role" # Invalid role + "role": "invalid_role", # Invalid role } - response = client.post( - "/auth/users", - headers=admin_headers, - json=user_data - ) + response = client.post("/auth/users", headers=admin_headers, json=user_data) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + def test_create_user_unauthorized(client): """Test user creation without authentication""" user_data = { "username": "newuser", "email": "new@test.com", "password": "testpass123", - "role": "case_worker" + "role": "case_worker", } response = client.post("/auth/users", json=user_data) assert response.status_code == status.HTTP_401_UNAUTHORIZED + def test_login_success_admin(client): """Test successful login for admin""" - response = client.post( - "/auth/token", - data={"username": "testadmin", "password": "testpass123"} - ) + response = client.post("/auth/token", data={"username": "testadmin", "password": "testpass123"}) assert response.status_code == status.HTTP_200_OK data = response.json() assert "access_token" in data assert data["token_type"] == "bearer" + def test_login_success_case_worker(client): """Test successful login for case worker""" response = client.post( - "/auth/token", - data={"username": "testworker", "password": "workerpass123"} + "/auth/token", data={"username": "testworker", "password": "workerpass123"} ) assert response.status_code == status.HTTP_200_OK data = response.json() assert "access_token" in data assert data["token_type"] == "bearer" + def test_login_wrong_password(client): """Test login with incorrect password""" response = client.post( - "/auth/token", - data={"username": "testadmin", "password": "wrongpassword"} + "/auth/token", data={"username": "testadmin", "password": "wrongpassword"} ) assert response.status_code == status.HTTP_401_UNAUTHORIZED assert "Incorrect username or password" in response.json()["detail"] + def test_login_nonexistent_user(client): """Test login with non-existent username""" response = client.post( - "/auth/token", - data={"username": "nonexistent", "password": "testpass123"} + "/auth/token", data={"username": "nonexistent", "password": "testpass123"} ) assert response.status_code == status.HTTP_401_UNAUTHORIZED assert "Incorrect username or password" in response.json()["detail"] + def test_invalid_token(client): """Test using invalid token""" headers = {"Authorization": "Bearer invalid_token_here"} @@ -125,12 +112,14 @@ def test_invalid_token(client): assert response.status_code == status.HTTP_401_UNAUTHORIZED assert "Could not validate credentials" in response.json()["detail"] + def test_missing_token(client): """Test accessing protected endpoint without token""" response = client.get("/clients/") assert response.status_code == status.HTTP_401_UNAUTHORIZED assert "Not authenticated" in response.json()["detail"] + def test_token_user_deleted(client, admin_headers): """Test using token of deleted user""" # First create a new user as admin @@ -138,22 +127,15 @@ def test_token_user_deleted(client, admin_headers): "username": "temporary", "email": "temp@test.com", "password": "temppass123", - "role": "admin" # Changed to admin so they can access /clients/ + "role": "admin", # Changed to admin so they can access /clients/ } - response = client.post( - "/auth/users", - headers=admin_headers, - json=user_data - ) + response = client.post("/auth/users", headers=admin_headers, json=user_data) assert response.status_code == status.HTTP_200_OK # Get token for new user - response = client.post( - "/auth/token", - data={"username": "temporary", "password": "temppass123"} - ) + response = client.post("/auth/token", data={"username": "temporary", "password": "temppass123"}) token = response.json()["access_token"] - + # Try using the token headers = {"Authorization": f"Bearer {token}"} response = client.get("/clients/", headers=headers) diff --git a/tests/test_clients.py b/tests/test_clients.py index 611a5b34..e6bd1cf4 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -1,12 +1,13 @@ -import pytest from fastapi import status + # Test GET Operations def test_get_clients_unauthorized(client): """Test that unauthorized access is prevented""" response = client.get("/clients/") assert response.status_code == status.HTTP_401_UNAUTHORIZED + def test_get_clients_as_admin(client, admin_headers): """Test getting all clients as admin""" response = client.get("/clients/", headers=admin_headers) @@ -16,24 +17,24 @@ def test_get_clients_as_admin(client, admin_headers): assert "total" in data assert len(data["clients"]) > 0 + def test_get_client_by_id(client, admin_headers): """Test getting specific client""" # Test existing client response = client.get("/clients/1", headers=admin_headers) assert response.status_code == status.HTTP_200_OK assert response.json()["id"] == 1 - + # Test non-existent client response = client.get("/clients/999", headers=admin_headers) assert response.status_code == status.HTTP_404_NOT_FOUND + def test_get_clients_by_criteria(client, admin_headers): """Test searching clients by various criteria""" # Test single criterion response = client.get( - "/clients/search/by-criteria", - params={"age_min": 25}, - headers=admin_headers + "/clients/search/by-criteria", params={"age_min": 25}, headers=admin_headers ) assert response.status_code == status.HTTP_200_OK assert len(response.json()) > 0 @@ -41,12 +42,8 @@ def test_get_clients_by_criteria(client, admin_headers): # Test multiple criteria response = client.get( "/clients/search/by-criteria", - params={ - "age_min": 25, - "currently_employed": True, - "gender": 2 - }, - headers=admin_headers + params={"age_min": 25, "currently_employed": True, "gender": 2}, + headers=admin_headers, ) assert response.status_code == status.HTTP_200_OK @@ -54,23 +51,22 @@ def test_get_clients_by_criteria(client, admin_headers): response = client.get( "/clients/search/by-criteria", params={"age_min": 15}, # Below minimum age - headers=admin_headers + headers=admin_headers, ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY # Changed from 400 + def test_get_clients_by_services(client, admin_headers): """Test getting clients by service status""" response = client.get( "/clients/search/by-services", - params={ - "employment_assistance": True, - "life_stabilization": True - }, - headers=admin_headers + params={"employment_assistance": True, "life_stabilization": True}, + headers=admin_headers, ) assert response.status_code == status.HTTP_200_OK assert len(response.json()) > 0 + def test_get_client_services(client, admin_headers): """Test getting services for a specific client""" response = client.get("/clients/1/services", headers=admin_headers) @@ -81,63 +77,54 @@ def test_get_client_services(client, admin_headers): assert "employment_assistance" in services[0] assert "success_rate" in services[0] + def test_get_clients_by_success_rate(client, admin_headers): """Test getting clients by success rate threshold""" response = client.get( - "/clients/search/success-rate", - params={"min_rate": 70}, - headers=admin_headers + "/clients/search/success-rate", params={"min_rate": 70}, headers=admin_headers ) assert response.status_code == status.HTTP_200_OK assert len(response.json()) > 0 + def test_get_clients_by_case_worker(client, admin_headers, case_worker_headers): """Test getting clients assigned to a case worker""" # Test as admin response = client.get("/clients/case-worker/2", headers=admin_headers) assert response.status_code == status.HTTP_200_OK - + # Test as case worker response = client.get("/clients/case-worker/2", headers=case_worker_headers) assert response.status_code == status.HTTP_200_OK + # Test UPDATE Operations def test_update_client(client, admin_headers): """Test updating client information""" - update_data = { - "age": 26, - "currently_employed": True, - "time_unemployed": 0 - } - response = client.put( - "/clients/1", - json=update_data, - headers=admin_headers - ) + update_data = {"age": 26, "currently_employed": True, "time_unemployed": 0} + response = client.put("/clients/1", json=update_data, headers=admin_headers) assert response.status_code == status.HTTP_200_OK updated_client = response.json() assert updated_client["age"] == 26 - assert updated_client["currently_employed"] == True + assert updated_client["currently_employed"] assert updated_client["time_unemployed"] == 0 + # Test Create Case Assignment def test_create_case_assignment(client, admin_headers): """Test creating new case assignment""" response = client.post( - "/clients/1/case-assignment", - params={"case_worker_id": 2}, - headers=admin_headers + "/clients/1/case-assignment", params={"case_worker_id": 2}, headers=admin_headers ) assert response.status_code == status.HTTP_200_OK # Test duplicate assignment response = client.post( - "/clients/1/case-assignment", - params={"case_worker_id": 2}, - headers=admin_headers + "/clients/1/case-assignment", params={"case_worker_id": 2}, headers=admin_headers ) assert response.status_code == status.HTTP_400_BAD_REQUEST + # Test DELETE Operation def test_delete_client(client, admin_headers): """Test deleting a client""" From a1562607b6b18b0adab793bfd1f963c3d6fd4e03 Mon Sep 17 00:00:00 2001 From: Steve Chen Date: Fri, 4 Apr 2025 14:11:36 -0700 Subject: [PATCH 023/109] more pylint adjustments --- app/auth/router.py | 2 +- app/clients/router.py | 3 +- app/clients/schema.py | 2 +- app/clients/service/client_service.py | 103 +++++++++++--------------- app/clients/service/constants.py | 26 +++++++ app/clients/service/logic.py | 33 ++------- app/clients/service/ml_models.py | 28 +++---- app/clients/service/model.py | 64 +++++----------- app/models.py | 2 +- 9 files changed, 113 insertions(+), 150 deletions(-) create mode 100644 app/clients/service/constants.py diff --git a/app/auth/router.py b/app/auth/router.py index 9138445a..e82670bf 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -1,4 +1,4 @@ -# pylint: disable=unused-argument +# pylint: disable=unused-argument, no-self-argument, too-few-public-methods from datetime import datetime, timedelta from typing import Optional diff --git a/app/clients/router.py b/app/clients/router.py index 1ab042a6..b08db093 100644 --- a/app/clients/router.py +++ b/app/clients/router.py @@ -2,8 +2,7 @@ Router module for client-related endpoints. Handles all HTTP requests for client operations including create, read, update, and delete. """ -# pylint: disable=unused-argument - +# pylint: disable=unused-argument, too-many-arguments, too-many-positional-arguments, too-many-locals from typing import List, Optional from fastapi import APIRouter, Depends, Query, status diff --git a/app/clients/schema.py b/app/clients/schema.py index d47bc982..9f94ee60 100644 --- a/app/clients/schema.py +++ b/app/clients/schema.py @@ -2,7 +2,7 @@ Pydantic models for data validation and serialization. Defines schemas for client data, predictions, and API responses. """ - +# pylint: disable=too-few-public-methods from enum import IntEnum from typing import List, Optional diff --git a/app/clients/service/client_service.py b/app/clients/service/client_service.py index 63dfd269..242855be 100644 --- a/app/clients/service/client_service.py +++ b/app/clients/service/client_service.py @@ -2,12 +2,11 @@ Client service module handling all database operations for clients. Provides CRUD operations and business logic for client management. """ - +# pylint: disable=arguments-differ, arguments-renamed, too-many-arguments, too-many-positional-arguments, too-many-locals from abc import ABC, abstractmethod from typing import Any, Dict, List, Optional from fastapi import HTTPException, status -from sqlalchemy import and_ from sqlalchemy.orm import Session from app.clients.schema import ClientUpdate, ServiceUpdate @@ -134,8 +133,6 @@ def get_clients_by_criteria( need_mental_health_support_bool: Optional[bool] = None, ): """Get clients filtered by any combination of criteria""" - query = db.query(Client) - if education_level is not None and not 1 <= education_level <= 14: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -153,59 +150,44 @@ def get_clients_by_criteria( ) # Apply filters for non-None values - if employment_status is not None: - query = query.filter(Client.currently_employed == employment_status) - if age_min is not None: - query = query.filter(Client.age >= age_min) - if gender is not None: - query = query.filter(Client.gender == gender) - if education_level is not None: - query = query.filter(Client.level_of_schooling == education_level) - if work_experience is not None: - query = query.filter(Client.work_experience == work_experience) - if canada_workex is not None: - query = query.filter(Client.canada_workex == canada_workex) - if dep_num is not None: - query = query.filter(Client.dep_num == dep_num) - if canada_born is not None: - query = query.filter(Client.canada_born == canada_born) - if citizen_status is not None: - query = query.filter(Client.citizen_status == citizen_status) - if fluent_english is not None: - query = query.filter(Client.fluent_english == fluent_english) - if reading_english_scale is not None: - query = query.filter(Client.reading_english_scale == reading_english_scale) - if speaking_english_scale is not None: - query = query.filter(Client.speaking_english_scale == speaking_english_scale) - if writing_english_scale is not None: - query = query.filter(Client.writing_english_scale == writing_english_scale) - if numeracy_scale is not None: - query = query.filter(Client.numeracy_scale == numeracy_scale) - if computer_scale is not None: - query = query.filter(Client.computer_scale == computer_scale) - if transportation_bool is not None: - query = query.filter(Client.transportation_bool == transportation_bool) - if caregiver_bool is not None: - query = query.filter(Client.caregiver_bool == caregiver_bool) - if housing is not None: - query = query.filter(Client.housing == housing) - if income_source is not None: - query = query.filter(Client.income_source == income_source) - if felony_bool is not None: - query = query.filter(Client.felony_bool == felony_bool) - if attending_school is not None: - query = query.filter(Client.attending_school == attending_school) - if substance_use is not None: - query = query.filter(Client.substance_use == substance_use) - if time_unemployed is not None: - query = query.filter(Client.time_unemployed == time_unemployed) - if need_mental_health_support_bool is not None: - query = query.filter( - Client.need_mental_health_support_bool == need_mental_health_support_bool - ) + filters = [] + + criteria_map = { + Client.currently_employed: employment_status, + Client.age: age_min, + Client.gender: gender, + Client.level_of_schooling: education_level, + Client.work_experience: work_experience, + Client.canada_workex: canada_workex, + Client.dep_num: dep_num, + Client.canada_born: canada_born, + Client.citizen_status: citizen_status, + Client.fluent_english: fluent_english, + Client.reading_english_scale: reading_english_scale, + Client.speaking_english_scale: speaking_english_scale, + Client.writing_english_scale: writing_english_scale, + Client.numeracy_scale: numeracy_scale, + Client.computer_scale: computer_scale, + Client.transportation_bool: transportation_bool, + Client.caregiver_bool: caregiver_bool, + Client.housing: housing, + Client.income_source: income_source, + Client.felony_bool: felony_bool, + Client.attending_school: attending_school, + Client.substance_use: substance_use, + Client.time_unemployed: time_unemployed, + Client.need_mental_health_support_bool: need_mental_health_support_bool, + } + + for column, value in criteria_map.items(): + if value is not None: + if column == Client.age: + filters.append(column >= value) + else: + filters.append(column == value) try: - return query.all() + return db.query(Client).filter(*filters).all() except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -219,9 +201,9 @@ def get_clients_by_services(db: Session, **service_filters: Optional[bool]): """ query = db.query(Client).join(ClientCase) - for service_name, status in service_filters.items(): - if status is not None: - filter_criteria = getattr(ClientCase, service_name) == status + for service_name, service_status in service_filters.items(): + if service_status is not None: + filter_criteria = getattr(ClientCase, service_name) == service_status query = query.filter(filter_criteria) try: @@ -357,7 +339,10 @@ def create_case_assignment(db: Session, client_id: int, case_worker_id: int): if existing_case: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Client {client_id} already has a case assigned to case worker {case_worker_id}", + detail=( + f"Client {client_id} already has a case assigned " + f"to case worker {case_worker_id}" + ), ) try: diff --git a/app/clients/service/constants.py b/app/clients/service/constants.py new file mode 100644 index 00000000..99f58289 --- /dev/null +++ b/app/clients/service/constants.py @@ -0,0 +1,26 @@ +COLUMNS_FIELDS = [ + "age", # Client's age + "gender", # Client's gender (bool) + "work_experience", # Years of work experience + "canada_workex", # Years of work experience in Canada + "dep_num", # Number of dependents + "canada_born", # Born in Canada + "citizen_status", # Citizenship status + "level_of_schooling", # Highest level achieved (1-14) + "fluent_english", # English fluency scale (1-10) + "reading_english_scale", # Reading ability scale (1-10) + "speaking_english_scale", # Speaking ability scale (1-10) + "writing_english_scale", # Writing ability scale (1-10) + "numeracy_scale", # Numeracy ability scale (1-10) + "computer_scale", # Computer proficiency scale (1-10) + "transportation_bool", # Needs transportation support (bool) + "caregiver_bool", # Is primary caregiver (bool) + "housing", # Housing situation (1-10) + "income_source", # Source of income (1-10) + "felony_bool", # Has a felony (bool) + "attending_school", # Currently a student (bool) + "currently_employed", # Currently employed (bool) + "substance_use", # Substance use disorder (bool) + "time_unemployed", # Years unemployed + "need_mental_health_support_bool", # Needs mental health support (bool) +] diff --git a/app/clients/service/logic.py b/app/clients/service/logic.py index 290e1dd9..c2968ceb 100644 --- a/app/clients/service/logic.py +++ b/app/clients/service/logic.py @@ -14,6 +14,8 @@ import numpy as np +from app.clients.service.constants import COLUMNS_FIELDS + # Constants COLUMN_INTERVENTIONS = [ "Life Stabilization", @@ -42,35 +44,10 @@ def clean_input_data(input_data): Returns: list: Cleaned and formatted data ready for model input """ - columns = [ - "age", - "gender", - "work_experience", - "canada_workex", - "dep_num", - "canada_born", - "citizen_status", - "level_of_schooling", - "fluent_english", - "reading_english_scale", - "speaking_english_scale", - "writing_english_scale", - "numeracy_scale", - "computer_scale", - "transportation_bool", - "caregiver_bool", - "housing", - "income_source", - "felony_bool", - "attending_school", - "currently_employed", - "substance_use", - "time_unemployed", - "need_mental_health_support_bool", - ] - demographics = {key: input_data[key] for key in columns} + + demographics = {key: input_data[key] for key in COLUMNS_FIELDS} output = [] - for column in columns: + for column in COLUMNS_FIELDS: value = demographics.get(column, None) if isinstance(value, str): value = convert_text(value) # Removed 'column' from here as it wasn't used diff --git a/app/clients/service/ml_models.py b/app/clients/service/ml_models.py index bb620ec5..d9de54b8 100644 --- a/app/clients/service/ml_models.py +++ b/app/clients/service/ml_models.py @@ -12,11 +12,11 @@ class InterfaceBaseMLModel(ABC): """Interface of a base ML Model""" @abstractmethod - def fit(self, X: np.ndarray, y: np.ndarray): + def fit(self, features: np.ndarray, targets: np.ndarray): """Fit the model to provided data""" @abstractmethod - def predict(self, X: np.ndarray) -> np.ndarray: + def predict(self, features: np.ndarray) -> np.ndarray: """Predict using the fitted model""" def save(self, path: str): @@ -37,11 +37,11 @@ class LinearRegressionModel(InterfaceBaseMLModel): def __init__(self): self.model = LinearRegression() - def fit(self, X, y): - self.model.fit(X, y) + def fit(self, features, targets): + self.model.fit(features, targets) - def predict(self, X): - return self.model.predict(X) + def predict(self, features): + return self.model.predict(features) def __str__(self): return "Linear Regression" @@ -51,11 +51,11 @@ class RandomForestModel(InterfaceBaseMLModel): def __init__(self, n_estimators=100, random_state=42): self.model = RandomForestRegressor(n_estimators=n_estimators, random_state=random_state) - def fit(self, X, y): - self.model.fit(X, y) + def fit(self, features, targets): + self.model.fit(features, targets) - def predict(self, X): - return self.model.predict(X) + def predict(self, features): + return self.model.predict(features) def __str__(self): return "Random Forest Regressor" @@ -65,11 +65,11 @@ class SVMModel(InterfaceBaseMLModel): def __init__(self): self.model = SVR() - def fit(self, X, y): - self.model.fit(X, y) + def fit(self, features, targets): + self.model.fit(features, targets) - def predict(self, X): - return self.model.predict(X) + def predict(self, features): + return self.model.predict(features) def __str__(self): return "Support Vector Machine" diff --git a/app/clients/service/model.py b/app/clients/service/model.py index 49110b95..64a8f4e6 100644 --- a/app/clients/service/model.py +++ b/app/clients/service/model.py @@ -15,17 +15,18 @@ import pandas as pd # Local imports -from ml_models import ( +# from sklearn import svm +# from sklearn.ensemble import RandomForestRegressor +# from sklearn.linear_model import LinearRegression +from sklearn.model_selection import train_test_split +from app.clients.service.constants import COLUMNS_FIELDS +from .ml_models import ( InterfaceBaseMLModel, LinearRegressionModel, MLModelRepository, RandomForestModel, SVMModel, ) -from sklearn import svm -from sklearn.ensemble import RandomForestRegressor -from sklearn.linear_model import LinearRegression -from sklearn.model_selection import train_test_split repo = MLModelRepository() @@ -60,33 +61,7 @@ def prepare_model_data(test_size=0.2, random_state=42): """ # Load dataset data = pd.read_csv("data_commontool.csv") - # Define feature columns - feature_columns = [ - "age", # Client's age - "gender", # Client's gender (bool) - "work_experience", # Years of work experience - "canada_workex", # Years of work experience in Canada - "dep_num", # Number of dependents - "canada_born", # Born in Canada - "citizen_status", # Citizenship status - "level_of_schooling", # Highest level achieved (1-14) - "fluent_english", # English fluency scale (1-10) - "reading_english_scale", # Reading ability scale (1-10) - "speaking_english_scale", # Speaking ability scale (1-10) - "writing_english_scale", # Writing ability scale (1-10) - "numeracy_scale", # Numeracy ability scale (1-10) - "computer_scale", # Computer proficiency scale (1-10) - "transportation_bool", # Needs transportation support (bool) - "caregiver_bool", # Is primary caregiver (bool) - "housing", # Housing situation (1-10) - "income_source", # Source of income (1-10) - "felony_bool", # Has a felony (bool) - "attending_school", # Currently a student (bool) - "currently_employed", # Currently employed (bool) - "substance_use", # Substance use disorder (bool) - "time_unemployed", # Years unemployed - "need_mental_health_support_bool", # Needs mental health support (bool) - ] + # Define intervention columns intervention_columns = [ "employment_assistance", @@ -98,12 +73,12 @@ def prepare_model_data(test_size=0.2, random_state=42): "enhanced_referrals", ] # Combine all feature columns - all_features = feature_columns + intervention_columns + all_features = COLUMNS_FIELDS + intervention_columns # Prepare training data - features = np.array(data[all_features]) # Changed from X to features - targets = np.array(data["success_rate"]) # Changed from y to targets + features = np.array(data[all_features]) # Input features for the model + targets = np.array(data["success_rate"]) # Target variable # Split the dataset - X_train, x_test, Y_train, y_test = train_test_split( + feature_train, feature_test, target_train, target_test = train_test_split( # Removed unused variables features, targets, @@ -111,18 +86,18 @@ def prepare_model_data(test_size=0.2, random_state=42): random_state=random_state, ) - return X_train, x_test, Y_train, y_test + return feature_train, feature_test, target_train, target_test def train_model( - X_train, Y_train, model_type, n_estimators=100, random_state=42 + feature_train, target_train, model_type, n_estimators=100, random_state=42 ) -> InterfaceBaseMLModel: """ Trains the model Args: - X_train: Training features - targets_train: Target features - Y_train: Which model to create + feature_train: Training features + target_train: Target features + model_type: Which model to create n_estimators: Number estimators (for random forest) random_state: Random state to train with (for random forest) @@ -130,7 +105,7 @@ def train_model( """ model = get_model_by_name(model_type, n_estimators, random_state) - model.fit(X_train, Y_train) + model.fit(feature_train, target_train) return model @@ -183,8 +158,9 @@ def main(argv): # Train and save the model print(f"Starting model training for {model_type} model...") - X_train, x_test, Y_train, y_test = prepare_model_data() - model = train_model(X_train, Y_train, model_type) + # feature_train, feature_test, target_train, target_test = prepare_model_data() + feature_train, _, target_train, _ = prepare_model_data() + model = train_model(feature_train, target_train, model_type) save_model(model, model_type) print("Model training completed and saved successfully.") diff --git a/app/models.py b/app/models.py index c824e432..4fc4b8ba 100644 --- a/app/models.py +++ b/app/models.py @@ -2,7 +2,7 @@ Database models module defining SQLAlchemy ORM models for the Common Assessment Tool. Contains the Client model for storing client information in the database. """ - +# pylint: disable=too-few-public-methods import enum from sqlalchemy import Boolean, CheckConstraint, Column, Enum, ForeignKey, Integer, String From 635d5989d013cb1559f4b66317f49b7b330743c9 Mon Sep 17 00:00:00 2001 From: Steve Chen Date: Fri, 4 Apr 2025 14:43:09 -0700 Subject: [PATCH 024/109] update deprecated method --- app/auth/router.py | 8 ++++---- app/clients/schema.py | 11 +++++------ app/clients/service/client_service.py | 2 +- app/database.py | 2 +- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/app/auth/router.py b/app/auth/router.py index e82670bf..d1a0523f 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -8,7 +8,7 @@ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from jose import JWTError, jwt from passlib.context import CryptContext -from pydantic import BaseModel, Field, validator +from pydantic import BaseModel, Field, field_validator, ConfigDict from sqlalchemy.orm import Session from dotenv import load_dotenv @@ -25,7 +25,8 @@ class UserCreate(BaseModel): password: str role: UserRole - @validator("role") + @field_validator("role") + @classmethod def validate_role(cls, v): if v not in [UserRole.ADMIN, UserRole.CASE_WORKER]: raise ValueError("Role must be either admin or case_worker") @@ -37,8 +38,7 @@ class UserResponse(BaseModel): email: str role: UserRole - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # Load configuration from .env diff --git a/app/clients/schema.py b/app/clients/schema.py index 9f94ee60..6160ad47 100644 --- a/app/clients/schema.py +++ b/app/clients/schema.py @@ -7,7 +7,7 @@ from typing import List, Optional # Standard library imports -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict # Enums for validation @@ -74,7 +74,7 @@ class ClientBase(BaseModel): time_unemployed: int = Field(ge=0, description="Time unemployed in months") need_mental_health_support_bool: bool = Field(description="Needs mental health support") - class Config: + model_config = ConfigDict( json_schema_extra = { "example": { "age": 25, @@ -103,13 +103,13 @@ class Config: "need_mental_health_support_bool": False, } } + ) class ClientResponse(ClientBase): id: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class ClientUpdate(BaseModel): @@ -151,8 +151,7 @@ class ServiceResponse(BaseModel): enhanced_referrals: bool success_rate: int = Field(ge=0, le=100) - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class ServiceUpdate(BaseModel): diff --git a/app/clients/service/client_service.py b/app/clients/service/client_service.py index 242855be..be6c98f2 100644 --- a/app/clients/service/client_service.py +++ b/app/clients/service/client_service.py @@ -262,7 +262,7 @@ def update_client(db: Session, client_id: int, client_update: ClientUpdate): detail=f"Client with id {client_id} not found", ) - update_data = client_update.dict(exclude_unset=True) + update_data = client_update.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(client, field, value) diff --git a/app/database.py b/app/database.py index b5c8b948..cb932579 100644 --- a/app/database.py +++ b/app/database.py @@ -4,7 +4,7 @@ """ from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import declarative_base from sqlalchemy.orm import sessionmaker # Here is where the database is located From 01e8b45a6b679c080883f51b546762d21e85a8d8 Mon Sep 17 00:00:00 2001 From: Steve Chen Date: Fri, 4 Apr 2025 15:53:58 -0700 Subject: [PATCH 025/109] docker image (syntax) in ci --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dafee754..ec042a76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,10 +32,14 @@ jobs: - name: Run Code Formatting with Black run: | black . # Format the entire repo - + - name: Run Linter run: | pylint ./app ./tests + + - name: Check Docker syntax and Build Docker Image + run: | + docker build -t common-assessment-tool . - name: Print Success Message if: success() From 276da7792ddc5cf50d2508bca88ea02355be4a03 Mon Sep 17 00:00:00 2001 From: Fran Date: Sat, 5 Apr 2025 14:27:55 -0700 Subject: [PATCH 026/109] test --- .github/workflows/cd.yml | 2 +- .github/workflows/ci.yml | 108 +++++++++++++++++++++------------------ 2 files changed, 59 insertions(+), 51 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index b801c2d3..6edb269e 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -2,7 +2,7 @@ name: CI/CD Pipeline on: push: - branches: [master, main] + branches: [master, main, dev] pull_request: branches: [master, main] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec042a76..9c15e92a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,55 +1,63 @@ name: Python CI Pipeline on: - push: - pull_request: - branches: [master, main] + push: + branches: [ dev ] + + pull_request: + branches: [ master, main ] jobs: - test: - runs-on: ubuntu-latest # Use the latest Ubuntu runner - - steps: - - name: Checkout Code - uses: actions/checkout@v4 # Checkout the repository - - - name: Set up Python - uses: actions/setup-python@v5 # Set up Python environment - with: - python-version: "3.11" - - - name: Install dependencies - run: | - python -m pip install --upgrade pip # Upgrade pip to the latest version - pip install setuptools wheel - pip install -r requirements.txt # Install dependencies from requirements.txt - pip install pylint pytest black - - - name: Run Tests - run: | - python -m pytest tests/ - - - name: Run Code Formatting with Black - run: | - black . # Format the entire repo - - - name: Run Linter - run: | - pylint ./app ./tests - - - name: Check Docker syntax and Build Docker Image - run: | - docker build -t common-assessment-tool . - - - name: Print Success Message - if: success() - run: | - echo "CI Pipeline completed successfully!" - echo "========================" - echo "✓ Code checked out" - echo "✓ Python environment set up" - echo "✓ Dependencies installed" - echo "✓ Tests executed" - echo "✓ Linting completed" - echo "✓ Formatting checked" - echo "========================" + test: + runs-on: ubuntu-latest # Use the latest Ubuntu runner + + steps: + - name: Checkout Code + uses: actions/checkout@v4 # Checkout the repository + + - name: Set up Python + uses: actions/setup-python@v5 # Set up Python environment + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip # Upgrade pip to the latest version + pip install setuptools wheel + pip install -r requirements.txt # Install dependencies from requirements.txt + pip install pylint pytest black isort + + - name: Run Code Formatting with Black # Format the entire repo + run: | + black . + - name: Run Code Formatting with isort + run: | + isort . + + - name: Run Linter + run: | + pylint ./app ./tests + + - name: Run Tests + run: | + python -m pytest tests/ + + - name: Check Docker syntax + run: docker run --rm -i hadolint/hadolint < Dockerfile + + - name: Build Docker Image + run: | + docker build -t common-assessment-tool . + + - name: Print Success Message + if: success() + run: | + echo "CI Pipeline completed successfully!" + echo "========================" + echo "✓ Code checked out" + echo "✓ Python environment set up" + echo "✓ Dependencies installed" + echo "✓ Tests executed" + echo "✓ Linting completed" + echo "✓ Formatting checked" + echo "========================" From 4e7c7f9354446e21841585ae7e59b24992c77d75 Mon Sep 17 00:00:00 2001 From: Fran Date: Sat, 5 Apr 2025 14:48:25 -0700 Subject: [PATCH 027/109] update --- .github/workflows/ci.yml | 4 +-- Dockerfile | 2 +- app/clients/service/constants.py | 10 ++++++++ app/clients/service/model.py | 13 ++-------- app/clients/service/model_helper.py | 40 ++++------------------------- 5 files changed, 20 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c15e92a..56c608d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,11 +40,11 @@ jobs: - name: Run Tests run: | - python -m pytest tests/ + python -m pytest tests/ - name: Check Docker syntax run: docker run --rm -i hadolint/hadolint < Dockerfile - + - name: Build Docker Image run: | docker build -t common-assessment-tool . diff --git a/Dockerfile b/Dockerfile index a3ff66eb..674179b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Use Python 3.11 image as base -FROM python:3.11 +FROM python:3.11-slim # Set working directory WORKDIR /code diff --git a/app/clients/service/constants.py b/app/clients/service/constants.py index 99f58289..01d02094 100644 --- a/app/clients/service/constants.py +++ b/app/clients/service/constants.py @@ -24,3 +24,13 @@ "time_unemployed", # Years unemployed "need_mental_health_support_bool", # Needs mental health support (bool) ] + +INTERVENTION_FIELDS = [ + "employment_assistance", + "life_stabilization", + "retention_services", + "specialized_services", + "employment_related_financial_supports", + "employer_financial_supports", + "enhanced_referrals" +] diff --git a/app/clients/service/model.py b/app/clients/service/model.py index 1aba7da4..65a83206 100644 --- a/app/clients/service/model.py +++ b/app/clients/service/model.py @@ -14,6 +14,7 @@ import numpy as np import pandas as pd from fastapi import HTTPException +from constants import COLUMNS_FIELDS, INTERVENTION_FIELDS # Local imports # from sklearn import svm @@ -63,18 +64,8 @@ def prepare_model_data(test_size=0.2, random_state=42): # Load dataset data = pd.read_csv("data_commontool.csv") - # Define intervention columns - intervention_columns = [ - "employment_assistance", - "life_stabilization", - "retention_services", - "specialized_services", - "employment_related_financial_supports", - "employer_financial_supports", - "enhanced_referrals", - ] # Combine all feature columns - all_features = COLUMNS_FIELDS + intervention_columns + all_features = COLUMNS_FIELDS + INTERVENTION_FIELDS # Prepare training data features = np.array(data[all_features]) # Input features for the model targets = np.array(data["success_rate"]) # Target variable diff --git a/app/clients/service/model_helper.py b/app/clients/service/model_helper.py index 0fdd8722..97313f91 100644 --- a/app/clients/service/model_helper.py +++ b/app/clients/service/model_helper.py @@ -1,44 +1,14 @@ +from constants import COLUMNS_FIELDS, INTERVENTION_FIELDS + + def get_feature_columns(): """Get all feature columns""" - return [ - "age", - "gender", - "work_experience", - "canada_workex", - "dep_num", - "canada_born", - "citizen_status", - "level_of_schooling", - "fluent_english", - "reading_english_scale", - "speaking_english_scale", - "writing_english_scale", - "numeracy_scale", - "computer_scale", - "transportation_bool", - "caregiver_bool", - "housing", - "income_source", - "felony_bool", - "attending_school", - "currently_employed", - "substance_use", - "time_unemployed", - "need_mental_health_support_bool", - ] + return COLUMNS_FIELDS def get_intervention_columns(): """Get all intervention columns""" - return [ - "employment_assistance", - "life_stabilization", - "retention_services", - "specialized_services", - "employment_related_financial_supports", - "employer_financial_supports", - "enhanced_referrals", - ] + return INTERVENTION_FIELDS def get_all_feature_columns(): From 4ba77b5d5fbb3cd3e98d64c0ccdb61501d3f6822 Mon Sep 17 00:00:00 2001 From: Fran Date: Sat, 5 Apr 2025 15:02:59 -0700 Subject: [PATCH 028/109] fixed bugs --- app/clients/service/ml_models.py | 17 +++++++++------- app/clients/service/ml_models_router.py | 27 +++++++++++++------------ app/clients/service/model.py | 1 - 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/app/clients/service/ml_models.py b/app/clients/service/ml_models.py index a6a1008e..8c0f51c5 100644 --- a/app/clients/service/ml_models.py +++ b/app/clients/service/ml_models.py @@ -42,6 +42,9 @@ def load(path: str): def __str__(self) -> str: """Return the name of the model""" + def load_if_trained(self): + pass + class LinearRegressionModel(InterfaceBaseMLModel): def __init__(self): @@ -57,11 +60,11 @@ def predict(self, features): def __str__(self): return "Linear Regression" - def _load_if_trained(self): + def load_if_trained(self): path = get_true_file_name(str(self), default_unformatted_model_path) print(f"Attempting to load model from: {path}") if os.path.exists(path): - print(f"Model file exists, loading...") + print("Model file exists, loading...") self.model = InterfaceBaseMLModel.load(path) else: print(f"Model file not found at {path}") @@ -81,11 +84,11 @@ def predict(self, features): def __str__(self): return "Random Forest Regressor" - def _load_if_trained(self): + def load_if_trained(self): path = get_true_file_name(str(self), default_unformatted_model_path) print(f"Attempting to load model from: {path}") if os.path.exists(path): - print(f"Model file exists, loading...") + print("Model file exists, loading...") self.model = InterfaceBaseMLModel.load(path) else: print(f"Model file not found at {path}") @@ -105,11 +108,11 @@ def predict(self, features): def __str__(self): return "Support Vector Machine" - def _load_if_trained(self): + def load_if_trained(self): path = get_true_file_name(str(self), default_unformatted_model_path) print(f"Attempting to load model from: {path}") if os.path.exists(path): - print(f"Model file exists, loading...") + print("Model file exists, loading...") self.model = InterfaceBaseMLModel.load(path) else: print(f"Model file not found at {path}") @@ -168,7 +171,7 @@ def __init__(self, repository: InterfaceMLModelRepository): self._repository = repository self._current_model = repository.get_model_instance("Random Forest Regressor") - def get_current_model(self) -> str: + def get_current_model(self) -> InterfaceBaseMLModel: return self._current_model def switch_model(self, model_name: str) -> bool: diff --git a/app/clients/service/ml_models_router.py b/app/clients/service/ml_models_router.py index f875c419..96f9e26c 100644 --- a/app/clients/service/ml_models_router.py +++ b/app/clients/service/ml_models_router.py @@ -1,7 +1,8 @@ import numpy as np from fastapi import APIRouter, HTTPException -from app.clients.service.ml_models import MLModelManager, MLModelRepository +from app.clients.service.ml_models import MLModelManager, MLModelRepository, \ + InterfaceBaseMLModel from app.clients.service.models import PredictionFeatures, PredictionRequest router = APIRouter(prefix="/ml_models", tags=["model"]) @@ -37,16 +38,8 @@ def predict_with_model_name(features: PredictionFeatures, model_name: str): """Predict based on a given ML model name""" prediction_request = PredictionRequest.from_structured_features(features) model = model_repository.get_model_instance(model_name) - model._load_if_trained() - try: - prediction = model.predict(np.array([prediction_request.features])) - return { - "model": str(model), - "input": prediction_request.features, - "prediction": prediction.tolist(), - } - except Exception as e: - raise HTTPException(status_code=500, detail=f"Prediction failed: {str(e)}") + model.load_if_trained() + _predict(model, features) @router.post("/predict") @@ -54,7 +47,14 @@ def predict_with_current_model(features: PredictionFeatures): """Predict based on current ML model""" prediction_request = PredictionRequest.from_structured_features(features) model = model_manager.get_current_model() - model._load_if_trained() + model.load_if_trained() + _predict(model, features) + + +def _predict(model: InterfaceBaseMLModel, features: PredictionFeatures): + """Predict based on given ML model""" + model.load_if_trained() + prediction_request = PredictionRequest.from_structured_features(features) try: prediction = model.predict(np.array([prediction_request.features])) return { @@ -63,4 +63,5 @@ def predict_with_current_model(features: PredictionFeatures): "prediction": prediction.tolist(), } except Exception as e: - raise HTTPException(status_code=500, detail=f"Prediction failed: {str(e)}") + raise HTTPException(status_code=500, + detail=f"Prediction failed: {str(e)}") from e diff --git a/app/clients/service/model.py b/app/clients/service/model.py index 65a83206..515b0813 100644 --- a/app/clients/service/model.py +++ b/app/clients/service/model.py @@ -13,7 +13,6 @@ # Third-party imports import numpy as np import pandas as pd -from fastapi import HTTPException from constants import COLUMNS_FIELDS, INTERVENTION_FIELDS # Local imports From 567025456917348aea49bbb540fdfee34eacfb70 Mon Sep 17 00:00:00 2001 From: Fran Date: Sat, 5 Apr 2025 15:05:36 -0700 Subject: [PATCH 029/109] fixed import --- app/clients/service/model.py | 3 +-- app/clients/service/model_helper.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/clients/service/model.py b/app/clients/service/model.py index 515b0813..86bd782a 100644 --- a/app/clients/service/model.py +++ b/app/clients/service/model.py @@ -13,14 +13,13 @@ # Third-party imports import numpy as np import pandas as pd -from constants import COLUMNS_FIELDS, INTERVENTION_FIELDS # Local imports # from sklearn import svm # from sklearn.ensemble import RandomForestRegressor # from sklearn.linear_model import LinearRegression from sklearn.model_selection import train_test_split -from app.clients.service.constants import COLUMNS_FIELDS +from app.clients.service.constants import COLUMNS_FIELDS, INTERVENTION_FIELDS from .ml_models import ( InterfaceBaseMLModel, LinearRegressionModel, diff --git a/app/clients/service/model_helper.py b/app/clients/service/model_helper.py index 97313f91..b976a9ec 100644 --- a/app/clients/service/model_helper.py +++ b/app/clients/service/model_helper.py @@ -1,4 +1,4 @@ -from constants import COLUMNS_FIELDS, INTERVENTION_FIELDS +from app.clients.service.constants import * def get_feature_columns(): From 57dd9f01e9de51c76df208ee3bb3d15423258784 Mon Sep 17 00:00:00 2001 From: Fran Date: Sat, 5 Apr 2025 15:08:02 -0700 Subject: [PATCH 030/109] fixed import --- app/clients/service/ml_models_router.py | 2 -- app/clients/service/model_helper.py | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/clients/service/ml_models_router.py b/app/clients/service/ml_models_router.py index 96f9e26c..69647f55 100644 --- a/app/clients/service/ml_models_router.py +++ b/app/clients/service/ml_models_router.py @@ -36,7 +36,6 @@ def current_model(): @router.post("/predict/{model_name}") def predict_with_model_name(features: PredictionFeatures, model_name: str): """Predict based on a given ML model name""" - prediction_request = PredictionRequest.from_structured_features(features) model = model_repository.get_model_instance(model_name) model.load_if_trained() _predict(model, features) @@ -45,7 +44,6 @@ def predict_with_model_name(features: PredictionFeatures, model_name: str): @router.post("/predict") def predict_with_current_model(features: PredictionFeatures): """Predict based on current ML model""" - prediction_request = PredictionRequest.from_structured_features(features) model = model_manager.get_current_model() model.load_if_trained() _predict(model, features) diff --git a/app/clients/service/model_helper.py b/app/clients/service/model_helper.py index b976a9ec..125cec76 100644 --- a/app/clients/service/model_helper.py +++ b/app/clients/service/model_helper.py @@ -1,4 +1,4 @@ -from app.clients.service.constants import * +from app.clients.service.constants import COLUMNS_FIELDS, INTERVENTION_FIELDS def get_feature_columns(): @@ -17,5 +17,5 @@ def get_all_feature_columns(): def get_true_file_name(model_type, filename): - """Format pikle file name""" + """Format pickle file name""" return filename.format(model_type).replace(" ", "_") From 1a25894590ecbcfb40cb6edf2cdcdadb85af94fc Mon Sep 17 00:00:00 2001 From: Fran Date: Sat, 5 Apr 2025 15:15:09 -0700 Subject: [PATCH 031/109] fixed action --- .github/workflows/ci.yml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56c608d4..d03a4452 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,11 @@ jobs: with: python-version: "3.11" + - name: Hadolint Action + uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: ./Dockerfile + - name: Install dependencies run: | python -m pip install --upgrade pip # Upgrade pip to the latest version @@ -42,13 +47,19 @@ jobs: run: | python -m pytest tests/ - - name: Check Docker syntax - run: docker run --rm -i hadolint/hadolint < Dockerfile - - name: Build Docker Image run: | docker build -t common-assessment-tool . + - name: Run Docker container + run: | + docker run -d -p 8000:8000 common-assessment-tool + sleep 10 + + - name: Test Docker container + run: | + curl http://localhost:8000/docs + - name: Print Success Message if: success() run: | @@ -60,4 +71,4 @@ jobs: echo "✓ Tests executed" echo "✓ Linting completed" echo "✓ Formatting checked" - echo "========================" + echo "========================" From 446c18bb4db06a828d0190dde494eb02a81dc6e2 Mon Sep 17 00:00:00 2001 From: Fran Date: Sat, 5 Apr 2025 15:18:22 -0700 Subject: [PATCH 032/109] test wrong dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 674179b6..5d281dbb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.11-slim # Set working directory -WORKDIR /code +WORKDR /code # Copy requirements first to leverage Docker cache COPY ./requirements.txt /code/requirements.txt From d4c4393ecc9d2fbb4c4f923dcebcacad4a028daa Mon Sep 17 00:00:00 2001 From: Fran Date: Sat, 5 Apr 2025 15:33:12 -0700 Subject: [PATCH 033/109] fixed minor bug --- .github/workflows/ci.yml | 4 ++-- Dockerfile | 4 ++-- app/clients/service/ml_models_router.py | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d03a4452..be02ab23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: Python CI Pipeline on: push: - branches: [ dev ] + branches: [ master, main, dev ] pull_request: branches: [ master, main ] @@ -20,7 +20,7 @@ jobs: with: python-version: "3.11" - - name: Hadolint Action + - name: Hadolint Action Check Dockerfile Syntax uses: hadolint/hadolint-action@v3.1.0 with: dockerfile: ./Dockerfile diff --git a/Dockerfile b/Dockerfile index 5d281dbb..a3ff66eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ # Use Python 3.11 image as base -FROM python:3.11-slim +FROM python:3.11 # Set working directory -WORKDR /code +WORKDIR /code # Copy requirements first to leverage Docker cache COPY ./requirements.txt /code/requirements.txt diff --git a/app/clients/service/ml_models_router.py b/app/clients/service/ml_models_router.py index 69647f55..7d250d61 100644 --- a/app/clients/service/ml_models_router.py +++ b/app/clients/service/ml_models_router.py @@ -37,19 +37,19 @@ def current_model(): def predict_with_model_name(features: PredictionFeatures, model_name: str): """Predict based on a given ML model name""" model = model_repository.get_model_instance(model_name) - model.load_if_trained() - _predict(model, features) + # model.load_if_trained() + return predict_model(model, features) @router.post("/predict") def predict_with_current_model(features: PredictionFeatures): """Predict based on current ML model""" model = model_manager.get_current_model() - model.load_if_trained() - _predict(model, features) + # model.load_if_trained() + return predict_model(model, features) -def _predict(model: InterfaceBaseMLModel, features: PredictionFeatures): +def predict_model(model: InterfaceBaseMLModel, features: PredictionFeatures): """Predict based on given ML model""" model.load_if_trained() prediction_request = PredictionRequest.from_structured_features(features) From ca1496e1f020d7d28be5e058991cd39ab611a362 Mon Sep 17 00:00:00 2001 From: Fran Date: Sat, 5 Apr 2025 15:52:04 -0700 Subject: [PATCH 034/109] refined ci yml --- .github/workflows/ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be02ab23..589e7702 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ jobs: - name: Run Code Formatting with Black # Format the entire repo run: | black . + - name: Run Code Formatting with isort run: | isort . @@ -58,7 +59,14 @@ jobs: - name: Test Docker container run: | - curl http://localhost:8000/docs + curl --fail http://localhost:8000/docs || { + echo "Health check failed" + docker logs common-assessment-tool + exit 1 + } + + - name: Stop Docker container + run: docker stop common-assessment-tool - name: Print Success Message if: success() From 0c0d29f516127345ed2a5c95906cf56f814cf2ca Mon Sep 17 00:00:00 2001 From: Fran Date: Sat, 5 Apr 2025 15:55:59 -0700 Subject: [PATCH 035/109] refined ci yml --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 589e7702..3b42f66f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,7 @@ jobs: - name: Run Docker container run: | - docker run -d -p 8000:8000 common-assessment-tool + docker docker run -d --name common-assessment-container -p 8000:8000 common-assessment-tool sleep 10 - name: Test Docker container @@ -66,7 +66,7 @@ jobs: } - name: Stop Docker container - run: docker stop common-assessment-tool + run: docker stop common-assessment-container - name: Print Success Message if: success() From 8fb3d5f094afa5efc9e446525214f968c12b6576 Mon Sep 17 00:00:00 2001 From: Fran Date: Sat, 5 Apr 2025 15:58:56 -0700 Subject: [PATCH 036/109] refined ci yml --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b42f66f..38d4d342 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,7 @@ jobs: - name: Run Docker container run: | - docker docker run -d --name common-assessment-container -p 8000:8000 common-assessment-tool + docker run -d --name common-assessment-container -p 8000:8000 common-assessment-tool sleep 10 - name: Test Docker container From 5c485ac0d264e3c5309ebc241fd2bae38a303b5f Mon Sep 17 00:00:00 2001 From: dtread4 <64123049+dtread4@users.noreply.github.com> Date: Wed, 9 Apr 2025 19:40:57 -0700 Subject: [PATCH 037/109] Update docker-compose.yml --- docker-compose.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d3b893b6..1f04ae2f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,10 @@ services: web: build: . - command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] ports: - "8000:8000" develop: watch: - action: sync path: . - target: /code \ No newline at end of file + target: /code From 5cc9b46a8264e10196b233810ddc8dfb10bc0b72 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 11 Apr 2025 17:26:12 -0700 Subject: [PATCH 038/109] Initial CD setup --- .github/workflows/ci.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38d4d342..cf739dc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,4 +79,23 @@ jobs: echo "✓ Tests executed" echo "✓ Linting completed" echo "✓ Formatting checked" - echo "========================" + echo "========================" + + deploy: + needs: test # This ensures deploy only runs if tests pass + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t common-assessment-tool . + + - name: Run Docker container + run: | + docker compose up -d + sleep 10 # Wait for container to start + + - name: Test Docker container + run: | + http://ec2-54-165-172-227.compute-1.amazonaws.com:8000/docs \ No newline at end of file From 8f6603be9abdf7da3595c54f750ee5fb27b38f74 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 11 Apr 2025 17:30:57 -0700 Subject: [PATCH 039/109] Update so CD pipeline separate again --- .github/workflows/cd.yml | 4 ++-- .github/workflows/ci.yml | 21 +-------------------- 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 6edb269e..6fc98452 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -39,9 +39,9 @@ jobs: - name: Run Docker container run: | - docker run -d -p 8000:8000 common-assessment-tool + docker compose up -d sleep 10 # Wait for container to start - name: Test Docker container run: | - curl http://localhost:8000/docs + http://ec2-54-165-172-227.compute-1.amazonaws.com:8000/docs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf739dc5..584cf160 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,23 +79,4 @@ jobs: echo "✓ Tests executed" echo "✓ Linting completed" echo "✓ Formatting checked" - echo "========================" - - deploy: - needs: test # This ensures deploy only runs if tests pass - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Build Docker image - run: docker build -t common-assessment-tool . - - - name: Run Docker container - run: | - docker compose up -d - sleep 10 # Wait for container to start - - - name: Test Docker container - run: | - http://ec2-54-165-172-227.compute-1.amazonaws.com:8000/docs \ No newline at end of file + echo "========================" \ No newline at end of file From 4b8ab2251639726fdd639fb03f546a6dd5fc66d9 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 11 Apr 2025 17:33:55 -0700 Subject: [PATCH 040/109] Update README with new docker compose commands --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 70b0c190..8d24144f 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,15 @@ docker build -t common_assessment_tool . docker run --rm -p 8000:8000 common_assessment_tool ``` 6. Follow the steps to run the Swagger UI as described above (clicking link in step 5 should take you to the UI) -7. To run using Docker-Compose, run the command below in the CommonAssessmentTool repo's directory +7. To run using Docker-Compose in the foreground, run the command below in the CommonAssessmentTool repo's directory ``` docker compose up +``` +8. To run using Docker-Compose in the background, run the command below in the CommonAssessmentTool repo's directory +``` +docker compose up -d +``` +9. If running using the background command, you can stop the container gracefully with the following command: +``` +docker compose stop ``` \ No newline at end of file From d717a14184c57dacf36bb359ff2df8e0567de394 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 11 Apr 2025 17:37:37 -0700 Subject: [PATCH 041/109] Put CD steps back into ci file and rename so only use one file --- .github/workflows/cd.yml | 4 ++-- .github/workflows/{ci.yml => ci_cd.yml} | 21 ++++++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) rename .github/workflows/{ci.yml => ci_cd.yml} (81%) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 6fc98452..adb694f9 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -39,9 +39,9 @@ jobs: - name: Run Docker container run: | - docker compose up -d + docker run -d -p 8000:8000 common-assessment-tool sleep 10 # Wait for container to start - name: Test Docker container run: | - http://ec2-54-165-172-227.compute-1.amazonaws.com:8000/docs + curl http://localhost:8000/docs \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci_cd.yml similarity index 81% rename from .github/workflows/ci.yml rename to .github/workflows/ci_cd.yml index 584cf160..0b2f873f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci_cd.yml @@ -79,4 +79,23 @@ jobs: echo "✓ Tests executed" echo "✓ Linting completed" echo "✓ Formatting checked" - echo "========================" \ No newline at end of file + echo "========================" + + deploy: + needs: test # This ensures deploy only runs if tests pass + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t common-assessment-tool . + + - name: Run Docker container + run: | + docker compose up -d + sleep 10 # Wait for container to start + + - name: Test Docker container + run: | + http://ec2-54-165-172-227.compute-1.amazonaws.com:8000/docs \ No newline at end of file From 239b6d64db37bd2faa40cf3035fbb98f93d3ae9b Mon Sep 17 00:00:00 2001 From: David Date: Fri, 11 Apr 2025 17:44:00 -0700 Subject: [PATCH 042/109] Add env to docker compose --- .env | 6 +++++- README.md | 8 ++++++-- docker-compose.yml | 5 +++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.env b/.env index f292cb8d..e7c1a5fb 100644 --- a/.env +++ b/.env @@ -1,3 +1,7 @@ -SECRET_KEY = "your-secret-key-here" +AWS_ACCESS_KEY_ID = "" +EC2_INSTANCE_UP = "" +EC2_INSTANCE_IP = "" +EC2_USER = "" +SECRET_KEY = "" ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 \ No newline at end of file diff --git a/README.md b/README.md index 8d24144f..851d018d 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,13 @@ This also has an API file to interact with the front end, and logic in order to -------------------------How to Use------------------------- 1. In the virtual environment you've created for this project, install all dependencies in requirements.txt (pip install -r requirements.txt) -2. Create a .env file with the following fields: +2. Create a .env file with the following fields. The top four are just for CD pipeline purposes, so if running locally they will not be needed: ```markdown -SECRET_KEY = "your-secret-key-here" +AWS_ACCESS_KEY_ID = "" +EC2_INSTANCE_UP = "" +EC2_INSTANCE_IP = "" +EC2_USER = "" +SECRET_KEY = "" ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 ``` diff --git a/docker-compose.yml b/docker-compose.yml index 1f04ae2f..02934d41 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,3 +8,8 @@ services: - action: sync path: . target: /code + environment: + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} + - EC2_INSTANCE_UP=${AWS_ACCESS_KEY_ID} + - EC2_INSTANCE_IP=${AWS_ACCESS_KEY_ID} + - EC2_USER=${AWS_ACCESS_KEY_ID} \ No newline at end of file From cce2e6b645e8bb3190fcdbc3ebf3016cec280a12 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 11 Apr 2025 18:07:56 -0700 Subject: [PATCH 043/109] Change where .env read from and update CI-CD pipeline --- .github/workflows/ci_cd.yml | 14 +++++++++----- docker-compose.yml | 7 +------ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 0b2f873f..efe8a6ae 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -91,11 +91,15 @@ jobs: - name: Build Docker image run: docker build -t common-assessment-tool . - - name: Run Docker container + - name: Read .env file run: | - docker compose up -d - sleep 10 # Wait for container to start + export $(grep 'AWS_ACCESS_KEY_ID' .env) + export $(grep 'EC2_INSTANCE_UP' .env) + export $(grep 'EC2_INSTANCE_IP' .env) + export $(grep 'EC2_USER' .env) - - name: Test Docker container + - name: Deploy Docker Container run: | - http://ec2-54-165-172-227.compute-1.amazonaws.com:8000/docs \ No newline at end of file + ssh -o StrictHostKeyChecking=no ${{ AWS_ACCESS_KEY_ID }}@${{ EC2_INSTANCE_IP }} << 'EOF' + docker run -d -p 8000:8000 common-assessment-tool + sleep 10 # Wait for container to start \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 02934d41..85aca9be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,9 +7,4 @@ services: watch: - action: sync path: . - target: /code - environment: - - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} - - EC2_INSTANCE_UP=${AWS_ACCESS_KEY_ID} - - EC2_INSTANCE_IP=${AWS_ACCESS_KEY_ID} - - EC2_USER=${AWS_ACCESS_KEY_ID} \ No newline at end of file + target: /code \ No newline at end of file From 3235f4021066a40235bcf022663d6953e339a04e Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 15:46:52 -0700 Subject: [PATCH 044/109] Update env with secret access key field --- .env | 1 + README.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.env b/.env index e7c1a5fb..6de23bdd 100644 --- a/.env +++ b/.env @@ -2,6 +2,7 @@ AWS_ACCESS_KEY_ID = "" EC2_INSTANCE_UP = "" EC2_INSTANCE_IP = "" EC2_USER = "" +SECRET_ACCESS_KEY = "" SECRET_KEY = "" ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 \ No newline at end of file diff --git a/README.md b/README.md index 851d018d..1de493db 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,13 @@ This also has an API file to interact with the front end, and logic in order to -------------------------How to Use------------------------- 1. In the virtual environment you've created for this project, install all dependencies in requirements.txt (pip install -r requirements.txt) -2. Create a .env file with the following fields. The top four are just for CD pipeline purposes, so if running locally they will not be needed: +2. Create a .env file with the following fields. The top five are just for CD pipeline purposes, so if running locally they will not be needed: ```markdown AWS_ACCESS_KEY_ID = "" EC2_INSTANCE_UP = "" EC2_INSTANCE_IP = "" EC2_USER = "" +SECRET_ACCESS_KEY = "" SECRET_KEY = "" ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 From 4061e132ec23c6cc1e92e2961c063978c50096f3 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 15:55:58 -0700 Subject: [PATCH 045/109] Update SVM model name to test deployment --- app/clients/service/ml_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/clients/service/ml_models.py b/app/clients/service/ml_models.py index 8c0f51c5..c48d8cf9 100644 --- a/app/clients/service/ml_models.py +++ b/app/clients/service/ml_models.py @@ -106,7 +106,7 @@ def predict(self, features): return self.model.predict(features) def __str__(self): - return "Support Vector Machine" + return "SVM" def load_if_trained(self): path = get_true_file_name(str(self), default_unformatted_model_path) From dce01244edaad0cd465aaa1ac66c76f08e8abd91 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 16:02:12 -0700 Subject: [PATCH 046/109] Update CI/CD pipeline name --- .github/workflows/cd.yml | 47 ------------------------------------- .github/workflows/ci_cd.yml | 2 +- 2 files changed, 1 insertion(+), 48 deletions(-) delete mode 100644 .github/workflows/cd.yml diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml deleted file mode 100644 index adb694f9..00000000 --- a/.github/workflows/cd.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: CI/CD Pipeline - -on: - push: - branches: [master, main, dev] - pull_request: - branches: [master, main] - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run Tests - run: | - python -m pytest tests/ - - deploy: - needs: test # This ensures deploy only runs if tests pass - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Build Docker image - run: docker build -t common-assessment-tool . - - - name: Run Docker container - run: | - docker run -d -p 8000:8000 common-assessment-tool - sleep 10 # Wait for container to start - - - name: Test Docker container - run: | - curl http://localhost:8000/docs \ No newline at end of file diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index efe8a6ae..fea3a7b0 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -1,4 +1,4 @@ -name: Python CI Pipeline +name: Python CI/CD Pipeline on: push: From e65598a56210cf4ffc13c3a50dd3634e8d63c590 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 16:04:12 -0700 Subject: [PATCH 047/109] Break out CI/CD --- .github/workflows/cd.yml | 51 +++++++++++++++++++++++++ .github/workflows/{ci_cd.yml => ci.yml} | 23 ----------- 2 files changed, 51 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/cd.yml rename .github/workflows/{ci_cd.yml => ci.yml} (76%) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 00000000..f103cb73 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,51 @@ + name: CI/CD Pipeline + +on: + push: + branches: [master, main, dev] + pull_request: + branches: [master, main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run Tests + run: | + python -m pytest tests/ + + deploy: + needs: test # This ensures deploy only runs if tests pass + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t common-assessment-tool . + + - name: Read .env file + run: | + export $(grep 'AWS_ACCESS_KEY_ID' .env) + export $(grep 'EC2_INSTANCE_UP' .env) + export $(grep 'EC2_INSTANCE_IP' .env) + export $(grep 'EC2_USER' .env) + + - name: Deploy Docker Container + run: | + ssh -o StrictHostKeyChecking=no ${{ AWS_ACCESS_KEY_ID }}@${{ EC2_INSTANCE_IP }} << 'EOF' + docker run -d -p 8000:8000 common-assessment-tool + sleep 10 # Wait for container to start \ No newline at end of file diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci.yml similarity index 76% rename from .github/workflows/ci_cd.yml rename to .github/workflows/ci.yml index fea3a7b0..fc34259a 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci.yml @@ -80,26 +80,3 @@ jobs: echo "✓ Linting completed" echo "✓ Formatting checked" echo "========================" - - deploy: - needs: test # This ensures deploy only runs if tests pass - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Build Docker image - run: docker build -t common-assessment-tool . - - - name: Read .env file - run: | - export $(grep 'AWS_ACCESS_KEY_ID' .env) - export $(grep 'EC2_INSTANCE_UP' .env) - export $(grep 'EC2_INSTANCE_IP' .env) - export $(grep 'EC2_USER' .env) - - - name: Deploy Docker Container - run: | - ssh -o StrictHostKeyChecking=no ${{ AWS_ACCESS_KEY_ID }}@${{ EC2_INSTANCE_IP }} << 'EOF' - docker run -d -p 8000:8000 common-assessment-tool - sleep 10 # Wait for container to start \ No newline at end of file From cfcb929b4c455cc9e82b78c94ecedbc8abc69fa7 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 16:05:58 -0700 Subject: [PATCH 048/109] Change names to be more clear for CI and CD pipelines --- .github/workflows/cd.yml | 2 +- .github/workflows/ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index f103cb73..25298ad5 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,4 +1,4 @@ - name: CI/CD Pipeline +name: CI/CD Pipeline on: push: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc34259a..1f2abfde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Python CI/CD Pipeline +name: CI Pipeline on: push: From 65f30c70e0d06db5446ffdc7451cc8113044e365 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 16:13:50 -0700 Subject: [PATCH 049/109] Fix syntax when grabbing from .env --- .github/workflows/cd.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 25298ad5..04e7b926 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -39,10 +39,14 @@ jobs: - name: Read .env file run: | - export $(grep 'AWS_ACCESS_KEY_ID' .env) - export $(grep 'EC2_INSTANCE_UP' .env) - export $(grep 'EC2_INSTANCE_IP' .env) - export $(grep 'EC2_USER' .env) + export $(grep '^AWS_ACCESS_KEY_ID=' .env) + export $(grep '^EC2_INSTANCE_UP=' .env) + export $(grep '^EC2_INSTANCE_IP=' .env) + export $(grep '^EC2_USER=' .env) + echo "AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID" + echo "EC2_INSTANCE_UP: EC2_INSTANCE_UP" + echo "EC2_INSTANCE_IP: EC2_INSTANCE_IP" + echo "EC2_USER: EC2_USER" - name: Deploy Docker Container run: | From e952ebc890adc2c843ebad3fb39dbd2a0a394e3d Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 16:16:19 -0700 Subject: [PATCH 050/109] Fix using syntax for variables --- .github/workflows/cd.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 04e7b926..6cd49f14 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -43,13 +43,9 @@ jobs: export $(grep '^EC2_INSTANCE_UP=' .env) export $(grep '^EC2_INSTANCE_IP=' .env) export $(grep '^EC2_USER=' .env) - echo "AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID" - echo "EC2_INSTANCE_UP: EC2_INSTANCE_UP" - echo "EC2_INSTANCE_IP: EC2_INSTANCE_IP" - echo "EC2_USER: EC2_USER" - name: Deploy Docker Container run: | - ssh -o StrictHostKeyChecking=no ${{ AWS_ACCESS_KEY_ID }}@${{ EC2_INSTANCE_IP }} << 'EOF' + ssh -o StrictHostKeyChecking=no ${{ $AWS_ACCESS_KEY_ID }}@${{ $EC2_INSTANCE_IP }} << 'EOF' docker run -d -p 8000:8000 common-assessment-tool sleep 10 # Wait for container to start \ No newline at end of file From 3a89411e9bf07dcae5e4cfb98ab992fbb55e585c Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 16:20:57 -0700 Subject: [PATCH 051/109] Change syntax --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 6cd49f14..9cf6f7d5 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -46,6 +46,6 @@ jobs: - name: Deploy Docker Container run: | - ssh -o StrictHostKeyChecking=no ${{ $AWS_ACCESS_KEY_ID }}@${{ $EC2_INSTANCE_IP }} << 'EOF' + ssh -o StrictHostKeyChecking=no $AWS_ACCESS_KEY_ID@$EC2_INSTANCE_IP << 'EOF' docker run -d -p 8000:8000 common-assessment-tool sleep 10 # Wait for container to start \ No newline at end of file From bf8b0d7dc8e78205c8001b5793a6fb1d71196811 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 16:22:30 -0700 Subject: [PATCH 052/109] Fix CD pipeline name --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 9cf6f7d5..c96a2363 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,4 +1,4 @@ -name: CI/CD Pipeline +name: CD Pipeline on: push: From de0535c9549e214a0e266a3666ecbe9ef2e28525 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 16:30:08 -0700 Subject: [PATCH 053/109] Small fix --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index c96a2363..15d2ee5b 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -48,4 +48,4 @@ jobs: run: | ssh -o StrictHostKeyChecking=no $AWS_ACCESS_KEY_ID@$EC2_INSTANCE_IP << 'EOF' docker run -d -p 8000:8000 common-assessment-tool - sleep 10 # Wait for container to start \ No newline at end of file + EOF \ No newline at end of file From 1bf10dbb30c48220cb700abf77b6376faae97e08 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 16:35:25 -0700 Subject: [PATCH 054/109] Try fix ssh --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 15d2ee5b..d329819d 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -46,6 +46,6 @@ jobs: - name: Deploy Docker Container run: | - ssh -o StrictHostKeyChecking=no $AWS_ACCESS_KEY_ID@$EC2_INSTANCE_IP << 'EOF' + ssh -o StrictHostKeyChecking=no $AWS_ACCESS_KEY_ID @ $EC2_INSTANCE_IP << 'EOF' docker run -d -p 8000:8000 common-assessment-tool EOF \ No newline at end of file From 1ef2db1e6976305945c181199a4329647f260c6f Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 16:43:24 -0700 Subject: [PATCH 055/109] Remove CD related vars from .env --- .env | 5 ----- README.md | 7 +------ 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/.env b/.env index 6de23bdd..14d7b937 100644 --- a/.env +++ b/.env @@ -1,8 +1,3 @@ -AWS_ACCESS_KEY_ID = "" -EC2_INSTANCE_UP = "" -EC2_INSTANCE_IP = "" -EC2_USER = "" -SECRET_ACCESS_KEY = "" SECRET_KEY = "" ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 \ No newline at end of file diff --git a/README.md b/README.md index 1de493db..3f1a0186 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,8 @@ This also has an API file to interact with the front end, and logic in order to -------------------------How to Use------------------------- 1. In the virtual environment you've created for this project, install all dependencies in requirements.txt (pip install -r requirements.txt) -2. Create a .env file with the following fields. The top five are just for CD pipeline purposes, so if running locally they will not be needed: +2. Create a .env file with the following fields: ```markdown -AWS_ACCESS_KEY_ID = "" -EC2_INSTANCE_UP = "" -EC2_INSTANCE_IP = "" -EC2_USER = "" -SECRET_ACCESS_KEY = "" SECRET_KEY = "" ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 From a1b3607113a5162f53a8ee12cd7a562fa5ba2c71 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 17:01:51 -0700 Subject: [PATCH 056/109] Overhaul CD pipeline --- .github/workflows/cd.yml | 24 +++++++----------------- docker-compose.yml | 4 +++- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index d329819d..081def73 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -32,20 +32,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - name: Build Docker image - run: docker build -t common-assessment-tool . - - - name: Read .env file - run: | - export $(grep '^AWS_ACCESS_KEY_ID=' .env) - export $(grep '^EC2_INSTANCE_UP=' .env) - export $(grep '^EC2_INSTANCE_IP=' .env) - export $(grep '^EC2_USER=' .env) - - - name: Deploy Docker Container - run: | - ssh -o StrictHostKeyChecking=no $AWS_ACCESS_KEY_ID @ $EC2_INSTANCE_IP << 'EOF' - docker run -d -p 8000:8000 common-assessment-tool - EOF \ No newline at end of file + - id: deploy + uses: bitovi/github-actions-deploy-docker-to-ec2@v1.0.1 + with: + aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws_default_region: us-west-2 + aws_elb_app_port: 8000 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 85aca9be..1f70f5fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,4 +7,6 @@ services: watch: - action: sync path: . - target: /code \ No newline at end of file + target: /code + app: + env_file: .env \ No newline at end of file From 15b33602674e563c940b2e31c95270d011c8a7d0 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 17:05:30 -0700 Subject: [PATCH 057/109] Update bitovi version --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 081def73..c8c7ad47 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -33,7 +33,7 @@ jobs: steps: - id: deploy - uses: bitovi/github-actions-deploy-docker-to-ec2@v1.0.1 + uses: bitovi/github-actions-deploy-docker-to-ec2@v1.0.2 with: aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} From 27e7f37506405454ee627bbf8ac4b6ff4088d843 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 17:05:59 -0700 Subject: [PATCH 058/109] Remove testing on CD for faster testing --- .github/workflows/cd.yml | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index c8c7ad47..06d28556 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -7,28 +7,7 @@ on: branches: [master, main] jobs: - test: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run Tests - run: | - python -m pytest tests/ - deploy: - needs: test # This ensures deploy only runs if tests pass runs-on: ubuntu-latest steps: From ded3a0439e3bcf594211d104ec5cf6bba991557a Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 17:13:22 -0700 Subject: [PATCH 059/109] Add reference --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 06d28556..db7b957b 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -12,7 +12,7 @@ jobs: steps: - id: deploy - uses: bitovi/github-actions-deploy-docker-to-ec2@v1.0.2 + uses: bitovi/github-actions-deploy-docker-to-ec2@v1.0.2 # https://github.com/marketplace/actions/deploy-docker-to-aws-ec2 with: aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} From e78f62526b1ecaa54edd3d8ba6cc652c27001d79 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 20:39:58 -0700 Subject: [PATCH 060/109] Try different aws region --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index db7b957b..58ac68e8 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -16,5 +16,5 @@ jobs: with: aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws_default_region: us-west-2 + aws_default_region: us-east-1 aws_elb_app_port: 8000 \ No newline at end of file From 22e041d8a8d4929915c5af3d2b463f089668d1d5 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 20:41:24 -0700 Subject: [PATCH 061/109] Revert to original region --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 58ac68e8..db7b957b 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -16,5 +16,5 @@ jobs: with: aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws_default_region: us-east-1 + aws_default_region: us-west-2 aws_elb_app_port: 8000 \ No newline at end of file From 7f34a4429d0d10b00db4b4ad0927ccb89194289a Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 20:42:17 -0700 Subject: [PATCH 062/109] Revert to east region --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index db7b957b..58ac68e8 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -16,5 +16,5 @@ jobs: with: aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws_default_region: us-west-2 + aws_default_region: us-east-1 aws_elb_app_port: 8000 \ No newline at end of file From 7771a66ac12da9986cc4423f7c8cef11fc3bfb5d Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 20:52:41 -0700 Subject: [PATCH 063/109] Revert to east region --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 58ac68e8..0d521e91 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -17,4 +17,4 @@ jobs: aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws_default_region: us-east-1 - aws_elb_app_port: 8000 \ No newline at end of file + aws_elb_app_port: 8000 # test commit to force commit \ No newline at end of file From 1fed35d4c827bb3e81317e0caa609b539746f691 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 21:00:48 -0700 Subject: [PATCH 064/109] testing --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 0d521e91..58ac68e8 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -17,4 +17,4 @@ jobs: aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws_default_region: us-east-1 - aws_elb_app_port: 8000 # test commit to force commit \ No newline at end of file + aws_elb_app_port: 8000 \ No newline at end of file From a3471e4644a549533aa35d08c2fe95632c87f84b Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 21:56:12 -0700 Subject: [PATCH 065/109] Alternative CD test --- .github/workflows/cd.yml | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 58ac68e8..0f7e95cb 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -6,15 +6,37 @@ on: pull_request: branches: [master, main] +# Source for how this was set up +# https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j jobs: deploy: runs-on: ubuntu-latest steps: - - id: deploy - uses: bitovi/github-actions-deploy-docker-to-ec2@v1.0.2 # https://github.com/marketplace/actions/deploy-docker-to-aws-ec2 - with: - aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws_default_region: us-east-1 - aws_elb_app_port: 8000 \ No newline at end of file + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Login to DockerHub + run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + + - name: Build and push Docker image + run: | + docker build -t common_assessment_tool . + docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + + - name: Install SSH Key + uses: webfactory/ssh-agent@v0.5.3 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + + - name: Deploy Docker image to EC2 + run: | + ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_INSTANCE_IP }} << 'EOF' + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + docker stop $(docker ps -a -q) || true + docker run -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + EOF \ No newline at end of file From b221aa5d267de3a28e7680b3580cd6f09bf96529 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 22:01:32 -0700 Subject: [PATCH 066/109] Try different SSH setup --- .github/workflows/cd.yml | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 0f7e95cb..6e39d47e 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -28,15 +28,14 @@ jobs: docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - - name: Install SSH Key - uses: webfactory/ssh-agent@v0.5.3 + - name: SSH and update EC2 container + uses: appleboy/ssh-action@v1.0.0 with: - ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - - - name: Deploy Docker image to EC2 - run: | - ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_INSTANCE_IP }} << 'EOF' - docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - docker stop $(docker ps -a -q) || true - docker run -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - EOF \ No newline at end of file + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + docker stop my_app_container || true + docker rm my_app_container || true + docker run -d --name my_app_container -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest \ No newline at end of file From ed2abb2ddb0d75f8d5b982e505dbc5c056554d9c Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 22:05:16 -0700 Subject: [PATCH 067/109] Try different SSH version --- .github/workflows/cd.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 6e39d47e..545fc27d 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -14,10 +14,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: actions/checkout@v3 - name: Login to DockerHub run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin @@ -29,7 +26,7 @@ jobs: docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - name: SSH and update EC2 container - uses: appleboy/ssh-action@v1.0.0 + uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USER }} From 2c9320ccb7cc7d0dcae296fb728dc80c416f694f Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 22:08:15 -0700 Subject: [PATCH 068/109] Try different SSH version --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 545fc27d..f53f6acd 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -28,7 +28,7 @@ jobs: - name: SSH and update EC2 container uses: appleboy/ssh-action@v1.0.3 with: - host: ${{ secrets.EC2_HOST }} + host: ${{ secrets.EC2_HOST }} # Test username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} script: | From f875b5d7580871a1052aa39509ad3013331bf6af Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 22:09:51 -0700 Subject: [PATCH 069/109] Try different SSH version --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index f53f6acd..545fc27d 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -28,7 +28,7 @@ jobs: - name: SSH and update EC2 container uses: appleboy/ssh-action@v1.0.3 with: - host: ${{ secrets.EC2_HOST }} # Test + host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} script: | From ed00991acc83268b049dcc6d8f76ddfd459add3f Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 22:27:11 -0700 Subject: [PATCH 070/109] Try different SSH version --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 545fc27d..50df0f22 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -6,7 +6,7 @@ on: pull_request: branches: [master, main] -# Source for how this was set up +# Source for how this was set up test # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j jobs: deploy: From e0569d767f46d87bff67fc056938cc571b0d3cdf Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 22:31:59 -0700 Subject: [PATCH 071/109] Add test if SSH key is being read --- .github/workflows/cd.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 50df0f22..fe3adadd 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -25,6 +25,9 @@ jobs: docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + - name: Debug Secret Value Length + run: echo "${{ secrets.EC2_SSH_KEY }}" | wc -l + - name: SSH and update EC2 container uses: appleboy/ssh-action@v1.0.3 with: From 7241695c9e0bfcf1db5ac223aa517d081911b683 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 22:34:58 -0700 Subject: [PATCH 072/109] Add test if SSH key is being read --- .github/workflows/cd.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index fe3adadd..22eaf77f 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -16,6 +16,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v3 + - name: Debug Secret Value Length + run: printf "%s" "${{ secrets.EC2_PEM_KEY }}" | grep -c '^' + - name: Login to DockerHub run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin @@ -25,9 +28,6 @@ jobs: docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - - name: Debug Secret Value Length - run: echo "${{ secrets.EC2_SSH_KEY }}" | wc -l - - name: SSH and update EC2 container uses: appleboy/ssh-action@v1.0.3 with: From 701986599c5251d9060aada66a0fdee8955ec742 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 22:35:41 -0700 Subject: [PATCH 073/109] Add test if SSH key is being read --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 22eaf77f..fda96086 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v3 - name: Debug Secret Value Length - run: printf "%s" "${{ secrets.EC2_PEM_KEY }}" | grep -c '^' + run: printf "%s" "${{ secrets.EC2_KEY }}" | grep -c '^' - name: Login to DockerHub run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin From 0b05df71d39e0db2cb27bdf9edfd72ee9fcc03a4 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 22:37:00 -0700 Subject: [PATCH 074/109] Fix run test --- .github/workflows/cd.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index fda96086..adc383e2 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -16,8 +16,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v3 - - name: Debug Secret Value Length - run: printf "%s" "${{ secrets.EC2_KEY }}" | grep -c '^' + - name: Debug PEM Key Line Count + run: | + printf "%s" "${{ secrets.EC2_KEY }}" | grep -c '^' - name: Login to DockerHub run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin From 2fe157a91d3286bbe5551e26b8d5f523ac18925d Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 22:39:51 -0700 Subject: [PATCH 075/109] Fix SSH key name --- .github/workflows/cd.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index adc383e2..be148564 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -18,7 +18,7 @@ jobs: - name: Debug PEM Key Line Count run: | - printf "%s" "${{ secrets.EC2_KEY }}" | grep -c '^' + printf "%s" "${{ secrets.SSH_PRIVATE_KEY }}" | grep -c '^' - name: Login to DockerHub run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin @@ -34,7 +34,7 @@ jobs: with: host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} + key: ${{ secrets.SSH_PRIVATE_KEY }} script: | docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest docker stop my_app_container || true From c5f686c8fba99889e99b142c81a1c38082f93834 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 22:43:23 -0700 Subject: [PATCH 076/109] Fix naming --- .github/workflows/cd.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index be148564..b9382540 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -37,6 +37,6 @@ jobs: key: ${{ secrets.SSH_PRIVATE_KEY }} script: | docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - docker stop my_app_container || true - docker rm my_app_container || true - docker run -d --name my_app_container -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest \ No newline at end of file + docker stop common_assessment_tool || true + docker rm common_assessment_tool || true + docker run -d --name common_assessment_tool_container -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest \ No newline at end of file From 7ba697246986c7dfe75184e9e9417547409e3914 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 22:50:52 -0700 Subject: [PATCH 077/109] Revert to previous CD version --- .github/workflows/cd.yml | 43 ++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index b9382540..8ee6d285 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -6,37 +6,32 @@ on: pull_request: branches: [master, main] -# Source for how this was set up test +# Source for how this was set up # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j jobs: deploy: runs-on: ubuntu-latest steps: - - name: Checkout repository - uses: actions/checkout@v3 + - name: Checkout repository + uses: actions/checkout@v2 - - name: Debug PEM Key Line Count - run: | - printf "%s" "${{ secrets.SSH_PRIVATE_KEY }}" | grep -c '^' + - name: Login to DockerHub + run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin - - name: Login to DockerHub - run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + - name: Build and push Docker image + run: | + docker build -t common_assessment_tool . + docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + - name: Install SSH Key + uses: webfactory/ssh-agent@v0.5.3 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - - name: Build and push Docker image - run: | - docker build -t common_assessment_tool . - docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - - - name: SSH and update EC2 container - uses: appleboy/ssh-action@v1.0.3 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.SSH_PRIVATE_KEY }} - script: | + - name: Deploy Docker image to EC2 + run: | + ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_INSTANCE_IP }} << 'EOF' docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - docker stop common_assessment_tool || true - docker rm common_assessment_tool || true - docker run -d --name common_assessment_tool_container -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest \ No newline at end of file + docker stop $(docker ps -a -q) || true + docker \ No newline at end of file From a0adfe55959df07af7c238e38b25c3ebb99695cf Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 22:55:42 -0700 Subject: [PATCH 078/109] Fix issues when reverting and update webfactory ssh connection version --- .github/workflows/cd.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 8ee6d285..a87d56f3 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -24,8 +24,9 @@ jobs: docker build -t common_assessment_tool . docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + - name: Install SSH Key - uses: webfactory/ssh-agent@v0.5.3 + uses: webfactory/ssh-agent@v0.9.1 with: ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} @@ -34,4 +35,5 @@ jobs: ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_INSTANCE_IP }} << 'EOF' docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest docker stop $(docker ps -a -q) || true - docker \ No newline at end of file + docker run -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + EOF \ No newline at end of file From aec4cb7319863fc2a87247f7b1875569fac5f81c Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 23:11:05 -0700 Subject: [PATCH 079/109] Test port accessible (not in code here) --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index a87d56f3..33a561ee 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -6,7 +6,7 @@ on: pull_request: branches: [master, main] -# Source for how this was set up +# Source for how this was set up test # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j jobs: deploy: From 8a77ea848a60365b7a365cab7a0861d64d36dc70 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 12 Apr 2025 23:17:33 -0700 Subject: [PATCH 080/109] Consolidate CI-CD pipeline so deployment only happens after tests clear --- .github/workflows/cd.yml | 39 ------------------------- .github/workflows/{ci.yml => ci_cd.yml} | 34 ++++++++++++++++++++- app/clients/service/ml_models.py | 2 +- 3 files changed, 34 insertions(+), 41 deletions(-) delete mode 100644 .github/workflows/cd.yml rename .github/workflows/{ci.yml => ci_cd.yml} (65%) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml deleted file mode 100644 index 33a561ee..00000000 --- a/.github/workflows/cd.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: CD Pipeline - -on: - push: - branches: [master, main, dev] - pull_request: - branches: [master, main] - -# Source for how this was set up test -# https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j -jobs: - deploy: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - - name: Login to DockerHub - run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin - - - name: Build and push Docker image - run: | - docker build -t common_assessment_tool . - docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - - - name: Install SSH Key - uses: webfactory/ssh-agent@v0.9.1 - with: - ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - - - name: Deploy Docker image to EC2 - run: | - ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_INSTANCE_IP }} << 'EOF' - docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - docker stop $(docker ps -a -q) || true - docker run -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - EOF \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci_cd.yml similarity index 65% rename from .github/workflows/ci.yml rename to .github/workflows/ci_cd.yml index 1f2abfde..b6e3781e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci_cd.yml @@ -1,4 +1,4 @@ -name: CI Pipeline +name: CI-CD Pipeline on: push: @@ -80,3 +80,35 @@ jobs: echo "✓ Linting completed" echo "✓ Formatting checked" echo "========================" + + # Source for how this was set up + # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j + deploy: + needs: test # This ensures deploy only runs if tests pass + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Login to DockerHub + run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + + - name: Build and push Docker image + run: | + docker build -t common_assessment_tool . + docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + + - name: Install SSH Key + uses: webfactory/ssh-agent@v0.9.1 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + + - name: Deploy Docker image to EC2 + run: | + ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_INSTANCE_IP }} << 'EOF' + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + docker stop $(docker ps -a -q) || true + docker run -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + EOF diff --git a/app/clients/service/ml_models.py b/app/clients/service/ml_models.py index c48d8cf9..8c0f51c5 100644 --- a/app/clients/service/ml_models.py +++ b/app/clients/service/ml_models.py @@ -106,7 +106,7 @@ def predict(self, features): return self.model.predict(features) def __str__(self): - return "SVM" + return "Support Vector Machine" def load_if_trained(self): path = get_true_file_name(str(self), default_unformatted_model_path) From 216fd912b9ded19dc6325f18538aa1c0b3150c6b Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 10:47:44 -0700 Subject: [PATCH 081/109] Try simpler setup for CD (commented CI tests for quicker CD testing) --- .github/workflows/ci_cd.yml | 152 ++++++++++++++++++------------------ 1 file changed, 74 insertions(+), 78 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index b6e3781e..526ccdc3 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -8,78 +8,78 @@ on: branches: [ master, main ] jobs: - test: - runs-on: ubuntu-latest # Use the latest Ubuntu runner - - steps: - - name: Checkout Code - uses: actions/checkout@v4 # Checkout the repository - - - name: Set up Python - uses: actions/setup-python@v5 # Set up Python environment - with: - python-version: "3.11" - - - name: Hadolint Action Check Dockerfile Syntax - uses: hadolint/hadolint-action@v3.1.0 - with: - dockerfile: ./Dockerfile - - - name: Install dependencies - run: | - python -m pip install --upgrade pip # Upgrade pip to the latest version - pip install setuptools wheel - pip install -r requirements.txt # Install dependencies from requirements.txt - pip install pylint pytest black isort - - - name: Run Code Formatting with Black # Format the entire repo - run: | - black . - - - name: Run Code Formatting with isort - run: | - isort . - - - name: Run Linter - run: | - pylint ./app ./tests - - - name: Run Tests - run: | - python -m pytest tests/ - - - name: Build Docker Image - run: | - docker build -t common-assessment-tool . - - - name: Run Docker container - run: | - docker run -d --name common-assessment-container -p 8000:8000 common-assessment-tool - sleep 10 - - - name: Test Docker container - run: | - curl --fail http://localhost:8000/docs || { - echo "Health check failed" - docker logs common-assessment-tool - exit 1 - } - - - name: Stop Docker container - run: docker stop common-assessment-container - - - name: Print Success Message - if: success() - run: | - echo "CI Pipeline completed successfully!" - echo "========================" - echo "✓ Code checked out" - echo "✓ Python environment set up" - echo "✓ Dependencies installed" - echo "✓ Tests executed" - echo "✓ Linting completed" - echo "✓ Formatting checked" - echo "========================" +# test: +# runs-on: ubuntu-latest # Use the latest Ubuntu runner +# +# steps: +# - name: Checkout Code +# uses: actions/checkout@v4 # Checkout the repository +# +# - name: Set up Python +# uses: actions/setup-python@v5 # Set up Python environment +# with: +# python-version: "3.11" +# +# - name: Hadolint Action Check Dockerfile Syntax +# uses: hadolint/hadolint-action@v3.1.0 +# with: +# dockerfile: ./Dockerfile +# +# - name: Install dependencies +# run: | +# python -m pip install --upgrade pip # Upgrade pip to the latest version +# pip install setuptools wheel +# pip install -r requirements.txt # Install dependencies from requirements.txt +# pip install pylint pytest black isort +# +# - name: Run Code Formatting with Black # Format the entire repo +# run: | +# black . +# +# - name: Run Code Formatting with isort +# run: | +# isort . +# +# - name: Run Linter +# run: | +# pylint ./app ./tests +# +# - name: Run Tests +# run: | +# python -m pytest tests/ +# +# - name: Build Docker Image +# run: | +# docker build -t common-assessment-tool . +# +# - name: Run Docker container +# run: | +# docker run -d --name common-assessment-container -p 8000:8000 common-assessment-tool +# sleep 10 +# +# - name: Test Docker container +# run: | +# curl --fail http://localhost:8000/docs || { +# echo "Health check failed" +# docker logs common-assessment-tool +# exit 1 +# } +# +# - name: Stop Docker container +# run: docker stop common-assessment-container +# +# - name: Print Success Message +# if: success() +# run: | +# echo "CI Pipeline completed successfully!" +# echo "========================" +# echo "✓ Code checked out" +# echo "✓ Python environment set up" +# echo "✓ Dependencies installed" +# echo "✓ Tests executed" +# echo "✓ Linting completed" +# echo "✓ Formatting checked" +# echo "========================" # Source for how this was set up # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j @@ -94,21 +94,17 @@ jobs: - name: Login to DockerHub run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin - - name: Build and push Docker image + - name: Build Docker image run: | docker build -t common_assessment_tool . - docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - name: Install SSH Key uses: webfactory/ssh-agent@v0.9.1 with: ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - - name: Deploy Docker image to EC2 + - name: Deploy Docker image to EC2 instance run: | ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_INSTANCE_IP }} << 'EOF' - docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - docker stop $(docker ps -a -q) || true docker run -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest EOF From e9a6383acfa20e19a3ca6aa5288187ed651115c2 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 10:48:20 -0700 Subject: [PATCH 082/109] Try simpler setup for CD (commented CI tests for quicker CD testing) --- .github/workflows/ci_cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 526ccdc3..f6dce07b 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -84,7 +84,7 @@ jobs: # Source for how this was set up # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j deploy: - needs: test # This ensures deploy only runs if tests pass + #needs: test # This ensures deploy only runs if tests pass runs-on: ubuntu-latest steps: From bd975d5a8c81bb42f776c835ce57ff607259fe39 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 10:50:09 -0700 Subject: [PATCH 083/109] Try simpler setup for CD (commented CI tests for quicker CD testing) --- .github/workflows/ci_cd.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index f6dce07b..fcc6f7b4 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -106,5 +106,6 @@ jobs: - name: Deploy Docker image to EC2 instance run: | ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_INSTANCE_IP }} << 'EOF' + docker stop $(docker ps -a -q) || true docker run -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest EOF From c1f851b68d460729305dd6ba050defaeadd79e97 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 10:52:01 -0700 Subject: [PATCH 084/109] Change name to confirm deployment works --- app/clients/service/ml_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/clients/service/ml_models.py b/app/clients/service/ml_models.py index 8c0f51c5..c48d8cf9 100644 --- a/app/clients/service/ml_models.py +++ b/app/clients/service/ml_models.py @@ -106,7 +106,7 @@ def predict(self, features): return self.model.predict(features) def __str__(self): - return "Support Vector Machine" + return "SVM" def load_if_trained(self): path = get_true_file_name(str(self), default_unformatted_model_path) From 428e241f3210e912e918ffb305454570374da853 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 11:01:07 -0700 Subject: [PATCH 085/109] Revert functionality and clean up tasks for deployment --- .github/workflows/ci_cd.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index fcc6f7b4..15e2a77b 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -91,13 +91,16 @@ jobs: - name: Checkout repository uses: actions/checkout@v2 - - name: Login to DockerHub - run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin - - name: Build Docker image run: | docker build -t common_assessment_tool . + - name: Push image to Docker Hub + run: | + echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + - name: Install SSH Key uses: webfactory/ssh-agent@v0.9.1 with: @@ -106,6 +109,7 @@ jobs: - name: Deploy Docker image to EC2 instance run: | ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_INSTANCE_IP }} << 'EOF' + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest docker stop $(docker ps -a -q) || true - docker run -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + docker run --rm -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest EOF From eb4be8b7876ac3cee9aab0e072dbca01a8f5fc23 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 11:08:48 -0700 Subject: [PATCH 086/109] Try old version --- .github/workflows/ci_cd.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 15e2a77b..93e28970 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -84,20 +84,19 @@ jobs: # Source for how this was set up # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j deploy: - #needs: test # This ensures deploy only runs if tests pass + needs: test # This ensures deploy only runs if tests pass runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v2 - - name: Build Docker image - run: | - docker build -t common_assessment_tool . + - name: Login to DockerHub + run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin - - name: Push image to Docker Hub + - name: Build and push Docker image run: | - echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + docker build -t common_assessment_tool . docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest @@ -106,10 +105,10 @@ jobs: with: ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - - name: Deploy Docker image to EC2 instance + - name: Deploy Docker image to EC2 run: | ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_INSTANCE_IP }} << 'EOF' docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest docker stop $(docker ps -a -q) || true - docker run --rm -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + docker run -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest EOF From 844ebbab6c6343832c3b4899e627bde30e6730fa Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 11:09:11 -0700 Subject: [PATCH 087/109] Try old version --- .github/workflows/ci_cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 93e28970..ca1372c3 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -84,7 +84,7 @@ jobs: # Source for how this was set up # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j deploy: - needs: test # This ensures deploy only runs if tests pass + #needs: test # This ensures deploy only runs if tests pass runs-on: ubuntu-latest steps: From 70f66da82a157a032f7fcb2c749c8d29a0f11598 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 11:13:02 -0700 Subject: [PATCH 088/109] Try old version --- .github/workflows/ci_cd.yml | 146 ++++++++++++++++++------------------ 1 file changed, 73 insertions(+), 73 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index ca1372c3..41fb4e58 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -8,83 +8,83 @@ on: branches: [ master, main ] jobs: -# test: -# runs-on: ubuntu-latest # Use the latest Ubuntu runner -# -# steps: -# - name: Checkout Code -# uses: actions/checkout@v4 # Checkout the repository -# -# - name: Set up Python -# uses: actions/setup-python@v5 # Set up Python environment -# with: -# python-version: "3.11" -# -# - name: Hadolint Action Check Dockerfile Syntax -# uses: hadolint/hadolint-action@v3.1.0 -# with: -# dockerfile: ./Dockerfile -# -# - name: Install dependencies -# run: | -# python -m pip install --upgrade pip # Upgrade pip to the latest version -# pip install setuptools wheel -# pip install -r requirements.txt # Install dependencies from requirements.txt -# pip install pylint pytest black isort -# -# - name: Run Code Formatting with Black # Format the entire repo -# run: | -# black . -# -# - name: Run Code Formatting with isort -# run: | -# isort . -# -# - name: Run Linter -# run: | -# pylint ./app ./tests -# -# - name: Run Tests -# run: | -# python -m pytest tests/ -# -# - name: Build Docker Image -# run: | -# docker build -t common-assessment-tool . -# -# - name: Run Docker container -# run: | -# docker run -d --name common-assessment-container -p 8000:8000 common-assessment-tool -# sleep 10 -# -# - name: Test Docker container -# run: | -# curl --fail http://localhost:8000/docs || { -# echo "Health check failed" -# docker logs common-assessment-tool -# exit 1 -# } -# -# - name: Stop Docker container -# run: docker stop common-assessment-container -# -# - name: Print Success Message -# if: success() -# run: | -# echo "CI Pipeline completed successfully!" -# echo "========================" -# echo "✓ Code checked out" -# echo "✓ Python environment set up" -# echo "✓ Dependencies installed" -# echo "✓ Tests executed" -# echo "✓ Linting completed" -# echo "✓ Formatting checked" -# echo "========================" + test: + runs-on: ubuntu-latest # Use the latest Ubuntu runner + + steps: + - name: Checkout Code + uses: actions/checkout@v4 # Checkout the repository + + - name: Set up Python + uses: actions/setup-python@v5 # Set up Python environment + with: + python-version: "3.11" + + - name: Hadolint Action Check Dockerfile Syntax + uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: ./Dockerfile + + - name: Install dependencies + run: | + python -m pip install --upgrade pip # Upgrade pip to the latest version + pip install setuptools wheel + pip install -r requirements.txt # Install dependencies from requirements.txt + pip install pylint pytest black isort + + - name: Run Code Formatting with Black # Format the entire repo + run: | + black . + + - name: Run Code Formatting with isort + run: | + isort . + + - name: Run Linter + run: | + pylint ./app ./tests + + - name: Run Tests + run: | + python -m pytest tests/ + + - name: Build Docker Image + run: | + docker build -t common-assessment-tool . + + - name: Run Docker container + run: | + docker run -d --name common-assessment-container -p 8000:8000 common-assessment-tool + sleep 10 + + - name: Test Docker container + run: | + curl --fail http://localhost:8000/docs || { + echo "Health check failed" + docker logs common-assessment-tool + exit 1 + } + + - name: Stop Docker container + run: docker stop common-assessment-container + + - name: Print Success Message + if: success() + run: | + echo "CI Pipeline completed successfully!" + echo "========================" + echo "✓ Code checked out" + echo "✓ Python environment set up" + echo "✓ Dependencies installed" + echo "✓ Tests executed" + echo "✓ Linting completed" + echo "✓ Formatting checked" + echo "========================" # Source for how this was set up # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j deploy: - #needs: test # This ensures deploy only runs if tests pass + needs: test # This ensures deploy only runs if tests pass runs-on: ubuntu-latest steps: From 907533052a03f2f55e0e106cb9a5e8da47fde90a Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 11:18:13 -0700 Subject: [PATCH 089/109] Try old version --- .github/workflows/ci_cd.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 41fb4e58..db30a2fe 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -46,7 +46,7 @@ jobs: - name: Run Tests run: | - python -m pytest tests/ + python -m pytest tests/ - name: Build Docker Image run: | @@ -55,7 +55,7 @@ jobs: - name: Run Docker container run: | docker run -d --name common-assessment-container -p 8000:8000 common-assessment-tool - sleep 10 + sleep 10 - name: Test Docker container run: | @@ -111,4 +111,4 @@ jobs: docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest docker stop $(docker ps -a -q) || true docker run -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - EOF + EOF \ No newline at end of file From 2d1565dc092557e75caeb2abba33c988fc6a5004 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 11:35:52 -0700 Subject: [PATCH 090/109] Update formatting --- .github/workflows/ci_cd.yml | 104 ++++++++++++++++++++++++++---------- 1 file changed, 75 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index db30a2fe..2ec51d16 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -8,7 +8,56 @@ on: branches: [ master, main ] jobs: - test: + test_code_formatting: + runs-on: ubuntu-latest # Use the latest Ubuntu runner + + steps: + - name: Checkout Code + uses: actions/checkout@v4 # Checkout the repository + + - name: Set up Python + uses: actions/setup-python@v5 # Set up Python environment + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip # Upgrade pip to the latest version + pip install setuptools wheel + pip install -r requirements.txt # Install dependencies from requirements.txt + pip install pylint pytest black isort + + - name: Run Code Formatting with Black # Format the entire repo + run: | + black . + + - name: Run Code Formatting with isort + run: | + isort . + + - name: Run Linter + run: | + pylint ./app ./tests + + - name: Run Tests + run: | + python -m pytest tests/ + + - name: Print Success Message + if: success() + run: | + echo "Confirmed code formatting and functionality successfully!" + echo "========================" + echo "✓ Code checked out" + echo "✓ Python environment set up" + echo "✓ Dependencies installed" + echo "✓ Code formatting checked with Black" + echo "✓ Code formatting checked with isort" + echo "✓ Linting completed" + echo "✓ Tests completed" + echo "========================" + + test_docker_setup: runs-on: ubuntu-latest # Use the latest Ubuntu runner steps: @@ -32,22 +81,6 @@ jobs: pip install -r requirements.txt # Install dependencies from requirements.txt pip install pylint pytest black isort - - name: Run Code Formatting with Black # Format the entire repo - run: | - black . - - - name: Run Code Formatting with isort - run: | - isort . - - - name: Run Linter - run: | - pylint ./app ./tests - - - name: Run Tests - run: | - python -m pytest tests/ - - name: Build Docker Image run: | docker build -t common-assessment-tool . @@ -71,32 +104,33 @@ jobs: - name: Print Success Message if: success() run: | - echo "CI Pipeline completed successfully!" + echo "Confirmed Docker image can be built and run successfully!" echo "========================" echo "✓ Code checked out" echo "✓ Python environment set up" + echo "✓ Docker file syntax checked" echo "✓ Dependencies installed" - echo "✓ Tests executed" - echo "✓ Linting completed" - echo "✓ Formatting checked" + echo "✓ Docker container built" + echo "✓ Docker container run" + echo "✓ Docker container tested with good health" + echo "✓ Docker container stopped" echo "========================" # Source for how this was set up # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j deploy: - needs: test # This ensures deploy only runs if tests pass + needs: [test_docker_setup, test_code_formatting] # This ensures deploy only runs if tests pass runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - - name: Login to DockerHub - run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + - name: Build Docker image + run: docker build -t common_assessment_tool . - - name: Build and push Docker image - run: | - docker build -t common_assessment_tool . + - name: Push image to Docker Hub + echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest @@ -111,4 +145,16 @@ jobs: docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest docker stop $(docker ps -a -q) || true docker run -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - EOF \ No newline at end of file + EOF + + - name: Print Success Message + if: success() + run: | + echo "Deployed to EC2 successfully!" + echo "========================" + echo "✓ Code checked out" + echo "✓ Docker image built" + echo "✓ Docker image pushed to Docker Hub" + echo "✓ SSH key for EC2 instance installed" + echo "✓ Deployed Docker image to EC2" + echo "========================" \ No newline at end of file From 9724967bdc632bcb4b1d71c3e6a09333407efc7f Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 11:39:45 -0700 Subject: [PATCH 091/109] Update formatting --- .github/workflows/ci_cd.yml | 270 ++++++++++++++++++------------------ 1 file changed, 135 insertions(+), 135 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 2ec51d16..fdc47032 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -9,152 +9,152 @@ on: jobs: test_code_formatting: - runs-on: ubuntu-latest # Use the latest Ubuntu runner - - steps: - - name: Checkout Code - uses: actions/checkout@v4 # Checkout the repository - - - name: Set up Python - uses: actions/setup-python@v5 # Set up Python environment - with: - python-version: "3.11" - - - name: Install dependencies - run: | - python -m pip install --upgrade pip # Upgrade pip to the latest version - pip install setuptools wheel - pip install -r requirements.txt # Install dependencies from requirements.txt - pip install pylint pytest black isort - - - name: Run Code Formatting with Black # Format the entire repo - run: | - black . - - - name: Run Code Formatting with isort - run: | - isort . - - - name: Run Linter - run: | - pylint ./app ./tests - - - name: Run Tests - run: | - python -m pytest tests/ - - - name: Print Success Message - if: success() - run: | - echo "Confirmed code formatting and functionality successfully!" - echo "========================" - echo "✓ Code checked out" - echo "✓ Python environment set up" - echo "✓ Dependencies installed" - echo "✓ Code formatting checked with Black" - echo "✓ Code formatting checked with isort" - echo "✓ Linting completed" - echo "✓ Tests completed" - echo "========================" + runs-on: ubuntu-latest # Use the latest Ubuntu runner + + steps: + - name: Checkout Code + uses: actions/checkout@v4 # Checkout the repository + + - name: Set up Python + uses: actions/setup-python@v5 # Set up Python environment + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip # Upgrade pip to the latest version + pip install setuptools wheel + pip install -r requirements.txt # Install dependencies from requirements.txt + pip install pylint pytest black isort + + - name: Run Code Formatting with Black # Format the entire repo + run: | + black . + + - name: Run Code Formatting with isort + run: | + isort . + + - name: Run Linter + run: | + pylint ./app ./tests + + - name: Run Tests + run: | + python -m pytest tests/ + + - name: Print Success Message + if: success() + run: | + echo "Confirmed code formatting and functionality successfully!" + echo "========================" + echo "✓ Code checked out" + echo "✓ Python environment set up" + echo "✓ Dependencies installed" + echo "✓ Code formatting checked with Black" + echo "✓ Code formatting checked with isort" + echo "✓ Linting completed" + echo "✓ Tests completed" + echo "========================" test_docker_setup: - runs-on: ubuntu-latest # Use the latest Ubuntu runner - - steps: - - name: Checkout Code - uses: actions/checkout@v4 # Checkout the repository - - - name: Set up Python - uses: actions/setup-python@v5 # Set up Python environment - with: - python-version: "3.11" - - - name: Hadolint Action Check Dockerfile Syntax - uses: hadolint/hadolint-action@v3.1.0 - with: - dockerfile: ./Dockerfile - - - name: Install dependencies - run: | - python -m pip install --upgrade pip # Upgrade pip to the latest version - pip install setuptools wheel - pip install -r requirements.txt # Install dependencies from requirements.txt - pip install pylint pytest black isort - - - name: Build Docker Image - run: | - docker build -t common-assessment-tool . - - - name: Run Docker container - run: | - docker run -d --name common-assessment-container -p 8000:8000 common-assessment-tool - sleep 10 - - - name: Test Docker container - run: | - curl --fail http://localhost:8000/docs || { - echo "Health check failed" - docker logs common-assessment-tool - exit 1 - } - - - name: Stop Docker container - run: docker stop common-assessment-container - - - name: Print Success Message - if: success() - run: | - echo "Confirmed Docker image can be built and run successfully!" - echo "========================" - echo "✓ Code checked out" - echo "✓ Python environment set up" - echo "✓ Docker file syntax checked" - echo "✓ Dependencies installed" - echo "✓ Docker container built" - echo "✓ Docker container run" - echo "✓ Docker container tested with good health" - echo "✓ Docker container stopped" - echo "========================" + runs-on: ubuntu-latest # Use the latest Ubuntu runner - # Source for how this was set up - # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j - deploy: - needs: [test_docker_setup, test_code_formatting] # This ensures deploy only runs if tests pass - runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 # Checkout the repository - steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 # Set up Python environment + with: + python-version: "3.11" - - name: Build Docker image - run: docker build -t common_assessment_tool . + - name: Hadolint Action Check Dockerfile Syntax + uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: ./Dockerfile - - name: Push image to Docker Hub - echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin - docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + - name: Install dependencies + run: | + python -m pip install --upgrade pip # Upgrade pip to the latest version + pip install setuptools wheel + pip install -r requirements.txt # Install dependencies from requirements.txt + pip install pylint pytest black isort - - name: Install SSH Key - uses: webfactory/ssh-agent@v0.9.1 - with: - ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + - name: Build Docker Image + run: | + docker build -t common-assessment-tool . - - name: Deploy Docker image to EC2 - run: | - ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_INSTANCE_IP }} << 'EOF' - docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - docker stop $(docker ps -a -q) || true - docker run -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - EOF + - name: Run Docker container + run: | + docker run -d --name common-assessment-container -p 8000:8000 common-assessment-tool + sleep 10 + + - name: Test Docker container + run: | + curl --fail http://localhost:8000/docs || { + echo "Health check failed" + docker logs common-assessment-tool + exit 1 + } + + - name: Stop Docker container + run: docker stop common-assessment-container - name: Print Success Message if: success() run: | - echo "Deployed to EC2 successfully!" + echo "Confirmed Docker image can be built and run successfully!" echo "========================" echo "✓ Code checked out" - echo "✓ Docker image built" - echo "✓ Docker image pushed to Docker Hub" - echo "✓ SSH key for EC2 instance installed" - echo "✓ Deployed Docker image to EC2" - echo "========================" \ No newline at end of file + echo "✓ Python environment set up" + echo "✓ Docker file syntax checked" + echo "✓ Dependencies installed" + echo "✓ Docker container built" + echo "✓ Docker container run" + echo "✓ Docker container tested with good health" + echo "✓ Docker container stopped" + echo "========================" + + # Source for how this was set up + # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j + deploy: + needs: [test_docker_setup, test_code_formatting] # This ensures deploy only runs if tests pass + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t common_assessment_tool . + + - name: Push image to Docker Hub + echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + + - name: Install SSH Key + uses: webfactory/ssh-agent@v0.9.1 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + + - name: Deploy Docker image to EC2 + run: | + ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_INSTANCE_IP }} << 'EOF' + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + docker stop $(docker ps -a -q) || true + docker run -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + EOF + + - name: Print Success Message + if: success() + run: | + echo "Deployed to EC2 successfully!" + echo "========================" + echo "✓ Code checked out" + echo "✓ Docker image built" + echo "✓ Docker image pushed to Docker Hub" + echo "✓ SSH key for EC2 instance installed" + echo "✓ Deployed Docker image to EC2" + echo "========================" \ No newline at end of file From 06a49ce091754ce6a9550d67d3b6a8627efa8dd7 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 11:43:35 -0700 Subject: [PATCH 092/109] Update formatting --- .github/workflows/ci_cd.yml | 162 ++++++++++-------------------------- 1 file changed, 46 insertions(+), 116 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index fdc47032..fc10cbdf 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -8,44 +8,44 @@ on: branches: [ master, main ] jobs: - test_code_formatting: - runs-on: ubuntu-latest # Use the latest Ubuntu runner + test: + runs-on: ubuntu-latest # Use the latest Ubuntu runner - steps: - - name: Checkout Code - uses: actions/checkout@v4 # Checkout the repository + steps: + - name: Checkout Code + uses: actions/checkout@v4 # Checkout the repository - - name: Set up Python - uses: actions/setup-python@v5 # Set up Python environment - with: + - name: Set up Python + uses: actions/setup-python@v5 # Set up Python environment + with: python-version: "3.11" - - name: Install dependencies - run: | + - name: Install dependencies + run: | python -m pip install --upgrade pip # Upgrade pip to the latest version pip install setuptools wheel pip install -r requirements.txt # Install dependencies from requirements.txt pip install pylint pytest black isort - - name: Run Code Formatting with Black # Format the entire repo - run: | + - name: Run Code Formatting with Black # Format the entire repo + run: | black . - - name: Run Code Formatting with isort - run: | + - name: Run Code Formatting with isort + run: | isort . - - name: Run Linter - run: | + - name: Run Linter + run: | pylint ./app ./tests - - name: Run Tests - run: | + - name: Run Tests + run: | python -m pytest tests/ - - name: Print Success Message - if: success() - run: | + - name: Print Success Message + if: success() + run: | echo "Confirmed code formatting and functionality successfully!" echo "========================" echo "✓ Code checked out" @@ -57,104 +57,34 @@ jobs: echo "✓ Tests completed" echo "========================" - test_docker_setup: - runs-on: ubuntu-latest # Use the latest Ubuntu runner - - steps: - - name: Checkout Code - uses: actions/checkout@v4 # Checkout the repository + # Source for how this was set up + # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j + deploy: + needs: test # This ensures deploy only runs if tests pass + runs-on: ubuntu-latest - - name: Set up Python - uses: actions/setup-python@v5 # Set up Python environment - with: - python-version: "3.11" + steps: + - name: Checkout repository + uses: actions/checkout@v2 - - name: Hadolint Action Check Dockerfile Syntax - uses: hadolint/hadolint-action@v3.1.0 - with: - dockerfile: ./Dockerfile + - name: Login to DockerHub + run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin - - name: Install dependencies - run: | - python -m pip install --upgrade pip # Upgrade pip to the latest version - pip install setuptools wheel - pip install -r requirements.txt # Install dependencies from requirements.txt - pip install pylint pytest black isort + - name: Build and push Docker image + run: | + docker build -t common_assessment_tool . + docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - - name: Build Docker Image - run: | - docker build -t common-assessment-tool . - - - name: Run Docker container - run: | - docker run -d --name common-assessment-container -p 8000:8000 common-assessment-tool - sleep 10 - - - name: Test Docker container - run: | - curl --fail http://localhost:8000/docs || { - echo "Health check failed" - docker logs common-assessment-tool - exit 1 - } - - - name: Stop Docker container - run: docker stop common-assessment-container - - - name: Print Success Message - if: success() - run: | - echo "Confirmed Docker image can be built and run successfully!" - echo "========================" - echo "✓ Code checked out" - echo "✓ Python environment set up" - echo "✓ Docker file syntax checked" - echo "✓ Dependencies installed" - echo "✓ Docker container built" - echo "✓ Docker container run" - echo "✓ Docker container tested with good health" - echo "✓ Docker container stopped" - echo "========================" + - name: Install SSH Key + uses: webfactory/ssh-agent@v0.9.1 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - # Source for how this was set up - # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j - deploy: - needs: [test_docker_setup, test_code_formatting] # This ensures deploy only runs if tests pass - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Build Docker image - run: docker build -t common_assessment_tool . - - - name: Push image to Docker Hub - echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin - docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - - - name: Install SSH Key - uses: webfactory/ssh-agent@v0.9.1 - with: - ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - - - name: Deploy Docker image to EC2 - run: | - ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_INSTANCE_IP }} << 'EOF' - docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - docker stop $(docker ps -a -q) || true - docker run -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - EOF - - - name: Print Success Message - if: success() + - name: Deploy Docker image to EC2 run: | - echo "Deployed to EC2 successfully!" - echo "========================" - echo "✓ Code checked out" - echo "✓ Docker image built" - echo "✓ Docker image pushed to Docker Hub" - echo "✓ SSH key for EC2 instance installed" - echo "✓ Deployed Docker image to EC2" - echo "========================" \ No newline at end of file + ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_INSTANCE_IP }} << 'EOF' + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + docker stop $(docker ps -a -q) || true + docker run -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + EOF \ No newline at end of file From 4f734d6cbb17833a860be479bb7d7bd7d6332157 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 11:43:56 -0700 Subject: [PATCH 093/109] Old version --- .github/workflows/ci_cd.yml | 62 +++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index fc10cbdf..db30a2fe 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -18,44 +18,68 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 # Set up Python environment with: - python-version: "3.11" + python-version: "3.11" + + - name: Hadolint Action Check Dockerfile Syntax + uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: ./Dockerfile - name: Install dependencies run: | - python -m pip install --upgrade pip # Upgrade pip to the latest version - pip install setuptools wheel - pip install -r requirements.txt # Install dependencies from requirements.txt - pip install pylint pytest black isort + python -m pip install --upgrade pip # Upgrade pip to the latest version + pip install setuptools wheel + pip install -r requirements.txt # Install dependencies from requirements.txt + pip install pylint pytest black isort - name: Run Code Formatting with Black # Format the entire repo run: | - black . + black . - name: Run Code Formatting with isort run: | - isort . + isort . - name: Run Linter run: | - pylint ./app ./tests + pylint ./app ./tests - name: Run Tests run: | - python -m pytest tests/ + python -m pytest tests/ + + - name: Build Docker Image + run: | + docker build -t common-assessment-tool . + + - name: Run Docker container + run: | + docker run -d --name common-assessment-container -p 8000:8000 common-assessment-tool + sleep 10 + + - name: Test Docker container + run: | + curl --fail http://localhost:8000/docs || { + echo "Health check failed" + docker logs common-assessment-tool + exit 1 + } + + - name: Stop Docker container + run: docker stop common-assessment-container - name: Print Success Message if: success() run: | - echo "Confirmed code formatting and functionality successfully!" - echo "========================" - echo "✓ Code checked out" - echo "✓ Python environment set up" - echo "✓ Dependencies installed" - echo "✓ Code formatting checked with Black" - echo "✓ Code formatting checked with isort" - echo "✓ Linting completed" - echo "✓ Tests completed" - echo "========================" + echo "CI Pipeline completed successfully!" + echo "========================" + echo "✓ Code checked out" + echo "✓ Python environment set up" + echo "✓ Dependencies installed" + echo "✓ Tests executed" + echo "✓ Linting completed" + echo "✓ Formatting checked" + echo "========================" # Source for how this was set up # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j From 7061ee65185d51a6762f43136e9e73404796ff47 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 11:44:15 -0700 Subject: [PATCH 094/109] Revert semi new test --- .github/workflows/ci_cd.yml | 62 ++++++++++++------------------------- 1 file changed, 19 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index db30a2fe..fc10cbdf 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -18,68 +18,44 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 # Set up Python environment with: - python-version: "3.11" - - - name: Hadolint Action Check Dockerfile Syntax - uses: hadolint/hadolint-action@v3.1.0 - with: - dockerfile: ./Dockerfile + python-version: "3.11" - name: Install dependencies run: | - python -m pip install --upgrade pip # Upgrade pip to the latest version - pip install setuptools wheel - pip install -r requirements.txt # Install dependencies from requirements.txt - pip install pylint pytest black isort + python -m pip install --upgrade pip # Upgrade pip to the latest version + pip install setuptools wheel + pip install -r requirements.txt # Install dependencies from requirements.txt + pip install pylint pytest black isort - name: Run Code Formatting with Black # Format the entire repo run: | - black . + black . - name: Run Code Formatting with isort run: | - isort . + isort . - name: Run Linter run: | - pylint ./app ./tests + pylint ./app ./tests - name: Run Tests run: | - python -m pytest tests/ - - - name: Build Docker Image - run: | - docker build -t common-assessment-tool . - - - name: Run Docker container - run: | - docker run -d --name common-assessment-container -p 8000:8000 common-assessment-tool - sleep 10 - - - name: Test Docker container - run: | - curl --fail http://localhost:8000/docs || { - echo "Health check failed" - docker logs common-assessment-tool - exit 1 - } - - - name: Stop Docker container - run: docker stop common-assessment-container + python -m pytest tests/ - name: Print Success Message if: success() run: | - echo "CI Pipeline completed successfully!" - echo "========================" - echo "✓ Code checked out" - echo "✓ Python environment set up" - echo "✓ Dependencies installed" - echo "✓ Tests executed" - echo "✓ Linting completed" - echo "✓ Formatting checked" - echo "========================" + echo "Confirmed code formatting and functionality successfully!" + echo "========================" + echo "✓ Code checked out" + echo "✓ Python environment set up" + echo "✓ Dependencies installed" + echo "✓ Code formatting checked with Black" + echo "✓ Code formatting checked with isort" + echo "✓ Linting completed" + echo "✓ Tests completed" + echo "========================" # Source for how this was set up # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j From 69bc154bf3d01fd6da541e96b4990623baf4843c Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 11:45:22 -0700 Subject: [PATCH 095/109] Update test --- .github/workflows/ci_cd.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index fc10cbdf..e0960f6f 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -8,7 +8,7 @@ on: branches: [ master, main ] jobs: - test: + test_code: runs-on: ubuntu-latest # Use the latest Ubuntu runner steps: @@ -60,7 +60,7 @@ jobs: # Source for how this was set up # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j deploy: - needs: test # This ensures deploy only runs if tests pass + needs: test_code # This ensures deploy only runs if tests pass runs-on: ubuntu-latest steps: From 339e13c2523e2458df57e6bffc17d369434a16e8 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 11:46:40 -0700 Subject: [PATCH 096/109] Add Docker tests --- .github/workflows/ci_cd.yml | 59 +++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index e0960f6f..02c9bb57 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -57,6 +57,65 @@ jobs: echo "✓ Tests completed" echo "========================" + test_docker_setup: + runs-on: ubuntu-latest # Use the latest Ubuntu runner + + steps: + - name: Checkout Code + uses: actions/checkout@v4 # Checkout the repository + + - name: Set up Python + uses: actions/setup-python@v5 # Set up Python environment + with: + python-version: "3.11" + + - name: Hadolint Action Check Dockerfile Syntax + uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: ./Dockerfile + + - name: Install dependencies + run: | + python -m pip install --upgrade pip # Upgrade pip to the latest version + pip install setuptools wheel + pip install -r requirements.txt # Install dependencies from requirements.txt + pip install pylint pytest black isort + + - name: Build Docker Image + run: | + docker build -t common-assessment-tool . + + - name: Run Docker container + run: | + docker run -d --name common-assessment-container -p 8000:8000 common-assessment-tool + sleep 10 + + - name: Test Docker container + run: | + curl --fail http://localhost:8000/docs || { + echo "Health check failed" + docker logs common-assessment-tool + exit 1 + } + + - name: Stop Docker container + run: docker stop common-assessment-container + + - name: Print Success Message + if: success() + run: | + echo "Confirmed Docker image can be built and run successfully!" + echo "========================" + echo "✓ Code checked out" + echo "✓ Python environment set up" + echo "✓ Docker file syntax checked" + echo "✓ Dependencies installed" + echo "✓ Docker container built" + echo "✓ Docker container run" + echo "✓ Docker container tested with good health" + echo "✓ Docker container stopped" + echo "========================" + # Source for how this was set up # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j deploy: From 6be74a27d26034023578c2c919cc7078648bfa25 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 11:49:40 -0700 Subject: [PATCH 097/109] Clean up formatting --- .github/workflows/ci_cd.yml | 93 +++++++++++++++++++------------------ 1 file changed, 47 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 02c9bb57..669c0480 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -18,44 +18,44 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 # Set up Python environment with: - python-version: "3.11" + python-version: "3.11" - name: Install dependencies run: | - python -m pip install --upgrade pip # Upgrade pip to the latest version - pip install setuptools wheel - pip install -r requirements.txt # Install dependencies from requirements.txt - pip install pylint pytest black isort + python -m pip install --upgrade pip # Upgrade pip to the latest version + pip install setuptools wheel + pip install -r requirements.txt # Install dependencies from requirements.txt + pip install pylint pytest black isort - name: Run Code Formatting with Black # Format the entire repo run: | - black . + black . - name: Run Code Formatting with isort run: | - isort . + isort . - name: Run Linter run: | - pylint ./app ./tests + pylint ./app ./tests - name: Run Tests run: | - python -m pytest tests/ + python -m pytest tests/ - name: Print Success Message if: success() run: | - echo "Confirmed code formatting and functionality successfully!" - echo "========================" - echo "✓ Code checked out" - echo "✓ Python environment set up" - echo "✓ Dependencies installed" - echo "✓ Code formatting checked with Black" - echo "✓ Code formatting checked with isort" - echo "✓ Linting completed" - echo "✓ Tests completed" - echo "========================" + echo "Confirmed code formatting and functionality successfully!" + echo "========================" + echo "✓ Code checked out" + echo "✓ Python environment set up" + echo "✓ Dependencies installed" + echo "✓ Code formatting checked with Black" + echo "✓ Code formatting checked with isort" + echo "✓ Linting completed" + echo "✓ Tests completed" + echo "========================" test_docker_setup: runs-on: ubuntu-latest # Use the latest Ubuntu runner @@ -98,8 +98,8 @@ jobs: exit 1 } - - name: Stop Docker container - run: docker stop common-assessment-container + - name: Stop Docker container + run: docker stop common-assessment-container - name: Print Success Message if: success() @@ -119,31 +119,32 @@ jobs: # Source for how this was set up # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j deploy: - needs: test_code # This ensures deploy only runs if tests pass + needs: [test_code, test_docker_setup] # This ensures deploy only runs if tests pass runs-on: ubuntu-latest steps: - - name: Checkout repository - uses: actions/checkout@v2 - - - name: Login to DockerHub - run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin - - - name: Build and push Docker image - run: | - docker build -t common_assessment_tool . - docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - - - name: Install SSH Key - uses: webfactory/ssh-agent@v0.9.1 - with: - ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - - - name: Deploy Docker image to EC2 - run: | - ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_INSTANCE_IP }} << 'EOF' - docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - docker stop $(docker ps -a -q) || true - docker run -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - EOF \ No newline at end of file + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Login to DockerHub + run: | + echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + + - name: Build and push Docker image + run: | + docker build -t common_assessment_tool . + docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + + - name: Install SSH Key + uses: webfactory/ssh-agent@v0.9.1 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + + - name: Deploy Docker image to EC2 + run: | + ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_INSTANCE_IP }} << 'EOF' + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + docker stop $(docker ps -a -q) || true + docker run -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest + EOF \ No newline at end of file From e6e36dfeab4b01db583c5d4e06d849cbf10d8b57 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 11:55:12 -0700 Subject: [PATCH 098/109] Remove unnecessary dependency building --- .github/workflows/ci_cd.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 669c0480..5258f3b8 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -74,13 +74,6 @@ jobs: with: dockerfile: ./Dockerfile - - name: Install dependencies - run: | - python -m pip install --upgrade pip # Upgrade pip to the latest version - pip install setuptools wheel - pip install -r requirements.txt # Install dependencies from requirements.txt - pip install pylint pytest black isort - - name: Build Docker Image run: | docker build -t common-assessment-tool . @@ -109,7 +102,6 @@ jobs: echo "✓ Code checked out" echo "✓ Python environment set up" echo "✓ Docker file syntax checked" - echo "✓ Dependencies installed" echo "✓ Docker container built" echo "✓ Docker container run" echo "✓ Docker container tested with good health" From fca7eda62c060b2df6ba1dff14914c4ee75b9409 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 12:07:46 -0700 Subject: [PATCH 099/109] Change name of SVM to test deployment setup --- app/clients/service/ml_models.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/clients/service/ml_models.py b/app/clients/service/ml_models.py index c48d8cf9..5ed15fed 100644 --- a/app/clients/service/ml_models.py +++ b/app/clients/service/ml_models.py @@ -106,7 +106,7 @@ def predict(self, features): return self.model.predict(features) def __str__(self): - return "SVM" + return "Support Vector Regressor" def load_if_trained(self): path = get_true_file_name(str(self), default_unformatted_model_path) @@ -120,7 +120,6 @@ def load_if_trained(self): class InterfaceMLModelRepository(ABC): """Interface for ML Models storage""" - @abstractmethod def list_models(self) -> List[InterfaceBaseMLModel]: """Get list of all available models instances""" From d8296b3c1d2bd1e151074e3f3a781aa9592c942b Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 12:15:49 -0700 Subject: [PATCH 100/109] Separate building Docker image from pushing to Docker Hub --- .github/workflows/ci_cd.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 5258f3b8..6aa1e356 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -116,15 +116,17 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Login to DockerHub run: | echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin - - name: Build and push Docker image + - name: Build Docker image run: | docker build -t common_assessment_tool . + + - name: Push image to Docker Hub docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest From 63780a293ee99aa5a90f2577078c26ed75c65b61 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 12:16:42 -0700 Subject: [PATCH 101/109] Change model name to test --- app/clients/service/ml_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/clients/service/ml_models.py b/app/clients/service/ml_models.py index 5ed15fed..ca59d4f8 100644 --- a/app/clients/service/ml_models.py +++ b/app/clients/service/ml_models.py @@ -106,7 +106,7 @@ def predict(self, features): return self.model.predict(features) def __str__(self): - return "Support Vector Regressor" + return "SVM" def load_if_trained(self): path = get_true_file_name(str(self), default_unformatted_model_path) From d25e6c644e09d9904dc5941f1ea3005160dcefe5 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 12:17:14 -0700 Subject: [PATCH 102/109] Fix issue with CD --- .github/workflows/ci_cd.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 6aa1e356..3ddbe26a 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -127,6 +127,7 @@ jobs: docker build -t common_assessment_tool . - name: Push image to Docker Hub + run: | docker tag common_assessment_tool ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest docker push ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest From d322766ac5ebe0e1e12a060fba5a79fba4219108 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 12:33:46 -0700 Subject: [PATCH 103/109] Try removing container before run --- .github/workflows/ci_cd.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 3ddbe26a..f1113fb1 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -141,5 +141,6 @@ jobs: ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_INSTANCE_IP }} << 'EOF' docker pull ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest docker stop $(docker ps -a -q) || true + docker rm $(docker ps -a -q) || true docker run -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest EOF \ No newline at end of file From fe940808a5aac5320b35540307d07a24009971f5 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 13 Apr 2025 12:39:09 -0700 Subject: [PATCH 104/109] Small changes to CD pipeline deployment --- .github/workflows/ci_cd.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index f1113fb1..bbc9c21b 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -108,7 +108,7 @@ jobs: echo "✓ Docker container stopped" echo "========================" - # Source for how this was set up + # Source for how deployment was set up # https://dev.to/s3cloudhub/automate-docker-deployments-push-your-images-to-ec2-with-github-actions-3a3j deploy: needs: [test_code, test_docker_setup] # This ensures deploy only runs if tests pass @@ -143,4 +143,17 @@ jobs: docker stop $(docker ps -a -q) || true docker rm $(docker ps -a -q) || true docker run -d -p 8000:8000 ${{ secrets.DOCKERHUB_USERNAME }}/common_assessment_tool:latest - EOF \ No newline at end of file + EOF + + - name: Print Success Message + if: success() + run: | + echo "Deployed updates to EC2 instance successfully!" + echo "========================" + echo "✓ Code checked out" + echo "✓ Logged in to Docker Hub" + echo "✓ Docker image built" + echo "✓ Docker image pushed to Docker Hub" + echo "✓ Installed EC2 SSH key" + echo "✓ Docker image deployed successfully" + echo "========================" \ No newline at end of file From 0c49d6e0ccca3c57c8a38e43eff49c2c183d5955 Mon Sep 17 00:00:00 2001 From: Steve Chen Date: Mon, 14 Apr 2025 14:29:06 -0700 Subject: [PATCH 105/109] update readme --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 70b0c190..e5c490b7 100644 --- a/README.md +++ b/README.md @@ -78,4 +78,8 @@ docker run --rm -p 8000:8000 common_assessment_tool 7. To run using Docker-Compose, run the command below in the CommonAssessmentTool repo's directory ``` docker compose up -``` \ No newline at end of file +``` + +## Access public address +Backend application is now deployed to the AWS Cloud. +Access the backend application from the endpoint by clicking: http://ec2-54-165-172-227.compute-1.amazonaws.com:8000/docs \ No newline at end of file From 1eabdd7efd19865e3a92557b8cf14f4828b7a5d5 Mon Sep 17 00:00:00 2001 From: Steve Chen Date: Mon, 14 Apr 2025 17:23:55 -0700 Subject: [PATCH 106/109] testing --- app/clients/service/ml_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/clients/service/ml_models.py b/app/clients/service/ml_models.py index ca59d4f8..284b7786 100644 --- a/app/clients/service/ml_models.py +++ b/app/clients/service/ml_models.py @@ -106,7 +106,7 @@ def predict(self, features): return self.model.predict(features) def __str__(self): - return "SVM" + return "SVM1" def load_if_trained(self): path = get_true_file_name(str(self), default_unformatted_model_path) From a9ab17baf682a6db67ffdf95e1878f758a137276 Mon Sep 17 00:00:00 2001 From: Steve Chen Date: Mon, 14 Apr 2025 18:18:13 -0700 Subject: [PATCH 107/109] test --- app/clients/service/ml_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/clients/service/ml_models.py b/app/clients/service/ml_models.py index 284b7786..c66f0bdc 100644 --- a/app/clients/service/ml_models.py +++ b/app/clients/service/ml_models.py @@ -106,7 +106,7 @@ def predict(self, features): return self.model.predict(features) def __str__(self): - return "SVM1" + return "SVM2" def load_if_trained(self): path = get_true_file_name(str(self), default_unformatted_model_path) From b0775c90edd3c95a9115dd031b54fe5d4cdb2cda Mon Sep 17 00:00:00 2001 From: Steve Chen Date: Mon, 14 Apr 2025 18:35:38 -0700 Subject: [PATCH 108/109] test cd --- app/clients/service/ml_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/clients/service/ml_models.py b/app/clients/service/ml_models.py index c66f0bdc..f865e6a3 100644 --- a/app/clients/service/ml_models.py +++ b/app/clients/service/ml_models.py @@ -106,7 +106,7 @@ def predict(self, features): return self.model.predict(features) def __str__(self): - return "SVM2" + return "SVM3" def load_if_trained(self): path = get_true_file_name(str(self), default_unformatted_model_path) From a010fd3da87687e62704eeca01d7c28f0bd4af70 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 15 Apr 2025 21:16:11 -0700 Subject: [PATCH 109/109] Change name of SVM --- app/clients/service/ml_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/clients/service/ml_models.py b/app/clients/service/ml_models.py index f865e6a3..ca59d4f8 100644 --- a/app/clients/service/ml_models.py +++ b/app/clients/service/ml_models.py @@ -106,7 +106,7 @@ def predict(self, features): return self.model.predict(features) def __str__(self): - return "SVM3" + return "SVM" def load_if_trained(self): path = get_true_file_name(str(self), default_unformatted_model_path)