From 80c61dc127d5c4de976dbaec95c066f96c2cf5cc Mon Sep 17 00:00:00 2001 From: deepvasoya Date: Fri, 9 May 2025 19:15:53 +0530 Subject: [PATCH] feat: initial commit --- .gitignore | 108 +++++++++ alembic.ini | 119 +++++++++ apis/__init__.py | 14 ++ apis/endpoints/__init__.py | 0 apis/endpoints/admin.py | 8 + apis/endpoints/appointments.py | 117 +++++++++ apis/endpoints/auth.py | 23 ++ apis/endpoints/calender.py | 41 ++++ apis/endpoints/clinics.py | 79 ++++++ apis/endpoints/doctors.py | 143 +++++++++++ apis/endpoints/patients.py | 50 ++++ apis/endpoints/twilio.py | 183 ++++++++++++++ database.py | 28 +++ enums/__init__.py | 0 enums/enums.py | 37 +++ exceptions/__init__.py | 17 ++ exceptions/api_exceptions.py | 7 + exceptions/business_exception.py | 6 + exceptions/forbidden_exception.py | 9 + exceptions/internal_server_error_exception.py | 12 + exceptions/resource_not_found_exception.py | 9 + exceptions/unauthorized_exception.py | 9 + exceptions/validation_exception.py | 6 + main.py | 71 ++++++ middleware/ErrorHandlerMiddleware.py | 124 ++++++++++ middleware/__init__.py | 0 middleware/auth_dependency.py | 27 +++ migrations/README | 1 + migrations/env.py | 83 +++++++ migrations/script.py.mako | 28 +++ models/Appointments.py | 20 ++ models/Calendar.py | 16 ++ models/Clinics.py | 17 ++ models/CustomBase.py | 8 + models/Doctors.py | 22 ++ models/Patients.py | 19 ++ models/Users.py | 14 ++ models/__init__.py | 15 ++ requirements.txt | Bin 0 -> 4412 bytes schemas/ApiResponse.py | 25 ++ schemas/BaseSchemas.py | 52 ++++ schemas/CreateSchemas.py | 36 +++ schemas/ResponseSchemas.py | 126 ++++++++++ schemas/UpdateSchemas.py | 38 +++ schemas/__init__.py | 0 services/__init__.py | 0 services/authService.py | 32 +++ services/bot.py | 227 ++++++++++++++++++ services/jwtService.py | 33 +++ services/userServices.py | 93 +++++++ utils.py | 3 + utils/__init__.py | 10 + utils/constants.py | 14 ++ utils/password_utils.py | 16 ++ 54 files changed, 2195 insertions(+) create mode 100644 .gitignore create mode 100644 alembic.ini create mode 100644 apis/__init__.py create mode 100644 apis/endpoints/__init__.py create mode 100644 apis/endpoints/admin.py create mode 100644 apis/endpoints/appointments.py create mode 100644 apis/endpoints/auth.py create mode 100644 apis/endpoints/calender.py create mode 100644 apis/endpoints/clinics.py create mode 100644 apis/endpoints/doctors.py create mode 100644 apis/endpoints/patients.py create mode 100644 apis/endpoints/twilio.py create mode 100644 database.py create mode 100644 enums/__init__.py create mode 100644 enums/enums.py create mode 100644 exceptions/__init__.py create mode 100644 exceptions/api_exceptions.py create mode 100644 exceptions/business_exception.py create mode 100644 exceptions/forbidden_exception.py create mode 100644 exceptions/internal_server_error_exception.py create mode 100644 exceptions/resource_not_found_exception.py create mode 100644 exceptions/unauthorized_exception.py create mode 100644 exceptions/validation_exception.py create mode 100644 main.py create mode 100644 middleware/ErrorHandlerMiddleware.py create mode 100644 middleware/__init__.py create mode 100644 middleware/auth_dependency.py create mode 100644 migrations/README create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 models/Appointments.py create mode 100644 models/Calendar.py create mode 100644 models/Clinics.py create mode 100644 models/CustomBase.py create mode 100644 models/Doctors.py create mode 100644 models/Patients.py create mode 100644 models/Users.py create mode 100644 models/__init__.py create mode 100644 requirements.txt create mode 100644 schemas/ApiResponse.py create mode 100644 schemas/BaseSchemas.py create mode 100644 schemas/CreateSchemas.py create mode 100644 schemas/ResponseSchemas.py create mode 100644 schemas/UpdateSchemas.py create mode 100644 schemas/__init__.py create mode 100644 services/__init__.py create mode 100644 services/authService.py create mode 100644 services/bot.py create mode 100644 services/jwtService.py create mode 100644 services/userServices.py create mode 100644 utils.py create mode 100644 utils/__init__.py create mode 100644 utils/constants.py create mode 100644 utils/password_utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..662ac77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,108 @@ +# Created by .ignore support plugin (hsz.mobi) +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +.idea/* \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..909c373 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,119 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +# version_path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +version_path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = postgresql://postgres:/*7984@localhost:5432/ai-appointment + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/apis/__init__.py b/apis/__init__.py new file mode 100644 index 0000000..e5e0c55 --- /dev/null +++ b/apis/__init__.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter, Depends +from middleware.auth_dependency import auth_required + +from .endpoints import clinics, doctors, calender, appointments, patients, admin, auth + +api_router = APIRouter() +# api_router.include_router(twilio.router, prefix="/twilio") +api_router.include_router(clinics.router, prefix="/clinics", tags=["clinics"]) +api_router.include_router(doctors.router, prefix="/doctors", tags=["doctors"]) +api_router.include_router(calender.router, prefix="/calender", tags=["calender"]) +api_router.include_router(appointments.router, prefix="/appointments", tags=["appointments"]) +api_router.include_router(patients.router, prefix="/patients", tags=["patients"]) +api_router.include_router(admin.router, prefix="/admin", dependencies=[Depends(auth_required)], tags=["admin"]) +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) diff --git a/apis/endpoints/__init__.py b/apis/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apis/endpoints/admin.py b/apis/endpoints/admin.py new file mode 100644 index 0000000..8163248 --- /dev/null +++ b/apis/endpoints/admin.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter, status + +router = APIRouter() + + +@router.get("/", status_code=status.HTTP_200_OK) +def get_admin(): + return {"message": "Admin"} diff --git a/apis/endpoints/appointments.py b/apis/endpoints/appointments.py new file mode 100644 index 0000000..f113f91 --- /dev/null +++ b/apis/endpoints/appointments.py @@ -0,0 +1,117 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List + +# database +from database import get_db +from schemas.ResponseSchemas import AppointmentDetailed, AppointmentSchema +from models.Appointments import Appointments +from schemas.CreateSchemas import AppointmentCreate, AppointmentCreateWithNames +from models.Doctors import Doctors +from models.Patients import Patients + +router = APIRouter() + + +@router.get( + "/", response_model=List[AppointmentDetailed], status_code=status.HTTP_200_OK +) +def get_appointments( + doc_name: str | None = None, + patient_name: str | None = None, + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), +): + """ + Get a list of appointments with optional pagination. + """ + try: + query = db.query(Appointments) + if doc_name: + query = query.join(Appointments.doctor).filter( + Doctors.name.ilike(f"%{doc_name}%") + ) + if patient_name: + query = query.join(Appointments.patient).filter( + Patients.name.ilike(f"%{patient_name}%") + ) + + appointments = query.offset(skip).limit(limit).all() + + return appointments + except Exception as e: + raise HTTPException( + status_code=500, + detail=str(e.__cause__), + ) from e + + +# @router.post("/", response_model=AppointmentSchema, status_code=status.HTTP_201_CREATED) +# def create_appointment(appointment: AppointmentCreate, db: Session = Depends(get_db)): +# """ +# Create a new appointment. +# """ +# try: +# db_appointment = Appointments(**appointment.model_dump()) +# db.add(db_appointment) +# db.commit() +# db.refresh(db_appointment) +# return db_appointment +# except Exception as e: +# db.rollback() +# raise HTTPException( +# status_code=500, +# detail=str(e.__cause__), +# ) from e + + +@router.post("/", response_model=AppointmentSchema, status_code=status.HTTP_201_CREATED) +def create_appointment_with_names( + appointment: AppointmentCreateWithNames, db: Session = Depends(get_db) +): + """ + Create a new appointment using doctor name and patient name instead of IDs. + """ + try: + # Find doctor by name + doctor = ( + db.query(Doctors).filter(Doctors.name == appointment.doctor_name).first() + ) + if not doctor: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Doctor with name '{appointment.doctor_name}' not found", + ) + + # Find patient by name + patient = ( + db.query(Patients).filter(Patients.name == appointment.patient_name).first() + ) + if not patient: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Patient with name '{appointment.patient_name}' not found", + ) + + # Create appointment with doctor_id and patient_id + db_appointment = Appointments( + doctor_id=doctor.id, + patient_id=patient.id, + appointment_time=appointment.appointment_time, + status=appointment.status, + ) + + db.add(db_appointment) + db.commit() + db.refresh(db_appointment) + return db_appointment + except HTTPException: + db.rollback() + raise + except Exception as e: + db.rollback() + raise HTTPException( + status_code=500, + detail=str(e.__cause__), + ) from e diff --git a/apis/endpoints/auth.py b/apis/endpoints/auth.py new file mode 100644 index 0000000..9cf728f --- /dev/null +++ b/apis/endpoints/auth.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter +from services.authService import AuthService +from schemas.CreateSchemas import UserCreate +from schemas.ApiResponse import ApiResponse + +router = APIRouter() + +@router.post("/login") +async def login(email: str, password: str): + response = await AuthService().login(email, password) + return ApiResponse( + data=response, + message="Login successful" + ) + + +@router.post("/register") +async def register(user_data: UserCreate): + response = await AuthService().register(user_data) + return ApiResponse( + data=response, + message="User registered successfully" + ) diff --git a/apis/endpoints/calender.py b/apis/endpoints/calender.py new file mode 100644 index 0000000..88ecedd --- /dev/null +++ b/apis/endpoints/calender.py @@ -0,0 +1,41 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List + +# database +from database import get_db +from models.Calendar import Calenders +from schemas.CreateSchemas import CalendarCreate +from schemas.ResponseSchemas import Calendar + + +router = APIRouter() + + +@router.get("/", response_model=List[Calendar]) +def get_calendar_events(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + """ + Get a list of calendar events with optional pagination. + """ + # Placeholder for actual database query + events = db.query("CalendarEvents").offset(skip).limit(limit).all() + return events + + +@router.post("/", response_model=Calendar, status_code=status.HTTP_201_CREATED) +def create_calendar_event(event: CalendarCreate, db: Session = Depends(get_db)): + """ + Create a new calendar event. + """ + try: + db_event = Calenders(**event.model_dump()) + db.add(db_event) + db.commit() + db.refresh(db_event) + return db_event + except Exception as e: + db.rollback() + raise HTTPException( + status_code=500, + detail=str(e.__cause__), + ) from e diff --git a/apis/endpoints/clinics.py b/apis/endpoints/clinics.py new file mode 100644 index 0000000..1ab2f0f --- /dev/null +++ b/apis/endpoints/clinics.py @@ -0,0 +1,79 @@ +from asyncio.log import logger +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +# database +from database import get_db + +# schemas +from schemas.ResponseSchemas import Clinic, ClinicWithDoctors +from schemas.CreateSchemas import ClinicCreate +from schemas.UpdateSchemas import ClinicUpdate +from models.Clinics import Clinics + +# Constants +from utils.constants import DEFAULT_SKIP, DEFAULT_LIMIT + +router = APIRouter() + + +@router.get("/", response_model=List[Clinic]) +async def get_clinics( + skip: int = DEFAULT_SKIP, limit: int = DEFAULT_LIMIT, db: Session = Depends(get_db) +): + clinics = db.query(Clinics).offset(skip).limit(limit).all() + return clinics + + +@router.get("/{clinic_id}", response_model=ClinicWithDoctors) +async def get_clinic(clinic_id: int, db: Session = Depends(get_db)): + db_clinic = db.query(Clinics).where(Clinics.id == clinic_id).first() + if db_clinic is None: + raise HTTPException(status_code=404, detail="Clinic not found") + return db_clinic + + +@router.post("/", response_model=Clinic, status_code=status.HTTP_201_CREATED) +async def create_clinic(clinic: ClinicCreate, db: Session = Depends(get_db)): + try: + db_clinic = Clinics(**clinic.model_dump()) + db.add(db_clinic) + db.commit() + db.refresh(db_clinic) + return db_clinic + except Exception as e: + db.rollback() + + raise HTTPException( + status_code=500, + detail=str(e.__cause__), + ) from e + + +@router.put("/{clinic_id}", response_model=Clinic) +async def update_clinic( + clinic_id: int, clinic: ClinicUpdate, db: Session = Depends(get_db) +): + db_clinic = db.query(Clinics).filter(Clinics.id == clinic_id).first() + if db_clinic is None: + raise HTTPException(status_code=404, detail="Clinic not found") + + update_data = clinic.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(db_clinic, key, value) + + db.commit() + db.refresh(db_clinic) + return db_clinic + + +@router.delete("/{clinic_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_clinic(clinic_id: int, db: Session = Depends(get_db)): + db_clinic = db.query(Clinics).where(Clinics.id == clinic_id).first() + if db_clinic is None: + raise HTTPException(status_code=404, detail="Clinic not found") + + db.delete(db_clinic) + db.commit() + return None diff --git a/apis/endpoints/doctors.py b/apis/endpoints/doctors.py new file mode 100644 index 0000000..0076b80 --- /dev/null +++ b/apis/endpoints/doctors.py @@ -0,0 +1,143 @@ +from asyncio.log import logger +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List, Dict +from datetime import datetime, timedelta +from sqlalchemy import and_ + +# database +from database import get_db + +# schemas +from models.Doctors import Doctors +from models.Appointments import Appointments +from models.Calendar import Calenders +from schemas.ResponseSchemas import ( + Doctor, + DoctorWithAppointments, + DoctorWithCalendar, + CalendarTimeSchema, +) +from schemas.CreateSchemas import DoctorCreate +from schemas.UpdateSchemas import DoctorUpdate +from enums.enums import AppointmentStatus + +router = APIRouter() + + +@router.post("/", response_model=Doctor, status_code=status.HTTP_201_CREATED) +def create_doctor(doctor: DoctorCreate, db: Session = Depends(get_db)): + try: + db_doctor = Doctors(**doctor.model_dump()) + db.add(db_doctor) + db.commit() + db.refresh(db_doctor) + return db_doctor + except Exception as e: + db.rollback() + + raise HTTPException( + status_code=500, + detail=str(e.__cause__), + ) from e + + +@router.get("/", response_model=List[DoctorWithCalendar]) +def read_doctors( + doctor_name: str | None = None, + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), +): + query = db.query(Doctors) + if doctor_name: + query = query.filter(Doctors.name.ilike(f"%{doctor_name}%")) + + doctors = query.offset(skip).limit(limit).all() + return doctors + + +@router.get("/available-slots/{doctor_name}", response_model=Dict[str, List[str]]) +def get_available_slots( + doctor_name: str | None = None, + date: str | None = datetime.now().strftime("%Y-%m-%d"), + db: Session = Depends(get_db), +): + """ + Get available slots for a doctor on a specific date. + date format: YYYY-MM-DD + """ + # Get the doctor + print(f"-----------------doctor_name: {doctor_name}") + doctor = db.query(Doctors).filter(Doctors.name.ilike(f"%{doctor_name}%")).first() + if not doctor: + raise HTTPException(status_code=404, detail="Doctor not found") + + # Get all calendar slots for the doctor + calendar_slots = db.query(Calenders).filter(Calenders.doc_id == doctor.id).all() + if not calendar_slots: + return {"available_slots": []} + + available_slots = [slot.time for slot in calendar_slots] + + try: + target_date = datetime.strptime(date, "%Y-%m-%d").date() + except ValueError: + raise HTTPException( + status_code=400, detail="Invalid date format. Use YYYY-MM-DD" + ) + + # Get all appointments for the doctor on the specified date + appointments = ( + db.query(Appointments) + .filter( + and_( + Appointments.doctor_id == doctor.id, + Appointments.appointment_time >= target_date, + Appointments.appointment_time < target_date + timedelta(days=1), + ) + ) + .all() + ) + + # Remove slots that have appointments + for appointment in appointments: + appointment_time = appointment.appointment_time.strftime("%H:%M") + if appointment_time in available_slots and ( + not appointment.status == AppointmentStatus.COMPLETED + ): + available_slots.remove(appointment_time) + + return {"available_slots": available_slots} + + +# @router.get("/{doctor_name}", response_model=DoctorWithAppointments) +# def read_doctor(doctor_name: str, db: Session = Depends(get_db)): +# db_doctor = db.query(Doctors).filter(Doctors.name.ilike(f"%{doctor_name}%")).all() +# return db_doctor + + +@router.put("/{doctor_id}", response_model=Doctor) +def update_doctor(doctor_id: int, doctor: DoctorUpdate, db: Session = Depends(get_db)): + db_doctor = db.query(Doctors).filter(Doctors.id == doctor_id).first() + if db_doctor is None: + raise HTTPException(status_code=404, detail="Doctor not found") + + update_data = doctor.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(db_doctor, key, value) + + db.commit() + db.refresh(db_doctor) + return db_doctor + + +@router.delete("/{doctor_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_doctor(doctor_id: int, db: Session = Depends(get_db)): + db_doctor = db.query(Doctors).filter(Doctors.id == doctor_id).first() + if db_doctor is None: + raise HTTPException(status_code=404, detail="Doctor not found") + + db.delete(db_doctor) + db.commit() + return None diff --git a/apis/endpoints/patients.py b/apis/endpoints/patients.py new file mode 100644 index 0000000..556e8b1 --- /dev/null +++ b/apis/endpoints/patients.py @@ -0,0 +1,50 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List + +# database +from database import get_db +from models.Patients import Patients +from schemas.CreateSchemas import PatientCreate +from schemas.ResponseSchemas import Patient + +router = APIRouter() + + +@router.get("/", response_model=List[Patient]) +def read_patients( + name: str | None = None, + dob: str | None = None, + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), +): + """ + Get a list of patients with optional pagination. + """ + query = db.query(Patients) + if name: + query = query.filter(Patients.name.ilike(f"%{name}%")) + if dob: + query = query.filter(Patients.dob == dob) + patients = query.offset(skip).limit(limit).all() + return patients + + +@router.post("/", response_model=Patient, status_code=status.HTTP_201_CREATED) +def create_patient(patient: PatientCreate, db: Session = Depends(get_db)): + """ + Create a new patient. + """ + try: + db_patient = Patients(**patient.model_dump()) + db.add(db_patient) + db.commit() + db.refresh(db_patient) + return db_patient + except Exception as e: + db.rollback() + raise HTTPException( + status_code=500, + detail=str(e.__cause__), + ) from e diff --git a/apis/endpoints/twilio.py b/apis/endpoints/twilio.py new file mode 100644 index 0000000..3fb5be6 --- /dev/null +++ b/apis/endpoints/twilio.py @@ -0,0 +1,183 @@ +# import asyncio +# import json +# import logging +# import os +# from fastapi import APIRouter, Request, WebSocket, status +# from twilio.twiml.voice_response import VoiceResponse, Connect +# from twilio.rest import Client +# from fastapi import WebSocket, Request, Response +# from enum import Enum +# from typing import Optional + +# from services.bot import run_bot +# from services.call_state import CallState + +# logger = logging.getLogger(__name__) + + +# router = APIRouter() + +# BASE_WS_URL = "wss://13.229.247.61:8000/api/twilio" +# BASE_URL = "http://13.229.247.61:8000/api/twilio" + +# DTMF_SWITCH_KEY = "*" # Key to switch between models + +# LOG_EVENT_TYPES = [ +# "error", +# "response.content.done", +# "rate_limits.updated", +# "response.done", +# "input_audio_buffer.committed", +# "input_audio_buffer.speech_stopped", +# "input_audio_buffer.speech_started", +# "session.created", +# ] + +# SHOW_TIMING_MATH = False + +# MENU_OPTIONS = """ +# Press 1 for Model 1. +# Press 2 for Model 2. +# Press 3 for Model 3. +# Press 4 for Model 4. +# Press 5 for Model 5. +# Press 0 to repeat the options. +# """ + +# call_state = CallState + + +# # Define model options as enum for type safety +# class ModelOption(int, Enum): +# MODEL_1 = 1 +# MODEL_2 = 2 +# MODEL_3 = 3 +# MODEL_4 = 4 +# MODEL_5 = 5 + + +# @router.post("/call") +# async def get_call(): + +# SID = os.getenv("TWILIO_SID") +# AUTH_TOKEN = os.getenv("TWILIO_AUTH") + +# client = Client(SID, AUTH_TOKEN) + +# call = client.calls.create( +# from_="+14149466486", +# to="+917984372159", +# record=True, +# url=f"{BASE_URL}/receive", +# ) + +# return status.HTTP_200_OK + + +# # @router.websocket("/media-stream/{id}") +# # async def handle_media_stream(websocket: WebSocket, id: int): +# # """Handle WebSocket connections between Twilio and OpenAI.""" +# # print(f"Client connected with id: {id}") +# # await websocket.accept() +# # start_data = websocket.iter_text() +# # await start_data.__anext__() +# # call_data = json.loads(await start_data.__anext__()) +# # print(call_data, flush=True) +# # stream_sid = call_data["start"]["streamSid"] +# # print("WebSocket connection accepted") +# # await run_bot(websocket, stream_sid, False, option=id) + + +# @router.websocket("/media-stream/{id}") +# async def handle_media_stream(websocket: WebSocket, id: int): +# """Handle WebSocket connections between Twilio and OpenAI.""" +# logger.info(f"Client connected with id: {id}") +# print(f"Client connected with id: {id}") +# await websocket.accept() +# start_data = websocket.iter_text() +# await start_data.__anext__() +# call_data = json.loads(await start_data.__anext__()) +# print(call_data, flush=True) +# logger.info(call_data) +# stream_sid = call_data["start"]["streamSid"] +# print("WebSocket connection accepted") +# logger.info("WebSocket connection accepted") +# await run_bot(websocket, stream_sid, False, option=id) + + +# # @router.post("/receive") +# # async def receive_call(req: Request): +# # print("----------------- received call -----------------") + +# # resp = VoiceResponse() +# # connect = Connect() +# # connect.stream( +# # url=f"wss://allegedly-known-wasp.ngrok-free.app/api/twilio/media-stream" +# # ) +# # resp.append(connect) +# # return Response(content=str(resp), media_type="application/xml") + + +# @router.post("/receive") +# async def receive_call(req: Request): +# print("----------------- received call -----------------") +# resp = VoiceResponse() + +# # Gather DTMF input +# gather = resp.gather( +# num_digits=1, action="/api/twilio/handle-key", method="POST", timeout=10 +# ) + +# # Initial greeting and menu options +# gather.say(MENU_OPTIONS) + +# # If no input received, redirect back to the main menu +# resp.redirect("/api/twilio/receive") + +# return Response(content=str(resp), media_type="application/xml") + + +# @router.post("/handle-key") +# async def handle_key_press(req: Request): +# """Process the key pressed by the caller and connect to the appropriate model.""" +# try: +# form_data = await req.form() +# digits_pressed = form_data.get("Digits", "") +# print(f"User pressed: {digits_pressed}") + +# resp = VoiceResponse() + +# if digits_pressed == "0": +# # Repeat options +# resp.redirect("/api/twilio/receive") +# elif digits_pressed in "12345": +# # Valid model selection +# model_id = int(digits_pressed) +# resp.say(f"You have selected model {model_id}.") + +# # Connect to WebSocket +# connect = Connect() +# connect.stream(url=f"{BASE_WS_URL}/media-stream/{model_id}") +# resp.append(connect) +# else: +# # Invalid selection +# resp.say("Invalid selection. Please try again.") +# resp.redirect("/api/twilio/receive") + +# return Response(content=str(resp), media_type="application/xml") + +# except Exception as e: +# print(f"Error handling key press: {str(e)}") +# resp = VoiceResponse() +# resp.say( +# "We encountered a problem processing your request. Please try again later." +# ) +# return Response(content=str(resp), media_type="application/xml") + + +# @router.post("/error") +# async def read_item(req: Request): +# print(await req.body()) +# print(await req.form()) +# logger.error(await req.body()) +# return status.HTTP_200_OK diff --git a/database.py b/database.py new file mode 100644 index 0000000..3e100eb --- /dev/null +++ b/database.py @@ -0,0 +1,28 @@ +import dotenv +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +import os + +dotenv.load_dotenv() + +engine = create_engine( + os.getenv("DB_URL"), + pool_pre_ping=True, # Check connection before using it + pool_size=5, # Connection pool size + max_overflow=10, # Max extra connections when pool is full + pool_recycle=3600, # Recycle connections after 1 hour + echo=True, # Log SQL queries +) + +Base = declarative_base() # Base class for ORM models + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/enums/__init__.py b/enums/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/enums/enums.py b/enums/enums.py new file mode 100644 index 0000000..f71d749 --- /dev/null +++ b/enums/enums.py @@ -0,0 +1,37 @@ +from enum import Enum + + +class AppointmentStatus(Enum): + PENDING = "pending" + CONFIRMED = "confirmed" + CANCELLED = "cancelled" + COMPLETED = "completed" + + +class ClinicStatus(Enum): + ACTIVE = "active" + INACTIVE = "inactive" + UNDER_REVIEW = "under_review" + REQUESTED_DOCTOR = "requested_doctor" + REJECTED = "rejected" + PAYMENT_DUE = "payment_due" + +class ClinicUserRoles(Enum): + DIRECTOR = "director" + PRACTICE_MANAGER = "practice_manager" + +class ClinicDoctorStatus(Enum): + ACTIVE = "active" + INACTIVE = "inactive" + +class ClinicDoctorType(Enum): + DOCTOR = "doctor" + NURSE = "nurse" + +class UserType(Enum): + SUPER_ADMIN = "super_admin" + CLINIC_ADMIN = "clinic_admin" + +class Integration(Enum): + BP = "bp" + MEDICAL_DIRECTOR = "medical_director" diff --git a/exceptions/__init__.py b/exceptions/__init__.py new file mode 100644 index 0000000..101705d --- /dev/null +++ b/exceptions/__init__.py @@ -0,0 +1,17 @@ +from .api_exceptions import ApiException +from .business_exception import BusinessValidationException +from .validation_exception import ValidationException +from .forbidden_exception import ForbiddenException +from .internal_server_error_exception import InternalServerErrorException +from .resource_not_found_exception import ResourceNotFoundException +from .unauthorized_exception import UnauthorizedException + +__all__ = [ + "ApiException", + "BusinessValidationException", + "ValidationException", + "ForbiddenException", + "InternalServerErrorException", + "ResourceNotFoundException", + "UnauthorizedException", +] \ No newline at end of file diff --git a/exceptions/api_exceptions.py b/exceptions/api_exceptions.py new file mode 100644 index 0000000..35d8ca5 --- /dev/null +++ b/exceptions/api_exceptions.py @@ -0,0 +1,7 @@ +class ApiException(Exception): + """Base API exception class for HTTP errors.""" + + def __init__(self, status_code: int, message: str): + self.status_code = status_code + self.message = message + super().__init__(self.message) \ No newline at end of file diff --git a/exceptions/business_exception.py b/exceptions/business_exception.py new file mode 100644 index 0000000..f2d3d5b --- /dev/null +++ b/exceptions/business_exception.py @@ -0,0 +1,6 @@ +class BusinessValidationException(Exception): + """Exception for business logic validation errors.""" + + def __init__(self, message: str): + self.message = message + super().__init__(self.message) \ No newline at end of file diff --git a/exceptions/forbidden_exception.py b/exceptions/forbidden_exception.py new file mode 100644 index 0000000..e4e1898 --- /dev/null +++ b/exceptions/forbidden_exception.py @@ -0,0 +1,9 @@ +from http import HTTPStatus +from .api_exceptions import ApiException + + +class ForbiddenException(ApiException): + """Exception for forbidden access errors.""" + + def __init__(self, message: str = "Forbidden"): + super().__init__(HTTPStatus.FORBIDDEN, message) \ No newline at end of file diff --git a/exceptions/internal_server_error_exception.py b/exceptions/internal_server_error_exception.py new file mode 100644 index 0000000..b3027cf --- /dev/null +++ b/exceptions/internal_server_error_exception.py @@ -0,0 +1,12 @@ +from http import HTTPStatus +from .api_exceptions import ApiException + + +class InternalServerErrorException(ApiException): + """Exception for internal server errors.""" + + def __init__(self): + super().__init__( + HTTPStatus.INTERNAL_SERVER_ERROR, + "An unexpected error has occurred. Please contact the administrator." + ) diff --git a/exceptions/resource_not_found_exception.py b/exceptions/resource_not_found_exception.py new file mode 100644 index 0000000..ece422e --- /dev/null +++ b/exceptions/resource_not_found_exception.py @@ -0,0 +1,9 @@ +from http import HTTPStatus +from .api_exceptions import ApiException + + +class ResourceNotFoundException(ApiException): + """Exception for resource not found errors.""" + + def __init__(self, message: str): + super().__init__(HTTPStatus.NOT_FOUND, message) \ No newline at end of file diff --git a/exceptions/unauthorized_exception.py b/exceptions/unauthorized_exception.py new file mode 100644 index 0000000..9a979a4 --- /dev/null +++ b/exceptions/unauthorized_exception.py @@ -0,0 +1,9 @@ +from http import HTTPStatus +from .api_exceptions import ApiException + + +class UnauthorizedException(ApiException): + """Exception for unauthorized access errors.""" + + def __init__(self, message: str = "Failed to authenticate."): + super().__init__(HTTPStatus.UNAUTHORIZED, message) \ No newline at end of file diff --git a/exceptions/validation_exception.py b/exceptions/validation_exception.py new file mode 100644 index 0000000..35619fe --- /dev/null +++ b/exceptions/validation_exception.py @@ -0,0 +1,6 @@ +class ValidationException(Exception): + """Exception for data validation errors.""" + + def __init__(self, message: str): + self.message = message + super().__init__(self.message) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..a1528be --- /dev/null +++ b/main.py @@ -0,0 +1,71 @@ +import dotenv +from fastapi import FastAPI +from contextlib import asynccontextmanager +import logging +from fastapi.middleware.cors import CORSMiddleware + +# db +from database import Base, engine + +# IMPORTANT: Import all models to register them with SQLAlchemy +# from models.Clinics import Clinics +# from models.Doctors import Doctors +# from models.Patients import Patients +# from models.Appointments import Appointments +# from models.Calendar import Calenders + +# routers +from apis import api_router + +# middleware +from middleware.ErrorHandlerMiddleware import ErrorHandlerMiddleware, configure_exception_handlers + +dotenv.load_dotenv() + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("Starting application") + try: + Base.metadata.create_all(bind=engine) + logger.info("Created database tables") + except Exception as e: + logger.error(f"Error creating database tables: {e}") + raise e + yield + logger.info("Stopping application") + + +app = FastAPI( + lifespan=lifespan, +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Allows all origins + allow_credentials=True, + allow_methods=["*"], # Allows all methods + allow_headers=["*"], # Allows all headers +) + +# Error handler middleware +app.add_middleware(ErrorHandlerMiddleware) + +# Configure exception handlers +configure_exception_handlers(app) + +@app.get("/") +async def hello_world(): + return {"Hello": "World"} + + +# Routes +app.include_router(api_router, prefix="/api") diff --git a/middleware/ErrorHandlerMiddleware.py b/middleware/ErrorHandlerMiddleware.py new file mode 100644 index 0000000..fe1173e --- /dev/null +++ b/middleware/ErrorHandlerMiddleware.py @@ -0,0 +1,124 @@ +from fastapi import Request, status +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.exceptions import HTTPException as StarletteHTTPException +import traceback + +from exceptions import ( + ApiException, + BusinessValidationException, + ValidationException +) +from schemas.ApiResponse import ApiResponse + + +class ErrorHandlerMiddleware(BaseHTTPMiddleware): + """Middleware for handling exceptions globally in the application.""" + + async def dispatch(self, request: Request, call_next): + try: + return await call_next(request) + except Exception as exc: + return self.handle_exception(exc) + + def handle_exception(self, exc: Exception) -> JSONResponse: + if isinstance(exc, ApiException): + return JSONResponse( + status_code=exc.status_code, + content=ApiResponse.from_api_exception(exc) + ) + elif isinstance(exc, StarletteHTTPException): + return JSONResponse( + status_code=exc.status_code, + content=ApiResponse( + message=str(exc.detail), + error=str(traceback.format_exc()) + ).model_dump(exclude_none=True) + ) + elif isinstance(exc, (ValidationException, BusinessValidationException)): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content=ApiResponse( + message=str(exc), + error=str(traceback.format_exc()) + ).model_dump(exclude_none=True) + ) + elif isinstance(exc, RequestValidationError): + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content=ApiResponse( + message="Validation error", + error=str(exc.errors()) + ).model_dump(exclude_none=True) + ) + else: + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content=ApiResponse( + message=str(exc), + error=str(traceback.format_exc()) + ).model_dump(exclude_none=True) + ) + + +# Exception handlers for FastAPI +def configure_exception_handlers(app): + """Configure exception handlers for the FastAPI application.""" + + @app.exception_handler(ApiException) + async def api_exception_handler(request: Request, exc: ApiException): + return JSONResponse( + status_code=exc.status_code, + content=ApiResponse.from_api_exception(exc) + ) + + @app.exception_handler(RequestValidationError) + async def validation_exception_handler(request: Request, exc: RequestValidationError): + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content=ApiResponse( + message="Validation error", + error=str(exc.errors()) + ).model_dump(exclude_none=True) + ) + + @app.exception_handler(StarletteHTTPException) + async def http_exception_handler(request: Request, exc: StarletteHTTPException): + return JSONResponse( + status_code=exc.status_code, + content=ApiResponse( + message=str(exc.detail), + error=str(traceback.format_exc()) + ).model_dump(exclude_none=True) + ) + + @app.exception_handler(BusinessValidationException) + async def business_validation_exception_handler(request: Request, exc: BusinessValidationException): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content=ApiResponse( + message=str(exc), + error=str(traceback.format_exc()) + ).model_dump(exclude_none=True) + ) + + @app.exception_handler(ValidationException) + async def custom_validation_exception_handler(request: Request, exc: ValidationException): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content=ApiResponse( + message=str(exc), + error=str(traceback.format_exc()) + ).model_dump(exclude_none=True) + ) + + @app.exception_handler(Exception) + async def general_exception_handler(request: Request, exc: Exception): + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content=ApiResponse( + message=str(exc), + error=str(traceback.format_exc()) + ).model_dump(exclude_none=True) + ) \ No newline at end of file diff --git a/middleware/__init__.py b/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/middleware/auth_dependency.py b/middleware/auth_dependency.py new file mode 100644 index 0000000..7e15a2b --- /dev/null +++ b/middleware/auth_dependency.py @@ -0,0 +1,27 @@ +from fastapi import HTTPException, Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from services.jwtService import verify_jwt_token +from services.userServices import UserServices +from fastapi import Request + +security = HTTPBearer() + +async def auth_required(request: Request ,credentials: HTTPAuthorizationCredentials = Depends(security)): + """ + Dependency function to verify JWT token for protected routes + """ + if credentials.scheme != "Bearer": + raise HTTPException(status_code=401, detail="Invalid authentication scheme") + + payload = verify_jwt_token(credentials.credentials) + if payload is None: + raise HTTPException(status_code=401, detail="Invalid authentication token") + + # Get user from database + user = UserServices().get_user(payload["user_id"]) + + # set user to request state + request.state.user = user + request.state.payload = payload + + return True diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..6b73d9e --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,83 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +from database import Base, engine + +from models import * + +# for 'autogenerate' support +# from myapp import mymodel + +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..480b130 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/models/Appointments.py b/models/Appointments.py new file mode 100644 index 0000000..7ec2a98 --- /dev/null +++ b/models/Appointments.py @@ -0,0 +1,20 @@ +from sqlalchemy import Column, DateTime, Enum, Integer, ForeignKey +from sqlalchemy.orm import relationship + +from enums.enums import AppointmentStatus +from database import Base +from .CustomBase import CustomBase + + +class Appointments(Base, CustomBase): + __tablename__ = "appointments" + + id = Column(Integer, primary_key=True, index=True) + appointment_time = Column(DateTime) + status = Column(Enum(AppointmentStatus)) + + doctor_id = Column(Integer, ForeignKey("doctors.id"), index=True) + doctor = relationship("Doctors", back_populates="appointments") + + patient_id = Column(Integer, ForeignKey("patients.id"), index=True) + patient = relationship("Patients", back_populates="appointments") diff --git a/models/Calendar.py b/models/Calendar.py new file mode 100644 index 0000000..8091b1f --- /dev/null +++ b/models/Calendar.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy.orm import relationship + +from database import Base +from .CustomBase import CustomBase + + +class Calenders(Base, CustomBase): + __tablename__ = "calenders" + + id = Column(Integer, primary_key=True, index=True) + doc_id = Column(Integer, ForeignKey("doctors.id"), nullable=False, index=True) + # rrule = Column(String) + time = Column(String) + + doctor = relationship("Doctors", back_populates="calendars") diff --git a/models/Clinics.py b/models/Clinics.py new file mode 100644 index 0000000..e1e28ff --- /dev/null +++ b/models/Clinics.py @@ -0,0 +1,17 @@ +from sqlalchemy import Column, Integer, String +from sqlalchemy.orm import relationship + +from database import Base +from .CustomBase import CustomBase + + +class Clinics(Base, CustomBase): + __tablename__ = "clinics" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String) + address = Column(String, nullable=True) + phone = Column(String, unique=True, index=True) + email = Column(String, unique=True, index=True, nullable=True) + + doctors = relationship("Doctors", back_populates="clinic") diff --git a/models/CustomBase.py b/models/CustomBase.py new file mode 100644 index 0000000..16bbe3c --- /dev/null +++ b/models/CustomBase.py @@ -0,0 +1,8 @@ +from sqlalchemy import Column, DateTime, func + + +class CustomBase: + create_time = Column(DateTime(timezone=True), server_default=func.now()) + update_time = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) diff --git a/models/Doctors.py b/models/Doctors.py new file mode 100644 index 0000000..0323d0e --- /dev/null +++ b/models/Doctors.py @@ -0,0 +1,22 @@ +from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy.orm import relationship + +from database import Base +from .CustomBase import CustomBase + + +class Doctors(Base, CustomBase): + __tablename__ = "doctors" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String) + age = Column(Integer, nullable=True) + email = Column(String, unique=True, index=True, nullable=True) + phone = Column(String, unique=True, index=True) + address = Column(String, nullable=True) + + clinic_id = Column(Integer, ForeignKey("clinics.id"), nullable=False, index=True) + clinic = relationship("Clinics", back_populates="doctors") + + appointments = relationship("Appointments", back_populates="doctor") + calendars = relationship("Calenders", back_populates="doctor") diff --git a/models/Patients.py b/models/Patients.py new file mode 100644 index 0000000..a87cad3 --- /dev/null +++ b/models/Patients.py @@ -0,0 +1,19 @@ +from sqlalchemy import Column, Integer, String +from sqlalchemy.orm import relationship + +from database import Base +from .CustomBase import CustomBase + + +class Patients(Base, CustomBase): + __tablename__ = "patients" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String) + age = Column(Integer, nullable=True) + email = Column(String, unique=True, index=True, nullable=True) + phone = Column(String, unique=True, index=True) + address = Column(String, nullable=True) + dob = Column(String, nullable=True) + + appointments = relationship("Appointments", back_populates="patient") diff --git a/models/Users.py b/models/Users.py new file mode 100644 index 0000000..853567e --- /dev/null +++ b/models/Users.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Integer, String +from database import Base +from sqlalchemy import Enum +from enums.enums import ClinicUserRoles, UserType +from models.CustomBase import CustomBase + +class Users(Base, CustomBase): + __tablename__ = "users" + id = Column(Integer, primary_key=True, index=True) + username = Column(String, index=True) + email = Column(String, unique=True, index=True) + password = Column(String) + clinicRole = Column(Enum(ClinicUserRoles), nullable=True) + userType = Column(Enum(UserType), nullable=True) diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e7d7f98 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,15 @@ +from .Users import Users +from .Clinics import Clinics +from .Doctors import Doctors +from .Patients import Patients +from .Appointments import Appointments +from .Calendar import Calenders + +__all__ = [ + "Users", + "Clinics", + "Doctors", + "Patients", + "Appointments", + "Calenders", +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..42db48d47d78611e713afa7b83da8d49a02b6263 GIT binary patch literal 4412 zcmai&OK)0N6ot>YQh&-t0c@NMGRQP)q*SR?b!1_{Kw`k*;e`J9w(HyLZq5ZGRS0Z+ z*4dA>pV$BXGcL2TE{C!z@AVp%Ug^jCw)|RtDdVy%oARj7C*gL18I+qA^`?Va2>Yzp zQ8?ZY`u?O>-s#1y(bsLSgn&=F~GNQ!!KpF-i!we*j2w@Ex zo8V%rsKeH>{2fxb4P%Zk%ueETQoe;|d_(5jRx?&_By${cxNUxV#c`g5Um)Nc?7U^8 zANGN^byLY8GUyu%gOxj3`Bk4A*_yIPx^WlUr$WpWnR%JqEjb)#gF?NAYr=o#rELg9q|r9e%(} zvVwJqI_U3*U^h>($)|HkAm;RrMqV!UIS+|wb}U}>otUq6a?nrC(>tOK7x;H63&vo> z*~JZWOYXXMuHWcJFxkXMduIklQ32O|z~4n!2zJ=XvrBzOHbT44kqr;BExqIZH67Hz zMqJRL$T;$xPLVziBQyW+COzY4Xx?0v;tp|5iX%j{(C8j=-O_8Z%zI=3HF)zXdd_yL zo9M0dq8^*5QTj9Xrsh$n%TwuaK|a#Mz&OLRi-35RR4P0r+?}yi&*9dy;Ly?6MK5%- zA9CEM$Sm`f5$$FlJk@GU#Os*Xs3+#U|09FEH*bmgd9q^=th#G1lqGcVwQ6}Qui=J1 z>lxV(du%a%h{;$v%fFK+u0<-z%wt2f^P?oM)rrS#?k4B1%da}M3}kbYsJjB0|1Yu- z7QiIprc+;@3UrXe>70(`E#Q;1Zq?PNis%nnk@kBA;CuJ92|Z+z8NgC3vac!Ypz|-f zN&icE_Lx3nnLE`Rzu^Iz6E}$1nEjQk!ftOHNFWF7`ARytpS|L|idMS!q%)$zX5n0R zw@YhRv6~!K7p`Ms%ML)T)cUC2d52@`N|r~R%=c;XVmoV=A#<&6K@8}GOWAI2jI?jC z!!Cp+S>>GHMvcCUIwyx#vTrUN)7*Ols5*u6gkDc(&BJ1Em1d9L-esOTG`o%@=h)$S z-khv48(8Go$<)Wg=(#yRyJvr)hCrs@$&I8ycUU_;iUN@0(brZ+mx~qX^+i(QEui8&=-pDR7_hBu4 z6@NevI@|4MNt{P6Ak(~9V%@!wNS8`x9Vy3=`yb1n<&Tqe_oQV0D84=E*v!eCb1BII z8AETX*+OM^V|dj0Oene^sF@;yEO120-4t7MduxI#x)O7md`kwr^Yz3&lW5;|kITDE zDk|Y6?%{Q8z`tb}iPtMMVio^;Nl+B2vSo^aT5>umQVvW89cugo80;(f-@8TY09E_Of2 z4nKL(V>9ZY+2$;b&4A9%o^d|8kZeAw^~$v^#v-5Y6|ZUQB|MFyu58C%6NQXRUmORR zKzlxw`iTL%%w^u?nbnoRyz^5Dy|6~h8C+!4U0+yrQ=2Gu$w8(g>cCyvcWKCZp)8!lEBx15wN8oZ6+G dict: + """Create an API response from an API exception.""" + import traceback + return cls( + data=None, + message=exception.message, + error=traceback.format_exc() if exception else None + ).model_dump(exclude_none=True) \ No newline at end of file diff --git a/schemas/BaseSchemas.py b/schemas/BaseSchemas.py new file mode 100644 index 0000000..a9916af --- /dev/null +++ b/schemas/BaseSchemas.py @@ -0,0 +1,52 @@ +# schemas.py +from datetime import datetime +from typing import List, Optional +from pydantic import BaseModel, EmailStr +from enums.enums import AppointmentStatus, ClinicUserRoles, UserType + + +# Base schemas (shared attributes for create/read operations) +class ClinicBase(BaseModel): + name: str + address: Optional[str] = None + phone: str + email: Optional[EmailStr] = None + + +class DoctorBase(BaseModel): + name: str + age: Optional[int] = None + email: Optional[EmailStr] = None + phone: str + address: Optional[str] = None + clinic_id: int + + +class PatientBase(BaseModel): + name: str + age: Optional[int] = None + email: Optional[EmailStr] = None + phone: Optional[str] = None + address: Optional[str] = None + dob: Optional[str] = None + + +class AppointmentBase(BaseModel): + doctor_id: int + patient_id: int + appointment_time: datetime + status: AppointmentStatus = AppointmentStatus.CONFIRMED + + +class CalendarBase(BaseModel): + doc_id: int + # rrule: str # Recurrence rule in iCalendar format + time: str + + +class UserBase(BaseModel): + username: str + email: EmailStr + password: str + clinicRole: Optional[ClinicUserRoles] = None + userType: Optional[UserType] = None \ No newline at end of file diff --git a/schemas/CreateSchemas.py b/schemas/CreateSchemas.py new file mode 100644 index 0000000..22c9a10 --- /dev/null +++ b/schemas/CreateSchemas.py @@ -0,0 +1,36 @@ +from .BaseSchemas import * +from datetime import datetime +from typing import Optional +from enums.enums import AppointmentStatus + + +# Create schemas (used for creating new records) +class ClinicCreate(ClinicBase): + pass + + +class DoctorCreate(DoctorBase): + pass + + +class PatientCreate(PatientBase): + pass + + +class AppointmentCreate(AppointmentBase): + pass + + +class CalendarCreate(CalendarBase): + pass + + +class AppointmentCreateWithNames(BaseModel): + doctor_name: str + patient_name: str + appointment_time: datetime + status: AppointmentStatus = AppointmentStatus.CONFIRMED + + +class UserCreate(UserBase): + pass diff --git a/schemas/ResponseSchemas.py b/schemas/ResponseSchemas.py new file mode 100644 index 0000000..edd453f --- /dev/null +++ b/schemas/ResponseSchemas.py @@ -0,0 +1,126 @@ +from datetime import datetime +from typing import List +from .BaseSchemas import * +from pydantic import Field + +# Response schemas (used for API responses) +class Clinic(ClinicBase): + id: int + create_time: datetime + update_time: datetime + + class Config: + orm_mode = True + +class UserResponse(UserBase): + id: int + create_time: datetime + update_time: datetime + password: str = Field(exclude=True) + + class Config: + orm_mode = True + allow_population_by_field_name = True + +class Doctor(DoctorBase): + id: int + create_time: datetime + update_time: datetime + + class Config: + orm_mode = True + + +class Patient(PatientBase): + id: int + create_time: datetime + update_time: datetime + + class Config: + orm_mode = True + + +class AppointmentSchema(AppointmentBase): + id: int + create_time: datetime + update_time: datetime + + class Config: + orm_mode = True + + +class Calendar(CalendarBase): + id: int + create_time: datetime + update_time: datetime + + class Config: + orm_mode = True + + +# custom schema for response +class CalendarTimeSchema(BaseModel): + time: str + + class Config: + orm_mode = True + + +class ClinicSchema(BaseModel): + id: int + name: str + address: str + phone: str + email: str + + class Config: + orm_mode = True + + +# Detailed response schemas with nested relationships +class ClinicWithDoctors(Clinic): + doctors: List[Doctor] = [] + + +class DoctorWithAppointments(Doctor): + appointments: List[AppointmentSchema] = [] + calendars: List[CalendarTimeSchema] = [] + clinic: ClinicSchema + + +class DoctorWithCalendar(Doctor): + calendars: List[CalendarTimeSchema] = [] + clinic: ClinicSchema + + +class PatientWithAppointments(Patient): + appointments: List[AppointmentSchema] = [] + + +class AppointmentDetailed(AppointmentSchema): + + class Doctor(BaseModel): + id: int + name: str + age: int + email: str + phone: str + address: str + + class Config: + orm_mode = True + + class Patient(BaseModel): + id: int + name: str + age: int + email: str + phone: str + address: str + dob: str + + class Config: + orm_mode = True + + doctor: Doctor + patient: Patient diff --git a/schemas/UpdateSchemas.py b/schemas/UpdateSchemas.py new file mode 100644 index 0000000..b87e52f --- /dev/null +++ b/schemas/UpdateSchemas.py @@ -0,0 +1,38 @@ +from .BaseSchemas import * + + +# Update schemas (all fields optional for partial updates) +class ClinicUpdate(BaseModel): + name: Optional[str] = None + address: Optional[str] = None + phone: Optional[str] = None + email: Optional[EmailStr] = None + + +class DoctorUpdate(BaseModel): + name: Optional[str] = None + age: Optional[int] = None + email: Optional[EmailStr] = None + phone: Optional[str] = None + address: Optional[str] = None + clinic_id: Optional[int] = None + + +class PatientUpdate(BaseModel): + name: Optional[str] = None + age: Optional[int] = None + email: Optional[EmailStr] = None + phone: Optional[str] = None + address: Optional[str] = None + + +class AppointmentUpdate(BaseModel): + doctor_id: Optional[int] = None + patient_id: Optional[int] = None + appointment_time: Optional[datetime] = None + status: Optional[AppointmentStatus] = None + + +class CalendarUpdate(BaseModel): + doc_id: Optional[int] = None + rrule: Optional[str] = None diff --git a/schemas/__init__.py b/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/authService.py b/services/authService.py new file mode 100644 index 0000000..127b8b0 --- /dev/null +++ b/services/authService.py @@ -0,0 +1,32 @@ +from database import get_db +from services.jwtService import create_jwt_token +from services.userServices import UserServices +from utils.password_utils import verify_password +from schemas.CreateSchemas import UserCreate +from exceptions.unauthorized_exception import UnauthorizedException + +class AuthService: + def __init__(self): + self.user_service = UserServices() + + async def login(self, email, password) -> str: + + # get user + user = await self.user_service.get_user_by_email(email) + + # verify password + if not verify_password(password, user.password): + raise UnauthorizedException("Invalid credentials") + + # remove password from user dict + user_dict = user.__dict__.copy() + user_dict.pop("password", None) + + # create token + token = create_jwt_token(user_dict) + return token + + async def register(self, user_data: UserCreate) -> str: + user = await self.user_service.create_user(user_data) + token = create_jwt_token(user) + return token \ No newline at end of file diff --git a/services/bot.py b/services/bot.py new file mode 100644 index 0000000..a590b33 --- /dev/null +++ b/services/bot.py @@ -0,0 +1,227 @@ +# +# Copyright (c) 2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import datetime +import io +import os +import sys +import wave + +import aiofiles +from dotenv import load_dotenv +from fastapi import WebSocket +from loguru import logger + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext +from pipecat.processors.audio.audio_buffer_processor import AudioBufferProcessor +from pipecat.serializers.twilio import TwilioFrameSerializer + +from pipecat.services.elevenlabs import ElevenLabsTTSService +from pipecat.services.playht import PlayHTTTSService, Language +from pipecat.services.deepgram import DeepgramSTTService +from pipecat.services.fish import FishAudioTTSService +from pipecat.services.rime import RimeTTSService +from pipecat.services.cartesia import CartesiaTTSService + +from pipecat.services.openai_realtime_beta import ( + OpenAIRealtimeBetaLLMService, + SessionProperties, + TurnDetection, +) +from pipecat.services.anthropic import AnthropicLLMService +from pipecat.services.openai import OpenAILLMService +from pipecat.services.google import GoogleLLMService, GoogleLLMContext +from pipecat.transports.network.fastapi_websocket import ( + FastAPIWebsocketParams, + FastAPIWebsocketTransport, +) + +load_dotenv(override=True) + +logger.remove(0) +logger.add(sys.stderr, level="DEBUG") + + +async def save_audio( + server_name: str, audio: bytes, sample_rate: int, num_channels: int +): + if len(audio) > 0: + filename = f"{server_name}_recording_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.wav" + with io.BytesIO() as buffer: + with wave.open(buffer, "wb") as wf: + wf.setsampwidth(2) + wf.setnchannels(num_channels) + wf.setframerate(sample_rate) + wf.writeframes(audio) + async with aiofiles.open(filename, "wb") as file: + await file.write(buffer.getvalue()) + logger.info(f"Merged audio saved to {filename}") + else: + logger.info("No audio data to save") + + +async def run_bot( + websocket_client: WebSocket, stream_sid: str, testing: bool, option: int = 1 +): + transport = FastAPIWebsocketTransport( + websocket=websocket_client, + params=FastAPIWebsocketParams( + audio_in_enabled=True, + audio_out_enabled=True, + add_wav_header=False, + vad_enabled=True, + vad_analyzer=SileroVADAnalyzer(), + vad_audio_passthrough=True, + serializer=TwilioFrameSerializer(stream_sid), + ), + ) + + # llm = OpenAIRealtimeBetaLLMService( + # api_key=os.getenv("OPENAI_API_KEY"), + # session_properties=SessionProperties( + # modalities=["text"], + # turn_detection=TurnDetection(threshold=0.5, silence_duration_ms=800), + # voice=None, + # ), + # ) + + llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o") + + # llm = AnthropicLLMService(api_key=os.getenv("ANTRHOPIC_API_KEY")) + + # llm = GoogleLLMService(api_key=os.getenv("GOOGLE_API_KEY"), model="phone_call") + + stt = DeepgramSTTService( + api_key=os.getenv("DEEPGRAM_API_KEY"), audio_passthrough=True + ) + + # tts = PlayHTTTSService( + # api_key=os.getenv("PLAYHT_SECRE_KEY"), + # user_id=os.getenv("PLAYHT_USERID"), + # voice_url="s3://voice-cloning-zero-shot/80ba8839-a6e6-470c-8f68-7c1e5d3ee2ff/abigailsaad/manifest.json", + # params=PlayHTTTSService.InputParams( + # language=Language.EN, + # speed=1.0, + # ), + # ) # not working + + # tts = FishAudioTTSService( + # api_key=os.getenv("FISH_AUDIO_API_KEY"), + # model="b545c585f631496c914815291da4e893", # Get this from Fish Audio playground + # output_format="pcm", # Choose output format + # sample_rate=24000, # Set sample rate + # params=FishAudioTTSService.InputParams(latency="normal", prosody_speed=1.0), + # ) # not working + + if option == 1: + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + voice_id="156fb8d2-335b-4950-9cb3-a2d33befec77", # British Lady + push_silence_after_stop=testing, + ) + elif option == 2: + tts = RimeTTSService( + api_key=os.getenv("RIME_API_KEY"), + voice_id="stream", + model="mistv2", + ) + elif option == 3: + tts = ElevenLabsTTSService( + api_key=os.getenv("ELEVEN_LABS_API_KEY"), + voice_id="79a125e8-cd45-4c13-8a67-188112f4dd22", + push_silence_after_stop=testing, + ) + elif option == 4: + tts = RimeTTSService( + api_key=os.getenv("RIME_API_KEY"), + voice_id="breeze", + model="mistv2", + ) + elif option == 5: + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + voice_id="1d3ba41a-96e6-44ad-aabb-9817c56caa68", # British Lady + push_silence_after_stop=testing, + ) + else: + tts = RimeTTSService( + api_key=os.getenv("RIME_API_KEY"), + voice_id="peak", + model="mistv2", + ) + + messages = [ + { + "role": "system", + "content": f""" + Welcome to 365 Days Medical Centre Para Hills - we care about you. + If this is an emergency, please call triple zero. + We are open from 8 AM to 8 PM every day of the year. + All calls are recorded for training and quality purposes - please let us know if you do not wish to be recorded. + I am Nishka, your 24/7 healthcare receptionist. Which language would you like to speak? + """, + } + ] + + context = OpenAILLMContext(messages) + context_aggregator = llm.create_context_aggregator(context) + + # NOTE: Watch out! This will save all the conversation in memory. You can + # pass `buffer_size` to get periodic callbacks. + audiobuffer = AudioBufferProcessor(user_continuous_stream=not testing) + + pipeline = Pipeline( + [ + transport.input(), # Websocket input from client + stt, # Speech-To-Text + context_aggregator.user(), # User context + llm, # LLM + tts, # Text-To-Speech + transport.output(), # Websocket output to client + audiobuffer, # Used to buffer the audio in the pipeline + context_aggregator.assistant(), # Assistant context + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + audio_in_sample_rate=8000, + audio_out_sample_rate=8000, + allow_interruptions=True, + ), + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + # Start recording. + await audiobuffer.start_recording() + # Kick off the conversation. + messages.append( + {"role": "system", "content": "Please introduce yourself to the user."} + ) + await task.queue_frames([context_aggregator.user().get_context_frame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + await task.cancel() + + # @audiobuffer.event_handler("on_audio_data") + # async def on_audio_data(buffer, audio, sample_rate, num_channels): + # server_name = f"server_{websocket_client.client.port}" + # await save_audio(server_name, audio, sample_rate, num_channels) + + # We use `handle_sigint=False` because `uvicorn` is controlling keyboard + # interruptions. We use `force_gc=True` to force garbage collection after + # the runner finishes running a task which could be useful for long running + # applications with multiple clients connecting. + runner = PipelineRunner(handle_sigint=False, force_gc=True) + + await runner.run(task) diff --git a/services/jwtService.py b/services/jwtService.py new file mode 100644 index 0000000..b397d33 --- /dev/null +++ b/services/jwtService.py @@ -0,0 +1,33 @@ +from datetime import datetime, timedelta, timezone +import jwt +from enum import Enum + +from utils.constants import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_MINUTES + +def create_jwt_token(data: dict): + # Create a copy of the data and handle Enum and datetime serialization + to_encode = {} + for key, value in data.items(): + if isinstance(value, Enum): + to_encode[key] = value.value # Convert Enum to its string value + elif isinstance(value, datetime): + to_encode[key] = value.isoformat() # Convert datetime to ISO format string + else: + to_encode[key] = value + + # Safely evaluate the JWT_EXPIRE_MINUTES expression + minutes = eval(JWT_EXPIRE_MINUTES) if isinstance(JWT_EXPIRE_MINUTES, str) else JWT_EXPIRE_MINUTES + expire = datetime.now(timezone.utc) + timedelta(minutes=minutes) + to_encode.update({"exp": expire.timestamp()}) # Use timestamp for expiration + encoded_jwt = jwt.encode(to_encode, JWT_SECRET, algorithm=JWT_ALGORITHM) + return encoded_jwt + + +def verify_jwt_token(token: str): + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + return payload + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError: + return None diff --git a/services/userServices.py b/services/userServices.py new file mode 100644 index 0000000..4e65c3c --- /dev/null +++ b/services/userServices.py @@ -0,0 +1,93 @@ +from loguru import logger +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session +from sqlalchemy import or_ + +from database import get_db +from models.Users import Users +from exceptions.validation_exception import ValidationException +from schemas.ResponseSchemas import UserResponse +from utils.password_utils import hash_password +from schemas.CreateSchemas import UserCreate +from exceptions.resource_not_found_exception import ResourceNotFoundException + + +class UserServices: + def __init__(self): + self.db: Session = next(get_db()) + + async def create_user(self, user_data: UserCreate): + try: + # Check if user with same username or email exists + existing_user = ( + self.db.query(Users) + .filter(Users.email == user_data.email.lower()) + .first() + ) + + if existing_user: + raise ValidationException( + "User with same email already exists" + ) + + # Create a new user instance + new_user = Users( + username=user_data.username, + email=user_data.email.lower(), + password=hash_password(user_data.password), + clinicRole=user_data.clinicRole, + userType=user_data.userType, + ) + + # Add to database and commit + self.db.add(new_user) + self.db.commit() + self.db.refresh(new_user) + + user_dict = new_user.__dict__.copy() + + user_response = UserResponse(**user_dict).model_dump() + + return user_response + except Exception as e: + logger.error("Error creating user", e) + self.db.rollback() + if e.__class__ == ValidationException: + raise ValidationException(e.message) + if e.__class__ == ResourceNotFoundException: + raise ResourceNotFoundException(e.message) + raise e + + def get_user(self, user_id) -> UserResponse: + + # Query the user by ID + user = self.db.query(Users).filter(Users.id == user_id).first() + + if not user: + logger.error("User not found") + raise ResourceNotFoundException("User not found") + + user_dict = user.__dict__.copy() + + user_response = UserResponse(**user_dict).model_dump() + + return user_response + + async def get_user_by_email(self, email: str) -> UserResponse: + user = self.db.query(Users).filter(Users.email == email.lower()).first() + + if not user: + logger.error("User not found") + raise ResourceNotFoundException("User not found") + + user_dict = user.__dict__.copy() + + user_response = UserResponse(**user_dict) + + return user_response + + def update_user(self, user_id, user): + return user + + def delete_user(self, user_id): + return user diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..e1b1b0a --- /dev/null +++ b/utils.py @@ -0,0 +1,3 @@ +# Database pagination constants +DEFAULT_SKIP = 0 +DEFAULT_LIMIT = 10 diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..c49d8db --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,10 @@ +from .password_utils import hash_password, verify_password +from .constants import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_MINUTES + +__all__ = [ + "hash_password", + "verify_password", + "JWT_SECRET", + "JWT_ALGORITHM", + "JWT_EXPIRE_MINUTES", +] diff --git a/utils/constants.py b/utils/constants.py new file mode 100644 index 0000000..3f499bc --- /dev/null +++ b/utils/constants.py @@ -0,0 +1,14 @@ +import dotenv +import os +dotenv.load_dotenv() + +DEFAULT_SKIP = 0 +DEFAULT_PAGE = 1 +DEFAULT_LIMIT = 10 +DEFAULT_ORDER_BY = "id" +DEFAULT_ORDER = "desc" + +# jwt +JWT_ALGORITHM = os.getenv("JWT_ALGORITHM") +JWT_SECRET = os.getenv("JWT_SECRET") +JWT_EXPIRE_MINUTES = os.getenv("JWT_EXPIRE_MINUTES") \ No newline at end of file diff --git a/utils/password_utils.py b/utils/password_utils.py new file mode 100644 index 0000000..d7c6b15 --- /dev/null +++ b/utils/password_utils.py @@ -0,0 +1,16 @@ +from passlib.context import CryptContext + +# Create a password context for hashing and verifying +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def hash_password(password: str) -> str: + """ + Hash a password using bcrypt + """ + return pwd_context.hash(password) + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """ + Verify a password against a hash + """ + return pwd_context.verify(plain_password, hashed_password)