feat: full clinic signup

This commit is contained in:
deepvasoya 2025-05-12 16:18:25 +05:30
parent 80c61dc127
commit 25e105e714
15 changed files with 473 additions and 44 deletions

View File

@ -1,7 +1,11 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Security
from middleware.auth_dependency import auth_required
from fastapi.security import HTTPBearer
from .endpoints import clinics, doctors, calender, appointments, patients, admin, auth
# Import the security scheme
bearer_scheme = HTTPBearer(scheme_name="Bearer Authentication")
from .endpoints import clinics, doctors, calender, appointments, patients, admin, auth, s3
api_router = APIRouter()
# api_router.include_router(twilio.router, prefix="/twilio")
@ -10,5 +14,10 @@ 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(
admin.router,
prefix="/admin",
dependencies=[Depends(auth_required)],
tags=["admin"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(s3.router, dependencies=[Depends(auth_required)], prefix="/s3", tags=["s3"])

View File

@ -7,17 +7,17 @@ router = APIRouter()
@router.post("/login")
async def login(email: str, password: str):
response = await AuthService().login(email, password)
token = await AuthService().login(email, password)
return ApiResponse(
data=response,
data=token,
message="Login successful"
)
@router.post("/register")
async def register(user_data: UserCreate):
response = await AuthService().register(user_data)
await AuthService().register(user_data)
return ApiResponse(
data=response,
data="OK",
message="User registered successfully"
)

15
apis/endpoints/s3.py Normal file
View File

@ -0,0 +1,15 @@
from fastapi import APIRouter, status
from fastapi import Request
from services.s3Service import upload_file as upload_file_service
from enums.enums import S3FolderNameEnum
from typing import Optional
from schemas.ApiResponse import ApiResponse
router = APIRouter()
@router.post("/", status_code=status.HTTP_200_OK)
async def upload_file(request: Request, folder: S3FolderNameEnum, file_name: str, clinic_id: Optional[str] = None):
userId = request.state.user["id"]
resp = await upload_file_service(userId, folder, file_name, clinic_id)
return ApiResponse(data=resp, message="File uploaded successfully")

View File

@ -7,12 +7,16 @@ class AppointmentStatus(Enum):
CANCELLED = "cancelled"
COMPLETED = "completed"
class Integration(Enum):
BP = "bp"
MEDICAL_DIRECTOR = "medical_director"
class ClinicStatus(Enum):
ACTIVE = "active"
INACTIVE = "inactive"
UNDER_REVIEW = "under_review"
REQUESTED_DOCTOR = "requested_doctor"
REQUESTED_DOC = "requested_doc"
REJECTED = "rejected"
PAYMENT_DUE = "payment_due"
@ -35,3 +39,7 @@ class UserType(Enum):
class Integration(Enum):
BP = "bp"
MEDICAL_DIRECTOR = "medical_director"
class S3FolderNameEnum(str, Enum):
PROFILE = "profile"
ASSETS = "assets"

27
main.py
View File

@ -1,19 +1,13 @@
import dotenv
from fastapi import FastAPI
from fastapi import FastAPI, Security
from contextlib import asynccontextmanager
import logging
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
# 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
@ -43,8 +37,25 @@ async def lifespan(app: FastAPI):
logger.info("Stopping application")
# Define the security scheme
bearer_scheme = HTTPBearer(scheme_name="Bearer Authentication")
app = FastAPI(
lifespan=lifespan,
title="Twillio Voice API",
description="API for Twillio Voice application",
version="1.0.0",
# Define security scheme for Swagger UI
openapi_tags=[
{"name": "admin", "description": "Admin operations requiring authentication"},
{"name": "auth", "description": "Authentication operations"},
{"name": "clinics", "description": "Operations with clinics"},
{"name": "doctors", "description": "Operations with doctors"},
{"name": "calender", "description": "Calendar operations"},
{"name": "appointments", "description": "Operations with appointments"},
{"name": "patients", "description": "Operations with patients"}
],
swagger_ui_parameters={"defaultModelsExpandDepth": -1}
)
# CORS middleware

View File

@ -18,7 +18,7 @@ async def auth_required(request: Request ,credentials: HTTPAuthorizationCredenti
raise HTTPException(status_code=401, detail="Invalid authentication token")
# Get user from database
user = UserServices().get_user(payload["user_id"])
user = UserServices().get_user(payload["id"])
# set user to request state
request.state.user = user

View File

@ -0,0 +1,32 @@
"""updated clinic enum status
Revision ID: 827c736d4aeb
Revises: 928001a9d80f
Create Date: 2025-05-12 15:36:42.117900
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '827c736d4aeb'
down_revision: Union[str, None] = '928001a9d80f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# Update the enum type to replace 'requested_doctor' with 'requested_doc'
op.execute("ALTER TYPE clinicstatus RENAME VALUE 'REQUESTED_DOCTOR' TO 'REQUESTED_DOC'")
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# Revert the enum type change back to 'requested_doctor' from 'requested_doc'
op.execute("ALTER TYPE clinicstatus RENAME VALUE 'REQUESTED_DOC' TO 'REQUESTED_DOCTOR'")
# ### end Alembic commands ###

View File

@ -0,0 +1,110 @@
"""updated clinic
Revision ID: 928001a9d80f
Revises:
Create Date: 2025-05-12 14:19:15.351582
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '928001a9d80f'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('appointments', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('calenders', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('clinics', sa.Column('emergency_phone', sa.String(), nullable=True))
op.add_column('clinics', sa.Column('fax', sa.String(), nullable=True))
# Create the integration enum type first
integration_type = sa.Enum('BP', 'MEDICAL_DIRECTOR', name='integration')
integration_type.create(op.get_bind())
op.add_column('clinics', sa.Column('integration', integration_type, nullable=True))
op.add_column('clinics', sa.Column('pms_id', sa.String(), nullable=True))
op.add_column('clinics', sa.Column('practice_name', sa.String(), nullable=True))
op.add_column('clinics', sa.Column('logo', sa.String(), nullable=True))
op.add_column('clinics', sa.Column('country', sa.String(), nullable=True))
op.add_column('clinics', sa.Column('postal_code', sa.String(), nullable=True))
op.add_column('clinics', sa.Column('city', sa.String(), nullable=True))
op.add_column('clinics', sa.Column('state', sa.String(), nullable=True))
op.add_column('clinics', sa.Column('abn_doc', sa.String(), nullable=True))
op.add_column('clinics', sa.Column('abn_number', sa.String(), nullable=True))
op.add_column('clinics', sa.Column('contract_doc', sa.String(), nullable=True))
op.add_column('clinics', sa.Column('clinic_phone', sa.String(), nullable=True))
op.add_column('clinics', sa.Column('is_clinic_phone_enabled', sa.Boolean(), nullable=True))
op.add_column('clinics', sa.Column('other_info', sa.String(), nullable=True))
op.add_column('clinics', sa.Column('greeting_msg', sa.String(), nullable=True))
op.add_column('clinics', sa.Column('voice_model', sa.String(), nullable=True))
op.add_column('clinics', sa.Column('voice_model_provider', sa.String(), nullable=True))
op.add_column('clinics', sa.Column('voice_model_gender', sa.String(), nullable=True))
op.add_column('clinics', sa.Column('scenarios', sa.String(), nullable=True))
op.add_column('clinics', sa.Column('general_info', sa.String(), nullable=True))
# Create the clinicstatus enum type first
clinic_status_type = sa.Enum('ACTIVE', 'INACTIVE', 'UNDER_REVIEW', 'REQUESTED_DOCTOR', 'REJECTED', 'PAYMENT_DUE', name='clinicstatus')
clinic_status_type.create(op.get_bind())
op.add_column('clinics', sa.Column('status', clinic_status_type, nullable=True))
op.add_column('clinics', sa.Column('domain', sa.String(), nullable=True))
op.add_column('clinics', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True))
op.create_unique_constraint(None, 'clinics', ['emergency_phone'])
op.add_column('doctors', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('patients', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('users', sa.Column('profile_pic', sa.String(), nullable=True))
op.add_column('users', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'deleted_at')
op.drop_column('users', 'profile_pic')
op.drop_column('patients', 'deleted_at')
op.drop_column('doctors', 'deleted_at')
op.drop_constraint(None, 'clinics', type_='unique')
op.drop_column('clinics', 'deleted_at')
op.drop_column('clinics', 'domain')
# Drop the status column first
op.drop_column('clinics', 'status')
# Then drop the enum type
sa.Enum(name='clinicstatus').drop(op.get_bind())
op.drop_column('clinics', 'general_info')
op.drop_column('clinics', 'scenarios')
op.drop_column('clinics', 'voice_model_gender')
op.drop_column('clinics', 'voice_model_provider')
op.drop_column('clinics', 'voice_model')
op.drop_column('clinics', 'greeting_msg')
op.drop_column('clinics', 'other_info')
op.drop_column('clinics', 'is_clinic_phone_enabled')
op.drop_column('clinics', 'clinic_phone')
op.drop_column('clinics', 'contract_doc')
op.drop_column('clinics', 'abn_number')
op.drop_column('clinics', 'abn_doc')
op.drop_column('clinics', 'state')
op.drop_column('clinics', 'city')
op.drop_column('clinics', 'postal_code')
op.drop_column('clinics', 'country')
op.drop_column('clinics', 'logo')
op.drop_column('clinics', 'practice_name')
op.drop_column('clinics', 'pms_id')
# Drop the integration column first
op.drop_column('clinics', 'integration')
# Then drop the enum type
sa.Enum(name='integration').drop(op.get_bind())
op.drop_column('clinics', 'fax')
op.drop_column('clinics', 'emergency_phone')
op.drop_column('calenders', 'deleted_at')
op.drop_column('appointments', 'deleted_at')
# ### end Alembic commands ###

View File

@ -1,7 +1,9 @@
from sqlalchemy import Column, Integer, String
from sqlalchemy import Column, Integer, String, Boolean
from sqlalchemy.orm import relationship
from database import Base
from enums.enums import Integration, ClinicStatus
from sqlalchemy import Enum
from .CustomBase import CustomBase
@ -11,7 +13,32 @@ class Clinics(Base, CustomBase):
id = Column(Integer, primary_key=True, index=True)
name = Column(String)
address = Column(String, nullable=True)
phone = Column(String, unique=True, index=True)
phone = Column(String, unique=True, index=True) # clinic phone
emergency_phone = Column(String, unique=True, nullable=True)
email = Column(String, unique=True, index=True, nullable=True)
fax = Column(String, nullable=True)
integration = Column(Enum(Integration))
pms_id = Column(String, nullable=True)
practice_name = Column(String, nullable=True)
logo = Column(String, nullable=True)
country = Column(String, nullable=True)
postal_code = Column(String, nullable=True)
city = Column(String, nullable=True)
state = Column(String, nullable=True)
abn_doc = Column(String, nullable=True)
abn_number = Column(String, nullable=True)
contract_doc = Column(String, nullable=True)
clinic_phone = Column(String, nullable=True) # AI Receptionist Phone
is_clinic_phone_enabled = Column(Boolean, default=False)
other_info = Column(String, nullable=True)
greeting_msg = Column(String, nullable=True)
voice_model = Column(String, nullable=True)
voice_model_provider = Column(String, nullable=True)
voice_model_gender = Column(String, nullable=True)
scenarios = Column(String, nullable=True)
general_info = Column(String, nullable=True)
status = Column(Enum(ClinicStatus))
domain = Column(String, nullable=True) # unique for each clinic
doctors = relationship("Doctors", back_populates="clinic")

View File

@ -12,3 +12,4 @@ class Users(Base, CustomBase):
password = Column(String)
clinicRole = Column(Enum(ClinicUserRoles), nullable=True)
userType = Column(Enum(UserType), nullable=True)
profile_pic = Column(String, nullable=True)

View File

@ -2,7 +2,7 @@
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, EmailStr
from enums.enums import AppointmentStatus, ClinicUserRoles, UserType
from enums.enums import AppointmentStatus, ClinicUserRoles, UserType, Integration
# Base schemas (shared attributes for create/read operations)
@ -11,6 +11,26 @@ class ClinicBase(BaseModel):
address: Optional[str] = None
phone: str
email: Optional[EmailStr] = None
integration: Integration
pms_id: str
practice_name: str
logo: Optional[str] = None
country: Optional[str] = None
postal_code: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
abn_doc: Optional[str] = None
abn_number: Optional[str] = None
contract_doc: Optional[str] = None
clinic_phone: Optional[str] = None
is_clinic_phone_enabled: Optional[bool] = None
other_info: Optional[str] = None
greeting_msg: Optional[str] = None
voice_model: Optional[str] = None
voice_model_provider: Optional[str] = None
voice_model_gender: Optional[str] = None
scenarios: Optional[str] = None
general_info: Optional[str] = None
class DoctorBase(BaseModel):

View File

@ -32,5 +32,8 @@ class AppointmentCreateWithNames(BaseModel):
status: AppointmentStatus = AppointmentStatus.CONFIRMED
class UserCreate(UserBase):
pass
class UserCreate(BaseModel):
# User data sent from frontend
user: UserBase
# Clinic data sent from frontend
clinic: ClinicBase

View File

@ -26,7 +26,6 @@ class AuthService:
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
async def register(self, user_data: UserCreate) -> None:
await self.user_service.create_user(user_data)
return

149
services/s3Service.py Normal file
View File

@ -0,0 +1,149 @@
from enum import Enum
from typing import Optional, Dict, Any
import os
from datetime import datetime
from urllib.parse import urlparse
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
from fastapi import HTTPException
from pydantic_settings import BaseSettings
from enums.enums import S3FolderNameEnum
from exceptions.business_exception import BusinessValidationException
class Settings(BaseSettings):
AWS_REGION: str
AWS_ACCESS_KEY: str
AWS_SECRET_KEY: str
AWS_BUCKET_NAME: str
AWS_S3_EXPIRES: int = 60 * 60 # Default 1 hour
class Config:
env_file = ".env"
extra = "ignore" # Allow extra fields from environment
class S3Service:
def __init__(self):
self.settings = Settings()
self.bucket_name = self.settings.AWS_BUCKET_NAME
self.s3 = boto3.client(
's3',
region_name=self.settings.AWS_REGION,
aws_access_key_id=self.settings.AWS_ACCESS_KEY,
aws_secret_access_key=self.settings.AWS_SECRET_KEY,
config=Config(signature_version='s3v4')
)
def get_s3_service():
return S3Service()
async def upload_file(
user_id: str,
folder: S3FolderNameEnum,
file_name: str,
clinic_id: Optional[str] = None
) -> Dict[str, str]:
"""
Generate a pre-signed URL for uploading a file to S3.
Args:
user_id: The ID of the user
folder: The folder enum to store the file in
file_name: The name of the file
clinic_id: Optional design ID for assets
Returns:
Dict containing the URLs and key information
"""
s3_service = get_s3_service()
if folder == S3FolderNameEnum.ASSETS and not clinic_id:
raise BusinessValidationException("Clinic id is required!")
if folder != S3FolderNameEnum.PROFILE and not user_id:
raise BusinessValidationException("User id is required!")
timestamp = int(datetime.now().timestamp() * 1000)
if folder == S3FolderNameEnum.PROFILE:
key = f"common/{S3FolderNameEnum.PROFILE.value}/{user_id}/{timestamp}_{file_name}"
else:
key = f"common/{S3FolderNameEnum.ASSETS.value}/clinic/{clinic_id}/{timestamp}_{file_name}"
try:
put_url = s3_service.s3.generate_presigned_url(
ClientMethod='put_object',
Params={
'Bucket': s3_service.bucket_name,
'Key': key,
},
ExpiresIn=s3_service.settings.AWS_S3_EXPIRES
)
get_url = s3_service.s3.generate_presigned_url(
ClientMethod='get_object',
Params={
'Bucket': s3_service.bucket_name,
'Key': key,
},
ExpiresIn=s3_service.settings.AWS_S3_EXPIRES
)
url = urlparse(put_url)
return {
"api_url": put_url,
"key": key,
"location": f"{url.scheme}://{url.netloc}/{key}",
"get_url": get_url,
}
except ClientError as e:
print(f"Error generating pre-signed URL: {e}")
raise BusinessValidationException(str(e))
async def get_signed_url(key: str) -> str:
"""
Generate a pre-signed URL for retrieving a file from S3.
Args:
key: The key of the file in S3
Returns:
The pre-signed URL for getting the object
"""
s3_service = get_s3_service()
try:
url = s3_service.s3.generate_presigned_url(
ClientMethod='get_object',
Params={
'Bucket': s3_service.bucket_name,
'Key': key,
},
ExpiresIn=3600 # 1 hour
)
return url
except ClientError as e:
print(f"Error in get_signed_url: {e}")
raise BusinessValidationException(str(e))
def get_file_key(url: str) -> str:
"""
Extract the file key from a URL or return the key if already provided.
Args:
url: The URL or key
Returns:
The file key
"""
try:
if not url.startswith("http://") and not url.startswith("https://"):
return url
parsed_url = urlparse(url)
return parsed_url.path.lstrip('/')
except Exception as e:
print(f"Error in get_file_key: {e}")
raise BusinessValidationException(str(e))

View File

@ -1,12 +1,12 @@
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 models import Clinics
from enums.enums import ClinicStatus
from utils.password_utils import hash_password
from schemas.CreateSchemas import UserCreate
from exceptions.resource_not_found_exception import ResourceNotFoundException
@ -17,11 +17,13 @@ class UserServices:
self.db: Session = next(get_db())
async def create_user(self, user_data: UserCreate):
# Start a transaction
try:
user = user_data.user
# Check if user with same username or email exists
existing_user = (
self.db.query(Users)
.filter(Users.email == user_data.email.lower())
.filter(Users.email == user.email.lower())
.first()
)
@ -32,29 +34,72 @@ class UserServices:
# 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,
username=user.username,
email=user.email.lower(),
password=hash_password(user.password),
clinicRole=user.clinicRole,
userType=user.userType,
)
# Add to database and commit
# Add user to database but don't commit yet
self.db.add(new_user)
# Get clinic data
clinic = user_data.clinic
# cross verify domain, in db
# Convert to lowercase and keep only alphabetic characters, hyphens, and underscores
domain = ''.join(char for char in clinic.name.lower() if char.isalpha() or char == '-' or char == '_')
existing_clinic = self.db.query(Clinics).filter(Clinics.domain == domain).first()
if existing_clinic:
# This will trigger rollback in the exception handler
raise ValidationException("Clinic with same domain already exists")
# Create clinic instance
new_clinic = Clinics(
name=clinic.name,
address=clinic.address,
phone=clinic.phone,
email=clinic.email,
integration=clinic.integration,
pms_id=clinic.pms_id,
practice_name=clinic.practice_name,
logo=clinic.logo,
country=clinic.country,
postal_code=clinic.postal_code,
city=clinic.city,
state=clinic.state,
abn_doc=clinic.abn_doc,
abn_number=clinic.abn_number,
contract_doc=clinic.contract_doc,
clinic_phone=clinic.clinic_phone,
is_clinic_phone_enabled=clinic.is_clinic_phone_enabled,
other_info=clinic.other_info,
greeting_msg=clinic.greeting_msg,
voice_model=clinic.voice_model,
voice_model_provider=clinic.voice_model_provider,
voice_model_gender=clinic.voice_model_gender,
scenarios=clinic.scenarios,
general_info=clinic.general_info,
status=ClinicStatus.UNDER_REVIEW,
domain=domain,
)
# Add clinic to database
self.db.add(new_clinic)
# Now commit both user and clinic in a single transaction
self.db.commit()
self.db.refresh(new_user)
user_dict = new_user.__dict__.copy()
user_response = UserResponse(**user_dict).model_dump()
return user_response
return
except Exception as e:
logger.error("Error creating user", e)
logger.error(f"Error creating user: {str(e)}")
# Rollback the transaction if any error occurs
self.db.rollback()
if e.__class__ == ValidationException:
if isinstance(e, ValidationException):
raise ValidationException(e.message)
if e.__class__ == ResourceNotFoundException:
if isinstance(e, ResourceNotFoundException):
raise ResourceNotFoundException(e.message)
raise e