Spaces:
Sleeping
Sleeping
Upload backend code and PyTorch weights
Browse files- .gitattributes +1 -0
- backend/__pycache__/api_routes.cpython-313.pyc +3 -0
- backend/__pycache__/config.cpython-313.pyc +0 -0
- backend/__pycache__/main.cpython-313.pyc +0 -0
- backend/core/__pycache__/config.cpython-313.pyc +0 -0
- backend/core/__pycache__/dependencies.cpython-313.pyc +0 -0
- backend/core/__pycache__/security.cpython-313.pyc +0 -0
- backend/core/config.py +29 -0
- backend/core/dependencies.py +66 -0
- backend/core/security.py +41 -0
- backend/main.py +210 -0
- backend/models/__pycache__/cat_skin.cpython-313.pyc +0 -0
- backend/models/__pycache__/dog_eye.cpython-313.pyc +0 -0
- backend/models/__pycache__/dog_skin.cpython-313.pyc +0 -0
- backend/models/cat_skin.py +18 -0
- backend/models/dog_eye.py +24 -0
- backend/models/dog_skin.py +25 -0
- backend/routers/__pycache__/admin.cpython-313.pyc +0 -0
- backend/routers/__pycache__/appointments.cpython-313.pyc +0 -0
- backend/routers/__pycache__/auth.cpython-313.pyc +0 -0
- backend/routers/__pycache__/clinics.cpython-313.pyc +0 -0
- backend/routers/__pycache__/pets.cpython-313.pyc +0 -0
- backend/routers/admin.py +104 -0
- backend/routers/appointments.py +171 -0
- backend/routers/auth.py +254 -0
- backend/routers/clinics.py +173 -0
- backend/routers/pets.py +463 -0
- backend/schemas/__pycache__/schemas.cpython-313.pyc +0 -0
- backend/schemas/schemas.py +56 -0
- backend/services/__pycache__/appointment_service.cpython-313.pyc +0 -0
- backend/services/__pycache__/auth_service.cpython-313.pyc +0 -0
- backend/services/__pycache__/clinic_service.cpython-313.pyc +0 -0
- backend/services/__pycache__/pet_service.cpython-313.pyc +0 -0
- backend/services/__pycache__/predictor.cpython-313.pyc +0 -0
- backend/services/__pycache__/router.cpython-313.pyc +0 -0
- backend/services/appointment_service.py +289 -0
- backend/services/auth_service.py +203 -0
- backend/services/clinic_service.py +250 -0
- backend/services/pet_service.py +165 -0
- backend/services/predictor.py +15 -0
- backend/services/router.py +13 -0
- backend/utils/__pycache__/image.cpython-313.pyc +0 -0
- backend/utils/image.py +13 -0
- weights/cat_skin_model.pth +3 -0
- weights/dog_eye_model.pth +3 -0
- weights/dog_eye_model_ResNet_NEW.pth +3 -0
- weights/dog_skin_model.pth +3 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
backend/__pycache__/api_routes.cpython-313.pyc filter=lfs diff=lfs merge=lfs -text
|
backend/__pycache__/api_routes.cpython-313.pyc
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ba4cd72be3aca9ed555b38531910b78128c75c9bd7e9c027ff0ec714c55f5a10
|
| 3 |
+
size 100417
|
backend/__pycache__/config.cpython-313.pyc
ADDED
|
Binary file (1.45 kB). View file
|
|
|
backend/__pycache__/main.cpython-313.pyc
ADDED
|
Binary file (9.69 kB). View file
|
|
|
backend/core/__pycache__/config.cpython-313.pyc
ADDED
|
Binary file (1.94 kB). View file
|
|
|
backend/core/__pycache__/dependencies.cpython-313.pyc
ADDED
|
Binary file (3.42 kB). View file
|
|
|
backend/core/__pycache__/security.cpython-313.pyc
ADDED
|
Binary file (2.21 kB). View file
|
|
|
backend/core/config.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 4 |
+
# Load environment variables from .env file
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
# Base directory points to the backend/ folder
|
| 8 |
+
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 9 |
+
|
| 10 |
+
DOG_SKIN_MODEL = os.path.join(BASE_DIR, "weights/dog_skin_model.pth")
|
| 11 |
+
DOG_EYE_MODEL = os.path.join(BASE_DIR, "weights/dog_eye_model_ResNet_NEW.pth")
|
| 12 |
+
CAT_SKIN_MODEL = os.path.join(BASE_DIR, "weights/cat_skin_model.pth")
|
| 13 |
+
|
| 14 |
+
DEVICE = "cuda" if __import__("torch").cuda.is_available() else "cpu"
|
| 15 |
+
|
| 16 |
+
# Supabase Configurations
|
| 17 |
+
SUPABASE_URL = os.getenv("SUPABASE_URL", "")
|
| 18 |
+
SUPABASE_KEY = os.getenv("SUPABASE_KEY", "")
|
| 19 |
+
SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY", "")
|
| 20 |
+
|
| 21 |
+
# Admin Credentials
|
| 22 |
+
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "admin@petai.com")
|
| 23 |
+
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "AdminPass!234")
|
| 24 |
+
|
| 25 |
+
# LangSmith Configuration (for tracing and debugging)
|
| 26 |
+
LANGCHAIN_TRACING_V2 = os.getenv("LANGCHAIN_TRACING_V2", "false").lower() == "true"
|
| 27 |
+
LANGCHAIN_API_KEY = os.getenv("LANGCHAIN_API_KEY", None)
|
| 28 |
+
LANGCHAIN_ENDPOINT = os.getenv("LANGCHAIN_ENDPOINT", "https://api.smith.langchain.com")
|
| 29 |
+
LANGCHAIN_PROJECT = os.getenv("LANGCHAIN_PROJECT", "Pet_AI")
|
backend/core/dependencies.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Request, Depends, HTTPException, status
|
| 2 |
+
from fastapi.security import HTTPAuthorizationCredentials
|
| 3 |
+
from backend.core.security import verify_supabase_jwt, security_scheme
|
| 4 |
+
from chatbot.supabase_config import supabase
|
| 5 |
+
from typing import Dict, List, Optional
|
| 6 |
+
|
| 7 |
+
async def get_current_user(
|
| 8 |
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_scheme)
|
| 9 |
+
) -> Dict:
|
| 10 |
+
"""
|
| 11 |
+
Dependency to get the currently authenticated user.
|
| 12 |
+
Verifies the JWT and fetches their profile from public.users.
|
| 13 |
+
"""
|
| 14 |
+
if not credentials:
|
| 15 |
+
raise HTTPException(
|
| 16 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 17 |
+
detail="Authentication credentials missing"
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
token = credentials.credentials
|
| 21 |
+
# Verify JWT via Supabase Auth
|
| 22 |
+
user_data = verify_supabase_jwt(token)
|
| 23 |
+
user_id = user_data["id"]
|
| 24 |
+
|
| 25 |
+
# Query the public.users table to get the latest role and status
|
| 26 |
+
try:
|
| 27 |
+
user_response = supabase.table("users").select("*").eq("id", user_id).execute()
|
| 28 |
+
if not user_response.data:
|
| 29 |
+
# Fallback to metadata if user doesn't exist yet (e.g., during signup)
|
| 30 |
+
return user_data
|
| 31 |
+
|
| 32 |
+
db_user = user_response.data[0]
|
| 33 |
+
|
| 34 |
+
# Return merged user data
|
| 35 |
+
return {
|
| 36 |
+
"id": user_id,
|
| 37 |
+
"email": user_data["email"],
|
| 38 |
+
"role": db_user.get("role", "owner"),
|
| 39 |
+
"phone": db_user.get("phone_number", ""),
|
| 40 |
+
"full_name": db_user.get("full_name", ""),
|
| 41 |
+
"avatar_url": db_user.get("avatar_url", "")
|
| 42 |
+
}
|
| 43 |
+
except HTTPException:
|
| 44 |
+
raise
|
| 45 |
+
except Exception as e:
|
| 46 |
+
# Fallback to verified JWT data if DB query fails
|
| 47 |
+
return user_data
|
| 48 |
+
|
| 49 |
+
class RoleChecker:
|
| 50 |
+
def __init__(self, allowed_roles: List[str]):
|
| 51 |
+
self.allowed_roles = allowed_roles
|
| 52 |
+
|
| 53 |
+
def __call__(self, current_user: Dict = Depends(get_current_user)) -> Dict:
|
| 54 |
+
if current_user["role"] not in self.allowed_roles:
|
| 55 |
+
raise HTTPException(
|
| 56 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 57 |
+
detail=f"Access denied. Required role(s): {', '.join(self.allowed_roles)}"
|
| 58 |
+
)
|
| 59 |
+
return current_user
|
| 60 |
+
|
| 61 |
+
# Helper dependency builders
|
| 62 |
+
def require_role(role: str):
|
| 63 |
+
return Depends(RoleChecker([role]))
|
| 64 |
+
|
| 65 |
+
def require_any_role(roles: List[str]):
|
| 66 |
+
return Depends(RoleChecker(roles))
|
backend/core/security.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Request, HTTPException, status
|
| 2 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 3 |
+
from typing import Optional, Dict
|
| 4 |
+
import logging
|
| 5 |
+
from chatbot.supabase_config import supabase
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
security_scheme = HTTPBearer(auto_error=False)
|
| 10 |
+
|
| 11 |
+
def verify_supabase_jwt(token: str) -> Dict:
|
| 12 |
+
"""
|
| 13 |
+
Verify Supabase JWT token using the Supabase auth client.
|
| 14 |
+
Returns the user data dict if valid.
|
| 15 |
+
Raises HTTPException 401 if invalid/expired.
|
| 16 |
+
"""
|
| 17 |
+
try:
|
| 18 |
+
# get_user verifies the token against the Supabase Auth server
|
| 19 |
+
response = supabase.auth.get_user(token)
|
| 20 |
+
if not response or not response.user:
|
| 21 |
+
raise HTTPException(
|
| 22 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 23 |
+
detail="Invalid or expired authentication token"
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
user = response.user
|
| 27 |
+
# Convert user object to dictionary
|
| 28 |
+
user_data = {
|
| 29 |
+
"id": user.id,
|
| 30 |
+
"email": user.email,
|
| 31 |
+
"role": user.user_metadata.get("role", "owner") if user.user_metadata else "owner",
|
| 32 |
+
"phone": user.user_metadata.get("phone", "") if user.user_metadata else "",
|
| 33 |
+
"is_active": user.user_metadata.get("is_active", True) if user.user_metadata else True
|
| 34 |
+
}
|
| 35 |
+
return user_data
|
| 36 |
+
except Exception as e:
|
| 37 |
+
logger.error(f"JWT verification failed: {str(e)}")
|
| 38 |
+
raise HTTPException(
|
| 39 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 40 |
+
detail=f"Authentication failed: {str(e)}"
|
| 41 |
+
)
|
backend/main.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
import logging
|
| 4 |
+
import tempfile
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
# Import models & services with updated backend package prefix
|
| 8 |
+
from backend.models import dog_skin, dog_eye, cat_skin
|
| 9 |
+
from backend.utils.image import preprocess
|
| 10 |
+
from backend.services.router import route_prediction
|
| 11 |
+
|
| 12 |
+
# Import refactored routers
|
| 13 |
+
from backend.routers.auth import router as auth_router
|
| 14 |
+
from backend.routers.pets import router as pets_router
|
| 15 |
+
from backend.routers.appointments import router as appointments_router
|
| 16 |
+
from backend.routers.clinics import router as clinics_router
|
| 17 |
+
from backend.routers.admin import router as admin_router
|
| 18 |
+
|
| 19 |
+
from chatbot.langsmith_config import setup_langsmith
|
| 20 |
+
from chatbot.tools import _analyze_pet_image_impl
|
| 21 |
+
from chatbot.rag.agentic_rag import query_agentic_rag
|
| 22 |
+
|
| 23 |
+
# Configure logging
|
| 24 |
+
logging.basicConfig(level=logging.INFO)
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
app = FastAPI(title="Pet PULSE Disease Detection API")
|
| 28 |
+
|
| 29 |
+
# Enable CORS for frontend
|
| 30 |
+
app.add_middleware(
|
| 31 |
+
CORSMiddleware,
|
| 32 |
+
allow_origins=[
|
| 33 |
+
"http://localhost:3000",
|
| 34 |
+
"http://localhost:3001",
|
| 35 |
+
"http://127.0.0.1:3000",
|
| 36 |
+
"http://127.0.0.1:3001",
|
| 37 |
+
"*",
|
| 38 |
+
],
|
| 39 |
+
allow_credentials=True,
|
| 40 |
+
allow_methods=["*"],
|
| 41 |
+
allow_headers=["*"],
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# Include refactored routers under the /api prefix
|
| 45 |
+
app.include_router(auth_router, prefix="/api")
|
| 46 |
+
app.include_router(pets_router, prefix="/api")
|
| 47 |
+
app.include_router(appointments_router, prefix="/api")
|
| 48 |
+
app.include_router(clinics_router, prefix="/api")
|
| 49 |
+
app.include_router(admin_router, prefix="/api")
|
| 50 |
+
|
| 51 |
+
# 🏠 Root endpoint
|
| 52 |
+
@app.get("/")
|
| 53 |
+
async def root():
|
| 54 |
+
"""Root endpoint with API information"""
|
| 55 |
+
return {
|
| 56 |
+
"name": "Pet PULSE Disease Detection API",
|
| 57 |
+
"version": "1.0.0",
|
| 58 |
+
"docs": "http://localhost:8000/docs",
|
| 59 |
+
"status": "running",
|
| 60 |
+
"endpoints": {
|
| 61 |
+
"prediction": "/predict",
|
| 62 |
+
"image_analysis": "/analyze-image",
|
| 63 |
+
"pets": "/api/pets",
|
| 64 |
+
"clinics": "/api/clinics"
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
# 🏥 Health check endpoint
|
| 69 |
+
@app.get("/health")
|
| 70 |
+
async def health():
|
| 71 |
+
"""Health check endpoint"""
|
| 72 |
+
return {
|
| 73 |
+
"status": "healthy",
|
| 74 |
+
"service": "Pet PULSE API",
|
| 75 |
+
"models_loaded": {
|
| 76 |
+
"dog_skin": hasattr(app.state, 'dog_skin') and app.state.dog_skin is not None,
|
| 77 |
+
"dog_eye": hasattr(app.state, 'dog_eye') and app.state.dog_eye is not None,
|
| 78 |
+
"cat_skin": hasattr(app.state, 'cat_skin') and app.state.cat_skin is not None
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
# 🔥 Load models ONCE on startup
|
| 83 |
+
@app.on_event("startup")
|
| 84 |
+
def load_models():
|
| 85 |
+
try:
|
| 86 |
+
# Initialize LangSmith tracing (optional)
|
| 87 |
+
setup_langsmith()
|
| 88 |
+
logger.info("LangSmith tracing initialized")
|
| 89 |
+
except Exception as e:
|
| 90 |
+
logger.warning(f"LangSmith initialization failed (optional): {e}")
|
| 91 |
+
|
| 92 |
+
try:
|
| 93 |
+
app.state.dog_skin = dog_skin.load_model()
|
| 94 |
+
logger.info("Dog skin model loaded")
|
| 95 |
+
except Exception as e:
|
| 96 |
+
logger.warning(f"Dog skin model loading failed: {e}")
|
| 97 |
+
app.state.dog_skin = None
|
| 98 |
+
|
| 99 |
+
try:
|
| 100 |
+
app.state.dog_eye = dog_eye.load_model()
|
| 101 |
+
logger.info("Dog eye model loaded")
|
| 102 |
+
except Exception as e:
|
| 103 |
+
logger.warning(f"Dog eye model loading failed: {e}")
|
| 104 |
+
app.state.dog_eye = None
|
| 105 |
+
|
| 106 |
+
try:
|
| 107 |
+
app.state.cat_skin = cat_skin.load_model()
|
| 108 |
+
logger.info("Cat skin model loaded")
|
| 109 |
+
except Exception as e:
|
| 110 |
+
logger.warning(f"Cat skin model loading failed: {e}")
|
| 111 |
+
app.state.cat_skin = None
|
| 112 |
+
|
| 113 |
+
logger.info("✅ Backend startup complete")
|
| 114 |
+
|
| 115 |
+
# 📸 Prediction endpoint
|
| 116 |
+
@app.post("/predict")
|
| 117 |
+
async def predict(
|
| 118 |
+
file: UploadFile = File(...),
|
| 119 |
+
animal: str = Form(...),
|
| 120 |
+
disease_type: str = Form(...)
|
| 121 |
+
):
|
| 122 |
+
image = preprocess(file.file)
|
| 123 |
+
result = route_prediction(app, animal, disease_type, image)
|
| 124 |
+
return result
|
| 125 |
+
|
| 126 |
+
# 🔍 Analyze image endpoint (for chatbot integration)
|
| 127 |
+
@app.post("/analyze-image")
|
| 128 |
+
async def analyze_image(
|
| 129 |
+
file: UploadFile = File(...),
|
| 130 |
+
disease_type: str = Form(...),
|
| 131 |
+
animal: str = Form("dog"),
|
| 132 |
+
user_id: str = Form("demo")
|
| 133 |
+
):
|
| 134 |
+
"""
|
| 135 |
+
Analyze pet image for disease detection.
|
| 136 |
+
Used by chatbot for skin/eye disease diagnosis.
|
| 137 |
+
"""
|
| 138 |
+
image = preprocess(file.file)
|
| 139 |
+
result = route_prediction(app, animal, disease_type, image)
|
| 140 |
+
return result
|
| 141 |
+
|
| 142 |
+
# 📸 Chatbot image upload endpoint
|
| 143 |
+
@app.post("/api/chat/upload-image")
|
| 144 |
+
async def upload_image(
|
| 145 |
+
session_id: str = Form(...),
|
| 146 |
+
disease_type: str = Form(...),
|
| 147 |
+
file: UploadFile = File(...)
|
| 148 |
+
):
|
| 149 |
+
"""
|
| 150 |
+
Upload and analyze a pet image for chatbot.
|
| 151 |
+
"""
|
| 152 |
+
try:
|
| 153 |
+
animal = "dog" # Default - in production, retrieve from session
|
| 154 |
+
|
| 155 |
+
if disease_type not in ["skin", "eye"]:
|
| 156 |
+
raise HTTPException(status_code=400, detail="disease_type must be 'skin' or 'eye'")
|
| 157 |
+
|
| 158 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
|
| 159 |
+
content = await file.read()
|
| 160 |
+
tmp.write(content)
|
| 161 |
+
tmp_path = tmp.name
|
| 162 |
+
|
| 163 |
+
try:
|
| 164 |
+
tool_result = _analyze_pet_image_impl(
|
| 165 |
+
image_path=tmp_path,
|
| 166 |
+
animal=animal,
|
| 167 |
+
disease_type=disease_type
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
if isinstance(tool_result, dict) and "error" in tool_result:
|
| 171 |
+
raise HTTPException(status_code=400, detail=tool_result['error'])
|
| 172 |
+
|
| 173 |
+
disease_class = tool_result.get('class', 'Unknown')
|
| 174 |
+
confidence = tool_result.get('confidence', 0.0)
|
| 175 |
+
|
| 176 |
+
explanation_query = f"""The computer vision model detected {disease_class} (confidence: {confidence:.1%}) from a {animal}'s {disease_type} image.
|
| 177 |
+
|
| 178 |
+
Provide a detailed veterinary explanation covering:
|
| 179 |
+
1. What is {disease_class}?
|
| 180 |
+
2. Common causes and risk factors for this condition
|
| 181 |
+
3. Treatment options and recommendations
|
| 182 |
+
4. When to seek professional veterinary care
|
| 183 |
+
5. Prevention and management tips
|
| 184 |
+
|
| 185 |
+
Be thorough and informative. Use formatting with headers and bullet points for clarity."""
|
| 186 |
+
|
| 187 |
+
explanation_text = query_agentic_rag(
|
| 188 |
+
question=explanation_query,
|
| 189 |
+
chat_history="",
|
| 190 |
+
force_rag=True
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
logger.info(f"Image analyzed for {disease_type}: {disease_class}")
|
| 194 |
+
|
| 195 |
+
return {
|
| 196 |
+
"session_id": session_id,
|
| 197 |
+
"disease_class": disease_class,
|
| 198 |
+
"confidence": confidence,
|
| 199 |
+
"explanation": explanation_text
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
finally:
|
| 203 |
+
if os.path.exists(tmp_path):
|
| 204 |
+
os.remove(tmp_path)
|
| 205 |
+
|
| 206 |
+
except HTTPException:
|
| 207 |
+
raise
|
| 208 |
+
except Exception as e:
|
| 209 |
+
logger.error(f"Error uploading image: {e}")
|
| 210 |
+
raise HTTPException(status_code=500, detail=str(e))
|
backend/models/__pycache__/cat_skin.cpython-313.pyc
ADDED
|
Binary file (1.12 kB). View file
|
|
|
backend/models/__pycache__/dog_eye.cpython-313.pyc
ADDED
|
Binary file (1.15 kB). View file
|
|
|
backend/models/__pycache__/dog_skin.cpython-313.pyc
ADDED
|
Binary file (1.16 kB). View file
|
|
|
backend/models/cat_skin.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
from torchvision import models
|
| 4 |
+
from backend.core.config import CAT_SKIN_MODEL, DEVICE
|
| 5 |
+
|
| 6 |
+
class_names = ["Flea_Allergy", "Health", "Ringworm", "Scabies"]
|
| 7 |
+
|
| 8 |
+
def load_model():
|
| 9 |
+
model = models.resnet50(pretrained=False)
|
| 10 |
+
model.fc = nn.Linear(model.fc.in_features, len(class_names))
|
| 11 |
+
|
| 12 |
+
checkpoint = torch.load(CAT_SKIN_MODEL, map_location=DEVICE)
|
| 13 |
+
model.load_state_dict(checkpoint['model_state_dict'])
|
| 14 |
+
|
| 15 |
+
model.to(DEVICE)
|
| 16 |
+
model.eval()
|
| 17 |
+
|
| 18 |
+
return model, class_names
|
backend/models/dog_eye.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
from torchvision import models
|
| 4 |
+
from backend.core.config import DOG_EYE_MODEL, DEVICE
|
| 5 |
+
|
| 6 |
+
class_names = [
|
| 7 |
+
"Pigmented keratitis",
|
| 8 |
+
"blepharitis",
|
| 9 |
+
"entropion",
|
| 10 |
+
"eyelid_tumor",
|
| 11 |
+
"mastopathy"
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
def load_model():
|
| 15 |
+
model = models.resnet50(pretrained=False)
|
| 16 |
+
model.fc = nn.Linear(model.fc.in_features, len(class_names))
|
| 17 |
+
|
| 18 |
+
checkpoint = torch.load(DOG_EYE_MODEL, map_location=DEVICE)
|
| 19 |
+
model.load_state_dict(checkpoint['model_state_dict'])
|
| 20 |
+
|
| 21 |
+
model.to(DEVICE)
|
| 22 |
+
model.eval()
|
| 23 |
+
|
| 24 |
+
return model, class_names
|
backend/models/dog_skin.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
from torchvision import models
|
| 4 |
+
from backend.core.config import DOG_SKIN_MODEL, DEVICE
|
| 5 |
+
|
| 6 |
+
class_names = [
|
| 7 |
+
"Dermatitis",
|
| 8 |
+
"Fungal_infections",
|
| 9 |
+
"Healthy",
|
| 10 |
+
"Hypersensitivity",
|
| 11 |
+
"demodicosis",
|
| 12 |
+
"ringworm"
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
def load_model():
|
| 16 |
+
model = models.resnet50(pretrained=False)
|
| 17 |
+
model.fc = nn.Linear(model.fc.in_features, len(class_names))
|
| 18 |
+
|
| 19 |
+
checkpoint = torch.load(DOG_SKIN_MODEL, map_location=DEVICE)
|
| 20 |
+
model.load_state_dict(checkpoint['model_state_dict'])
|
| 21 |
+
|
| 22 |
+
model.to(DEVICE)
|
| 23 |
+
model.eval()
|
| 24 |
+
|
| 25 |
+
return model, class_names
|
backend/routers/__pycache__/admin.cpython-313.pyc
ADDED
|
Binary file (7.26 kB). View file
|
|
|
backend/routers/__pycache__/appointments.cpython-313.pyc
ADDED
|
Binary file (8.76 kB). View file
|
|
|
backend/routers/__pycache__/auth.cpython-313.pyc
ADDED
|
Binary file (13.7 kB). View file
|
|
|
backend/routers/__pycache__/clinics.cpython-313.pyc
ADDED
|
Binary file (9.43 kB). View file
|
|
|
backend/routers/__pycache__/pets.cpython-313.pyc
ADDED
|
Binary file (23.2 kB). View file
|
|
|
backend/routers/admin.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 2 |
+
from typing import Optional
|
| 3 |
+
import logging
|
| 4 |
+
from backend.core.dependencies import require_role
|
| 5 |
+
from backend.services.clinic_service import ClinicService
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
# Protect the entire router with require_role("admin")
|
| 10 |
+
router = APIRouter(prefix="/admin", tags=["Admin Operations"], dependencies=[require_role("admin")])
|
| 11 |
+
|
| 12 |
+
@router.get("/clinics/pending")
|
| 13 |
+
async def get_pending_clinics():
|
| 14 |
+
"""List all pending clinics (unverified and not rejected)"""
|
| 15 |
+
logger.info("Admin: Fetching pending clinics")
|
| 16 |
+
result = ClinicService.get_pending_clinics()
|
| 17 |
+
if not result["success"]:
|
| 18 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 19 |
+
return result
|
| 20 |
+
|
| 21 |
+
@router.get("/clinics")
|
| 22 |
+
async def get_all_clinics():
|
| 23 |
+
"""List all clinics with their derived verification status"""
|
| 24 |
+
logger.info("Admin: Fetching all clinics")
|
| 25 |
+
result = ClinicService.get_public_clinics() # We can fetch all or public
|
| 26 |
+
# Let's query all clinics from Supabase directly for admin
|
| 27 |
+
from chatbot.supabase_config import supabase
|
| 28 |
+
try:
|
| 29 |
+
resp = supabase.table("clinics").select("*").order("created_at", desc=True).execute()
|
| 30 |
+
clinics = [ClinicService._parse_clinic_status(c) for c in (resp.data or [])]
|
| 31 |
+
return {"success": True, "clinics": clinics, "count": len(clinics)}
|
| 32 |
+
except Exception as e:
|
| 33 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 34 |
+
|
| 35 |
+
@router.get("/stats")
|
| 36 |
+
async def get_admin_stats():
|
| 37 |
+
"""Return admin dashboard counts and recent clinic activity"""
|
| 38 |
+
logger.info("Admin: Fetching dashboard stats")
|
| 39 |
+
|
| 40 |
+
from chatbot.supabase_config import supabase
|
| 41 |
+
try:
|
| 42 |
+
all_resp = supabase.table("clinics").select("id", "is_verified", "clinic_name", "created_at", "description").execute()
|
| 43 |
+
clinics = [ClinicService._parse_clinic_status(c) for c in (all_resp.data or [])]
|
| 44 |
+
|
| 45 |
+
total_clinics = len(clinics)
|
| 46 |
+
approved_clinics = sum(1 for c in clinics if c.get("verification_status") == "approved")
|
| 47 |
+
pending_clinics = sum(1 for c in clinics if c.get("verification_status") == "pending")
|
| 48 |
+
rejected_clinics = sum(1 for c in clinics if c.get("verification_status") == "rejected")
|
| 49 |
+
|
| 50 |
+
recent = sorted(clinics, key=lambda c: c.get("created_at") or "", reverse=True)[:3]
|
| 51 |
+
recent_verifications = []
|
| 52 |
+
for clinic in recent:
|
| 53 |
+
recent_verifications.append({
|
| 54 |
+
"clinic": clinic.get("clinic_name", "Unknown Clinic"),
|
| 55 |
+
"action": clinic.get("verification_status", "pending").title(),
|
| 56 |
+
"time": clinic.get("created_at"),
|
| 57 |
+
"badge": (
|
| 58 |
+
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
| 59 |
+
if clinic.get("verification_status") == "approved"
|
| 60 |
+
else "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
| 61 |
+
if clinic.get("verification_status") == "rejected"
|
| 62 |
+
else "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
| 63 |
+
),
|
| 64 |
+
})
|
| 65 |
+
|
| 66 |
+
return {
|
| 67 |
+
"success": True,
|
| 68 |
+
"stats": {
|
| 69 |
+
"total_clinics": total_clinics,
|
| 70 |
+
"pending_verifications": pending_clinics,
|
| 71 |
+
"approved_clinics": approved_clinics,
|
| 72 |
+
"rejected": rejected_clinics,
|
| 73 |
+
},
|
| 74 |
+
"recent_verifications": recent_verifications,
|
| 75 |
+
}
|
| 76 |
+
except Exception as e:
|
| 77 |
+
logger.error(f"Error fetching admin stats: {str(e)}")
|
| 78 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 79 |
+
|
| 80 |
+
@router.post("/clinics/{clinic_id}/approve")
|
| 81 |
+
async def approve_clinic(clinic_id: str):
|
| 82 |
+
"""Approve a clinic (verify it and activate its login profile)"""
|
| 83 |
+
logger.info(f"Admin: Approving clinic {clinic_id}")
|
| 84 |
+
result = ClinicService.approve_clinic(clinic_id=clinic_id)
|
| 85 |
+
if not result["success"]:
|
| 86 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 87 |
+
return result
|
| 88 |
+
|
| 89 |
+
@router.post("/clinics/{clinic_id}/reject")
|
| 90 |
+
async def reject_clinic(clinic_id: str, request: Request):
|
| 91 |
+
"""Reject a clinic registration with an optional reason"""
|
| 92 |
+
logger.info(f"Admin: Rejecting clinic {clinic_id}")
|
| 93 |
+
|
| 94 |
+
# Read optional reason from request body
|
| 95 |
+
try:
|
| 96 |
+
body = await request.json()
|
| 97 |
+
except Exception:
|
| 98 |
+
body = {}
|
| 99 |
+
reason = (body or {}).get("reason")
|
| 100 |
+
|
| 101 |
+
result = ClinicService.reject_clinic(clinic_id=clinic_id, reason=reason)
|
| 102 |
+
if not result["success"]:
|
| 103 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 104 |
+
return result
|
backend/routers/appointments.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Form
|
| 2 |
+
from typing import Optional
|
| 3 |
+
import logging
|
| 4 |
+
from backend.core.dependencies import get_current_user
|
| 5 |
+
from backend.schemas.schemas import CreateAppointmentRequest, CreateReviewRequest
|
| 6 |
+
from backend.services.appointment_service import AppointmentService
|
| 7 |
+
from chatbot.supabase_config import supabase
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
router = APIRouter(tags=["Appointments & Reviews"])
|
| 12 |
+
|
| 13 |
+
@router.post("/appointments")
|
| 14 |
+
async def create_appointment(
|
| 15 |
+
request: CreateAppointmentRequest,
|
| 16 |
+
current_user: dict = Depends(get_current_user)
|
| 17 |
+
):
|
| 18 |
+
"""Create a new appointment"""
|
| 19 |
+
logger.info(f"Creating appointment for pet {request.pet_id} by user: {current_user['id']}")
|
| 20 |
+
|
| 21 |
+
# Check authorization: If owner, must be the owner of the pet
|
| 22 |
+
if current_user["role"] == "owner":
|
| 23 |
+
pet_resp = supabase.table("pets").select("user_id").eq("id", request.pet_id).execute()
|
| 24 |
+
if not pet_resp.data or pet_resp.data[0]["user_id"] != current_user["id"]:
|
| 25 |
+
raise HTTPException(status_code=403, detail="You can only schedule appointments for your own pets")
|
| 26 |
+
|
| 27 |
+
result = AppointmentService.create_appointment(
|
| 28 |
+
pet_id=request.pet_id,
|
| 29 |
+
clinic_id=request.clinic_id,
|
| 30 |
+
owner_id=current_user["id"] if current_user["role"] == "owner" else request.owner_id,
|
| 31 |
+
appointment_date=request.appointment_date,
|
| 32 |
+
appointment_time=request.appointment_time,
|
| 33 |
+
reason=request.reason,
|
| 34 |
+
notes=request.notes,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
if not result["success"]:
|
| 38 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 39 |
+
|
| 40 |
+
return result
|
| 41 |
+
|
| 42 |
+
@router.get("/appointments/owner")
|
| 43 |
+
async def get_owner_appointments(
|
| 44 |
+
owner_id: Optional[str] = None,
|
| 45 |
+
current_user: dict = Depends(get_current_user)
|
| 46 |
+
):
|
| 47 |
+
"""Get appointments for a pet owner"""
|
| 48 |
+
target_owner_id = owner_id or current_user["id"]
|
| 49 |
+
|
| 50 |
+
# Check authorization: Owners can only view their own appointments
|
| 51 |
+
if current_user["role"] == "owner" and target_owner_id != current_user["id"]:
|
| 52 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 53 |
+
|
| 54 |
+
result = AppointmentService.get_owner_appointments(owner_id=target_owner_id)
|
| 55 |
+
if not result["success"]:
|
| 56 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 57 |
+
|
| 58 |
+
return result
|
| 59 |
+
|
| 60 |
+
@router.get("/appointments/clinic")
|
| 61 |
+
async def get_clinic_appointments(
|
| 62 |
+
clinic_id: str,
|
| 63 |
+
current_user: dict = Depends(get_current_user)
|
| 64 |
+
):
|
| 65 |
+
"""Get appointments for a clinic"""
|
| 66 |
+
# Check authorization: Clinics can only view their own appointments
|
| 67 |
+
if current_user["role"] == "clinic":
|
| 68 |
+
# Get the clinic's ID from user_id
|
| 69 |
+
clinic_resp = supabase.table("clinics").select("id").eq("user_id", current_user["id"]).execute()
|
| 70 |
+
if not clinic_resp.data or clinic_resp.data[0]["id"] != clinic_id:
|
| 71 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 72 |
+
|
| 73 |
+
result = AppointmentService.get_clinic_appointments(clinic_id=clinic_id)
|
| 74 |
+
if not result["success"]:
|
| 75 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 76 |
+
|
| 77 |
+
return result
|
| 78 |
+
|
| 79 |
+
@router.get("/appointments/pet")
|
| 80 |
+
async def get_pet_appointments(
|
| 81 |
+
pet_id: str,
|
| 82 |
+
current_user: dict = Depends(get_current_user)
|
| 83 |
+
):
|
| 84 |
+
"""Get all appointments for a pet"""
|
| 85 |
+
# Check authorization: Owners can only view appointments for their own pets
|
| 86 |
+
if current_user["role"] == "owner":
|
| 87 |
+
pet_resp = supabase.table("pets").select("user_id").eq("id", pet_id).execute()
|
| 88 |
+
if not pet_resp.data or pet_resp.data[0]["user_id"] != current_user["id"]:
|
| 89 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 90 |
+
|
| 91 |
+
result = AppointmentService.get_pet_appointments(pet_id=pet_id)
|
| 92 |
+
if not result["success"]:
|
| 93 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 94 |
+
|
| 95 |
+
return result
|
| 96 |
+
|
| 97 |
+
@router.post("/appointments/{appointment_id}/status")
|
| 98 |
+
async def update_appointment_status(
|
| 99 |
+
appointment_id: str,
|
| 100 |
+
status: str = Form(...),
|
| 101 |
+
current_user: dict = Depends(get_current_user)
|
| 102 |
+
):
|
| 103 |
+
"""Update status of an appointment"""
|
| 104 |
+
# Validate status
|
| 105 |
+
valid_statuses = {"scheduled", "completed", "cancelled", "in_progress"}
|
| 106 |
+
if status not in valid_statuses:
|
| 107 |
+
raise HTTPException(status_code=400, detail="Invalid status value")
|
| 108 |
+
|
| 109 |
+
# Check authorization
|
| 110 |
+
appt_resp = supabase.table("appointments").select("owner_id", "clinic_id").eq("id", appointment_id).execute()
|
| 111 |
+
if not appt_resp.data:
|
| 112 |
+
raise HTTPException(status_code=404, detail="Appointment not found")
|
| 113 |
+
|
| 114 |
+
appt = appt_resp.data[0]
|
| 115 |
+
|
| 116 |
+
if current_user["role"] == "owner":
|
| 117 |
+
if appt["owner_id"] != current_user["id"]:
|
| 118 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 119 |
+
if status != "cancelled":
|
| 120 |
+
raise HTTPException(status_code=403, detail="Owners can only cancel appointments")
|
| 121 |
+
elif current_user["role"] == "clinic":
|
| 122 |
+
clinic_resp = supabase.table("clinics").select("id").eq("user_id", current_user["id"]).execute()
|
| 123 |
+
if not clinic_resp.data or clinic_resp.data[0]["id"] != appt["clinic_id"]:
|
| 124 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 125 |
+
|
| 126 |
+
result = AppointmentService.update_appointment_status(appointment_id=appointment_id, status=status)
|
| 127 |
+
if not result["success"]:
|
| 128 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 129 |
+
|
| 130 |
+
return result
|
| 131 |
+
|
| 132 |
+
@router.post("/reviews")
|
| 133 |
+
async def create_review(
|
| 134 |
+
request: CreateReviewRequest,
|
| 135 |
+
current_user: dict = Depends(get_current_user)
|
| 136 |
+
):
|
| 137 |
+
"""Create a clinic review for a completed appointment"""
|
| 138 |
+
if current_user["role"] != "owner":
|
| 139 |
+
raise HTTPException(status_code=403, detail="Only pet owners can review clinics")
|
| 140 |
+
|
| 141 |
+
# Check authorization: Must be the owner of the appointment
|
| 142 |
+
appt_resp = supabase.table("appointments").select("owner_id").eq("id", request.appointment_id).execute()
|
| 143 |
+
if not appt_resp.data:
|
| 144 |
+
raise HTTPException(status_code=404, detail="Appointment not found")
|
| 145 |
+
|
| 146 |
+
if appt_resp.data[0]["owner_id"] != current_user["id"]:
|
| 147 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 148 |
+
|
| 149 |
+
result = AppointmentService.create_review(
|
| 150 |
+
appointment_id=request.appointment_id,
|
| 151 |
+
rating=request.rating,
|
| 152 |
+
treatment=request.treatment,
|
| 153 |
+
comment=request.comment
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
if not result["success"]:
|
| 157 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 158 |
+
|
| 159 |
+
return result
|
| 160 |
+
|
| 161 |
+
@router.get("/reviews/clinic")
|
| 162 |
+
async def get_reviews_for_clinic(
|
| 163 |
+
clinic_id: str,
|
| 164 |
+
current_user: dict = Depends(get_current_user)
|
| 165 |
+
):
|
| 166 |
+
"""Get all reviews for a clinic"""
|
| 167 |
+
result = AppointmentService.get_reviews_for_clinic(clinic_id=clinic_id)
|
| 168 |
+
if not result["success"]:
|
| 169 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 170 |
+
|
| 171 |
+
return result
|
backend/routers/auth.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
|
| 2 |
+
from typing import Optional
|
| 3 |
+
import logging
|
| 4 |
+
import time
|
| 5 |
+
import os
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from backend.core.dependencies import get_current_user
|
| 8 |
+
from backend.schemas.schemas import RegisterOwnerRequest, RegisterClinicRequest, NotificationReadRequest
|
| 9 |
+
from backend.services.auth_service import AuthService
|
| 10 |
+
from chatbot.supabase_config import supabase, SupabaseStorage
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
router = APIRouter(prefix="/auth", tags=["Authentication & Notifications"])
|
| 15 |
+
|
| 16 |
+
@router.post("/register/owner")
|
| 17 |
+
async def register_owner(
|
| 18 |
+
request: RegisterOwnerRequest,
|
| 19 |
+
current_user: dict = Depends(get_current_user)
|
| 20 |
+
):
|
| 21 |
+
"""Complete registration of a new pet owner profile"""
|
| 22 |
+
logger.info(f"Registering pet owner profile for user: {current_user['id']}")
|
| 23 |
+
|
| 24 |
+
result = AuthService.register_pet_owner(
|
| 25 |
+
user_id=current_user["id"],
|
| 26 |
+
email=current_user["email"],
|
| 27 |
+
first_name=request.first_name,
|
| 28 |
+
last_name=request.last_name,
|
| 29 |
+
phone=request.phone,
|
| 30 |
+
address=request.address,
|
| 31 |
+
state=request.state,
|
| 32 |
+
zip_code=request.zip_code,
|
| 33 |
+
country=request.country,
|
| 34 |
+
bio=request.bio,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
if not result["success"]:
|
| 38 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 39 |
+
|
| 40 |
+
return result
|
| 41 |
+
|
| 42 |
+
@router.post("/register/clinic")
|
| 43 |
+
async def register_clinic(
|
| 44 |
+
clinic_name: str = Form(...),
|
| 45 |
+
phone: str = Form(...),
|
| 46 |
+
address: str = Form(...),
|
| 47 |
+
city: Optional[str] = Form(None),
|
| 48 |
+
state: Optional[str] = Form(None),
|
| 49 |
+
zip_code: Optional[str] = Form(None),
|
| 50 |
+
country: Optional[str] = Form(None),
|
| 51 |
+
website: Optional[str] = Form(None),
|
| 52 |
+
opening_hours: Optional[str] = Form(None),
|
| 53 |
+
description: Optional[str] = Form(None),
|
| 54 |
+
clinic_photo: Optional[UploadFile] = File(None),
|
| 55 |
+
clinic_license: Optional[UploadFile] = File(None),
|
| 56 |
+
latitude: Optional[float] = Form(None),
|
| 57 |
+
longitude: Optional[float] = Form(None),
|
| 58 |
+
current_user: dict = Depends(get_current_user)
|
| 59 |
+
):
|
| 60 |
+
"""Complete registration of a new clinic profile"""
|
| 61 |
+
logger.info(f"Registering clinic profile for user: {current_user['id']}")
|
| 62 |
+
|
| 63 |
+
clinic_logo_url = None
|
| 64 |
+
license_document_url = None
|
| 65 |
+
|
| 66 |
+
# Handle image upload
|
| 67 |
+
if clinic_photo and clinic_photo.filename:
|
| 68 |
+
if not clinic_photo.content_type or not clinic_photo.content_type.startswith("image/"):
|
| 69 |
+
raise HTTPException(status_code=400, detail="Scale photo must be an image")
|
| 70 |
+
|
| 71 |
+
photo_bytes = await clinic_photo.read()
|
| 72 |
+
photo_ext = os.path.splitext(clinic_photo.filename)[1] or ".jpg"
|
| 73 |
+
photo_name = f"{clinic_name.replace(' ', '_')}-{int(time.time())}{photo_ext}"
|
| 74 |
+
photo_path = SupabaseStorage.upload_clinic_image(
|
| 75 |
+
user_id=current_user["id"],
|
| 76 |
+
file_data=photo_bytes,
|
| 77 |
+
filename=photo_name,
|
| 78 |
+
content_type=clinic_photo.content_type or "image/jpeg"
|
| 79 |
+
)
|
| 80 |
+
clinic_logo_url = supabase.storage.from_("clinic-images").get_public_url(photo_path)
|
| 81 |
+
|
| 82 |
+
# Handle license upload
|
| 83 |
+
if clinic_license and clinic_license.filename:
|
| 84 |
+
valid_types = ["image/jpeg", "image/png", "image/jpg", "application/pdf"]
|
| 85 |
+
if clinic_license.content_type and clinic_license.content_type not in valid_types:
|
| 86 |
+
raise HTTPException(status_code=400, detail="License must be a PDF or image (JPG/PNG)")
|
| 87 |
+
|
| 88 |
+
license_bytes = await clinic_license.read()
|
| 89 |
+
license_ext = os.path.splitext(clinic_license.filename)[1] or ".pdf"
|
| 90 |
+
license_name = f"license-{clinic_name.replace(' ', '_')}-{int(time.time())}{license_ext}"
|
| 91 |
+
license_path = SupabaseStorage.upload_clinic_document(
|
| 92 |
+
user_id=current_user["id"],
|
| 93 |
+
file_data=license_bytes,
|
| 94 |
+
filename=license_name,
|
| 95 |
+
content_type=clinic_license.content_type or "application/pdf"
|
| 96 |
+
)
|
| 97 |
+
license_document_url = supabase.storage.from_("clinic-documents").get_public_url(license_path)
|
| 98 |
+
|
| 99 |
+
result = AuthService.register_clinic(
|
| 100 |
+
user_id=current_user["id"],
|
| 101 |
+
email=current_user["email"],
|
| 102 |
+
clinic_name=clinic_name,
|
| 103 |
+
phone=phone,
|
| 104 |
+
address=address,
|
| 105 |
+
city=city,
|
| 106 |
+
state=state,
|
| 107 |
+
zip_code=zip_code,
|
| 108 |
+
country=country,
|
| 109 |
+
website=website,
|
| 110 |
+
opening_hours=opening_hours,
|
| 111 |
+
description=description,
|
| 112 |
+
clinic_logo_url=clinic_logo_url,
|
| 113 |
+
license_document_url=license_document_url,
|
| 114 |
+
latitude=latitude,
|
| 115 |
+
longitude=longitude,
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
if not result["success"]:
|
| 119 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 120 |
+
|
| 121 |
+
return result
|
| 122 |
+
|
| 123 |
+
@router.get("/profile")
|
| 124 |
+
async def get_profile(current_user: dict = Depends(get_current_user)):
|
| 125 |
+
"""Get the current authenticated user's profile"""
|
| 126 |
+
result = AuthService.get_user_profile(current_user["id"])
|
| 127 |
+
if not result["success"]:
|
| 128 |
+
raise HTTPException(status_code=404, detail=result.get("error"))
|
| 129 |
+
return result
|
| 130 |
+
|
| 131 |
+
@router.put("/profile")
|
| 132 |
+
async def update_profile(
|
| 133 |
+
full_name: Optional[str] = Form(None),
|
| 134 |
+
phone: Optional[str] = Form(None),
|
| 135 |
+
address: Optional[str] = Form(None),
|
| 136 |
+
state: Optional[str] = Form(None),
|
| 137 |
+
zip_code: Optional[str] = Form(None),
|
| 138 |
+
country: Optional[str] = Form(None),
|
| 139 |
+
bio: Optional[str] = Form(None),
|
| 140 |
+
emergency_contact_name: Optional[str] = Form(None),
|
| 141 |
+
emergency_contact_phone: Optional[str] = Form(None),
|
| 142 |
+
photo: Optional[UploadFile] = File(None),
|
| 143 |
+
latitude: Optional[float] = Form(None),
|
| 144 |
+
longitude: Optional[float] = Form(None),
|
| 145 |
+
current_user: dict = Depends(get_current_user)
|
| 146 |
+
):
|
| 147 |
+
"""Update pet owner profile details"""
|
| 148 |
+
profile_image_url = None
|
| 149 |
+
|
| 150 |
+
if photo and photo.filename:
|
| 151 |
+
if not photo.content_type or not photo.content_type.startswith("image/"):
|
| 152 |
+
raise HTTPException(status_code=400, detail="Profile photo must be an image")
|
| 153 |
+
|
| 154 |
+
photo_bytes = await photo.read()
|
| 155 |
+
photo_ext = os.path.splitext(photo.filename)[1] or ".jpg"
|
| 156 |
+
photo_name = f"avatar-{int(time.time())}{photo_ext}"
|
| 157 |
+
photo_path = SupabaseStorage.upload_user_avatar(
|
| 158 |
+
user_id=current_user["id"],
|
| 159 |
+
file_data=photo_bytes,
|
| 160 |
+
filename=photo_name,
|
| 161 |
+
content_type=photo.content_type or "image/jpeg"
|
| 162 |
+
)
|
| 163 |
+
profile_image_url = supabase.storage.from_("user-avatars").get_public_url(photo_path)
|
| 164 |
+
|
| 165 |
+
updates = {}
|
| 166 |
+
if full_name is not None:
|
| 167 |
+
updates["full_name"] = full_name
|
| 168 |
+
if phone is not None:
|
| 169 |
+
updates["phone"] = phone
|
| 170 |
+
if address is not None:
|
| 171 |
+
updates["address"] = address
|
| 172 |
+
if state is not None:
|
| 173 |
+
updates["state"] = state
|
| 174 |
+
if zip_code is not None:
|
| 175 |
+
updates["zip_code"] = zip_code
|
| 176 |
+
if country is not None:
|
| 177 |
+
updates["country"] = country
|
| 178 |
+
if bio is not None:
|
| 179 |
+
updates["bio"] = bio
|
| 180 |
+
if emergency_contact_name is not None:
|
| 181 |
+
updates["emergency_contact_name"] = emergency_contact_name
|
| 182 |
+
if emergency_contact_phone is not None:
|
| 183 |
+
updates["emergency_contact_phone"] = emergency_contact_phone
|
| 184 |
+
if profile_image_url is not None:
|
| 185 |
+
updates["profile_image_url"] = profile_image_url
|
| 186 |
+
if latitude is not None:
|
| 187 |
+
updates["latitude"] = latitude
|
| 188 |
+
if longitude is not None:
|
| 189 |
+
updates["longitude"] = longitude
|
| 190 |
+
|
| 191 |
+
result = AuthService.update_user_profile(user_id=current_user["id"], updates=updates)
|
| 192 |
+
if not result["success"]:
|
| 193 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 194 |
+
|
| 195 |
+
return result
|
| 196 |
+
|
| 197 |
+
# ============================================
|
| 198 |
+
# Notification Endpoints
|
| 199 |
+
# ============================================
|
| 200 |
+
|
| 201 |
+
@router.get("/notifications")
|
| 202 |
+
async def get_notifications(limit: int = 20, current_user: dict = Depends(get_current_user)):
|
| 203 |
+
"""Get notifications for the current authenticated user"""
|
| 204 |
+
logger.info(f"Fetching notifications for user: {current_user['id']}")
|
| 205 |
+
try:
|
| 206 |
+
response = (
|
| 207 |
+
supabase.table("notifications")
|
| 208 |
+
.select("*")
|
| 209 |
+
.eq("user_id", current_user["id"])
|
| 210 |
+
.order("created_at", desc=True)
|
| 211 |
+
.limit(limit)
|
| 212 |
+
.execute()
|
| 213 |
+
)
|
| 214 |
+
notifications = response.data or []
|
| 215 |
+
unread_count = sum(1 for item in notifications if not item.get("is_read"))
|
| 216 |
+
return {"success": True, "notifications": notifications, "count": len(notifications), "unread_count": unread_count}
|
| 217 |
+
except Exception as e:
|
| 218 |
+
logger.error(f"Error fetching notifications: {str(e)}")
|
| 219 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 220 |
+
|
| 221 |
+
@router.post("/notifications/{notification_id}/read")
|
| 222 |
+
async def mark_notification_read(notification_id: str, current_user: dict = Depends(get_current_user)):
|
| 223 |
+
"""Mark a single notification as read"""
|
| 224 |
+
try:
|
| 225 |
+
# Check authorization
|
| 226 |
+
notif_resp = supabase.table("notifications").select("user_id").eq("id", notification_id).execute()
|
| 227 |
+
if not notif_resp.data or notif_resp.data[0]["user_id"] != current_user["id"]:
|
| 228 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 229 |
+
|
| 230 |
+
supabase.table("notifications").update({
|
| 231 |
+
"is_read": True,
|
| 232 |
+
"read_at": datetime.utcnow().isoformat()
|
| 233 |
+
}).eq("id", notification_id).execute()
|
| 234 |
+
|
| 235 |
+
return {"success": True, "notification_id": notification_id}
|
| 236 |
+
except HTTPException:
|
| 237 |
+
raise
|
| 238 |
+
except Exception as e:
|
| 239 |
+
logger.error(f"Error marking notification read: {str(e)}")
|
| 240 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 241 |
+
|
| 242 |
+
@router.post("/notifications/read-all")
|
| 243 |
+
async def mark_all_notifications_read(current_user: dict = Depends(get_current_user)):
|
| 244 |
+
"""Mark all notifications for the current user as read"""
|
| 245 |
+
try:
|
| 246 |
+
supabase.table("notifications").update({
|
| 247 |
+
"is_read": True,
|
| 248 |
+
"read_at": datetime.utcnow().isoformat()
|
| 249 |
+
}).eq("user_id", current_user["id"]).eq("is_read", False).execute()
|
| 250 |
+
|
| 251 |
+
return {"success": True, "user_id": current_user["id"]}
|
| 252 |
+
except Exception as e:
|
| 253 |
+
logger.error(f"Error marking all notifications read: {str(e)}")
|
| 254 |
+
raise HTTPException(status_code=400, detail=str(e))
|
backend/routers/clinics.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Form, UploadFile, File
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
import logging
|
| 4 |
+
import time
|
| 5 |
+
import os
|
| 6 |
+
from backend.core.dependencies import get_current_user, require_role
|
| 7 |
+
from backend.services.clinic_service import ClinicService
|
| 8 |
+
from chatbot.supabase_config import supabase, SupabaseStorage
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
router = APIRouter(tags=["Clinics"])
|
| 13 |
+
|
| 14 |
+
@router.get("/clinic/profile")
|
| 15 |
+
async def get_clinic_profile(current_user: dict = Depends(get_current_user)):
|
| 16 |
+
"""Get the current logged-in clinic's profile details"""
|
| 17 |
+
if current_user["role"] != "clinic":
|
| 18 |
+
raise HTTPException(status_code=403, detail="Access denied. Clinic role required.")
|
| 19 |
+
|
| 20 |
+
result = ClinicService.get_clinic_profile(user_id=current_user["id"])
|
| 21 |
+
if not result["success"]:
|
| 22 |
+
raise HTTPException(status_code=404, detail=result.get("error"))
|
| 23 |
+
return result
|
| 24 |
+
|
| 25 |
+
@router.put("/clinic/profile")
|
| 26 |
+
async def update_clinic_profile(
|
| 27 |
+
clinic_name: Optional[str] = Form(None),
|
| 28 |
+
phone: Optional[str] = Form(None),
|
| 29 |
+
address: Optional[str] = Form(None),
|
| 30 |
+
city: Optional[str] = Form(None),
|
| 31 |
+
state: Optional[str] = Form(None),
|
| 32 |
+
zip_code: Optional[str] = Form(None),
|
| 33 |
+
country: Optional[str] = Form(None),
|
| 34 |
+
website: Optional[str] = Form(None),
|
| 35 |
+
opening_hours: Optional[str] = Form(None),
|
| 36 |
+
description: Optional[str] = Form(None),
|
| 37 |
+
photo: Optional[UploadFile] = File(None),
|
| 38 |
+
photos: Optional[List[UploadFile]] = File(None),
|
| 39 |
+
latitude: Optional[float] = Form(None),
|
| 40 |
+
longitude: Optional[float] = Form(None),
|
| 41 |
+
current_user: dict = Depends(get_current_user)
|
| 42 |
+
):
|
| 43 |
+
"""Update the current logged-in clinic's profile details"""
|
| 44 |
+
if current_user["role"] != "clinic":
|
| 45 |
+
raise HTTPException(status_code=403, detail="Access denied. Clinic role required.")
|
| 46 |
+
|
| 47 |
+
updates = {}
|
| 48 |
+
if clinic_name is not None:
|
| 49 |
+
updates["clinic_name"] = clinic_name
|
| 50 |
+
if phone is not None:
|
| 51 |
+
updates["phone"] = phone
|
| 52 |
+
if address is not None:
|
| 53 |
+
updates["address"] = address
|
| 54 |
+
if city is not None:
|
| 55 |
+
updates["city"] = city
|
| 56 |
+
if state is not None:
|
| 57 |
+
updates["state"] = state
|
| 58 |
+
if zip_code is not None:
|
| 59 |
+
updates["zip_code"] = zip_code
|
| 60 |
+
if country is not None:
|
| 61 |
+
updates["country"] = country
|
| 62 |
+
if website is not None:
|
| 63 |
+
updates["website"] = website
|
| 64 |
+
if opening_hours is not None:
|
| 65 |
+
updates["opening_hours"] = opening_hours
|
| 66 |
+
if description is not None:
|
| 67 |
+
updates["description"] = description
|
| 68 |
+
if latitude is not None:
|
| 69 |
+
updates["latitude"] = latitude
|
| 70 |
+
if longitude is not None:
|
| 71 |
+
updates["longitude"] = longitude
|
| 72 |
+
|
| 73 |
+
# Upload main photo
|
| 74 |
+
if photo and photo.filename:
|
| 75 |
+
if not photo.content_type or not photo.content_type.startswith("image/"):
|
| 76 |
+
raise HTTPException(status_code=400, detail="Clinic photo must be an image")
|
| 77 |
+
|
| 78 |
+
photo_bytes = await photo.read()
|
| 79 |
+
photo_ext = os.path.splitext(photo.filename)[1] or ".jpg"
|
| 80 |
+
photo_name = f"{clinic_name or 'logo'}-{int(time.time())}{photo_ext}"
|
| 81 |
+
photo_path = SupabaseStorage.upload_clinic_image(
|
| 82 |
+
user_id=current_user["id"],
|
| 83 |
+
file_data=photo_bytes,
|
| 84 |
+
filename=photo_name,
|
| 85 |
+
content_type=photo.content_type or "image/jpeg"
|
| 86 |
+
)
|
| 87 |
+
updates["clinic_logo_url"] = supabase.storage.from_("clinic-images").get_public_url(photo_path)
|
| 88 |
+
|
| 89 |
+
# Upload gallery photos
|
| 90 |
+
for gallery_photo in photos or []:
|
| 91 |
+
if not gallery_photo or not gallery_photo.filename:
|
| 92 |
+
continue
|
| 93 |
+
if not gallery_photo.content_type or not gallery_photo.content_type.startswith("image/"):
|
| 94 |
+
raise HTTPException(status_code=400, detail="Clinic gallery images must be images")
|
| 95 |
+
|
| 96 |
+
image_bytes = await gallery_photo.read()
|
| 97 |
+
image_ext = os.path.splitext(gallery_photo.filename)[1] or ".jpg"
|
| 98 |
+
image_name = f"gallery-{int(time.time())}-{gallery_photo.filename.replace(' ', '_')}"
|
| 99 |
+
if not image_name.endswith(image_ext):
|
| 100 |
+
image_name += image_ext
|
| 101 |
+
|
| 102 |
+
# We upload it but we don't save to the main table, as SupabaseStorage lists them
|
| 103 |
+
SupabaseStorage.upload_clinic_image(
|
| 104 |
+
user_id=current_user["id"],
|
| 105 |
+
file_data=image_bytes,
|
| 106 |
+
filename=image_name,
|
| 107 |
+
content_type=gallery_photo.content_type or "image/jpeg",
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
result = ClinicService.update_clinic_profile(user_id=current_user["id"], updates=updates)
|
| 111 |
+
if not result["success"]:
|
| 112 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 113 |
+
|
| 114 |
+
return result
|
| 115 |
+
|
| 116 |
+
@router.get("/clinics")
|
| 117 |
+
async def get_public_clinics():
|
| 118 |
+
"""Public endpoint: Get all verified clinics"""
|
| 119 |
+
result = ClinicService.get_public_clinics()
|
| 120 |
+
if not result["success"]:
|
| 121 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 122 |
+
return result
|
| 123 |
+
|
| 124 |
+
@router.get("/clinics/{clinic_id}")
|
| 125 |
+
async def get_clinic_by_id(clinic_id: str):
|
| 126 |
+
"""Public endpoint: Get clinic details by clinic ID"""
|
| 127 |
+
result = ClinicService.get_clinic_by_id(clinic_id=clinic_id)
|
| 128 |
+
if not result["success"]:
|
| 129 |
+
raise HTTPException(status_code=404, detail=result.get("error"))
|
| 130 |
+
return result
|
| 131 |
+
|
| 132 |
+
@router.get("/clinic/patients")
|
| 133 |
+
async def get_clinic_patients(current_user: dict = Depends(get_current_user)):
|
| 134 |
+
"""Get all appointments / patients for the current clinic"""
|
| 135 |
+
if current_user["role"] != "clinic":
|
| 136 |
+
raise HTTPException(status_code=403, detail="Access denied. Clinic role required.")
|
| 137 |
+
|
| 138 |
+
# Get the clinic's ID from user_id
|
| 139 |
+
clinic_resp = supabase.table("clinics").select("id").eq("user_id", current_user["id"]).execute()
|
| 140 |
+
if not clinic_resp.data:
|
| 141 |
+
raise HTTPException(status_code=404, detail="Clinic profile not found")
|
| 142 |
+
|
| 143 |
+
clinic_id = clinic_resp.data[0]["id"]
|
| 144 |
+
|
| 145 |
+
try:
|
| 146 |
+
resp = supabase.table("appointments").select("*").eq("clinic_id", clinic_id).order("appointment_date", desc=False).execute()
|
| 147 |
+
appts = resp.data or []
|
| 148 |
+
|
| 149 |
+
# Enrich each appointment with pet name and owner full name
|
| 150 |
+
enriched = []
|
| 151 |
+
for a in appts:
|
| 152 |
+
pet_name = None
|
| 153 |
+
owner_name = None
|
| 154 |
+
try:
|
| 155 |
+
if a.get("pet_id"):
|
| 156 |
+
pet_resp = supabase.table("pets").select("name").eq("id", a.get("pet_id")).execute()
|
| 157 |
+
if pet_resp.data:
|
| 158 |
+
pet_name = pet_resp.data[0].get("name")
|
| 159 |
+
if a.get("owner_id"):
|
| 160 |
+
owner_resp = supabase.table("pet_owners").select("full_name").eq("user_id", a.get("owner_id")).execute()
|
| 161 |
+
if owner_resp.data:
|
| 162 |
+
owner_name = owner_resp.data[0].get("full_name")
|
| 163 |
+
except Exception:
|
| 164 |
+
pass
|
| 165 |
+
|
| 166 |
+
item = dict(a)
|
| 167 |
+
item["pet_name"] = pet_name or a.get("pet_id")
|
| 168 |
+
item["owner_name"] = owner_name or a.get("owner_id")
|
| 169 |
+
enriched.append(item)
|
| 170 |
+
|
| 171 |
+
return {"success": True, "appointments": enriched, "count": len(enriched)}
|
| 172 |
+
except Exception as e:
|
| 173 |
+
raise HTTPException(status_code=400, detail=str(e))
|
backend/routers/pets.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
import logging
|
| 4 |
+
import time
|
| 5 |
+
import os
|
| 6 |
+
import tempfile
|
| 7 |
+
from backend.core.dependencies import get_current_user
|
| 8 |
+
from backend.services.pet_service import PetService
|
| 9 |
+
from chatbot.supabase_config import supabase, SupabaseStorage
|
| 10 |
+
from chatbot.vaccine_service import VaccineService
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
router = APIRouter(tags=["Pets, Vaccines & Medical Records"])
|
| 15 |
+
|
| 16 |
+
# ============================================
|
| 17 |
+
# Pet Endpoints
|
| 18 |
+
# ============================================
|
| 19 |
+
|
| 20 |
+
@router.post("/pets")
|
| 21 |
+
async def create_pet(
|
| 22 |
+
name: str = Form(...),
|
| 23 |
+
pet_type: str = Form(...),
|
| 24 |
+
breed: str = Form(...),
|
| 25 |
+
date_of_birth: str = Form(...),
|
| 26 |
+
weight: float = Form(...),
|
| 27 |
+
weight_unit: str = Form("kg"),
|
| 28 |
+
gender: Optional[str] = Form(None),
|
| 29 |
+
blood_type: Optional[str] = Form(None),
|
| 30 |
+
allergies: Optional[str] = Form(None),
|
| 31 |
+
medical_conditions: Optional[str] = Form(None),
|
| 32 |
+
notes: Optional[str] = Form(None),
|
| 33 |
+
photo: Optional[UploadFile] = File(None),
|
| 34 |
+
current_user: dict = Depends(get_current_user)
|
| 35 |
+
):
|
| 36 |
+
"""Add a new pet (Owner only)"""
|
| 37 |
+
if current_user["role"] != "owner":
|
| 38 |
+
raise HTTPException(status_code=403, detail="Only pet owners can add pets")
|
| 39 |
+
|
| 40 |
+
profile_image_url = None
|
| 41 |
+
if photo and photo.filename:
|
| 42 |
+
if not photo.content_type or not photo.content_type.startswith("image/"):
|
| 43 |
+
raise HTTPException(status_code=400, detail="Pet photo must be an image")
|
| 44 |
+
|
| 45 |
+
file_data = await photo.read()
|
| 46 |
+
file_ext = os.path.splitext(photo.filename)[1] or ".jpg"
|
| 47 |
+
storage_path = f"{current_user['id']}/{int(time.time())}-{name.replace(' ', '_')}{file_ext}"
|
| 48 |
+
SupabaseStorage.ensure_bucket("pet-images", public=True, allowed_mime_types=["image/*"])
|
| 49 |
+
supabase.storage.from_("pet-images").upload(
|
| 50 |
+
file=file_data,
|
| 51 |
+
path=storage_path,
|
| 52 |
+
file_options={
|
| 53 |
+
"content-type": photo.content_type or "image/jpeg",
|
| 54 |
+
"upsert": "false",
|
| 55 |
+
},
|
| 56 |
+
)
|
| 57 |
+
profile_image_url = supabase.storage.from_("pet-images").get_public_url(storage_path)
|
| 58 |
+
|
| 59 |
+
result = PetService.add_pet(
|
| 60 |
+
user_id=current_user["id"],
|
| 61 |
+
name=name,
|
| 62 |
+
pet_type=pet_type,
|
| 63 |
+
breed=breed,
|
| 64 |
+
date_of_birth=date_of_birth,
|
| 65 |
+
weight=weight,
|
| 66 |
+
weight_unit=weight_unit,
|
| 67 |
+
gender=gender,
|
| 68 |
+
blood_type=blood_type,
|
| 69 |
+
allergies=allergies,
|
| 70 |
+
medical_conditions=medical_conditions,
|
| 71 |
+
notes=notes,
|
| 72 |
+
profile_image_url=profile_image_url
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
if not result["success"]:
|
| 76 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 77 |
+
|
| 78 |
+
return result
|
| 79 |
+
|
| 80 |
+
@router.get("/pets")
|
| 81 |
+
async def get_pets(current_user: dict = Depends(get_current_user)):
|
| 82 |
+
"""Get all pets for the currently logged-in user (Owner) or all pets if Clinic/Admin"""
|
| 83 |
+
if current_user["role"] == "owner":
|
| 84 |
+
result = PetService.get_user_pets(user_id=current_user["id"])
|
| 85 |
+
else:
|
| 86 |
+
try:
|
| 87 |
+
response = supabase.table("pets").select("*").execute()
|
| 88 |
+
result = {
|
| 89 |
+
"success": True,
|
| 90 |
+
"pets": response.data or [],
|
| 91 |
+
"count": len(response.data or [])
|
| 92 |
+
}
|
| 93 |
+
except Exception as e:
|
| 94 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 95 |
+
|
| 96 |
+
if not result["success"]:
|
| 97 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 98 |
+
|
| 99 |
+
return result
|
| 100 |
+
|
| 101 |
+
@router.get("/pets/{pet_id}")
|
| 102 |
+
async def get_pet_detail(pet_id: str, current_user: dict = Depends(get_current_user)):
|
| 103 |
+
"""Get detailed profile of a specific pet"""
|
| 104 |
+
try:
|
| 105 |
+
response = supabase.table("pets").select("*").eq("id", pet_id).execute()
|
| 106 |
+
if not response.data:
|
| 107 |
+
raise HTTPException(status_code=404, detail="Pet not found")
|
| 108 |
+
|
| 109 |
+
pet = response.data[0]
|
| 110 |
+
if current_user["role"] == "owner" and pet["user_id"] != current_user["id"]:
|
| 111 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 112 |
+
|
| 113 |
+
return {"success": True, "pet": pet}
|
| 114 |
+
except HTTPException:
|
| 115 |
+
raise
|
| 116 |
+
except Exception as e:
|
| 117 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 118 |
+
|
| 119 |
+
@router.put("/pets/{pet_id}")
|
| 120 |
+
async def update_pet_detail(
|
| 121 |
+
pet_id: str,
|
| 122 |
+
name: Optional[str] = Form(None),
|
| 123 |
+
pet_type: Optional[str] = Form(None),
|
| 124 |
+
breed: Optional[str] = Form(None),
|
| 125 |
+
date_of_birth: Optional[str] = Form(None),
|
| 126 |
+
weight: Optional[float] = Form(None),
|
| 127 |
+
weight_unit: Optional[str] = Form(None),
|
| 128 |
+
gender: Optional[str] = Form(None),
|
| 129 |
+
blood_type: Optional[str] = Form(None),
|
| 130 |
+
allergies: Optional[str] = Form(None),
|
| 131 |
+
medical_conditions: Optional[str] = Form(None),
|
| 132 |
+
notes: Optional[str] = Form(None),
|
| 133 |
+
photo: Optional[UploadFile] = File(None),
|
| 134 |
+
current_user: dict = Depends(get_current_user)
|
| 135 |
+
):
|
| 136 |
+
"""Update details of a specific pet"""
|
| 137 |
+
try:
|
| 138 |
+
current = supabase.table("pets").select("*").eq("id", pet_id).execute()
|
| 139 |
+
if not current.data:
|
| 140 |
+
raise HTTPException(status_code=404, detail="Pet not found")
|
| 141 |
+
|
| 142 |
+
pet = current.data[0]
|
| 143 |
+
if current_user["role"] == "owner" and pet["user_id"] != current_user["id"]:
|
| 144 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 145 |
+
|
| 146 |
+
updates = {}
|
| 147 |
+
if name is not None:
|
| 148 |
+
updates["name"] = name
|
| 149 |
+
if pet_type is not None:
|
| 150 |
+
updates["type"] = pet_type.lower()
|
| 151 |
+
if breed is not None:
|
| 152 |
+
updates["breed"] = breed
|
| 153 |
+
if date_of_birth is not None:
|
| 154 |
+
updates["date_of_birth"] = date_of_birth
|
| 155 |
+
if weight is not None:
|
| 156 |
+
updates["weight"] = weight
|
| 157 |
+
if weight_unit is not None:
|
| 158 |
+
updates["weight_unit"] = weight_unit
|
| 159 |
+
if gender is not None:
|
| 160 |
+
updates["gender"] = gender
|
| 161 |
+
if blood_type is not None:
|
| 162 |
+
updates["blood_type"] = blood_type
|
| 163 |
+
if allergies is not None:
|
| 164 |
+
updates["allergies"] = allergies
|
| 165 |
+
if medical_conditions is not None:
|
| 166 |
+
updates["medical_conditions"] = medical_conditions
|
| 167 |
+
if notes is not None:
|
| 168 |
+
updates["notes"] = notes
|
| 169 |
+
|
| 170 |
+
if photo and photo.filename:
|
| 171 |
+
if not photo.content_type or not photo.content_type.startswith("image/"):
|
| 172 |
+
raise HTTPException(status_code=400, detail="Pet photo must be an image")
|
| 173 |
+
|
| 174 |
+
file_data = await photo.read()
|
| 175 |
+
file_ext = os.path.splitext(photo.filename)[1] or ".jpg"
|
| 176 |
+
storage_path = f"{pet['user_id']}/{int(time.time())}-{name or pet['name']}{file_ext}".replace(' ', '_')
|
| 177 |
+
SupabaseStorage.ensure_bucket("pet-images", public=True, allowed_mime_types=["image/*"])
|
| 178 |
+
supabase.storage.from_("pet-images").upload(
|
| 179 |
+
file=file_data,
|
| 180 |
+
path=storage_path,
|
| 181 |
+
file_options={
|
| 182 |
+
"content-type": photo.content_type or "image/jpeg",
|
| 183 |
+
"upsert": "false",
|
| 184 |
+
},
|
| 185 |
+
)
|
| 186 |
+
updates["profile_image_url"] = supabase.storage.from_("pet-images").get_public_url(storage_path)
|
| 187 |
+
|
| 188 |
+
result = PetService.update_pet(pet_id=pet_id, updates=updates)
|
| 189 |
+
if not result["success"]:
|
| 190 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 191 |
+
|
| 192 |
+
return result
|
| 193 |
+
except HTTPException:
|
| 194 |
+
raise
|
| 195 |
+
except Exception as e:
|
| 196 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 197 |
+
|
| 198 |
+
# ============================================
|
| 199 |
+
# Vaccine Records Endpoints
|
| 200 |
+
# ============================================
|
| 201 |
+
|
| 202 |
+
@router.post("/vaccine-records")
|
| 203 |
+
async def upload_vaccine_record(
|
| 204 |
+
pet_id: str = Form(...),
|
| 205 |
+
file: UploadFile = File(...),
|
| 206 |
+
upload_date: Optional[str] = Form(None),
|
| 207 |
+
current_user: dict = Depends(get_current_user)
|
| 208 |
+
):
|
| 209 |
+
"""Upload a vaccination record for a pet"""
|
| 210 |
+
try:
|
| 211 |
+
pet_resp = supabase.table("pets").select("user_id").eq("id", pet_id).execute()
|
| 212 |
+
if not pet_resp.data:
|
| 213 |
+
raise HTTPException(status_code=404, detail="Pet not found")
|
| 214 |
+
|
| 215 |
+
if current_user["role"] == "owner" and pet_resp.data[0]["user_id"] != current_user["id"]:
|
| 216 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 217 |
+
|
| 218 |
+
file_data = await file.read()
|
| 219 |
+
file_type = "pdf" if file.content_type == "application/pdf" else "image"
|
| 220 |
+
|
| 221 |
+
result = PetService.upload_vaccine_record(
|
| 222 |
+
pet_id=pet_id,
|
| 223 |
+
file_data=file_data,
|
| 224 |
+
file_name=file.filename,
|
| 225 |
+
file_type=file_type,
|
| 226 |
+
uploaded_by=current_user["id"],
|
| 227 |
+
upload_date=upload_date
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
if not result["success"]:
|
| 231 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 232 |
+
|
| 233 |
+
return result
|
| 234 |
+
except HTTPException:
|
| 235 |
+
raise
|
| 236 |
+
except Exception as e:
|
| 237 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 238 |
+
|
| 239 |
+
@router.get("/vaccine-records")
|
| 240 |
+
async def get_vaccine_records(pet_id: str, current_user: dict = Depends(get_current_user)):
|
| 241 |
+
"""Get all vaccine records for a pet"""
|
| 242 |
+
try:
|
| 243 |
+
pet_resp = supabase.table("pets").select("user_id").eq("id", pet_id).execute()
|
| 244 |
+
if not pet_resp.data:
|
| 245 |
+
raise HTTPException(status_code=404, detail="Pet not found")
|
| 246 |
+
|
| 247 |
+
if current_user["role"] == "owner" and pet_resp.data[0]["user_id"] != current_user["id"]:
|
| 248 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 249 |
+
|
| 250 |
+
result = PetService.get_pet_vaccine_records(pet_id=pet_id)
|
| 251 |
+
if not result["success"]:
|
| 252 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 253 |
+
|
| 254 |
+
return result
|
| 255 |
+
except HTTPException:
|
| 256 |
+
raise
|
| 257 |
+
except Exception as e:
|
| 258 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 259 |
+
|
| 260 |
+
# ============================================
|
| 261 |
+
# Advanced Vaccine Processing Endpoints
|
| 262 |
+
# ============================================
|
| 263 |
+
|
| 264 |
+
@router.post("/vaccines/upload-document")
|
| 265 |
+
async def upload_vaccine_document(
|
| 266 |
+
pet_id: str = Form(...),
|
| 267 |
+
file: UploadFile = File(...),
|
| 268 |
+
current_user: dict = Depends(get_current_user)
|
| 269 |
+
):
|
| 270 |
+
"""Upload vaccine booklet/card image, extract data via VLM, and store records"""
|
| 271 |
+
logger.info(f"Uploading vaccine document for pet: {pet_id} by user: {current_user['id']}")
|
| 272 |
+
|
| 273 |
+
# Check authorization
|
| 274 |
+
pet_resp = supabase.table("pets").select("user_id").eq("id", pet_id).execute()
|
| 275 |
+
if not pet_resp.data:
|
| 276 |
+
raise HTTPException(status_code=404, detail="Pet not found")
|
| 277 |
+
if current_user["role"] == "owner" and pet_resp.data[0]["user_id"] != current_user["id"]:
|
| 278 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 279 |
+
|
| 280 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=os.path.splitext(file.filename or ".jpg")[1]) as tmp:
|
| 281 |
+
content = await file.read()
|
| 282 |
+
tmp.write(content)
|
| 283 |
+
tmp_path = tmp.name
|
| 284 |
+
|
| 285 |
+
try:
|
| 286 |
+
file_ext = os.path.splitext(file.filename or ".jpg")[1]
|
| 287 |
+
storage_path = f"vaccine-documents/{pet_id}/{int(time.time())}{file_ext}"
|
| 288 |
+
|
| 289 |
+
SupabaseStorage.ensure_bucket("vaccine-documents", public=True, allowed_mime_types=["image/*", "application/pdf"])
|
| 290 |
+
|
| 291 |
+
with open(tmp_path, "rb") as f:
|
| 292 |
+
file_data = f.read()
|
| 293 |
+
|
| 294 |
+
supabase.storage.from_("vaccine-documents").upload(
|
| 295 |
+
file=file_data,
|
| 296 |
+
path=storage_path,
|
| 297 |
+
file_options={
|
| 298 |
+
"content-type": file.content_type or "image/jpeg",
|
| 299 |
+
"upsert": "false",
|
| 300 |
+
},
|
| 301 |
+
)
|
| 302 |
+
image_url = supabase.storage.from_("vaccine-documents").get_public_url(storage_path)
|
| 303 |
+
|
| 304 |
+
result = VaccineService.upload_vaccine_document(
|
| 305 |
+
pet_id=pet_id,
|
| 306 |
+
image_url=image_url,
|
| 307 |
+
image_path=tmp_path
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
if not result.get("success"):
|
| 311 |
+
raise HTTPException(status_code=400, detail=result.get("error", "Failed to process vaccine document"))
|
| 312 |
+
|
| 313 |
+
return {
|
| 314 |
+
"success": True,
|
| 315 |
+
"document_id": result.get("document_id"),
|
| 316 |
+
"records_count": result.get("records_count"),
|
| 317 |
+
"records": result.get("records"),
|
| 318 |
+
"message": f"Successfully extracted {result.get('records_count')} vaccine records!"
|
| 319 |
+
}
|
| 320 |
+
finally:
|
| 321 |
+
if os.path.exists(tmp_path):
|
| 322 |
+
os.remove(tmp_path)
|
| 323 |
+
|
| 324 |
+
@router.post("/vaccines/manual-entry")
|
| 325 |
+
async def add_manual_vaccine(
|
| 326 |
+
pet_id: str = Form(...),
|
| 327 |
+
vaccine_name: str = Form(...),
|
| 328 |
+
vaccination_date: str = Form(...),
|
| 329 |
+
next_due_date: Optional[str] = Form(None),
|
| 330 |
+
batch_number: Optional[str] = Form(None),
|
| 331 |
+
veterinarian_name: Optional[str] = Form(None),
|
| 332 |
+
clinic_name: Optional[str] = Form(None),
|
| 333 |
+
clinic_id: Optional[str] = Form(None),
|
| 334 |
+
notes: Optional[str] = Form(None),
|
| 335 |
+
source: str = Form("vet_entry"),
|
| 336 |
+
current_user: dict = Depends(get_current_user)
|
| 337 |
+
):
|
| 338 |
+
"""Add a vaccine record manually (Clinics or Vets)"""
|
| 339 |
+
# Verify clinic role or admin
|
| 340 |
+
if current_user["role"] not in ["clinic", "admin"]:
|
| 341 |
+
raise HTTPException(status_code=403, detail="Only clinics and admins can make manual vaccine entries")
|
| 342 |
+
|
| 343 |
+
result = VaccineService.add_manual_vaccine_entry(
|
| 344 |
+
pet_id=pet_id,
|
| 345 |
+
vaccine_name=vaccine_name,
|
| 346 |
+
vaccination_date=vaccination_date,
|
| 347 |
+
next_due_date=next_due_date,
|
| 348 |
+
batch_number=batch_number,
|
| 349 |
+
veterinarian_name=veterinarian_name,
|
| 350 |
+
clinic_name=clinic_name,
|
| 351 |
+
clinic_id=clinic_id,
|
| 352 |
+
notes=notes,
|
| 353 |
+
source=source
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
if not result.get("success"):
|
| 357 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 358 |
+
|
| 359 |
+
return result
|
| 360 |
+
|
| 361 |
+
@router.get("/vaccines/{pet_id}")
|
| 362 |
+
async def get_pet_vaccines(pet_id: str, current_user: dict = Depends(get_current_user)):
|
| 363 |
+
"""Get all vaccination records for a pet"""
|
| 364 |
+
pet_resp = supabase.table("pets").select("user_id").eq("id", pet_id).execute()
|
| 365 |
+
if not pet_resp.data:
|
| 366 |
+
raise HTTPException(status_code=404, detail="Pet not found")
|
| 367 |
+
if current_user["role"] == "owner" and pet_resp.data[0]["user_id"] != current_user["id"]:
|
| 368 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 369 |
+
|
| 370 |
+
result = VaccineService.get_pet_vaccines(pet_id=pet_id)
|
| 371 |
+
if not result.get("success"):
|
| 372 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 373 |
+
|
| 374 |
+
return result
|
| 375 |
+
|
| 376 |
+
@router.get("/vaccines/{pet_id}/documents")
|
| 377 |
+
async def get_pet_vaccine_documents(pet_id: str, current_user: dict = Depends(get_current_user)):
|
| 378 |
+
"""Get uploaded vaccine documents for a pet"""
|
| 379 |
+
pet_resp = supabase.table("pets").select("user_id").eq("id", pet_id).execute()
|
| 380 |
+
if not pet_resp.data:
|
| 381 |
+
raise HTTPException(status_code=404, detail="Pet not found")
|
| 382 |
+
if current_user["role"] == "owner" and pet_resp.data[0]["user_id"] != current_user["id"]:
|
| 383 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 384 |
+
|
| 385 |
+
result = VaccineService.get_pet_vaccine_documents(pet_id=pet_id)
|
| 386 |
+
if not result.get("success"):
|
| 387 |
+
raise HTTPException(status_code=400, detail=result.get("error"))
|
| 388 |
+
|
| 389 |
+
return result
|
| 390 |
+
|
| 391 |
+
@router.post("/vaccines/check-reminders")
|
| 392 |
+
async def check_vaccine_reminders(current_user: dict = Depends(get_current_user)):
|
| 393 |
+
"""Trigger reminder check for all vaccines (Admin only)"""
|
| 394 |
+
if current_user["role"] != "admin":
|
| 395 |
+
raise HTTPException(status_code=403, detail="Admin access required")
|
| 396 |
+
|
| 397 |
+
result = VaccineService.check_and_send_reminders()
|
| 398 |
+
return result
|
| 399 |
+
|
| 400 |
+
# ============================================
|
| 401 |
+
# Medical Records Endpoints
|
| 402 |
+
# ============================================
|
| 403 |
+
|
| 404 |
+
@router.post("/medical-records")
|
| 405 |
+
async def create_medical_record(
|
| 406 |
+
clinic_id: str,
|
| 407 |
+
pet_id: str,
|
| 408 |
+
record_type: str,
|
| 409 |
+
visit_date: str,
|
| 410 |
+
diagnosis: Optional[str] = None,
|
| 411 |
+
treatment: Optional[str] = None,
|
| 412 |
+
notes: Optional[str] = None,
|
| 413 |
+
current_user: dict = Depends(get_current_user)
|
| 414 |
+
):
|
| 415 |
+
"""Create medical record (Clinics only)"""
|
| 416 |
+
if current_user["role"] != "clinic":
|
| 417 |
+
raise HTTPException(status_code=403, detail="Only clinics can create medical records")
|
| 418 |
+
|
| 419 |
+
# Check if the clinic owns this clinic_id
|
| 420 |
+
clinic_resp = supabase.table("clinics").select("id").eq("user_id", current_user["id"]).execute()
|
| 421 |
+
if not clinic_resp.data or clinic_resp.data[0]["id"] != clinic_id:
|
| 422 |
+
raise HTTPException(status_code=403, detail="Access denied. Invalid clinic identity.")
|
| 423 |
+
|
| 424 |
+
try:
|
| 425 |
+
medical_data = {
|
| 426 |
+
"pet_id": pet_id,
|
| 427 |
+
"clinic_id": clinic_id,
|
| 428 |
+
"record_type": record_type,
|
| 429 |
+
"visit_date": visit_date,
|
| 430 |
+
"diagnosis": diagnosis,
|
| 431 |
+
"treatment": treatment,
|
| 432 |
+
"notes": notes
|
| 433 |
+
}
|
| 434 |
+
response = supabase.table("medical_records").insert(medical_data).execute()
|
| 435 |
+
if not response.data:
|
| 436 |
+
raise HTTPException(status_code=400, detail="Failed to create medical record")
|
| 437 |
+
|
| 438 |
+
return {
|
| 439 |
+
"success": True,
|
| 440 |
+
"record_id": response.data[0]["id"],
|
| 441 |
+
"message": "Medical record created successfully!"
|
| 442 |
+
}
|
| 443 |
+
except Exception as e:
|
| 444 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 445 |
+
|
| 446 |
+
@router.get("/pet/medical-records")
|
| 447 |
+
async def get_pet_medical_records(pet_id: str, current_user: dict = Depends(get_current_user)):
|
| 448 |
+
"""Get all medical records for a pet"""
|
| 449 |
+
pet_resp = supabase.table("pets").select("user_id").eq("id", pet_id).execute()
|
| 450 |
+
if not pet_resp.data:
|
| 451 |
+
raise HTTPException(status_code=404, detail="Pet not found")
|
| 452 |
+
if current_user["role"] == "owner" and pet_resp.data[0]["user_id"] != current_user["id"]:
|
| 453 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 454 |
+
|
| 455 |
+
try:
|
| 456 |
+
response = supabase.table("medical_records").select("*").eq("pet_id", pet_id).execute()
|
| 457 |
+
return {
|
| 458 |
+
"success": True,
|
| 459 |
+
"records": response.data or [],
|
| 460 |
+
"count": len(response.data or [])
|
| 461 |
+
}
|
| 462 |
+
except Exception as e:
|
| 463 |
+
raise HTTPException(status_code=400, detail=str(e))
|
backend/schemas/__pycache__/schemas.cpython-313.pyc
ADDED
|
Binary file (3.2 kB). View file
|
|
|
backend/schemas/schemas.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
|
| 4 |
+
class RegisterOwnerRequest(BaseModel):
|
| 5 |
+
first_name: str
|
| 6 |
+
last_name: str
|
| 7 |
+
phone: str
|
| 8 |
+
address: Optional[str] = None
|
| 9 |
+
state: Optional[str] = None
|
| 10 |
+
zip_code: Optional[str] = None
|
| 11 |
+
country: Optional[str] = None
|
| 12 |
+
bio: Optional[str] = None
|
| 13 |
+
|
| 14 |
+
class RegisterClinicRequest(BaseModel):
|
| 15 |
+
clinic_name: str
|
| 16 |
+
phone: str
|
| 17 |
+
address: str
|
| 18 |
+
city: Optional[str] = None
|
| 19 |
+
state: Optional[str] = None
|
| 20 |
+
zip_code: Optional[str] = None
|
| 21 |
+
country: Optional[str] = None
|
| 22 |
+
website: Optional[str] = None
|
| 23 |
+
opening_hours: Optional[str] = None
|
| 24 |
+
description: Optional[str] = None
|
| 25 |
+
clinic_logo_url: Optional[str] = None
|
| 26 |
+
license_document_url: Optional[str] = None
|
| 27 |
+
|
| 28 |
+
class NotificationReadRequest(BaseModel):
|
| 29 |
+
user_id: str
|
| 30 |
+
|
| 31 |
+
class AddPetRequest(BaseModel):
|
| 32 |
+
name: str
|
| 33 |
+
pet_type: str
|
| 34 |
+
breed: str
|
| 35 |
+
date_of_birth: str
|
| 36 |
+
weight: float
|
| 37 |
+
weight_unit: Optional[str] = "kg"
|
| 38 |
+
gender: Optional[str] = None
|
| 39 |
+
blood_type: Optional[str] = None
|
| 40 |
+
allergies: Optional[str] = None
|
| 41 |
+
medical_conditions: Optional[str] = None
|
| 42 |
+
notes: Optional[str] = None
|
| 43 |
+
|
| 44 |
+
class CreateAppointmentRequest(BaseModel):
|
| 45 |
+
pet_id: str
|
| 46 |
+
clinic_id: str
|
| 47 |
+
appointment_date: str
|
| 48 |
+
appointment_time: str
|
| 49 |
+
reason: Optional[str] = None
|
| 50 |
+
notes: Optional[str] = None
|
| 51 |
+
|
| 52 |
+
class CreateReviewRequest(BaseModel):
|
| 53 |
+
appointment_id: str
|
| 54 |
+
rating: int
|
| 55 |
+
treatment: str
|
| 56 |
+
comment: Optional[str] = None
|
backend/services/__pycache__/appointment_service.cpython-313.pyc
ADDED
|
Binary file (14.7 kB). View file
|
|
|
backend/services/__pycache__/auth_service.cpython-313.pyc
ADDED
|
Binary file (8.87 kB). View file
|
|
|
backend/services/__pycache__/clinic_service.cpython-313.pyc
ADDED
|
Binary file (13.5 kB). View file
|
|
|
backend/services/__pycache__/pet_service.cpython-313.pyc
ADDED
|
Binary file (6.59 kB). View file
|
|
|
backend/services/__pycache__/predictor.cpython-313.pyc
ADDED
|
Binary file (980 Bytes). View file
|
|
|
backend/services/__pycache__/router.cpython-313.pyc
ADDED
|
Binary file (822 Bytes). View file
|
|
|
backend/services/appointment_service.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, List, Optional
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
import time
|
| 4 |
+
import logging
|
| 5 |
+
from chatbot.supabase_config import supabase
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
class AppointmentService:
|
| 10 |
+
"""Service for managing appointments and reviews"""
|
| 11 |
+
|
| 12 |
+
@staticmethod
|
| 13 |
+
def create_appointment(
|
| 14 |
+
pet_id: str,
|
| 15 |
+
clinic_id: str,
|
| 16 |
+
owner_id: str,
|
| 17 |
+
appointment_date: str,
|
| 18 |
+
appointment_time: str,
|
| 19 |
+
reason: Optional[str] = None,
|
| 20 |
+
notes: Optional[str] = None,
|
| 21 |
+
) -> Dict:
|
| 22 |
+
"""Create appointment"""
|
| 23 |
+
try:
|
| 24 |
+
appointment_data = {
|
| 25 |
+
"pet_id": pet_id,
|
| 26 |
+
"clinic_id": clinic_id,
|
| 27 |
+
"owner_id": owner_id,
|
| 28 |
+
"appointment_date": appointment_date,
|
| 29 |
+
"appointment_time": appointment_time,
|
| 30 |
+
"reason": reason,
|
| 31 |
+
"notes": notes,
|
| 32 |
+
"status": "scheduled"
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
response = supabase.table("appointments").insert(appointment_data).execute()
|
| 36 |
+
if not response.data:
|
| 37 |
+
return {"success": False, "error": "Failed to create appointment"}
|
| 38 |
+
|
| 39 |
+
created = response.data[0]
|
| 40 |
+
|
| 41 |
+
# Send notifications
|
| 42 |
+
try:
|
| 43 |
+
AppointmentService._send_appointment_notifications(created)
|
| 44 |
+
except Exception as notif_err:
|
| 45 |
+
logger.warning(f"Failed to send appointment notifications: {notif_err}")
|
| 46 |
+
|
| 47 |
+
return {
|
| 48 |
+
"success": True,
|
| 49 |
+
"appointment_id": created["id"],
|
| 50 |
+
"appointment": created,
|
| 51 |
+
"message": "Appointment created successfully!"
|
| 52 |
+
}
|
| 53 |
+
except Exception as e:
|
| 54 |
+
return {
|
| 55 |
+
"success": False,
|
| 56 |
+
"error": f"Error creating appointment: {str(e)}"
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
@staticmethod
|
| 60 |
+
def get_owner_appointments(owner_id: str) -> Dict:
|
| 61 |
+
"""Get all appointments for pet owner"""
|
| 62 |
+
try:
|
| 63 |
+
response = supabase.table("appointments").select("*").eq("owner_id", owner_id).execute()
|
| 64 |
+
appointments = response.data or []
|
| 65 |
+
# We can enrich reviews here if needed
|
| 66 |
+
return {
|
| 67 |
+
"success": True,
|
| 68 |
+
"appointments": appointments,
|
| 69 |
+
"count": len(appointments)
|
| 70 |
+
}
|
| 71 |
+
except Exception as e:
|
| 72 |
+
return {
|
| 73 |
+
"success": False,
|
| 74 |
+
"error": f"Error fetching owner appointments: {str(e)}"
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
@staticmethod
|
| 78 |
+
def get_clinic_appointments(clinic_id: str) -> Dict:
|
| 79 |
+
"""Get all appointments for clinic"""
|
| 80 |
+
try:
|
| 81 |
+
response = supabase.table("appointments").select("*").eq("clinic_id", clinic_id).execute()
|
| 82 |
+
appointments = response.data or []
|
| 83 |
+
return {
|
| 84 |
+
"success": True,
|
| 85 |
+
"appointments": appointments,
|
| 86 |
+
"count": len(appointments)
|
| 87 |
+
}
|
| 88 |
+
except Exception as e:
|
| 89 |
+
return {
|
| 90 |
+
"success": False,
|
| 91 |
+
"error": f"Error fetching clinic appointments: {str(e)}"
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
@staticmethod
|
| 95 |
+
def get_pet_appointments(pet_id: str) -> Dict:
|
| 96 |
+
"""Get all appointments for a pet"""
|
| 97 |
+
try:
|
| 98 |
+
response = supabase.table("appointments").select("*").eq("pet_id", pet_id).execute()
|
| 99 |
+
appointments = response.data or []
|
| 100 |
+
return {
|
| 101 |
+
"success": True,
|
| 102 |
+
"appointments": appointments,
|
| 103 |
+
"count": len(appointments)
|
| 104 |
+
}
|
| 105 |
+
except Exception as e:
|
| 106 |
+
return {
|
| 107 |
+
"success": False,
|
| 108 |
+
"error": f"Error fetching pet appointments: {str(e)}"
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
@staticmethod
|
| 112 |
+
def update_appointment_status(appointment_id: str, status: str) -> Dict:
|
| 113 |
+
"""Update appointment status (scheduled, completed, cancelled, etc.)"""
|
| 114 |
+
try:
|
| 115 |
+
# Fetch appointment
|
| 116 |
+
appt_resp = supabase.table("appointments").select("*").eq("id", appointment_id).execute()
|
| 117 |
+
if not appt_resp.data:
|
| 118 |
+
return {"success": False, "error": "Appointment not found"}
|
| 119 |
+
|
| 120 |
+
appt = appt_resp.data[0]
|
| 121 |
+
|
| 122 |
+
# Update status
|
| 123 |
+
timestamp = datetime.utcnow().isoformat()
|
| 124 |
+
supabase.table("appointments").update({"status": status, "updated_at": timestamp}).eq("id", appointment_id).execute()
|
| 125 |
+
|
| 126 |
+
# Send status update notifications
|
| 127 |
+
try:
|
| 128 |
+
AppointmentService._send_status_notifications(appt, status)
|
| 129 |
+
except Exception as notif_err:
|
| 130 |
+
logger.warning(f"Failed to send status notifications: {notif_err}")
|
| 131 |
+
|
| 132 |
+
return {
|
| 133 |
+
"success": True,
|
| 134 |
+
"appointment_id": appointment_id,
|
| 135 |
+
"status": status
|
| 136 |
+
}
|
| 137 |
+
except Exception as e:
|
| 138 |
+
return {
|
| 139 |
+
"success": False,
|
| 140 |
+
"error": f"Error updating appointment status: {str(e)}"
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
@staticmethod
|
| 144 |
+
def create_review(
|
| 145 |
+
appointment_id: str,
|
| 146 |
+
rating: int,
|
| 147 |
+
treatment: str,
|
| 148 |
+
comment: Optional[str] = None,
|
| 149 |
+
) -> Dict:
|
| 150 |
+
"""Create review for a completed appointment"""
|
| 151 |
+
try:
|
| 152 |
+
# Fetch appointment
|
| 153 |
+
appt_resp = supabase.table("appointments").select("*").eq("id", appointment_id).execute()
|
| 154 |
+
if not appt_resp.data:
|
| 155 |
+
return {"success": False, "error": "Appointment not found"}
|
| 156 |
+
|
| 157 |
+
appt = appt_resp.data[0]
|
| 158 |
+
if appt.get("status") != "completed":
|
| 159 |
+
return {"success": False, "error": "Only completed appointments can be reviewed"}
|
| 160 |
+
|
| 161 |
+
review_data = {
|
| 162 |
+
"appointment_id": appointment_id,
|
| 163 |
+
"clinic_id": appt.get("clinic_id"),
|
| 164 |
+
"pet_id": appt.get("pet_id"),
|
| 165 |
+
"owner_id": appt.get("owner_id"),
|
| 166 |
+
"rating": rating,
|
| 167 |
+
"treatment": treatment[:100],
|
| 168 |
+
"comment": comment,
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
response = supabase.table("clinic_reviews").insert(review_data).execute()
|
| 172 |
+
if not response.data:
|
| 173 |
+
return {"success": False, "error": "Failed to create review"}
|
| 174 |
+
|
| 175 |
+
created_review = response.data[0]
|
| 176 |
+
|
| 177 |
+
# Notify clinic
|
| 178 |
+
try:
|
| 179 |
+
AppointmentService._notify_clinic_about_review(created_review)
|
| 180 |
+
except Exception as notif_err:
|
| 181 |
+
logger.warning(f"Failed to notify clinic about review: {notif_err}")
|
| 182 |
+
|
| 183 |
+
return {
|
| 184 |
+
"success": True,
|
| 185 |
+
"review": created_review,
|
| 186 |
+
"message": "Review submitted successfully"
|
| 187 |
+
}
|
| 188 |
+
except Exception as e:
|
| 189 |
+
return {
|
| 190 |
+
"success": False,
|
| 191 |
+
"error": f"Error creating review: {str(e)}"
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
@staticmethod
|
| 195 |
+
def get_reviews_for_clinic(clinic_id: str) -> Dict:
|
| 196 |
+
"""Get reviews for a clinic"""
|
| 197 |
+
try:
|
| 198 |
+
response = supabase.table("clinic_reviews").select("*").eq("clinic_id", clinic_id).order("created_at", desc=True).execute()
|
| 199 |
+
reviews = response.data or []
|
| 200 |
+
avg_rating = round(sum(r.get("rating", 0) for r in reviews) / len(reviews), 1) if reviews else 0.0
|
| 201 |
+
return {
|
| 202 |
+
"success": True,
|
| 203 |
+
"reviews": reviews,
|
| 204 |
+
"count": len(reviews),
|
| 205 |
+
"average_rating": avg_rating
|
| 206 |
+
}
|
| 207 |
+
except Exception as e:
|
| 208 |
+
return {
|
| 209 |
+
"success": False,
|
| 210 |
+
"error": f"Error fetching clinic reviews: {str(e)}"
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
# Private helper methods for notifications
|
| 214 |
+
@staticmethod
|
| 215 |
+
def _create_notification(user_id: str, type_: str, title: str, message: str, role: str, entity_type: str, entity_id: str):
|
| 216 |
+
payload = {
|
| 217 |
+
"user_id": user_id,
|
| 218 |
+
"user_role": role,
|
| 219 |
+
"type": type_,
|
| 220 |
+
"title": title,
|
| 221 |
+
"message": message,
|
| 222 |
+
"entity_type": entity_type,
|
| 223 |
+
"entity_id": entity_id,
|
| 224 |
+
"is_read": False,
|
| 225 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 226 |
+
}
|
| 227 |
+
supabase.table("notifications").insert(payload).execute()
|
| 228 |
+
|
| 229 |
+
@staticmethod
|
| 230 |
+
def _send_appointment_notifications(appt: Dict):
|
| 231 |
+
pet_resp = supabase.table("pets").select("name").eq("id", appt["pet_id"]).execute()
|
| 232 |
+
pet_name = pet_resp.data[0]["name"] if pet_resp.data else "your pet"
|
| 233 |
+
|
| 234 |
+
clinic_resp = supabase.table("clinics").select("clinic_name", "user_id").eq("id", appt["clinic_id"]).execute()
|
| 235 |
+
clinic_name = "Clinic"
|
| 236 |
+
clinic_user_id = None
|
| 237 |
+
if clinic_resp.data:
|
| 238 |
+
clinic_name = clinic_resp.data[0]["clinic_name"]
|
| 239 |
+
clinic_user_id = clinic_resp.data[0]["user_id"]
|
| 240 |
+
|
| 241 |
+
title = "Appointment Scheduled"
|
| 242 |
+
message = f"{pet_name} has a new appointment with {clinic_name} on {appt['appointment_date']} at {appt['appointment_time']}."
|
| 243 |
+
|
| 244 |
+
# Notify owner
|
| 245 |
+
AppointmentService._create_notification(appt["owner_id"], "appointment", title, message, "owner", "appointment", appt["id"])
|
| 246 |
+
|
| 247 |
+
# Notify clinic
|
| 248 |
+
if clinic_user_id:
|
| 249 |
+
AppointmentService._create_notification(clinic_user_id, "appointment", title, message, "clinic", "appointment", appt["id"])
|
| 250 |
+
|
| 251 |
+
@staticmethod
|
| 252 |
+
def _send_status_notifications(appt: Dict, status: str):
|
| 253 |
+
pet_resp = supabase.table("pets").select("name").eq("id", appt["pet_id"]).execute()
|
| 254 |
+
pet_name = pet_resp.data[0]["name"] if pet_resp.data else "your pet"
|
| 255 |
+
|
| 256 |
+
clinic_resp = supabase.table("clinics").select("clinic_name", "user_id").eq("id", appt["clinic_id"]).execute()
|
| 257 |
+
clinic_name = "Clinic"
|
| 258 |
+
clinic_user_id = None
|
| 259 |
+
if clinic_resp.data:
|
| 260 |
+
clinic_name = clinic_resp.data[0]["clinic_name"]
|
| 261 |
+
clinic_user_id = clinic_resp.data[0]["user_id"]
|
| 262 |
+
|
| 263 |
+
status_label = status.replace("_", " ").title()
|
| 264 |
+
title = f"Appointment {status_label}"
|
| 265 |
+
message = f"{pet_name}'s appointment with {clinic_name} on {appt['appointment_date']} at {appt['appointment_time']} was updated to {status_label}."
|
| 266 |
+
|
| 267 |
+
# Notify owner
|
| 268 |
+
AppointmentService._create_notification(appt["owner_id"], "appointment_status", title, message, "owner", "appointment", appt["id"])
|
| 269 |
+
|
| 270 |
+
# Notify clinic
|
| 271 |
+
if clinic_user_id:
|
| 272 |
+
AppointmentService._create_notification(clinic_user_id, "appointment_status", title, message, "clinic", "appointment", appt["id"])
|
| 273 |
+
|
| 274 |
+
@staticmethod
|
| 275 |
+
def _notify_clinic_about_review(review: Dict):
|
| 276 |
+
clinic_resp = supabase.table("clinics").select("user_id", "clinic_name").eq("id", review["clinic_id"]).execute()
|
| 277 |
+
if clinic_resp.data and clinic_resp.data[0].get("user_id"):
|
| 278 |
+
clinic_user_id = clinic_resp.data[0]["user_id"]
|
| 279 |
+
clinic_name = clinic_resp.data[0]["clinic_name"]
|
| 280 |
+
AppointmentService._create_notification(
|
| 281 |
+
clinic_user_id,
|
| 282 |
+
"clinic_review",
|
| 283 |
+
"New Client Review",
|
| 284 |
+
f"A pet owner left a {review['rating']}-star review for {clinic_name}.",
|
| 285 |
+
"clinic",
|
| 286 |
+
"review",
|
| 287 |
+
review["id"]
|
| 288 |
+
)
|
| 289 |
+
|
backend/services/auth_service.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Optional
|
| 2 |
+
from chatbot.supabase_config import supabase
|
| 3 |
+
|
| 4 |
+
class AuthService:
|
| 5 |
+
"""Authentication and Profile Service"""
|
| 6 |
+
|
| 7 |
+
@staticmethod
|
| 8 |
+
def register_pet_owner(
|
| 9 |
+
user_id: str,
|
| 10 |
+
email: str,
|
| 11 |
+
first_name: str,
|
| 12 |
+
last_name: str,
|
| 13 |
+
phone: str,
|
| 14 |
+
address: Optional[str] = None,
|
| 15 |
+
state: Optional[str] = None,
|
| 16 |
+
zip_code: Optional[str] = None,
|
| 17 |
+
country: Optional[str] = None,
|
| 18 |
+
bio: Optional[str] = None,
|
| 19 |
+
) -> Dict:
|
| 20 |
+
"""Complete pet owner profile registration"""
|
| 21 |
+
try:
|
| 22 |
+
# 1. Update phone and role in users
|
| 23 |
+
supabase.table("users").update({"phone_number": phone, "role": "owner"}).eq("id", user_id).execute()
|
| 24 |
+
|
| 25 |
+
# 2. Insert or update pet_owners (idempotent registration)
|
| 26 |
+
owner_data = {
|
| 27 |
+
"user_id": user_id,
|
| 28 |
+
"full_name": f"{first_name} {last_name}",
|
| 29 |
+
"email": email,
|
| 30 |
+
"phone": phone,
|
| 31 |
+
"address": address,
|
| 32 |
+
"state": state,
|
| 33 |
+
"zip_code": zip_code,
|
| 34 |
+
"country": country,
|
| 35 |
+
"bio": bio,
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
existing = supabase.table("pet_owners").select("*").eq("user_id", user_id).execute()
|
| 39 |
+
if existing.data:
|
| 40 |
+
response = supabase.table("pet_owners").update(owner_data).eq("user_id", user_id).execute()
|
| 41 |
+
else:
|
| 42 |
+
response = supabase.table("pet_owners").insert(owner_data).execute()
|
| 43 |
+
|
| 44 |
+
return {
|
| 45 |
+
"success": True,
|
| 46 |
+
"user_id": user_id,
|
| 47 |
+
"role": "owner",
|
| 48 |
+
"profile": response.data[0] if response.data else {},
|
| 49 |
+
"message": "Pet owner profile registered successfully!"
|
| 50 |
+
}
|
| 51 |
+
except Exception as e:
|
| 52 |
+
return {
|
| 53 |
+
"success": False,
|
| 54 |
+
"error": f"Registration failed: {str(e)}"
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
@staticmethod
|
| 58 |
+
def register_clinic(
|
| 59 |
+
user_id: str,
|
| 60 |
+
email: str,
|
| 61 |
+
clinic_name: str,
|
| 62 |
+
phone: str,
|
| 63 |
+
address: str,
|
| 64 |
+
city: Optional[str] = None,
|
| 65 |
+
state: Optional[str] = None,
|
| 66 |
+
zip_code: Optional[str] = None,
|
| 67 |
+
country: Optional[str] = None,
|
| 68 |
+
website: Optional[str] = None,
|
| 69 |
+
opening_hours: Optional[str] = None,
|
| 70 |
+
description: Optional[str] = None,
|
| 71 |
+
clinic_logo_url: Optional[str] = None,
|
| 72 |
+
license_document_url: Optional[str] = None,
|
| 73 |
+
latitude: Optional[float] = None,
|
| 74 |
+
longitude: Optional[float] = None,
|
| 75 |
+
) -> Dict:
|
| 76 |
+
"""Complete clinic profile registration"""
|
| 77 |
+
try:
|
| 78 |
+
# 1. Update phone and role in users
|
| 79 |
+
supabase.table("users").update({"phone_number": phone, "role": "clinic"}).eq("id", user_id).execute()
|
| 80 |
+
|
| 81 |
+
# 2. Insert or update clinics (idempotent registration)
|
| 82 |
+
clinic_data = {
|
| 83 |
+
"user_id": user_id,
|
| 84 |
+
"clinic_name": clinic_name,
|
| 85 |
+
"email": email,
|
| 86 |
+
"phone": phone,
|
| 87 |
+
"address": address,
|
| 88 |
+
"city": city,
|
| 89 |
+
"state": state,
|
| 90 |
+
"zip_code": zip_code,
|
| 91 |
+
"country": country,
|
| 92 |
+
"website": website,
|
| 93 |
+
"opening_hours": opening_hours,
|
| 94 |
+
"description": description,
|
| 95 |
+
"clinic_logo_url": clinic_logo_url,
|
| 96 |
+
"license_document_url": license_document_url,
|
| 97 |
+
"latitude": latitude,
|
| 98 |
+
"longitude": longitude,
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
existing = supabase.table("clinics").select("*").eq("user_id", user_id).execute()
|
| 102 |
+
if existing.data:
|
| 103 |
+
# Preserve existing verification status
|
| 104 |
+
clinic_data["is_verified"] = existing.data[0].get("is_verified", False)
|
| 105 |
+
response = supabase.table("clinics").update(clinic_data).eq("user_id", user_id).execute()
|
| 106 |
+
else:
|
| 107 |
+
clinic_data["is_verified"] = False # Starts as pending
|
| 108 |
+
response = supabase.table("clinics").insert(clinic_data).execute()
|
| 109 |
+
|
| 110 |
+
return {
|
| 111 |
+
"success": True,
|
| 112 |
+
"user_id": user_id,
|
| 113 |
+
"role": "clinic",
|
| 114 |
+
"profile": response.data[0] if response.data else {},
|
| 115 |
+
"message": "Clinic profile registered successfully! Pending admin approval."
|
| 116 |
+
}
|
| 117 |
+
except Exception as e:
|
| 118 |
+
return {
|
| 119 |
+
"success": False,
|
| 120 |
+
"error": f"Clinic registration failed: {str(e)}"
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
@staticmethod
|
| 124 |
+
def get_user_profile(user_id: str) -> Dict:
|
| 125 |
+
"""Get user profile based on their role in the users table"""
|
| 126 |
+
try:
|
| 127 |
+
# Get profile
|
| 128 |
+
profile_response = supabase.table("users").select("*").eq("id", user_id).execute()
|
| 129 |
+
if not profile_response.data:
|
| 130 |
+
return {"success": False, "error": "User profile not found"}
|
| 131 |
+
|
| 132 |
+
profile = profile_response.data[0]
|
| 133 |
+
role = profile.get("role")
|
| 134 |
+
|
| 135 |
+
# Get detail profile based on role
|
| 136 |
+
if role == "owner":
|
| 137 |
+
detail_response = supabase.table("pet_owners").select("*").eq("user_id", user_id).execute()
|
| 138 |
+
elif role == "clinic":
|
| 139 |
+
detail_response = supabase.table("clinics").select("*").eq("user_id", user_id).execute()
|
| 140 |
+
else:
|
| 141 |
+
return {
|
| 142 |
+
"success": True,
|
| 143 |
+
"user_id": user_id,
|
| 144 |
+
"role": role,
|
| 145 |
+
"profile": {
|
| 146 |
+
"id": user_id,
|
| 147 |
+
"role": role,
|
| 148 |
+
"email": "admin@petai.com" if role == "admin" else ""
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
if not detail_response.data:
|
| 153 |
+
return {"success": False, "error": f"{role.title()} details not found"}
|
| 154 |
+
|
| 155 |
+
return {
|
| 156 |
+
"success": True,
|
| 157 |
+
"user_id": user_id,
|
| 158 |
+
"role": role,
|
| 159 |
+
"profile": detail_response.data[0]
|
| 160 |
+
}
|
| 161 |
+
except Exception as e:
|
| 162 |
+
return {
|
| 163 |
+
"success": False,
|
| 164 |
+
"error": f"Error fetching profile: {str(e)}"
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
@staticmethod
|
| 168 |
+
def update_user_profile(user_id: str, updates: Dict) -> Dict:
|
| 169 |
+
"""Update pet owner user profile"""
|
| 170 |
+
try:
|
| 171 |
+
# Check role
|
| 172 |
+
profile_resp = supabase.table("users").select("role").eq("id", user_id).execute()
|
| 173 |
+
if not profile_resp.data or profile_resp.data[0].get("role") != "owner":
|
| 174 |
+
return {"success": False, "error": "Only pet owner profiles can be updated here"}
|
| 175 |
+
|
| 176 |
+
# Update pet_owners
|
| 177 |
+
if updates:
|
| 178 |
+
supabase.table("pet_owners").update(updates).eq("user_id", user_id).execute()
|
| 179 |
+
|
| 180 |
+
# Also update the users table to keep them in sync
|
| 181 |
+
user_updates = {}
|
| 182 |
+
if "full_name" in updates:
|
| 183 |
+
user_updates["full_name"] = updates["full_name"]
|
| 184 |
+
if "phone" in updates:
|
| 185 |
+
user_updates["phone_number"] = updates["phone"]
|
| 186 |
+
if "profile_image_url" in updates:
|
| 187 |
+
user_updates["avatar_url"] = updates["profile_image_url"]
|
| 188 |
+
|
| 189 |
+
if user_updates:
|
| 190 |
+
supabase.table("users").update(user_updates).eq("id", user_id).execute()
|
| 191 |
+
|
| 192 |
+
# Retrieve updated profile
|
| 193 |
+
refreshed = supabase.table("pet_owners").select("*").eq("user_id", user_id).execute()
|
| 194 |
+
|
| 195 |
+
return {
|
| 196 |
+
"success": True,
|
| 197 |
+
"profile": refreshed.data[0] if refreshed.data else {}
|
| 198 |
+
}
|
| 199 |
+
except Exception as e:
|
| 200 |
+
return {
|
| 201 |
+
"success": False,
|
| 202 |
+
"error": f"Error updating profile: {str(e)}"
|
| 203 |
+
}
|
backend/services/clinic_service.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, List, Optional
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
import logging
|
| 4 |
+
from chatbot.supabase_config import supabase, SupabaseStorage
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
REJECTION_MARKER = "__ADMIN_REJECTION__::"
|
| 9 |
+
|
| 10 |
+
class ClinicService:
|
| 11 |
+
"""Service for managing clinic profiles, approvals, and public listings"""
|
| 12 |
+
|
| 13 |
+
@staticmethod
|
| 14 |
+
def get_clinic_profile(user_id: str) -> Dict:
|
| 15 |
+
"""Get clinic profile by owner's user_id"""
|
| 16 |
+
try:
|
| 17 |
+
response = supabase.table("clinics").select("*").eq("user_id", user_id).execute()
|
| 18 |
+
if not response.data:
|
| 19 |
+
return {"success": False, "error": "Clinic not found"}
|
| 20 |
+
|
| 21 |
+
clinic = ClinicService._parse_clinic_status(response.data[0])
|
| 22 |
+
clinic["gallery_urls"] = SupabaseStorage.list_clinic_images(user_id)
|
| 23 |
+
|
| 24 |
+
if not clinic.get("clinic_logo_url") and clinic["gallery_urls"]:
|
| 25 |
+
clinic["clinic_logo_url"] = clinic["gallery_urls"][0]
|
| 26 |
+
|
| 27 |
+
return {"success": True, "clinic": clinic}
|
| 28 |
+
except Exception as e:
|
| 29 |
+
return {"success": False, "error": f"Error fetching clinic: {str(e)}"}
|
| 30 |
+
|
| 31 |
+
@staticmethod
|
| 32 |
+
def update_clinic_profile(user_id: str, updates: Dict) -> Dict:
|
| 33 |
+
"""Update clinic profile details"""
|
| 34 |
+
try:
|
| 35 |
+
if updates:
|
| 36 |
+
supabase.table("clinics").update(updates).eq("user_id", user_id).execute()
|
| 37 |
+
|
| 38 |
+
# Refresh and return
|
| 39 |
+
return ClinicService.get_clinic_profile(user_id)
|
| 40 |
+
except Exception as e:
|
| 41 |
+
return {"success": False, "error": f"Error updating clinic: {str(e)}"}
|
| 42 |
+
|
| 43 |
+
@staticmethod
|
| 44 |
+
def get_public_clinics() -> Dict:
|
| 45 |
+
"""List verified clinics for the public frontend"""
|
| 46 |
+
try:
|
| 47 |
+
resp = supabase.table("clinics").select("*").eq("is_verified", True).order("created_at", desc=True).execute()
|
| 48 |
+
clinics = []
|
| 49 |
+
for clinic_data in (resp.data or []):
|
| 50 |
+
parsed = ClinicService._parse_clinic_status(clinic_data)
|
| 51 |
+
uid = parsed.get("user_id")
|
| 52 |
+
parsed["gallery_urls"] = SupabaseStorage.list_clinic_images(uid) if uid else []
|
| 53 |
+
if not parsed.get("clinic_logo_url") and parsed["gallery_urls"]:
|
| 54 |
+
parsed["clinic_logo_url"] = parsed["gallery_urls"][0]
|
| 55 |
+
clinics.append(parsed)
|
| 56 |
+
|
| 57 |
+
return {"success": True, "clinics": clinics, "count": len(clinics)}
|
| 58 |
+
except Exception as e:
|
| 59 |
+
return {"success": False, "error": f"Error fetching public clinics: {str(e)}"}
|
| 60 |
+
|
| 61 |
+
@staticmethod
|
| 62 |
+
def get_clinic_by_id(clinic_id: str) -> Dict:
|
| 63 |
+
"""Get public details of a clinic by its clinic ID"""
|
| 64 |
+
try:
|
| 65 |
+
resp = supabase.table("clinics").select("*").eq("id", clinic_id).execute()
|
| 66 |
+
if not resp.data:
|
| 67 |
+
return {"success": False, "error": "Clinic not found"}
|
| 68 |
+
|
| 69 |
+
clinic = ClinicService._parse_clinic_status(resp.data[0])
|
| 70 |
+
uid = clinic.get("user_id")
|
| 71 |
+
clinic["gallery_urls"] = SupabaseStorage.list_clinic_images(uid) if uid else []
|
| 72 |
+
if not clinic.get("clinic_logo_url") and clinic["gallery_urls"]:
|
| 73 |
+
clinic["clinic_logo_url"] = clinic["gallery_urls"][0]
|
| 74 |
+
|
| 75 |
+
return {"success": True, "clinic": clinic}
|
| 76 |
+
except Exception as e:
|
| 77 |
+
return {"success": False, "error": f"Error fetching clinic: {str(e)}"}
|
| 78 |
+
|
| 79 |
+
@staticmethod
|
| 80 |
+
def get_pending_clinics() -> Dict:
|
| 81 |
+
"""List all pending clinics (unverified and not rejected)"""
|
| 82 |
+
try:
|
| 83 |
+
resp = supabase.table("clinics").select("*").eq("is_verified", False).execute()
|
| 84 |
+
pending = []
|
| 85 |
+
for clinic in (resp.data or []):
|
| 86 |
+
parsed = ClinicService._parse_clinic_status(clinic)
|
| 87 |
+
if parsed["verification_status"] == "pending":
|
| 88 |
+
pending.append(parsed)
|
| 89 |
+
return {"success": True, "clinics": pending, "count": len(pending)}
|
| 90 |
+
except Exception as e:
|
| 91 |
+
return {"success": False, "error": f"Error fetching pending clinics: {str(e)}"}
|
| 92 |
+
|
| 93 |
+
@staticmethod
|
| 94 |
+
def approve_clinic(clinic_id: str) -> Dict:
|
| 95 |
+
"""Approve clinic (admin action)"""
|
| 96 |
+
try:
|
| 97 |
+
clinic_resp = supabase.table("clinics").select("id", "user_id", "clinic_name").eq("id", clinic_id).execute()
|
| 98 |
+
if not clinic_resp.data:
|
| 99 |
+
return {"success": False, "error": "Clinic not found"}
|
| 100 |
+
|
| 101 |
+
clinic = clinic_resp.data[0]
|
| 102 |
+
user_id = clinic.get("user_id")
|
| 103 |
+
clinic_name = clinic.get("clinic_name") or "Your clinic"
|
| 104 |
+
|
| 105 |
+
# Fetch current description and strip rejection marker if any
|
| 106 |
+
current = supabase.table("clinics").select("description").eq("id", clinic_id).execute()
|
| 107 |
+
current_desc = current.data[0].get("description") if current.data else ""
|
| 108 |
+
clean_desc = ClinicService._strip_rejection_marker(current_desc)
|
| 109 |
+
|
| 110 |
+
# Update clinic to verified
|
| 111 |
+
supabase.table("clinics").update({"is_verified": True, "description": clean_desc}).eq("id", clinic_id).execute()
|
| 112 |
+
|
| 113 |
+
# Enable corresponding profile (profile is already active via auth, no-op)
|
| 114 |
+
if user_id:
|
| 115 |
+
# Create notification
|
| 116 |
+
ClinicService._create_notification(
|
| 117 |
+
user_id,
|
| 118 |
+
"clinic_approval",
|
| 119 |
+
"Clinic Approved ✅",
|
| 120 |
+
f"Congratulations! {clinic_name} has been approved by the admin. You can now access all features.",
|
| 121 |
+
"clinic",
|
| 122 |
+
"clinic",
|
| 123 |
+
clinic_id
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
return {"success": True, "clinic_id": clinic_id, "message": "Clinic approved successfully!"}
|
| 127 |
+
except Exception as e:
|
| 128 |
+
return {"success": False, "error": f"Error approving clinic: {str(e)}"}
|
| 129 |
+
|
| 130 |
+
@staticmethod
|
| 131 |
+
def reject_clinic(clinic_id: str, reason: Optional[str] = None) -> Dict:
|
| 132 |
+
"""Reject clinic (admin action)"""
|
| 133 |
+
try:
|
| 134 |
+
clinic_resp = supabase.table("clinics").select("id", "user_id", "clinic_name").eq("id", clinic_id).execute()
|
| 135 |
+
if not clinic_resp.data:
|
| 136 |
+
return {"success": False, "error": "Clinic not found"}
|
| 137 |
+
|
| 138 |
+
clinic = clinic_resp.data[0]
|
| 139 |
+
user_id = clinic.get("user_id")
|
| 140 |
+
clinic_name = clinic.get("clinic_name") or "Your clinic"
|
| 141 |
+
|
| 142 |
+
# Fetch current description to append rejection marker
|
| 143 |
+
current = supabase.table("clinics").select("description").eq("id", clinic_id).execute()
|
| 144 |
+
current_desc = ""
|
| 145 |
+
if current.data:
|
| 146 |
+
current_desc = ClinicService._strip_rejection_marker(current.data[0].get("description") or "")
|
| 147 |
+
|
| 148 |
+
timestamp = datetime.utcnow().isoformat()
|
| 149 |
+
marker = REJECTION_MARKER
|
| 150 |
+
if reason:
|
| 151 |
+
safe_reason = str(reason).replace("::", "--")
|
| 152 |
+
marker_payload = f"reason={safe_reason}::time={timestamp}"
|
| 153 |
+
new_desc = current_desc + ("\n\n" if current_desc else "") + marker + marker_payload
|
| 154 |
+
else:
|
| 155 |
+
marker_payload = f"time={timestamp}"
|
| 156 |
+
new_desc = current_desc + ("\n\n" if current_desc else "") + marker + marker_payload
|
| 157 |
+
|
| 158 |
+
# Update clinic: set is_verified false and append description marker
|
| 159 |
+
supabase.table("clinics").update({"is_verified": False, "description": new_desc}).eq("id", clinic_id).execute()
|
| 160 |
+
|
| 161 |
+
# Keep associated auth profile active so they can log in, view status, and edit/resubmit profile
|
| 162 |
+
if user_id:
|
| 163 |
+
# Create notification
|
| 164 |
+
rejection_message = f"Your registration for {clinic_name} was rejected by the admin."
|
| 165 |
+
if reason:
|
| 166 |
+
rejection_message += f" Reason: {reason}"
|
| 167 |
+
else:
|
| 168 |
+
rejection_message += " Please check your documents and resubmit."
|
| 169 |
+
|
| 170 |
+
ClinicService._create_notification(
|
| 171 |
+
user_id,
|
| 172 |
+
"clinic_rejection",
|
| 173 |
+
"Clinic Verification Rejected ❌",
|
| 174 |
+
rejection_message,
|
| 175 |
+
"clinic",
|
| 176 |
+
"clinic",
|
| 177 |
+
clinic_id
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
return {
|
| 181 |
+
"success": True,
|
| 182 |
+
"clinic_id": clinic_id,
|
| 183 |
+
"message": "Clinic rejected successfully",
|
| 184 |
+
"reason": reason,
|
| 185 |
+
"rejected_at": timestamp
|
| 186 |
+
}
|
| 187 |
+
except Exception as e:
|
| 188 |
+
return {"success": False, "error": f"Error rejecting clinic: {str(e)}"}
|
| 189 |
+
|
| 190 |
+
# Private helper methods
|
| 191 |
+
@staticmethod
|
| 192 |
+
def _parse_clinic_status(clinic: dict) -> dict:
|
| 193 |
+
clinic_copy = dict(clinic)
|
| 194 |
+
desc = clinic_copy.get("description") or ""
|
| 195 |
+
is_rejected = False
|
| 196 |
+
rejection_reason = None
|
| 197 |
+
rejected_at = None
|
| 198 |
+
|
| 199 |
+
if REJECTION_MARKER in desc:
|
| 200 |
+
try:
|
| 201 |
+
marker_payload = desc.split(REJECTION_MARKER, 1)[1].split("::")
|
| 202 |
+
kv = {}
|
| 203 |
+
for part in marker_payload:
|
| 204 |
+
if "=" in part:
|
| 205 |
+
key, value = part.split("=", 1)
|
| 206 |
+
kv[key] = value
|
| 207 |
+
rejection_reason = kv.get("reason")
|
| 208 |
+
rejected_at = kv.get("time")
|
| 209 |
+
is_rejected = True
|
| 210 |
+
except Exception:
|
| 211 |
+
is_rejected = False
|
| 212 |
+
|
| 213 |
+
if clinic_copy.get("is_verified"):
|
| 214 |
+
verification_status = "approved"
|
| 215 |
+
is_rejected = False
|
| 216 |
+
rejection_reason = None
|
| 217 |
+
rejected_at = None
|
| 218 |
+
elif is_rejected:
|
| 219 |
+
verification_status = "rejected"
|
| 220 |
+
else:
|
| 221 |
+
verification_status = "pending"
|
| 222 |
+
|
| 223 |
+
clinic_copy["is_rejected"] = is_rejected
|
| 224 |
+
clinic_copy["rejection_reason"] = rejection_reason
|
| 225 |
+
clinic_copy["rejected_at"] = rejected_at
|
| 226 |
+
clinic_copy["verification_status"] = verification_status
|
| 227 |
+
return clinic_copy
|
| 228 |
+
|
| 229 |
+
@staticmethod
|
| 230 |
+
def _strip_rejection_marker(description: Optional[str]) -> str:
|
| 231 |
+
if not description:
|
| 232 |
+
return ""
|
| 233 |
+
if REJECTION_MARKER not in description:
|
| 234 |
+
return description.strip()
|
| 235 |
+
return description.split(REJECTION_MARKER, 1)[0].rstrip()
|
| 236 |
+
|
| 237 |
+
@staticmethod
|
| 238 |
+
def _create_notification(user_id: str, type_: str, title: str, message: str, role: str, entity_type: str, entity_id: str):
|
| 239 |
+
payload = {
|
| 240 |
+
"user_id": user_id,
|
| 241 |
+
"user_role": role,
|
| 242 |
+
"type": type_,
|
| 243 |
+
"title": title,
|
| 244 |
+
"message": message,
|
| 245 |
+
"entity_type": entity_type,
|
| 246 |
+
"entity_id": entity_id,
|
| 247 |
+
"is_read": False,
|
| 248 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 249 |
+
}
|
| 250 |
+
supabase.table("notifications").insert(payload).execute()
|
backend/services/pet_service.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Optional
|
| 2 |
+
import time
|
| 3 |
+
from chatbot.supabase_config import supabase, SupabaseStorage
|
| 4 |
+
|
| 5 |
+
class PetService:
|
| 6 |
+
"""Service for managing pets and their vaccine records"""
|
| 7 |
+
|
| 8 |
+
@staticmethod
|
| 9 |
+
def add_pet(
|
| 10 |
+
user_id: str,
|
| 11 |
+
name: str,
|
| 12 |
+
pet_type: str,
|
| 13 |
+
breed: str,
|
| 14 |
+
date_of_birth: str,
|
| 15 |
+
weight: float,
|
| 16 |
+
weight_unit: str = "kg",
|
| 17 |
+
gender: Optional[str] = None,
|
| 18 |
+
blood_type: Optional[str] = None,
|
| 19 |
+
allergies: Optional[str] = None,
|
| 20 |
+
medical_conditions: Optional[str] = None,
|
| 21 |
+
notes: Optional[str] = None,
|
| 22 |
+
profile_image_url: Optional[str] = None,
|
| 23 |
+
) -> Dict:
|
| 24 |
+
"""Add new pet"""
|
| 25 |
+
try:
|
| 26 |
+
pet_data = {
|
| 27 |
+
"user_id": user_id,
|
| 28 |
+
"name": name,
|
| 29 |
+
"type": pet_type,
|
| 30 |
+
"breed": breed,
|
| 31 |
+
"date_of_birth": date_of_birth,
|
| 32 |
+
"weight": weight,
|
| 33 |
+
"weight_unit": weight_unit,
|
| 34 |
+
"gender": gender,
|
| 35 |
+
"blood_type": blood_type,
|
| 36 |
+
"allergies": allergies,
|
| 37 |
+
"medical_conditions": medical_conditions,
|
| 38 |
+
"notes": notes,
|
| 39 |
+
"profile_image_url": profile_image_url,
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
response = supabase.table("pets").insert(pet_data).execute()
|
| 43 |
+
if not response.data:
|
| 44 |
+
return {"success": False, "error": "Failed to create pet"}
|
| 45 |
+
|
| 46 |
+
pet = response.data[0]
|
| 47 |
+
return {
|
| 48 |
+
"success": True,
|
| 49 |
+
"pet_id": pet["id"],
|
| 50 |
+
"pet_name": pet["name"],
|
| 51 |
+
"message": "Pet added successfully!"
|
| 52 |
+
}
|
| 53 |
+
except Exception as e:
|
| 54 |
+
return {
|
| 55 |
+
"success": False,
|
| 56 |
+
"error": f"Error adding pet: {str(e)}"
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
@staticmethod
|
| 60 |
+
def get_user_pets(user_id: str) -> Dict:
|
| 61 |
+
"""Get all pets for a user"""
|
| 62 |
+
try:
|
| 63 |
+
response = supabase.table("pets").select("*").eq("user_id", user_id).execute()
|
| 64 |
+
return {
|
| 65 |
+
"success": True,
|
| 66 |
+
"pets": response.data or [],
|
| 67 |
+
"count": len(response.data or [])
|
| 68 |
+
}
|
| 69 |
+
except Exception as e:
|
| 70 |
+
return {
|
| 71 |
+
"success": False,
|
| 72 |
+
"error": f"Error fetching pets: {str(e)}"
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
@staticmethod
|
| 76 |
+
def update_pet(pet_id: str, updates: Dict) -> Dict:
|
| 77 |
+
"""Update pet details"""
|
| 78 |
+
try:
|
| 79 |
+
response = supabase.table("pets").update(updates).eq("id", pet_id).execute()
|
| 80 |
+
if not response.data:
|
| 81 |
+
return {
|
| 82 |
+
"success": False,
|
| 83 |
+
"error": "Pet not found or not updated"
|
| 84 |
+
}
|
| 85 |
+
return {
|
| 86 |
+
"success": True,
|
| 87 |
+
"pet": response.data[0],
|
| 88 |
+
"message": "Pet updated successfully!"
|
| 89 |
+
}
|
| 90 |
+
except Exception as e:
|
| 91 |
+
return {
|
| 92 |
+
"success": False,
|
| 93 |
+
"error": f"Error updating pet: {str(e)}"
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
@staticmethod
|
| 97 |
+
def upload_vaccine_record(
|
| 98 |
+
pet_id: str,
|
| 99 |
+
file_data: bytes,
|
| 100 |
+
file_name: str,
|
| 101 |
+
file_type: str,
|
| 102 |
+
uploaded_by: str,
|
| 103 |
+
upload_date: Optional[str] = None,
|
| 104 |
+
) -> Dict:
|
| 105 |
+
"""Upload vaccine record file"""
|
| 106 |
+
try:
|
| 107 |
+
from datetime import datetime
|
| 108 |
+
if not upload_date:
|
| 109 |
+
upload_date = datetime.now().strftime("%Y-%m-%d")
|
| 110 |
+
|
| 111 |
+
file_path = f"vaccine-records/{pet_id}/{int(time.time())}-{file_name}"
|
| 112 |
+
|
| 113 |
+
# Upload to storage
|
| 114 |
+
SupabaseStorage.ensure_bucket("vaccine-documents", public=False)
|
| 115 |
+
supabase.storage.from_("vaccine-documents").upload(
|
| 116 |
+
file=file_data,
|
| 117 |
+
path=file_path,
|
| 118 |
+
file_options={
|
| 119 |
+
"content-type": "application/pdf" if file_type == "pdf" else "image/jpeg",
|
| 120 |
+
"upsert": "false",
|
| 121 |
+
},
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# Save record to database
|
| 125 |
+
record_data = {
|
| 126 |
+
"pet_id": pet_id,
|
| 127 |
+
"file_name": file_name,
|
| 128 |
+
"file_url": file_path,
|
| 129 |
+
"file_type": file_type,
|
| 130 |
+
"file_size": len(file_data),
|
| 131 |
+
"upload_date": upload_date,
|
| 132 |
+
"uploaded_by": uploaded_by,
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
response = supabase.table("vaccine_records").insert(record_data).execute()
|
| 136 |
+
if not response.data:
|
| 137 |
+
return {"success": False, "error": "Failed to save vaccine record"}
|
| 138 |
+
|
| 139 |
+
return {
|
| 140 |
+
"success": True,
|
| 141 |
+
"record_id": response.data[0]["id"],
|
| 142 |
+
"file_url": file_path,
|
| 143 |
+
"message": "Vaccine record uploaded successfully!"
|
| 144 |
+
}
|
| 145 |
+
except Exception as e:
|
| 146 |
+
return {
|
| 147 |
+
"success": False,
|
| 148 |
+
"error": f"Error uploading vaccine record: {str(e)}"
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
@staticmethod
|
| 152 |
+
def get_pet_vaccine_records(pet_id: str) -> Dict:
|
| 153 |
+
"""Get all vaccine records for a pet"""
|
| 154 |
+
try:
|
| 155 |
+
response = supabase.table("vaccine_records").select("*").eq("pet_id", pet_id).execute()
|
| 156 |
+
return {
|
| 157 |
+
"success": True,
|
| 158 |
+
"records": response.data or [],
|
| 159 |
+
"count": len(response.data or [])
|
| 160 |
+
}
|
| 161 |
+
except Exception as e:
|
| 162 |
+
return {
|
| 163 |
+
"success": False,
|
| 164 |
+
"error": f"Error fetching vaccine records: {str(e)}"
|
| 165 |
+
}
|
backend/services/predictor.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
from backend.core.config import DEVICE
|
| 3 |
+
|
| 4 |
+
def predict(model, class_names, tensor):
|
| 5 |
+
tensor = tensor.to(DEVICE)
|
| 6 |
+
|
| 7 |
+
with torch.no_grad():
|
| 8 |
+
outputs = model(tensor)
|
| 9 |
+
probs = torch.nn.functional.softmax(outputs, dim=1)
|
| 10 |
+
conf, pred = torch.max(probs, 1)
|
| 11 |
+
|
| 12 |
+
return {
|
| 13 |
+
"class": class_names[pred.item()],
|
| 14 |
+
"confidence": float(conf.item())
|
| 15 |
+
}
|
backend/services/router.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def route_prediction(app, animal, disease_type, tensor):
|
| 2 |
+
|
| 3 |
+
if animal == "dog" and disease_type == "skin":
|
| 4 |
+
model, classes = app.state.dog_skin
|
| 5 |
+
elif animal == "dog" and disease_type == "eye":
|
| 6 |
+
model, classes = app.state.dog_eye
|
| 7 |
+
elif animal == "cat" and disease_type == "skin":
|
| 8 |
+
model, classes = app.state.cat_skin
|
| 9 |
+
else:
|
| 10 |
+
return {"error": "Invalid input"}
|
| 11 |
+
|
| 12 |
+
from backend.services.predictor import predict
|
| 13 |
+
return predict(model, classes, tensor)
|
backend/utils/__pycache__/image.cpython-313.pyc
ADDED
|
Binary file (894 Bytes). View file
|
|
|
backend/utils/image.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from PIL import Image
|
| 2 |
+
import torchvision.transforms as transforms
|
| 3 |
+
|
| 4 |
+
transform = transforms.Compose([
|
| 5 |
+
transforms.Resize((224,224)),
|
| 6 |
+
transforms.ToTensor(),
|
| 7 |
+
transforms.Normalize([0.485,0.456,0.406],
|
| 8 |
+
[0.229,0.224,0.225])
|
| 9 |
+
])
|
| 10 |
+
|
| 11 |
+
def preprocess(image_bytes):
|
| 12 |
+
image = Image.open(image_bytes).convert("RGB")
|
| 13 |
+
return transform(image).unsqueeze(0)
|
weights/cat_skin_model.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3f454a8526359edd258af304d0f62c8e6e3931232ef96fcd4e69d4cd2d2176ff
|
| 3 |
+
size 94385321
|
weights/dog_eye_model.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6e9db83fc5e31b46612e23fac2b3fc104f60e5987b96a1b5002e44ec65145aaa
|
| 3 |
+
size 94393187
|
weights/dog_eye_model_ResNet_NEW.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ffef2d414b30090a756ab3d77be1b9ab2dc7ce82155a63449c046f34afcdcfcf
|
| 3 |
+
size 94396901
|
weights/dog_skin_model.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:649617acc0d788d10bebe214af294faf447fce5258bac44f3c1b8e4b6f05b29d
|
| 3 |
+
size 94401769
|