diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e725a4e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +__pycache__ +*.pyc +.env diff --git a/.github/workflows/Actions.yaml b/.github/workflows/Actions.yaml new file mode 100644 index 0000000..7a3aaa0 --- /dev/null +++ b/.github/workflows/Actions.yaml @@ -0,0 +1,70 @@ + +# name of the workflow. +# this is optional. +name: Interview Cloud Actions + +# events that will trigger this workflow. +# here, we only have "pull_request", so the workflow will run +# whenever we create a pull request. +# other examples: [push] and [pull_request, push] +on: + pull_request: + + push: + branches: + - master + +# each workflow must have at least one job. +# jobs run in parallel by default (we can change that). +# each job groups together a series of steps to accomplish a purpose. +jobs: + # name of the job + ruffLint: + # the platform or OS that the workflow will run on. + runs-on: ubuntu-latest + + # series of steps to finish the job. + steps: + # name of the step. + # steps run sequentially. + # this is optionale + - name: checkout + # each step can either have "uses" or "run". + # "uses" run an action written somewhere other than this workflow . + # usually from the community. + # this action checks out the repo code to the runner (instance) + # running the action + uses: actions/checkout@v3 + + # another step. + # this step runs a bash (Ubuntu's default shell) command + - name: install ruff + run: pip install ruff + + - name: Lint + run: ruff check ./*/*.py --ignore E402 + + Dockerhub: + runs-on: ubuntu-latest + needs: ruffLint # will only run if linter is successful + if: ${{ github.ref == 'refs/heads/master' || github.event.pull_request.merged == true }} # Runs if it's a push to 'main' or a merged PR to 'main' + steps: + - name: checkout + uses: actions/checkout@v3 + + - name: Login to Dockerhub # log into docker hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} # Using secret for Docker username + password: ${{ secrets.DOCKER_PASSWORD }} # Using secret for Docker password + id: docker-login + + - name: build container image # build the container + run: docker compose build --no-cache + id: docker-build + + - name: Upload to Dockerhub + run: docker push darkicewolf50/uofcbajacloud:latest + if: ${{ steps.docker-login.outcome == 'success' && steps.docker-build.outcome == 'success' }} + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..14935d1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# Use an official Python runtime as a parent image +FROM python:3.10-slim + +# Set the working directory inside the container +WORKDIR /BajaCloudBackend + +# Copy the current directory contents into the container at /app +COPY ./InterviewBooking /BajaCloudBackend/InterviewBooking +# Copy the main file to the working directory +COPY main.py /BajaCloudBackend + +# Install any necessary dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Expose port 8000 for the container to listen on +EXPOSE 8000 + +# Command to run the Python server when the container starts +#CMD ["fastapi", "run" "main.py"] +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--reload"] + diff --git a/InterviewBooking/NoSheet.py b/InterviewBooking/NoSheet.py new file mode 100644 index 0000000..0d1d118 --- /dev/null +++ b/InterviewBooking/NoSheet.py @@ -0,0 +1,141 @@ +import openpyxl +import yaml +import datetime +from openpyxl.styles import Font, Border, Side, PatternFill +from openpyxl.formatting.rule import FormulaRule + + +def NoSheet(file_path): + """ + Creates the Template for more data to be added + + ``REQUIRES``: ``None`` Ensure no other sheets are present, will overwrite them + + ``PROMISES``: ``XLSX File`` give the template for recuitment for the year + + ``Develop in part by``: Brock + + ``Contact``: darkicewolf50@gmail.com + + """ + + yamlraw = """ +Recruitment Responses: + - Frist Name (What we should call them): Steve + - Last Name: the Bug + - Ucalgary Email: steve.the.bug@ucalgary.ca + - What Subsystem/SubTeam are you interested in?: | + Chassis + Ergonomics + Suspension + Steering + Powertrain + Final Drive + Any Mechanical + Business - Content Creation + Business - Business Relations + Software + - Major: General (1st Year) + - Academic Year: 1st + - Why are you interested in joining UCalgary BAJA?: Example Interest + - Where did you hear about us?: Testing + - Are you available for team meetings/work days? Saturdays 10 am - 4 pm: "No" #add condiftional formatting for no to make whole line red +Interview TimeTable: + - Date: 2024-09-16 + - Meeting Duration: 30 min + - Start Time Slot: 10:00:00 AM + - Slot: 1 + - Interviewee Name (What to call them): Steve + - Interviewee Email: steve.the.bug@ucalgary.ca + - Category (if not general): Test + - Interviewer(s) Name(s): Example + - Status: Dropdown (Options in datahelp) #default is Unknown +Data Helper And Info: + - Status Dropdown: + - Unknown + - Done + - No Show + - Cancelled/Moved + - First time Startup: Move docker volume pointer to new drive and start up container + - Weird Date: Add more space and it will change from ### to a date + - How to Add Dropdown: Go into data, click data validation, select list then select the area you want to get values from in the formula spot + """ + # uses the base above "yaml file" to create the base template + yamlsheet = yaml.safe_load(yamlraw) + + year_donation = int(str(datetime.datetime.now().year)[2:]) + 1 # gets the last two digits of the current year then adds 1 for the current season + file_name = file_path + + # border style + border = Border( # defualt behaviour is thin + left=Side(style='thin'), + right=Side(style='thin'), + top=Side(style='thin'), + bottom=Side(style='thin') + ) + + # create workbook in memory + work_book = openpyxl.Workbook() + + # remove default sheet + default_sheet = work_book.active + work_book.remove(default_sheet) + + # decomposes the yaml file at the top and convertss it into a standard template + # does one sheet at a time + for sheet_name, title_list in yamlsheet.items(): + # add 1 standard sheet, by the outermost name + sheet = work_book.create_sheet(sheet_name) + + # gets header titles for each sheet, from the inner list + titles = [list(title.keys())[0] for title in title_list] + + # makes the header titles to bold, have a border and have text + for col_num, title in enumerate(titles, start=1): + cell = sheet.cell(row=1, column=col_num) + cell.value = title + cell.font = Font(bold=True) + cell.border = border + + # example data to show on what it will look like or to copy formatting down + example_data = [list(data.values())[0] for data in title_list] + + for col_num, data in enumerate(example_data, start=1): + + # for special case Data Helper where there a list in a dictionary + if isinstance(data, list): + row_num = 2 + for item in data: + cell = sheet.cell(row=row_num, column=col_num) + cell.value = item + row_num += 1 + # adds data to the cells + else: + cell = sheet.cell(row=2, column=col_num) + # changes the Dropdown data in status to unknown instead of the other option, only there for prep for a dropdown + if data == "Dropdown (Options in datahelp)": + cell.value = "Unknown" + + elif isinstance(data, datetime.date): + cell.value = data + # Convert the example date '2024-09-16' to a datetime object + # Set the number format to 'YYYY-MM-DD' + cell.number_format = 'yyyy-mmm-d' + else: + cell.value = data + + + if sheet.title == "Recruitment Responses": + sheet.conditional_formatting.add("A2:I2", FormulaRule(formula=['=$I2="No"'], fill=PatternFill(start_color="FF0000", end_color="FF0000", fill_type="solid"))) + + + # save to storage + work_book.save(file_name) + print(f"Created {file_name} for {year_donation}") + +if __name__ == "__main__": + year_donation = int(str(datetime.datetime.now().year)[2:]) + 1 # gets the last two digits of the current year then adds 1 for the current season + # name based off the 2025 naming system + # Define the path to the Excel file and the lock file + file_name = f"./Interviews/OR{year_donation}-L-Interview Data.xlsx" + NoSheet(file_name) \ No newline at end of file diff --git a/InterviewBooking/ReadDB.py b/InterviewBooking/ReadDB.py new file mode 100644 index 0000000..214d025 --- /dev/null +++ b/InterviewBooking/ReadDB.py @@ -0,0 +1,90 @@ +import pandas as pd +import json +from filelock import FileLock, Timeout +import time + +import datetime + +""" +TODO change to use new tempate +TODO change names to be more clear +""" + +def ReadDatabase(file_path): + """ + Reads the database for which slots are available + + ``REQUIRES``: ``File_Path`` where the file is + + ``PROMISES``: ``JSON`` Interview Available Slots + + ``Developed in part by``: Ahmad, Brock + + ``Contact``: ahmad.ahmad1@ucalgary.ca, darkicewolf50@gmail.com + + """ + + + # Define the path to the Excel file and the lock file + excel_file_path = file_path + lock_file_path = file_path + ".lock" + + # Retry parameters + max_retries = 60 # Maximum number of retries if the file is locked + retry_interval = 0.5 # Wait time (in seconds) between retries + + retries = 0 + while retries < max_retries: + try: + # Attempt to acquire a shared read (non-blocking) access + with FileLock(lock_file_path, timeout=0): # Non-blocking, checks if the lock exists + # Load the Excel file into a pandas DataFrame + df = pd.read_excel(excel_file_path, sheet_name="Interview TimeTable") + + # Initialize the dictionary to store the structured data + interview_data = {} + + # Group the DataFrame by Date, Start Time, and Slot for organization + for _, row in df.iterrows(): + date = str(row['Date']).split(" ")[0] + start_time = str(row['Start Time Slot']) + slot = int(row['Slot']) if not pd.isna(row['Slot']) else 0 + + # Returns the number of interviewees in the slot; returns 0 if empty + interviewee_amount = len(str(row['Interviewee Name (What to call them)']).split()) if str(row['Interviewee Name (What to call them)']) != "nan" else 0 + + # Check if the slot is available for an interviewee to attend + available_slots = interviewee_amount != slot + if available_slots: + # Initialize nested structure if not present + if date not in interview_data: + interview_data[date] = {} + # Add the start time and duration if not present + if start_time not in interview_data[date]: + interview_data[date][start_time] = { + 'Meeting Duration': row['Meeting Duration'], + } + return interview_data # Successfully read the database + + except Timeout: + # File is locked; wait and retry + retries += 1 + print(f"File is locked, retrying ({retries}/{max_retries})...") + time.sleep(retry_interval) + + # If max retries are exceeded, raise an error + raise RuntimeError("Unable to access the database after multiple attempts due to a file lock.") + + +# Example usage of the ReadDatabase function +if __name__ == "__main__": + import datetime + year_donation = int(str(datetime.datetime.now().year)[2:]) + 1 # gets the last two digits of the current year then adds 1 for the current season + # name based off the 2025 naming system + # Define the path to the Excel file and the lock file + file_name = f"./Interviews/OR{year_donation}-L-Interview Data.xlsx" + try: + data = ReadDatabase(file_name) + print(json.dumps(data, indent=4)) + except RuntimeError as e: + print(e) diff --git a/InterviewBooking/WriteDB.py b/InterviewBooking/WriteDB.py new file mode 100644 index 0000000..ec4e50c --- /dev/null +++ b/InterviewBooking/WriteDB.py @@ -0,0 +1,156 @@ +import pandas as pd +import json +from openpyxl import load_workbook +from .send_email import send_email +from filelock import FileLock + + +""" +TODO update names to be more clear +TODO try to remove pandas +""" +def ReadDatabase(file_path, lock_path): + """ + Reads the Database to retrieve available interview slots + + ``REQUIRES``: ``File_Path`` ``Lock_Path`` where the file and lock are located + + ``PROMISES``: JSON (Available interview slots) + + ``Developed by``: Ahmad, Brock + + ``Contact``: ahmad.ahmad1@ucalgary.ca, darkicewolf50@gmail.com + """ + + # Use a file-based lock for thread-safe and process-safe access + with FileLock(lock_path): + # Load the Excel file into a pandas DataFrame with specific columns + df = pd.read_excel(file_path, usecols=['Date', 'Start Time Slot', 'Slot', 'Interviewee Name (What to call them)', 'Interviewee Email', 'Meeting Duration'], sheet_name="Interview TimeTable") + + # Initialize the dictionary to store structured data for available slots + interview_data = {} + + # Process each row in the DataFrame to structure data by date and time + for _, row in df.iterrows(): + # Convert Date and Start Time to string format for easier comparison + date = str(row['Date']).split(" ")[0] # Format date to YYYY-MM-DD + start_time = str(row['Start Time Slot']) + + # Calculate the slot capacity and current number of interviewees + slot_capacity = int(row['Slot']) if not pd.isna(row['Slot']) else 0 + interviewee_names = [name.strip() for name in str(row['Interviewee Name (What to call them)']).split(',') if name.strip()] + interviewee_count = len(interviewee_names) if interviewee_names != ["nan"] else 0 + + # Check if there are available slots for more interviewees + if interviewee_count < slot_capacity: + # Organize data by date and time, keeping track of available slots and meeting duration + if date not in interview_data: + interview_data[date] = {} + interview_data[date][start_time] = { + 'Meeting Duration': row['Meeting Duration'], + 'Available Slots': slot_capacity - interviewee_count + } + + return interview_data + +def AppendAppointment(file_path, date, start_time, interviewee_name, interviewee_email): + """ + Appends a new appointment with the interviewee's name and email if the slot is available. + + ``REQUIRES``: ``File_Path`` ``str`` date, ``str`` start_time, ``str`` interviewee_name, ``str`` interviewee_email + + ``PROMISES``: ``None`` Updates the Excel file with the new interviewee's name and email if there is an available slot. Returns Bool. + + ``Developed by``: Ahmad, Brock + + ``Contact``: ahmad.ahmad1@ucalgary.ca, darkicewolf50@gmail.com + """ + + lock_path = file_path + ".lock" + + available_slots = ReadDatabase(file_path, lock_path) + + # Check if the requested slot is available in the `available_slots` structure + if date in available_slots and start_time in available_slots[date]: + with FileLock(lock_path): # Ensure process-safe access to the file + # Load workbook and select "Interview TimeTable" for updating appointments + workbook = load_workbook(file_path) + sheet = workbook["Interview TimeTable"] + df = pd.read_excel(file_path, sheet_name="Interview TimeTable") + + # Find and update the row that matches the provided date and start time + for index, row in df.iterrows(): + row_date = str(row['Date']).split(" ")[0] + row_start_time = str(row['Start Time Slot']) + + if row_date == date and row_start_time == start_time: + # Current entries for names and emails, and append new data with comma and space + current_names = str(row['Interviewee Name (What to call them)']).strip() + current_emails = str(row['Interviewee Email']).strip() + + updated_names = f"{current_names}, {interviewee_name}" if current_names != "nan" else interviewee_name + updated_emails = f"{current_emails}, {interviewee_email}" if current_emails != "nan" else interviewee_email + + # Update the cells with new names and emails + name_cell = sheet.cell(row=index + 2, column=df.columns.get_loc('Interviewee Name (What to call them)') + 1) + email_cell = sheet.cell(row=index + 2, column=df.columns.get_loc('Interviewee Email') + 1) + name_cell.value = updated_names + email_cell.value = updated_emails + + status_cell = sheet.cell(row=index + 2, column=df.columns.get_loc('Status') + 1) + status_cell.value = "Unknown" + + workbook.save(file_path) + send_email(interviewee_email, interviewee_name, date, start_time) + return True + + # If no slots available, return that the slot is unavailable + return False + + +def run_tests(): + """ + Executes test cases to verify appointment scheduling and slot availability. + + ``REQUIRES``: None + + ``PROMISES``: Prints test outcomes to validate successful booking or slot unavailability. + """ + import datetime + year_donation = int(str(datetime.datetime.now().year)[2:]) + 1 # gets the last two digits of the current year then adds 1 for the current season + # name based off the 2025 naming system + # Define the path to the Excel file and the lock file + file_name = f"./Interviews/OR{year_donation}-L-Interview Data.xlsx" + lock = file_name + ".lock" + + print("Available Slots:") + available_slots = ReadDatabase(file_path=file_name, lock_path=lock) + print(json.dumps(available_slots, indent=4)) + + # Test Case 1: Append to an available slot on 2024-09-16 at 10:30:00 + date_1 = "9/16/2024" + start_time_1 = "10:30:00" + interviewee_name_1 = "Alice Johnson" + interviewee_email_1 = "ahmadmuhammadofficial@gmail.com" + print(f"\nTest Case 1: Trying to book {date_1} at {start_time_1} for {interviewee_name_1} ({interviewee_email_1})") + AppendAppointment(file_name, date_1, start_time_1, interviewee_name_1, interviewee_email_1) + + # Test Case 2: Append to an available slot on 2024-09-17 at 13:30:00 + date_2 = "9/17/2024" + start_time_2 = "13:30:00" + interviewee_name_2 = "Bob Smith" + interviewee_email_2 = "bob.smith@example.com" + print(f"\nTest Case 2: Trying to book {date_2} at {start_time_2} for {interviewee_name_2} ({interviewee_email_2})") + AppendAppointment(file_name, date_2, start_time_2, interviewee_name_2, interviewee_email_2) + + # Test Case 3: Attempting to book at 10:30:00 on 2024-09-16 for a different interviewee + date_3 = "9/16/2024" + start_time_3 = "10:30:00" + interviewee_name_3 = "Charlie Brown" + interviewee_email_3 = "charlie.brown@example.com" + print(f"\nTest Case 3: Trying to book {date_3} at {start_time_3} for {interviewee_name_3} ({interviewee_email_3})") + AppendAppointment(file_name, date_3, start_time_3, interviewee_name_3, interviewee_email_3) + +# Run tests +if __name__ == "__main__": + run_tests() diff --git a/InterviewBooking/interviewPackagers.py b/InterviewBooking/interviewPackagers.py new file mode 100644 index 0000000..88e0431 --- /dev/null +++ b/InterviewBooking/interviewPackagers.py @@ -0,0 +1,55 @@ +from .ReadDB import ReadDatabase + + +def getSchedulePackager(file_name): + """ + Packages up the response for a http response + + ``REQUIRES``: ``File_Path`` where the file is + + ``PROMISES``: ``JSON`` http response ready + + ``Develop in part by``: Brock T + + ``Contact``: darkicewolf50@gmail.ocm + + """ + return { + "interviewDates": ReadDatabase(file_path=file_name) + } + +from .WriteDB import AppendAppointment +from email_validator import validate_email, EmailNotValidError + + +def SelectAppointment (file_name, appointmentJson): + """ + Packages up a response for a http request + + ``REQUIRES``: ``File_Path`` ``JSON`` where the file is, json has the data of interviewee name, date, starttime and interviewee email + + ``PROMISES``: ``JSON`` Returns if the booking was a success + + ``Developed in part by``: Brock + + ``Contact``: darkicewolf50@gmail.com + + """ + + try: + validEmail = validate_email(appointmentJson["intervieweeEmail"], check_deliverability=True) + if validEmail: + status = AppendAppointment(file_path=file_name, date=appointmentJson["date"], start_time=appointmentJson["startTime"], interviewee_name=appointmentJson["intervieweeName"], interviewee_email=appointmentJson["intervieweeEmail"]) + + if status: + resBody = {"Success": True, "validEmail": "true"} + else: + resBody = {"Success": False, "validEmail": "true"} + + # resBody["message"] = appointmentJson for testing + return resBody + + except EmailNotValidError as e: + print(e) + return {"Success": False, "validEmail": "false"} + diff --git a/InterviewBooking/send_email.py b/InterviewBooking/send_email.py new file mode 100644 index 0000000..eb209d5 --- /dev/null +++ b/InterviewBooking/send_email.py @@ -0,0 +1,94 @@ +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from datetime import datetime, timedelta +import pytz # For timezone handling + +""" +TODO add +""" +def send_email(interviewee_email="darkicewolf50@gmail.com", interviewee_name="brock", date="2024-1-10", start_time="10:00:00", location ="ENC25"): + """ + Sends an email notification to the interviewee and to the uofcbaja account + + ``REQUIRES``: ``str`` interviewee_email, ``str`` interviewee_name, ``str`` date, ``str`` start_time + + ``PROMISES``: ``EMAIL`` Sends an email to interviewee and static email on successful appointment booking. + + ``Developed by``: Ahmad + + ``Contact``: ahmad.ahmad1@ucalgary.ca + """ + # Define static email for notifications and Gmail credentials + static_email = "uofcbaja@gmail.com" + gmail_user = "uofcbaja.noreply@gmail.com" + gmail_apppassword = "pver lpnt upjd zvld" + + # Define Mountain Standard Time + mst = pytz.timezone("America/Edmonton") # MST with Daylight Savings considered + + # Parse the input date and time, localize to MST + start_datetime = mst.localize(datetime.strptime(f"{date} {start_time}", "%Y-%m-%d %H:%M:%S")) + end_datetime = start_datetime + timedelta(minutes=30) + + # Format date and time for the calendar URLs + start_time_google = start_datetime.strftime("%Y%m%dT%H%M%S") # Google Calendar (Local time without Z) + end_time_google = end_datetime.strftime("%Y%m%dT%H%M%S") # Google Calendar (Local time without Z) + outlook_start = start_datetime.isoformat() # Outlook Calendar (ISO local time) + outlook_end = end_datetime.isoformat() # Outlook Calendar (ISO local time) + + # Create message object + msg = MIMEMultipart() + msg['From'] = gmail_user + msg['To'] = f"{interviewee_email}, {static_email}" + msg['Subject'] = "Interview Appointment Confirmation" + + # Message body + body = f''' + +
+Dear {interviewee_name},
+Your interview has been scheduled on {date} at {start_time} MST.
+Your interview location is at {location} or will be emailed to you.
+Please ensure to be available at the designated time.
+ + + + + + +Best regards,
+UCalgary Baja Interview Team
+Dear {interviewee_name},
+Your interview has been scheduled on {date} at {start_time}
+Please ensure to be available at the designated time.
+ + + + + + +Best regards,
+UCalgary Baja Interview Team
+