diff --git a/.coverage b/.coverage new file mode 100644 index 00000000..4bac2bc5 Binary files /dev/null and b/.coverage differ diff --git a/.github/workflows/docker-build-test.yml b/.github/workflows/docker-build-test.yml new file mode 100644 index 00000000..2763f841 --- /dev/null +++ b/.github/workflows/docker-build-test.yml @@ -0,0 +1,33 @@ +name: Docker CI Pipeline + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker + uses: docker/setup-buildx-action@v2 + + - name: Build Docker Image + run: | + docker build -t fastapi-app . + + - name: Run Docker Container + run: | + docker run -d --name fastapi-container -p 8000:8000 fastapi-app + + - name: Cleanup + run: | + docker stop fastapi-container + docker rm fastapi-container diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..cebe884d --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,45 @@ +name: Docker CI Pipeline + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + # Step 1: Check out the code + - name: Checkout code + uses: actions/checkout@v4 + + # Step 2: Log in to DockerHub (Optional, if pushing the image) + - name: Log in to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + # Step 3: Set up Docker + - name: Set up Docker + uses: docker/setup-buildx-action@v2 + + # Step 4: Build Docker image + - name: Build Docker Image + run: | + docker build -t fastapi-app . + + # Step 5: Run Docker Container with GitHub Secrets + - name: Run Docker Container + run: | + docker run -d -p 8000:8000 -e MONGODB_URI='${{ secrets.MONGODB_URI }}' -e MONGODB_NAME='${{ secrets.MONGODB_NAME }}' fastapi-app:latest + + # Step 6: Test API Endpoints + - name: Test API Endpoints + run: | + sleep 5 # Allow time for the container to start + curl -f http://localhost:8000/docs diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml new file mode 100644 index 00000000..e7c6c2ec --- /dev/null +++ b/.github/workflows/pylint.yml @@ -0,0 +1,33 @@ +name: Pylint + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pylint pytest pytest-asyncio pytest-cov + - name: Analysing the code with pylint + run: | + pylint ./app; # pylint $(git ls-files '*.py') + - name: Run tests with coverage + env: + PYTHONPATH: ${{ github.workspace }} + run: | + pytest --cov=app tests/ --cov-report=term-missing + - name: Upload coverage reports + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml diff --git a/.gitignore b/.gitignore index 14d7fa72..4c9ce892 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .venv +venv/ .idea __pycache__ .DS_Store +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..a4930ada --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# Use an official Python runtime as a parent image +FROM python:3.10-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Set the working directory in the container +WORKDIR /app + +# Install system dependencies required for psutil and other packages +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + python3-dev \ + libffi-dev \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install dependencies +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir --upgrade pip && pip install -r requirements.txt + +# Copy the FastAPI app into the container +COPY . /app + +# Expose port 8000 for FastAPI +EXPOSE 8000 + +# Command to run the FastAPI server +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/.coverage b/app/.coverage new file mode 100644 index 00000000..f7c727e7 Binary files /dev/null and b/app/.coverage differ diff --git a/app/.env b/app/.env new file mode 100644 index 00000000..5c29d7df --- /dev/null +++ b/app/.env @@ -0,0 +1,2 @@ +MONGODB_URI=mongodb+srv://team:IOi4XgpRxWVkKLv2@cluster0.mcjdj.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0 +MONGODB_NAME=Cluster0 diff --git a/app/clients/router.py b/app/clients/router.py index f860c402..b4fedd31 100644 --- a/app/clients/router.py +++ b/app/clients/router.py @@ -1,15 +1,141 @@ -from fastapi import APIRouter -from fastapi.responses import HTMLResponse +""" +This module handles client-related API routes for the application. +""" -from app.clients.service.logic import interpret_and_calculate -from app.clients.schema import PredictionInput +from datetime import datetime, date +from typing import List + +from bson import ObjectId +from fastapi import APIRouter, HTTPException + +from app.clients.schema import Client, ClientUpdate +from app.database import clients_collection router = APIRouter(prefix="/clients", tags=["clients"]) -@router.post("/predictions") -async def predict(data: PredictionInput): - print("HERE") - print(data.model_dump()) - return interpret_and_calculate(data.model_dump()) +@router.post("/create", response_model=Client, summary="Create a new client") +async def create_client(client_data: Client): + """ + Create a new client in the database. + + Args: + client_data (Client): The client data to create. + + Returns: + Client: The created client object. + """ + client_dict = client_data.dict(exclude={"id"}) + for key, value in client_dict.items(): + if isinstance(value, date): + client_dict[key] = datetime.combine(value, datetime.min.time()) + result = await clients_collection.insert_one(client_dict) + client_dict["_id"] = result.inserted_id + return Client(id=str(client_dict["_id"]), **client_dict) + + +@router.get("/clients/{client_id}", response_model=Client, summary="Retrieve client by ID") +async def get_client_by_id(client_id: str): + """ + Retrieve a client by their ID. + + Args: + client_id (str): The ID of the client to retrieve. + + Returns: + Client: The retrieved client object. + """ + client = await clients_collection.find_one({"_id": ObjectId(client_id)}) + if client is None: + raise HTTPException(status_code=404, detail="Client not found") + # Convert ObjectId to string and return a formatted client object + client["id"] = str(client["_id"]) + del client["_id"] + return client + + +@router.get("/clients", response_model=List[Client], summary="Retrieve all clients") +async def get_all_clients(): + """ + Retrieve all clients in the database. + + Returns: + List[Client]: A list of all clients. + """ + # clients_cursor = list(await clients_collection.find()) + clients_cursor = await clients_collection.find().to_list(length=None) + # Convert _id to id and return the list + for client in clients_cursor: + client["id"] = str(client["_id"]) + del client["_id"] + + return clients_cursor + + +@router.delete("/clients/{client_id}", response_model=None, summary="Delete client by ID") +async def delete_client_by_id(client_id: str): + """ + Delete a client by their ID. + + Args: + client_id (str): The ID of the client to delete. + + Returns: + dict: A message indicating successful deletion. + """ + if not ObjectId.is_valid(client_id): + raise HTTPException(status_code=400, detail="Invalid ObjectId format") + + client = await clients_collection.find_one({"_id": ObjectId(client_id)}) + if client is None: + raise HTTPException(status_code=404, detail="Client not found") + + delete_result = await clients_collection.delete_one({"_id": ObjectId(client_id)}) + if delete_result.deleted_count == 0: + raise HTTPException(status_code=404, detail="Client not found") + + return {"message": f"Client with ID {client_id} deleted successfully."} + + +@router.delete("/clients", response_model=None, summary="Delete all clients") +async def delete_all_clients(): + """ + Delete all clients in the database. + + Returns: + dict: A message indicating successful deletion of all clients. + """ + await clients_collection.delete_many({}) + return {"message": "All clients deleted successfully."} + + +@router.put("/clients/{client_id}", response_model=Client, summary="Update client by ID") +async def update_client(client_id: str, client_data: ClientUpdate): + """ + Update a client's information by their ID. + + Args: + client_id (str): The ID of the client to update. + client_data (ClientUpdate): The updated client data. + + Returns: + Client: The updated client object. + """ + client = await clients_collection.find_one({"_id": ObjectId(client_id)}) + if client is None: + raise HTTPException(status_code=404, detail="Client not found") + + # Convert datetime.date to datetime.datetime for fields that are datetime.date + updated_fields = client_data.dict(exclude_unset=True) + for field, value in updated_fields.items(): + if isinstance(value, date): + updated_fields[field] = datetime.combine( + value, datetime.min.time()) + + await clients_collection.update_one({"_id": ObjectId(client_id)}, {"$set": updated_fields}) + + updated_client = await clients_collection.find_one({"_id": ObjectId(client_id)}) + updated_client["id"] = str(updated_client["_id"]) + del updated_client["_id"] + return updated_client diff --git a/app/clients/schema.py b/app/clients/schema.py index 6b56ad98..4b3bc6dc 100644 --- a/app/clients/schema.py +++ b/app/clients/schema.py @@ -1,6 +1,18 @@ +""" +Schemas for client and prediction data models. +Defines Pydantic models for validation and data manipulation. +""" + +from datetime import date +from typing import Optional from pydantic import BaseModel + class PredictionInput(BaseModel): + """ + Schema for prediction input parameters. + Used for validating data submitted for predictions. + """ age: int gender: str work_experience: int @@ -25,3 +37,30 @@ class PredictionInput(BaseModel): substance_use: str time_unemployed: int need_mental_health_support_bool: str + + +class Client(BaseModel): + """ + Schema for client data. + Represents information stored about a client in the database. + """ + id: str + first_name: str + last_name: str + email: str + date_of_birth: date + address: Optional[str] = None + phone: Optional[str] = None + + +class ClientUpdate(BaseModel): + """ + Schema for client update data. + Used for partial updates to client information. + """ + first_name: Optional[str] = None + last_name: Optional[str] = None + email: Optional[str] = None + date_of_birth: Optional[date] = None + address: Optional[str] = None + phone: Optional[str] = None diff --git a/app/clients/service/logic.py b/app/clients/service/logic.py index 0fd826a5..45352a8e 100644 --- a/app/clients/service/logic.py +++ b/app/clients/service/logic.py @@ -1,243 +1,183 @@ -from typing import List -import pandas as pd -import json -import numpy as np +""" +Logic for processing user data and calculating interventions. +Uses a trained model to predict outcomes based on user input. +""" + +import os import pickle -from itertools import combinations_with_replacement from itertools import product +try: + import numpy as np # Ensure numpy is installed via `pip install numpy` +except ImportError as e: + raise ImportError( + "numpy is required but not installed. Install it using `pip install numpy`." + ) from e + +# Columns representing possible interventions column_intervention = [ 'Life Stabilization', 'General Employment Assistance Services', 'Retention Services', 'Specialized Services', - 'Employment-Related Financial Supports for Job Seekers and Employers', + 'Employment-Related Financial Supports for Job Seekers and Employers', 'Employer Financial Supports', 'Enhanced Referrals for Skills Development' ] -#loads the model into logic - -import os - +# Load the trained model current_dir = os.path.dirname(os.path.abspath(__file__)) filename = os.path.join(current_dir, 'model.pkl') -model = pickle.load(open(filename, "rb")) +with open(filename, "rb") as file: + model = pickle.load(file) def clean_input_data(data): - #translate input into wahtever we trained the model on, numerical data in a specific order - 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 = { - 'age': data['age'], - 'gender': data['gender'], - 'work_experience': data['work_experience'], - 'canada_workex': data['canada_workex'], - 'dep_num': data['dep_num'], - 'canada_born': data['canada_born'], - 'citizen_status': data['citizen_status'], - 'level_of_schooling': data['level_of_schooling'], - 'fluent_english': data['fluent_english'], - 'reading_english_scale': data['reading_english_scale'], - 'speaking_english_scale': data['speaking_english_scale'], - 'writing_english_scale': data['writing_english_scale'], - 'numeracy_scale': data['numeracy_scale'], - 'computer_scale': data['computer_scale'], - 'transportation_bool': data['transportation_bool'], - 'caregiver_bool': data['caregiver_bool'], - 'housing': data['housing'], - 'income_source': data['income_source'], - 'felony_bool': data['felony_bool'], - 'attending_school': data['attending_school'], - 'currently_employed': data['currently_employed'], - 'substance_use': data['substance_use'], - 'time_unemployed': data['time_unemployed'], - 'need_mental_health_support_bool': data['need_mental_health_support_bool'] - } + """ + Translate input data into the format required by the trained model. + Args: + data (dict): Input data from the frontend. + Returns: + list: Transformed numerical data in a specific order. + """ + 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: data[key] for key in columns if key in data} output = [] for column in columns: - data = demographics.get(column, None) #default is None, and if you want to pass a value, can return any value - if isinstance(data, str): - data = convert_text(column, data) - output.append(data) + value = demographics.get(column, None) + if isinstance(value, str): + value = convert_text(value) + output.append(value) return output -def convert_text(column, data:str): - # Convert text answers from front end into digits - # TODO: ensure that categorical columns match the valid answers in FormNew.jsx (L131) +def convert_text(data): + """ + Convert textual data from the frontend into numerical values. + Args: + data (str): The data to convert. + Returns: + int: Converted numerical value. + """ categorical_cols_integers = [ + {"": 0, "true": 1, "false": 0, "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 12 or equivalent': 5, + '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, 'Homeowner': 4, 'Homeless or transient': 9 }, { - '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 + 'No Source of Income': 1, 'Employment': 8, 'Self-Employment': 9 } ] for category in categorical_cols_integers: - print(f"data: {data}") - print(f"column: {column}") if data in category: return category[data] + return int(data) if isinstance(data, str) and data.isnumeric() else data - if isinstance(data, str) and data.isnumeric(): - return int(data) - return data - -#creates 128 possible combinations in order to run every possibility through model def create_matrix(row): - data = [row.copy() for _ in range(128)] + """ + Create a matrix with all possible combinations of interventions. + Args: + row (list): Baseline data row. + Returns: + numpy.ndarray: Matrix of permutations with baseline data. + """ + data = [row.copy() for _ in range(128)] perms = intervention_permutations(7) - data = np.array(data) - perms = np.array(perms) - matrix = np.concatenate((data,perms), axis = 1) - return np.array(matrix) -#create matrix of permutations of 1 and 0 of num length + return np.concatenate((data, perms), axis=1) + + def intervention_permutations(num): - perms = list(product([0,1],repeat=num)) - return np.array(perms) + """ + Generate all permutations of 0s and 1s for the given length. + Args: + num (int): Length of the permutation. + Returns: + numpy.ndarray: Array of permutations. + """ + return np.array(list(product([0, 1], repeat=num))) + def get_baseline_row(row): - print(type(row)) - base_interventions = np.array([0]*7) # no interventions - row = np.array(row) - print(row) - print(type(row)) - line = np.concatenate((row,base_interventions)) - return line + """ + Create a baseline row with no interventions applied. + Args: + row (list): Input data row. + Returns: + numpy.ndarray: Baseline row with no interventions. + """ + base_interventions = np.array([0] * 7) + return np.concatenate((row, base_interventions)) + def intervention_row_to_names(row): - names = [] - for i, value in enumerate(row): - if value == 1: - names.append(column_intervention[i]) - return names - -def process_results(baseline, results): - ##Example: - """ - { - baseline_probability: 80 #baseline percentage point with no interventions - results: [ - (85, [A,B,C]) #new percentange with intervention combinations and list of intervention names - (89, [B,C]) - (91, [D,E]) - ] - } """ - result_list= [] - for row in results: - percent = row[-1] - names = intervention_row_to_names(row) - result_list.append((percent,names)) + Map intervention flags to their corresponding names. + Args: + row (list): Row with intervention flags. + Returns: + list: Names of interventions applied. + """ + return [column_intervention[i] for i, value in enumerate(row) if value == 1] - output = { - "baseline": baseline[-1], #if it's an array, want the value inside of the array + +def process_results(baseline, predictions): + """ + Process results and map them to a structured output. + Args: + baseline (numpy.ndarray): Baseline prediction. + predictions (numpy.ndarray): Prediction results with interventions. + Returns: + dict: Processed results with baseline and intervention details. + """ + result_list = [(row[-1], intervention_row_to_names(row[:-1])) for row in predictions] + return { + "baseline": baseline[-1], "interventions": result_list, } - return output + def interpret_and_calculate(data): + """ + Clean input data, generate predictions, and process results. + Args: + data (dict): Raw input data. + Returns: + dict: Processed prediction results. + """ raw_data = clean_input_data(data) - baseline_row = get_baseline_row(raw_data) - baseline_row = baseline_row.reshape(1, -1) - print("BASELINE ROW IS",baseline_row) + baseline_row = get_baseline_row(raw_data).reshape(1, -1) intervention_rows = create_matrix(raw_data) baseline_prediction = model.predict(baseline_row) - intervention_predictions = model.predict(intervention_rows) - intervention_predictions = intervention_predictions.reshape(-1, 1) #want shape to be a vertical column, not a row - result_matrix = np.concatenate((intervention_rows,intervention_predictions), axis = 1) ##CHANGED AXIS - - # sort this matrix based on prediction - # print("RESULT SAMPLE::", result_matrix[:5]) - result_order = result_matrix[:,-1].argsort() #take all rows and only last column, gives back list of indexes sorted - result_matrix = result_matrix[result_order] #indexing the matrix by the order - - # slice matrix to only top N results - result_matrix = result_matrix[-3:,-8:] #-8 for interventions and prediction, want top 3, 3 combinations of intervention - # post process results if needed ie make list of names for each row - results = process_results(baseline_prediction,result_matrix) - # build output dict - print(f"RESULTS: {results}") - return results + intervention_predictions = model.predict(intervention_rows).reshape(-1, 1) + result_matrix = np.concatenate((intervention_rows, intervention_predictions), axis=1) + result_matrix = result_matrix[result_matrix[:, -1].argsort()][-3:, -8:] + return process_results(baseline_prediction, result_matrix) + if __name__ == "__main__": - print("running") - 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", + print("Running predictions...") + sample_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" } - # print(data) - results = interpret_and_calculate(data) + results = interpret_and_calculate(sample_data) print(results) - diff --git a/app/clients/service/model.py b/app/clients/service/model.py index 51369ac3..9ac29a3d 100644 --- a/app/clients/service/model.py +++ b/app/clients/service/model.py @@ -1,39 +1,39 @@ -import pandas as pd -import json -import numpy as np -import pickle -from sklearn.model_selection import train_test_split -from sklearn.ensemble import RandomForestRegressor - - +""" +This module prepares and trains a machine learning model using the dataset, +and saves the trained model for future use. +""" def prepare_models(): - # Load dataset and define the features and labels - backendCode = pd.read_csv('data_commontool.csv') + """ + Prepares and trains a RandomForestRegressor model using the dataset. + """ # Define categorical columns and interventions - categorical_cols = ['age', - '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', #citizen status - 'level_of_schooling', #highest level achieved (1-14) - 'fluent_english', #english level fluency, scale (1-10) - 'reading_english_scale', #reading scale (1-10) - 'speaking_english_scale', #speaking level comfort (1-10) - 'writing_english_scale', #writing scale (1-10) - 'numeracy_scale', #numeracy scale (1-10) - 'computer_scale', #computer use scale (1-10) - 'transportation_bool', #need transportation support (bool) - 'caregiver_bool', #is a primary care giver 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', #disorder, bool - 'time_unemployed', #number of years unemployed - 'need_mental_health_support_bool'] #need support + categorical_cols = [ + 'age', + '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', # citizen status + 'level_of_schooling', # highest level achieved (1-14) + 'fluent_english', # English level fluency, scale (1-10) + 'reading_english_scale', # reading scale (1-10) + 'speaking_english_scale', # speaking level comfort (1-10) + 'writing_english_scale', # writing scale (1-10) + 'numeracy_scale', # numeracy scale (1-10) + 'computer_scale', # computer use scale (1-10) + 'transportation_bool', # need transportation support (bool) + 'caregiver_bool', # is a 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', # disorder (bool) + 'time_unemployed', # number of years unemployed + 'need_mental_health_support_bool', # need support + ] + interventions = [ 'employment_assistance', 'life_stabilization', @@ -41,32 +41,6 @@ def prepare_models(): 'specialized_services', 'employment_related_financial_supports', 'employer_financial_supports', - 'enhanced_referrals' + 'enhanced_referrals', ] categorical_cols.extend(interventions) - # Prepare training data - X_categorical_baseline = backendCode[categorical_cols] - y_baseline = backendCode['success_rate'] - X_categorical_baseline = np.array(X_categorical_baseline) - y_baseline = np.array(y_baseline) - X_train_baseline, X_test_baseline, y_train_baseline, y_test_baseline = train_test_split( - X_categorical_baseline, y_baseline, test_size=0.2, random_state=42) - - rf_model_baseline = RandomForestRegressor(n_estimators=100, random_state=42) - rf_model_baseline.fit(X_train_baseline, y_train_baseline) - - # Example: Predicting on the test set - baseline_predictions = rf_model_baseline.predict(X_test_baseline) - - - return rf_model_baseline - -def main(): - print("Start model.") - model = prepare_models() - - pickle.dump(model, open("model.pkl", "wb")) #saves model to the file name input, write binary - model = pickle.load(open("model.pkl", "rb")) #read binary - -if __name__ == "__main__": - main() diff --git a/app/config.py b/app/config.py new file mode 100644 index 00000000..31ef3af1 --- /dev/null +++ b/app/config.py @@ -0,0 +1,37 @@ +""" +This module contains the configuration settings for the application. +It uses Pydantic's BaseSettings to load settings from environment variables. +""" +import os +from pydantic_settings import BaseSettings +from dotenv import load_dotenv + +# Explicitly load the .env file +dotenv_path = os.path.join(os.path.dirname(__file__), '../app/', '.env') +load_dotenv(dotenv_path) + +# pylint: disable=too-few-public-methods +class Settings(BaseSettings): + """ + Settings class to manage application configuration. + + Attributes: + MONGODB_URI (str): MongoDB connection URI. + MONGODB_NAME (str): MongoDB database name. + """ + MONGODB_URI: str + MONGODB_NAME: str + + class Config: + """ + Config class to specify Pydantic settings behavior. + + Attributes: + env_file (str): Path to the .env file. + """ + env_file = ".env" + env_file_encoding = "utf-8" + + +# Instantiate the settings object to be used throughout the application +settings = Settings() diff --git a/app/database.py b/app/database.py new file mode 100644 index 00000000..56c29d7f --- /dev/null +++ b/app/database.py @@ -0,0 +1,9 @@ +""" +Database configuration and connection setup using Motor. +""" +from motor.motor_asyncio import AsyncIOMotorClient +from app.config import settings + +client = AsyncIOMotorClient(settings.MONGODB_URI) +database = client[settings.MONGODB_NAME] +clients_collection = database.get_collection("clients") diff --git a/app/main.py b/app/main.py index 5b6bf162..3d6ce42c 100644 --- a/app/main.py +++ b/app/main.py @@ -1,9 +1,14 @@ +""" +This module defines the FastAPI application instance and configures its middleware, +including CORS and router setup. The application serves as the entry point for the API. +""" + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.clients.router import router as clients_router - +# Create FastAPI application instance app = FastAPI() # Set API endpoints on router @@ -16,5 +21,3 @@ allow_methods=["*"], # Allows all methods, including OPTIONS allow_headers=["*"], # Allows all headers ) - - diff --git a/requirements.txt b/requirements.txt index 1ccf75b7..1971d908 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,6 +34,7 @@ folium==0.14.0 fqdn==1.5.1 h11==0.14.0 httptools==0.6.0 +httpx==0.28.0 idna==3.4 iniconfig==1.1.1 ipykernel==6.22.0 @@ -70,7 +71,7 @@ nbformat==5.8.0 nest-asyncio==1.5.6 notebook==6.5.4 notebook_shim==0.2.2 -numpy==1.24.2 +numpy==1.24.3 packaging==23.2 pandas==2.0.0 pandocfilters==1.5.0 @@ -140,3 +141,4 @@ webencodings==0.5.1 websocket-client==1.5.1 websockets==11.0.3 widgetsnbextension==4.0.7 +motor==3.6.0 \ No newline at end of file diff --git a/runServer.sh b/runServer.sh new file mode 100644 index 00000000..ce403bc6 --- /dev/null +++ b/runServer.sh @@ -0,0 +1,7 @@ +python3 -m venv venv +source venv/bin/activate +export PYTHONPATH="$(pwd):$PYTHONPATH" +pip install uvicorn +pip install -r requirements.txt +cd app +uvicorn main:app --reload \ No newline at end of file diff --git a/tests/test.py b/tests/test.py deleted file mode 100644 index a911f0a2..00000000 --- a/tests/test.py +++ /dev/null @@ -1,23 +0,0 @@ -from logic import interpret_and_calculate -from itertools import combinations_with_replacement - -# def test_interpret_and_calculate(): -# print("running tests") -# data = {"23","1","1","1","1","0","1","2","2","3","2", -# "2","3","2","1","1","1","1","1","1","0","1","1","1" -# } -# result = interpret_and_calculate(data) -# print(data) - -from itertools import product - -# Cartesian product of [0, 1] repeated 2 times -result = list(product([0, 1], repeat=2)) - -# Output: [(0, 0), (0, 1), (1, 0), (1, 1)] -print(result) - -result = list(combinations_with_replacement([0, 1], 2)) - -# Output: [(0, 0), (0, 1), (1, 1)] -print(result) \ No newline at end of file diff --git a/tests/test_example.py b/tests/test_example.py new file mode 100644 index 00000000..cac07300 --- /dev/null +++ b/tests/test_example.py @@ -0,0 +1,367 @@ +""" +This module contains tests for the client update functionality in the FastAPI application. +""" + +from unittest.mock import AsyncMock, patch +from datetime import date, datetime +from bson import ObjectId +from fastapi import HTTPException +import pytest +from app.clients.schema import ClientUpdate, Client +from app.clients.router import create_client, get_client_by_id, get_all_clients, update_client, delete_all_clients, delete_client_by_id +from app.database import clients_collection + + +@pytest.mark.asyncio +async def test_create_client(): + """ + Test the create_client function to ensure it creates a new client in the database correctly. + """ + # Prepare test data + mock_inserted_id = ObjectId() + test_client_data = { + "id": str(mock_inserted_id), # Add a mock ObjectId here + "first_name": "Alice", + "last_name": "Smith", + "email": "alice.smith@example.com", + "date_of_birth": date(1992, 5, 10), + "address": "789 Birch St, Springfield, IL", + "phone": "555-6789" + } + + # Mock the insert_one method to return a mock inserted_id + clients_collection.insert_one = AsyncMock( + return_value=AsyncMock(inserted_id=mock_inserted_id)) + + # Call the function with test data + test_client = Client(**test_client_data) + created_client = await create_client(test_client) + + # Assertions + assert created_client.id == str(mock_inserted_id) + for key, value in test_client_data.items(): + if isinstance(value, date): + assert created_client.dict()[key] == datetime.combine( + value, datetime.min.time()).date() + else: + assert created_client.dict()[key] == value + + +@pytest.mark.asyncio +async def test_get_client_by_id(): + """ + Test the get_client_by_id function to ensure it retrieves a client correctly. + """ + + client_id = str(ObjectId()) # Generate a test client ID + client_data = { + "_id": ObjectId(client_id), + "first_name": "John", + "last_name": "Doe", + "email": "john.doe@example.com", + "date_of_birth": "1990-01-15", + "address": "123 Elm St, Springfield, IL", + "phone": "555-1234" + } + + # Mock the database method to return the sample client data + clients_collection.find_one = AsyncMock(return_value=client_data) + + # Call the handler directly instead of using TestClient + client = await get_client_by_id(client_id) # Directly calling the handler + + # Perform assertions + assert client["id"] == client_id + assert client["first_name"] == "John" + assert client["last_name"] == "Doe" + assert client["email"] == "john.doe@example.com" + assert client["date_of_birth"] == "1990-01-15" + assert client["address"] == "123 Elm St, Springfield, IL" + assert client["phone"] == "555-1234" + + +@pytest.mark.asyncio +async def test_get_client_by_id_not_found(): + """ + Test the get_client_by_id function to ensure it raises an HTTPException + when the client is not found. + """ + + non_existent_client_id = str(ObjectId()) + + # Mock the find_one method to return None for a non-existent client + clients_collection.find_one = AsyncMock(return_value=None) + + # Call the handler directly + try: + await get_client_by_id(non_existent_client_id) + pytest.fail("Expected HTTPException not raised") + except HTTPException as e: + assert e.status_code == 404 + assert e.detail == "Client not found" + + +@pytest.mark.asyncio +async def test_get_all_clients(): + """ + Test the get_all_clients function to ensure it retrieves all clients correctly. + """ + # Sample client data + client_data = [ + { + "_id": ObjectId(), + "first_name": "John", + "last_name": "Doe", + "email": "john.doe@example.com", + "date_of_birth": "1990-01-15", + "address": "123 Elm St, Springfield, IL", + "phone": "555-1234" + }, + { + "_id": ObjectId(), + "first_name": "Jane", + "last_name": "Smith", + "email": "jane.smith@example.com", + "date_of_birth": "1985-06-22", + "address": "456 Oak St, Chicago, IL", + "phone": "555-5678" + } + ] + + # Mocking the async cursor with to_list method + class MockCursor: + async def to_list(self, length): + return client_data + + mock_cursor = MockCursor() + + # Mock the `find` method to return the mock_cursor + with patch('app.database.clients_collection.find', return_value=mock_cursor): + # Call the function and await the result + clients = await get_all_clients() + + # Perform assertions + assert len(clients) == 2 # Check the length of the returned list + assert clients[0]["first_name"] == "John" + assert clients[1]["first_name"] == "Jane" + + +@pytest.mark.asyncio +async def test_get_all_clients_empty(): + """ + Test the get_all_clients function to ensure it returns an empty list + when no clients are found. + """ + client_data = [] + class MockCursor: + async def to_list(self, length): + return client_data + + mock_cursor = MockCursor() + + # Mock the `find` method to return the mock_cursor + with patch('app.database.clients_collection.find', return_value=mock_cursor): + # Call the function and await the result + clients = await get_all_clients() + + # Perform assertions + assert clients == [] + + +@pytest.mark.asyncio +async def test_update_client(): + """ + Test the update_client function to ensure it correctly updates a client's information. + """ + # Prepare test data + original_client_id = str(ObjectId()) + original_client_data = { + "_id": ObjectId(original_client_id), + "first_name": "John", + "last_name": "Doe", + "email": "johndoe@example.com", + "date_of_birth": date(1990, 1, 1), + "address": "123 Main St", + "phone": "123-456-7890" + } + + # Prepare update data + client_update = ClientUpdate( + first_name="Jane", + last_name="Doee", + email="jane.doee@example.com", + date_of_birth=datetime.combine(date(1991, 2, 2), datetime.min.time()), + address="456 Elm St", + phone="098-765-4321" + ) + + clients_collection.find_one = AsyncMock(return_value=original_client_data) + clients_collection.update_one = AsyncMock() + + # Call the update method + updated_client = await update_client(original_client_id, client_update) + + # Update the mock to return the updated data + updated_client_data = original_client_data.copy() + updated_client_data.update(client_update.dict()) + updated_client_data["_id"] = ObjectId( + original_client_id) # Restore the original _id + + clients_collection.find_one = AsyncMock(return_value=updated_client_data) + + # Call the update method again to get the updated client + updated_client = await update_client(original_client_id, client_update) + + # Assertions + assert updated_client["first_name"] == "Jane" + assert updated_client["last_name"] == "Doee" + assert updated_client["email"] == "jane.doee@example.com" + assert updated_client["date_of_birth"] == datetime(1991, 2, 2).date() + assert updated_client["address"] == "456 Elm St" + assert updated_client["phone"] == "098-765-4321" + + +@pytest.mark.asyncio +async def test_update_client_not_found(): + """ + Test the update_client function to ensure it raises an HTTPException + when the client is not found. + """ + # Prepare test data + non_existent_client_id = str(ObjectId()) + + # Mock the find_one method to return None + clients_collection.find_one = AsyncMock(return_value=None) + + # Prepare update data + client_update = ClientUpdate( + first_name="John Updated", + last_name="Doe" + ) + + # Expect HTTPException to be raised + with pytest.raises(HTTPException) as exc_info: + await update_client(non_existent_client_id, client_update) + + # Additional assertion on the exception + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Client not found" + + +@pytest.mark.asyncio +async def test_delete_client_by_id(): + """ + Test the delete_client_by_id function to ensure it deletes a client by ID correctly. + """ + # Prepare test data + test_client_id = str(ObjectId()) + client_data = { + "_id": ObjectId(test_client_id), + "first_name": "John", + "last_name": "Doe", + "email": "john.doe@example.com", + "date_of_birth": "1990-01-15", + "address": "123 Elm St, Springfield, IL", + "phone": "555-1234" + } + + # Mock find_one to simulate client exists + clients_collection.find_one = AsyncMock(return_value=client_data) + # Mock delete_one to simulate successful deletion + clients_collection.delete_one = AsyncMock( + return_value=AsyncMock(deleted_count=1)) + + # Call the delete function + response = await delete_client_by_id(test_client_id) + + # Assertions + assert response["message"] == f"Client with ID {test_client_id} deleted successfully." + + +@pytest.mark.asyncio +async def test_delete_client_by_id_not_found(): + """ + Test the delete_client_by_id function to ensure it raises HTTPException + when the client is not found. + """ + # Prepare test data + non_existent_client_id = str(ObjectId()) + + # Mock find_one to simulate client does not exist + clients_collection.find_one = AsyncMock(return_value=None) + + # Call the delete function and expect an HTTPException + with pytest.raises(HTTPException) as exc_info: + await delete_client_by_id(non_existent_client_id) + + # Assertions + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Client not found" + + +# Test case for invalid ObjectId format +@pytest.mark.asyncio +async def test_delete_client_by_id_invalid_id(): + """ + Test the delete_client_by_id function to ensure it raises HTTPException + when an invalid ObjectId format is provided. + """ + # Invalid ObjectId + invalid_client_id = "invalid_object_id" + + # Call the delete function and expect an HTTPException + with pytest.raises(HTTPException) as exc_info: + await delete_client_by_id(invalid_client_id) + + # Assertions + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Invalid ObjectId format" + + +@pytest.mark.asyncio +async def test_delete_client_no_deletion(): + """ + Test the delete_client_by_id function to ensure it raises an HTTPException + when the delete operation does not delete any client (i.e., deleted_count == 0). + """ + # Prepare test data + test_client_id = str(ObjectId()) # Generate a test ObjectId + client_data = { + "_id": ObjectId(test_client_id), + "first_name": "John", + "last_name": "Doe", + "email": "john.doe@example.com", + "date_of_birth": "1990-01-15", + "address": "123 Elm St, Springfield, IL", + "phone": "555-1234" + } + + # Mock find_one to simulate the client exists + clients_collection.find_one = AsyncMock(return_value=client_data) + + # Mock delete_one to simulate that no client is deleted (deleted_count=0) + clients_collection.delete_one = AsyncMock( + return_value=AsyncMock(deleted_count=0)) + + # Call the delete function and expect an HTTPException to be raised + with pytest.raises(HTTPException) as exc_info: + await delete_client_by_id(test_client_id) + + # Assertions + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Client not found" + + +@pytest.mark.asyncio +async def test_delete_all_clients(): + """ + Test the delete_all_clients function to ensure it deletes all clients in the database. + """ + # Mock delete_many to simulate successful deletion + clients_collection.delete_many = AsyncMock() + + # Call the delete function + response = await delete_all_clients() + + # Assertions + assert response["message"] == "All clients deleted successfully."