Spaces:
Sleeping
Sleeping
Commit ·
4367adf
1
Parent(s): 215b2aa
feat: add core functionality for user management and services
Browse files- Implement user authentication with JWT tokens
- Add OTP service for email and SMS verification
- Create rate limiting middleware for API protection
- Set up MongoDB and Redis clients for data storage
- Add utility modules for common operations (logging, validation, etc.)
- Implement profile, wallet, favorite, and review services
- Add schemas and models for data validation and storage
This view is limited to 50 files because it contains too many changes.
See raw diff
- app/app.py +46 -85
- app/core/cache_client.py +79 -0
- app/core/config.py +70 -0
- app/core/nosql_client.py +10 -0
- app/middleware/rate_limiter.py +27 -0
- app/models/__init__.py +0 -0
- app/models/address_model.py +243 -0
- app/models/favorite_model.py +209 -0
- app/models/guest_model.py +290 -0
- app/models/otp_model.py +230 -0
- app/models/pet_model.py +270 -0
- app/models/refresh_token_model.py +282 -0
- app/models/review_model.py +45 -0
- app/models/social_account_model.py +257 -0
- app/models/social_security_model.py +188 -0
- app/models/user_model.py +159 -0
- app/models/wallet_model.py +158 -0
- app/routers/__init__.py +11 -2
- app/routers/account_router.py +218 -0
- app/routers/address_router.py +327 -0
- app/routers/favorite_router.py +205 -0
- app/routers/guest_router.py +309 -0
- app/routers/pet_router.py +305 -0
- app/routers/profile_router.py +200 -0
- app/routers/review_router.py +48 -0
- app/routers/user_router.py +556 -0
- app/routers/wallet_router.py +223 -0
- app/schemas/__init__.py +0 -0
- app/schemas/address_schema.py +74 -0
- app/schemas/favorite_schema.py +45 -0
- app/schemas/guest_schema.py +231 -0
- app/schemas/pet_schema.py +114 -0
- app/schemas/profile_schema.py +91 -0
- app/schemas/review_schema.py +24 -0
- app/schemas/user_schema.py +198 -0
- app/schemas/wallet_schema.py +71 -0
- app/services/__init__.py +0 -0
- app/services/account_service.py +396 -0
- app/services/favorite_service.py +158 -0
- app/services/otp_service.py +36 -0
- app/services/profile_service.py +72 -0
- app/services/user_service.py +350 -0
- app/services/wallet_service.py +212 -0
- app/utils/__init__.py +0 -0
- app/utils/common_utils.py +31 -0
- app/utils/db.py +26 -0
- app/utils/email_utils.py +16 -0
- app/utils/jwt.py +137 -0
- app/utils/logger.py +32 -0
- app/utils/sms_utils.py +15 -0
app/app.py
CHANGED
|
@@ -4,13 +4,26 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
| 4 |
from fastapi.responses import JSONResponse
|
| 5 |
from insightfy_utils.logging import setup_logging, get_logger
|
| 6 |
from insightfy_utils.config import load_env
|
| 7 |
-
from app.routers.user import router as user_router
|
| 8 |
from app.middleware.security_middleware import create_security_middleware
|
|
|
|
| 9 |
from app.config.validation import validate_environment_variables, validate_database_isolation
|
| 10 |
import os
|
| 11 |
import time
|
| 12 |
import asyncio
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
load_env()
|
| 15 |
_env = os.getenv("ENVIRONMENT", "development")
|
| 16 |
_log_level = os.getenv("LOG_LEVEL") or ("WARNING" if _env == "development" else "INFO")
|
|
@@ -51,6 +64,9 @@ app.add_middleware(
|
|
| 51 |
enable_security_headers=True
|
| 52 |
)
|
| 53 |
|
|
|
|
|
|
|
|
|
|
| 54 |
allowed_origins = [
|
| 55 |
"http://localhost:3000",
|
| 56 |
"http://localhost:3001",
|
|
@@ -94,91 +110,36 @@ async def add_service_headers(request: Request, call_next):
|
|
| 94 |
response.headers["X-Powered-By"] = "Insightfy"
|
| 95 |
return response
|
| 96 |
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
prefix="/ums/v1/users",
|
| 100 |
-
tags=["Users"],
|
| 101 |
-
responses={404: {"description": "Not found"}, 500: {"description": "Internal error"}},
|
| 102 |
-
)
|
| 103 |
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
return {
|
| 107 |
-
"service": "Insightfy BMS - User Management Service",
|
| 108 |
-
"version": "1.0.0",
|
| 109 |
-
"status": "running",
|
| 110 |
-
"description": "User Management Service API for Insightfy BMS platform",
|
| 111 |
-
"docs_url": "/docs",
|
| 112 |
-
"health_check": "/ums/health",
|
| 113 |
-
}
|
| 114 |
-
|
| 115 |
-
@app.get("/ums/health", tags=["Health"])
|
| 116 |
-
async def health_check():
|
| 117 |
-
return {"status": "healthy", "service": "insightfy-bms-ms-ums", "version": "1.0.0", "timestamp": time.time()}
|
| 118 |
-
|
| 119 |
-
@app.get("/ums/health/detailed", tags=["Health"])
|
| 120 |
-
async def detailed_health_check():
|
| 121 |
-
try:
|
| 122 |
-
health_status = await get_comprehensive_health_status()
|
| 123 |
-
return health_status
|
| 124 |
-
except Exception as e:
|
| 125 |
-
logger.error("detailed_health_failed", extra={"error": str(e), "service": "insightfy-bms-ms-ums"}, exc_info=True)
|
| 126 |
-
return {"status": "unhealthy", "service": "insightfy-bms-ms-ums", "version": "1.0.0", "error": "Health check failed", "timestamp": time.time()}
|
| 127 |
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
try:
|
| 131 |
-
from app.nosql import get_connection_metrics
|
| 132 |
-
connection_metrics = await get_connection_metrics()
|
| 133 |
-
return {"service": "insightfy-bms-ms-ums", "version": "1.0.0", "connection_metrics": connection_metrics, "status": "healthy", "timestamp": time.time(), "environment": os.getenv("ENVIRONMENT", "development")}
|
| 134 |
-
except Exception as e:
|
| 135 |
-
logger.error("metrics_failed", extra={"error": str(e), "service": "insightfy-bms-ms-ums"}, exc_info=True)
|
| 136 |
-
return {"service": "insightfy-bms-ms-ums", "version": "1.0.0", "error": "Failed to retrieve metrics", "status": "unhealthy", "timestamp": time.time()}
|
| 137 |
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
from app.nosql import get_database_status
|
| 159 |
-
from app.config.validation import validate_environment_variables, validate_database_isolation
|
| 160 |
-
db_status = await get_database_status()
|
| 161 |
-
try:
|
| 162 |
-
config_status = validate_environment_variables()
|
| 163 |
-
config_valid = True
|
| 164 |
-
except Exception as e:
|
| 165 |
-
config_status = {"error": str(e)}
|
| 166 |
-
config_valid = False
|
| 167 |
-
isolation_valid = validate_database_isolation()
|
| 168 |
-
overall_healthy = (db_status["overall_status"] == "healthy" and config_valid and isolation_valid)
|
| 169 |
-
return {
|
| 170 |
-
"status": "healthy" if overall_healthy else "unhealthy",
|
| 171 |
-
"service": "insightfy-bms-ms-ums",
|
| 172 |
-
"version": "1.0.0",
|
| 173 |
-
"components": {
|
| 174 |
-
"mongodb": "healthy" if db_status["mongodb"]["connected"] else "unhealthy",
|
| 175 |
-
"redis": "healthy" if db_status["redis"]["connected"] else "unhealthy",
|
| 176 |
-
"configuration": "healthy" if config_valid else "unhealthy",
|
| 177 |
-
"database_isolation": "healthy" if isolation_valid else "unhealthy",
|
| 178 |
-
},
|
| 179 |
-
"database": {"name": db_status["mongodb"]["database"], "connected": db_status["mongodb"]["connected"], "isolation_valid": isolation_valid},
|
| 180 |
-
"configuration": config_status,
|
| 181 |
-
"metrics": {"check_duration_ms": db_status["check_duration_ms"], "timestamp": db_status["timestamp"]},
|
| 182 |
-
"environment": os.getenv("ENVIRONMENT", "development"),
|
| 183 |
-
}
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
from fastapi.responses import JSONResponse
|
| 5 |
from insightfy_utils.logging import setup_logging, get_logger
|
| 6 |
from insightfy_utils.config import load_env
|
|
|
|
| 7 |
from app.middleware.security_middleware import create_security_middleware
|
| 8 |
+
from app.middleware.rate_limiter import RateLimitMiddleware
|
| 9 |
from app.config.validation import validate_environment_variables, validate_database_isolation
|
| 10 |
import os
|
| 11 |
import time
|
| 12 |
import asyncio
|
| 13 |
|
| 14 |
+
# Import migrated routers
|
| 15 |
+
from app.routers import (
|
| 16 |
+
user_router,
|
| 17 |
+
profile_router,
|
| 18 |
+
account_router,
|
| 19 |
+
wallet_router,
|
| 20 |
+
address_router,
|
| 21 |
+
pet_router,
|
| 22 |
+
guest_router,
|
| 23 |
+
favorite_router,
|
| 24 |
+
review_router
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
load_env()
|
| 28 |
_env = os.getenv("ENVIRONMENT", "development")
|
| 29 |
_log_level = os.getenv("LOG_LEVEL") or ("WARNING" if _env == "development" else "INFO")
|
|
|
|
| 64 |
enable_security_headers=True
|
| 65 |
)
|
| 66 |
|
| 67 |
+
# Add rate limiting middleware
|
| 68 |
+
app.add_middleware(RateLimitMiddleware, calls=100, period=60)
|
| 69 |
+
|
| 70 |
allowed_origins = [
|
| 71 |
"http://localhost:3000",
|
| 72 |
"http://localhost:3001",
|
|
|
|
| 110 |
response.headers["X-Powered-By"] = "Insightfy"
|
| 111 |
return response
|
| 112 |
|
| 113 |
+
# Include Routers
|
| 114 |
+
# Mapping source paths to /ums/v1/... convention
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
+
# Auth / User
|
| 117 |
+
app.include_router(user_router.router, prefix="/ums/v1/auth", tags=["User Auth"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
+
# Profile
|
| 120 |
+
app.include_router(profile_router.router, prefix="/ums/v1/profile", tags=["Profile"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
+
# Account
|
| 123 |
+
app.include_router(account_router.router, prefix="/ums/v1/account", tags=["Account Management"])
|
| 124 |
+
|
| 125 |
+
# Wallet
|
| 126 |
+
app.include_router(wallet_router.router, prefix="/ums/v1/wallet", tags=["Wallet Management"])
|
| 127 |
+
|
| 128 |
+
# Address
|
| 129 |
+
app.include_router(address_router.router, prefix="/ums/v1/addresses", tags=["Address Management"])
|
| 130 |
+
|
| 131 |
+
# Others (Pet, Guest, Favorite were under /api/v1/users in source)
|
| 132 |
+
app.include_router(pet_router.router, prefix="/ums/v1/users", tags=["Pet Management"])
|
| 133 |
+
app.include_router(guest_router.router, prefix="/ums/v1/users", tags=["Guest Management"])
|
| 134 |
+
app.include_router(favorite_router.router, prefix="/ums/v1/users", tags=["Favorites"])
|
| 135 |
+
|
| 136 |
+
# Reviews
|
| 137 |
+
app.include_router(review_router.router, prefix="/ums/v1/reviews", tags=["Reviews"])
|
| 138 |
+
|
| 139 |
+
@app.get("/")
|
| 140 |
+
def root():
|
| 141 |
+
return {"message": "Insightfy BMS User Management Service is running"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
+
@app.get("/health")
|
| 144 |
+
def health():
|
| 145 |
+
return {"status": "healthy", "service": "insightfy-bms-ms-ums", "timestamp": time.time()}
|
app/core/cache_client.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from redis.asyncio import Redis
|
| 3 |
+
from redis.exceptions import RedisError, ConnectionError, AuthenticationError
|
| 4 |
+
from app.core.config import settings
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
# Parse host and port
|
| 9 |
+
CACHE_HOST, CACHE_PORT = settings.CACHE_URI.split(":")
|
| 10 |
+
CACHE_PORT = int(CACHE_PORT)
|
| 11 |
+
|
| 12 |
+
async def create_redis_client():
|
| 13 |
+
"""Create Redis client with proper error handling and fallback"""
|
| 14 |
+
try:
|
| 15 |
+
# First try with authentication if password is provided
|
| 16 |
+
if settings.CACHE_K and settings.CACHE_K.strip():
|
| 17 |
+
redis_client = Redis(
|
| 18 |
+
host=CACHE_HOST,
|
| 19 |
+
port=CACHE_PORT,
|
| 20 |
+
username="default",
|
| 21 |
+
password=settings.CACHE_K,
|
| 22 |
+
decode_responses=True,
|
| 23 |
+
socket_connect_timeout=5,
|
| 24 |
+
socket_timeout=5,
|
| 25 |
+
retry_on_timeout=True
|
| 26 |
+
)
|
| 27 |
+
# Test the connection
|
| 28 |
+
await redis_client.ping()
|
| 29 |
+
logger.info(f"Connected to Redis at {CACHE_HOST}:{CACHE_PORT} with authentication")
|
| 30 |
+
return redis_client
|
| 31 |
+
else:
|
| 32 |
+
# Try without authentication for local Redis
|
| 33 |
+
redis_client = Redis(
|
| 34 |
+
host=CACHE_HOST,
|
| 35 |
+
port=CACHE_PORT,
|
| 36 |
+
decode_responses=True,
|
| 37 |
+
socket_connect_timeout=5,
|
| 38 |
+
socket_timeout=5,
|
| 39 |
+
retry_on_timeout=True
|
| 40 |
+
)
|
| 41 |
+
# Test the connection
|
| 42 |
+
await redis_client.ping()
|
| 43 |
+
logger.info(f"Connected to Redis at {CACHE_HOST}:{CACHE_PORT} without authentication")
|
| 44 |
+
return redis_client
|
| 45 |
+
|
| 46 |
+
except AuthenticationError as e:
|
| 47 |
+
logger.warning(f"Authentication failed for Redis: {e}")
|
| 48 |
+
# Try without authentication as fallback
|
| 49 |
+
try:
|
| 50 |
+
redis_client = Redis(
|
| 51 |
+
host=CACHE_HOST,
|
| 52 |
+
port=CACHE_PORT,
|
| 53 |
+
decode_responses=True,
|
| 54 |
+
socket_connect_timeout=5,
|
| 55 |
+
socket_timeout=5,
|
| 56 |
+
retry_on_timeout=True
|
| 57 |
+
)
|
| 58 |
+
await redis_client.ping()
|
| 59 |
+
logger.info(f"Connected to Redis at {CACHE_HOST}:{CACHE_PORT} without authentication (fallback)")
|
| 60 |
+
return redis_client
|
| 61 |
+
except Exception as fallback_error:
|
| 62 |
+
logger.error(f"Redis fallback connection also failed: {fallback_error}")
|
| 63 |
+
raise
|
| 64 |
+
|
| 65 |
+
except ConnectionError as e:
|
| 66 |
+
logger.error(f"Failed to connect to Redis at {CACHE_HOST}:{CACHE_PORT}: {e}")
|
| 67 |
+
raise
|
| 68 |
+
except Exception as e:
|
| 69 |
+
logger.error(f"Unexpected error connecting to Redis: {e}")
|
| 70 |
+
raise
|
| 71 |
+
|
| 72 |
+
# Initialize Redis client
|
| 73 |
+
redis_client = None
|
| 74 |
+
|
| 75 |
+
async def get_redis() -> Redis:
|
| 76 |
+
global redis_client
|
| 77 |
+
if redis_client is None:
|
| 78 |
+
redis_client = await create_redis_client()
|
| 79 |
+
return redis_client
|
app/core/config.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dotenv import load_dotenv
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
load_dotenv()
|
| 5 |
+
|
| 6 |
+
class Settings:
|
| 7 |
+
# MongoDB
|
| 8 |
+
MONGO_URI: str = os.getenv("MONGO_URI")
|
| 9 |
+
DB_NAME: str = os.getenv("DB_NAME")
|
| 10 |
+
|
| 11 |
+
# Redis
|
| 12 |
+
CACHE_URI: str = os.getenv("CACHE_URI")
|
| 13 |
+
CACHE_K: str = os.getenv("CACHE_K")
|
| 14 |
+
|
| 15 |
+
# JWT (Unified across services)
|
| 16 |
+
# Prefer JWT_* envs; fall back to legacy names to ensure compatibility
|
| 17 |
+
JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY") or os.getenv("SECRET_KEY", "B00Kmyservice@7")
|
| 18 |
+
JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM") or os.getenv("ALGORITHM", "HS256")
|
| 19 |
+
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = int(
|
| 20 |
+
os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "43200"))
|
| 21 |
+
)
|
| 22 |
+
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = int(
|
| 23 |
+
os.getenv("JWT_REFRESH_TOKEN_EXPIRE_DAYS", os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
|
| 24 |
+
)
|
| 25 |
+
JWT_TEMP_TOKEN_EXPIRE_MINUTES: int = int(
|
| 26 |
+
os.getenv("JWT_TEMP_TOKEN_EXPIRE_MINUTES", os.getenv("TEMP_TOKEN_EXPIRE_MINUTES", "10"))
|
| 27 |
+
)
|
| 28 |
+
JWT_REMEMBER_ME_EXPIRE_DAYS: int = int(
|
| 29 |
+
os.getenv("JWT_REMEMBER_ME_EXPIRE_DAYS", "30") # 30 days for remember me
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# Backward compatibility: keep legacy attributes pointing to unified values
|
| 33 |
+
SECRET_KEY: str = JWT_SECRET_KEY
|
| 34 |
+
ALGORITHM: str = JWT_ALGORITHM
|
| 35 |
+
|
| 36 |
+
# Twilio SMS
|
| 37 |
+
TWILIO_ACCOUNT_SID: str = os.getenv("TWILIO_ACCOUNT_SID")
|
| 38 |
+
TWILIO_AUTH_TOKEN: str = os.getenv("TWILIO_AUTH_TOKEN")
|
| 39 |
+
TWILIO_SMS_FROM: str = os.getenv("TWILIO_SMS_FROM")
|
| 40 |
+
|
| 41 |
+
# SMTP Email
|
| 42 |
+
SMTP_HOST: str = os.getenv("SMTP_HOST")
|
| 43 |
+
SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587"))
|
| 44 |
+
SMTP_USER: str = os.getenv("SMTP_USER")
|
| 45 |
+
SMTP_PASS: str = os.getenv("SMTP_PASS")
|
| 46 |
+
SMTP_FROM: str = os.getenv("SMTP_FROM")
|
| 47 |
+
|
| 48 |
+
# OAuth Providers
|
| 49 |
+
GOOGLE_CLIENT_ID: str = os.getenv("GOOGLE_CLIENT_ID")
|
| 50 |
+
APPLE_AUDIENCE: str = os.getenv("APPLE_AUDIENCE")
|
| 51 |
+
FACEBOOK_APP_ID: str = os.getenv("FACEBOOK_APP_ID")
|
| 52 |
+
FACEBOOK_APP_SECRET: str = os.getenv("FACEBOOK_APP_SECRET")
|
| 53 |
+
|
| 54 |
+
# Local testing: bypass external OAuth verification when enabled
|
| 55 |
+
OAUTH_TEST_MODE: bool = os.getenv("OAUTH_TEST_MODE", "false").lower() == "true"
|
| 56 |
+
|
| 57 |
+
# Security Settings
|
| 58 |
+
MAX_LOGIN_ATTEMPTS: int = int(os.getenv("MAX_LOGIN_ATTEMPTS", "5"))
|
| 59 |
+
ACCOUNT_LOCK_DURATION: int = int(os.getenv("ACCOUNT_LOCK_DURATION", "900")) # 15 minutes
|
| 60 |
+
OTP_VALIDITY_MINUTES: int = int(os.getenv("OTP_VALIDITY_MINUTES", "5"))
|
| 61 |
+
IP_RATE_LIMIT_MAX: int = int(os.getenv("IP_RATE_LIMIT_MAX", "10"))
|
| 62 |
+
IP_RATE_LIMIT_WINDOW: int = int(os.getenv("IP_RATE_LIMIT_WINDOW", "3600")) # 1 hour
|
| 63 |
+
|
| 64 |
+
def __post_init__(self):
|
| 65 |
+
if not self.MONGO_URI or not self.DB_NAME:
|
| 66 |
+
raise ValueError("MongoDB URI or DB_NAME not configured.")
|
| 67 |
+
if not self.CACHE_URI or not self.CACHE_K:
|
| 68 |
+
raise ValueError("Redis URI or password (CACHE_K) not configured.")
|
| 69 |
+
|
| 70 |
+
settings = Settings()
|
app/core/nosql_client.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from app.nosql import mongo_db, mongo_client
|
| 3 |
+
|
| 4 |
+
logger = logging.getLogger(__name__)
|
| 5 |
+
|
| 6 |
+
# Alias for backward compatibility with migrated code
|
| 7 |
+
db = mongo_db
|
| 8 |
+
|
| 9 |
+
async def get_mongo_client():
|
| 10 |
+
return mongo_client
|
app/middleware/rate_limiter.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Request, HTTPException
|
| 2 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 3 |
+
import time
|
| 4 |
+
from collections import defaultdict, deque
|
| 5 |
+
|
| 6 |
+
class RateLimitMiddleware(BaseHTTPMiddleware):
|
| 7 |
+
def __init__(self, app, calls: int = 100, period: int = 60):
|
| 8 |
+
super().__init__(app)
|
| 9 |
+
self.calls = calls
|
| 10 |
+
self.period = period
|
| 11 |
+
self.clients = defaultdict(deque)
|
| 12 |
+
|
| 13 |
+
async def dispatch(self, request: Request, call_next):
|
| 14 |
+
client_ip = request.client.host
|
| 15 |
+
now = time.time()
|
| 16 |
+
|
| 17 |
+
# Clean old requests
|
| 18 |
+
while self.clients[client_ip] and self.clients[client_ip][0] <= now - self.period:
|
| 19 |
+
self.clients[client_ip].popleft()
|
| 20 |
+
|
| 21 |
+
# Check rate limit
|
| 22 |
+
if len(self.clients[client_ip]) >= self.calls:
|
| 23 |
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
| 24 |
+
|
| 25 |
+
self.clients[client_ip].append(now)
|
| 26 |
+
response = await call_next(request)
|
| 27 |
+
return response
|
app/models/__init__.py
ADDED
|
File without changes
|
app/models/address_model.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import Optional, List, Dict, Any
|
| 3 |
+
from bson import ObjectId
|
| 4 |
+
import logging
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
from app.core.nosql_client import db
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
class AddressModel:
|
| 12 |
+
"""Model for managing user delivery addresses embedded under customer documents"""
|
| 13 |
+
|
| 14 |
+
@staticmethod
|
| 15 |
+
async def create_address(customer_id: str, address_data: Dict[str, Any]) -> Optional[str]:
|
| 16 |
+
"""Create a new embedded address for a user inside customers collection"""
|
| 17 |
+
try:
|
| 18 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 19 |
+
|
| 20 |
+
address_id = str(uuid.uuid4())
|
| 21 |
+
current_time = datetime.utcnow()
|
| 22 |
+
|
| 23 |
+
address_doc = {
|
| 24 |
+
"address_id": address_id, # New field for address identification
|
| 25 |
+
"address_line_1": address_data.get("address_line_1"),
|
| 26 |
+
"address_line_2": address_data.get("address_line_2", ""),
|
| 27 |
+
"city": address_data.get("city"),
|
| 28 |
+
"state": address_data.get("state"),
|
| 29 |
+
"postal_code": address_data.get("postal_code"),
|
| 30 |
+
"country": address_data.get("country", "India"),
|
| 31 |
+
"address_type": address_data.get("address_type", "home"), # home, work, other
|
| 32 |
+
"is_default": address_data.get("is_default", False),
|
| 33 |
+
"landmark": address_data.get("landmark", ""),
|
| 34 |
+
"created_at": current_time,
|
| 35 |
+
"updated_at": current_time,
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
# Fetch user doc
|
| 39 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 40 |
+
if not user:
|
| 41 |
+
logger.error(f"User not found for customer_id {customer_id}")
|
| 42 |
+
return None
|
| 43 |
+
|
| 44 |
+
addresses = user.get("addresses", [])
|
| 45 |
+
|
| 46 |
+
# If setting default, unset any existing defaults
|
| 47 |
+
if address_doc.get("is_default"):
|
| 48 |
+
for a in addresses:
|
| 49 |
+
if a.get("is_default"):
|
| 50 |
+
a["is_default"] = False
|
| 51 |
+
a["updated_at"] = datetime.utcnow()
|
| 52 |
+
else:
|
| 53 |
+
# If this is the first address, set default
|
| 54 |
+
if len(addresses) == 0:
|
| 55 |
+
address_doc["is_default"] = True
|
| 56 |
+
|
| 57 |
+
addresses.append(address_doc)
|
| 58 |
+
|
| 59 |
+
update_result = await BookMyServiceUserModel.collection.update_one(
|
| 60 |
+
{"customer_id": customer_id},
|
| 61 |
+
{"$set": {"addresses": addresses}}
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
if update_result.modified_count == 0:
|
| 65 |
+
logger.error(f"Failed to insert embedded address for user {customer_id}")
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
logger.info(f"Created embedded address for user {customer_id}")
|
| 69 |
+
return address_doc["address_id"] # Return the address_id field instead of _id
|
| 70 |
+
|
| 71 |
+
except Exception as e:
|
| 72 |
+
logger.error(f"Error creating embedded address for user {customer_id}: {str(e)}")
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
@staticmethod
|
| 76 |
+
async def get_user_addresses(customer_id: str) -> List[Dict[str, Any]]:
|
| 77 |
+
"""Get all embedded addresses for a user from customers collection"""
|
| 78 |
+
try:
|
| 79 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 80 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 81 |
+
if not user:
|
| 82 |
+
return []
|
| 83 |
+
|
| 84 |
+
addresses = user.get("addresses", [])
|
| 85 |
+
# Sort by created_at desc and return as-is (no _id field)
|
| 86 |
+
addresses.sort(key=lambda x: x.get("created_at", datetime.utcnow()), reverse=True)
|
| 87 |
+
return addresses
|
| 88 |
+
|
| 89 |
+
except Exception as e:
|
| 90 |
+
logger.error(f"Error getting embedded addresses for user {customer_id}: {str(e)}")
|
| 91 |
+
return []
|
| 92 |
+
|
| 93 |
+
@staticmethod
|
| 94 |
+
async def get_address_by_id(customer_id: str, address_id: str) -> Optional[Dict[str, Any]]:
|
| 95 |
+
"""Get a specific embedded address by ID for a user"""
|
| 96 |
+
try:
|
| 97 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 98 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 99 |
+
if not user:
|
| 100 |
+
return None
|
| 101 |
+
|
| 102 |
+
addresses = user.get("addresses", [])
|
| 103 |
+
|
| 104 |
+
for a in addresses:
|
| 105 |
+
if a.get("address_id") == address_id:
|
| 106 |
+
a_copy = dict(a)
|
| 107 |
+
# Inject customer_id for backward-compat where used in router
|
| 108 |
+
a_copy["customer_id"] = customer_id
|
| 109 |
+
return a_copy
|
| 110 |
+
return None
|
| 111 |
+
|
| 112 |
+
except Exception as e:
|
| 113 |
+
logger.error(f"Error getting embedded address {address_id} for user {customer_id}: {str(e)}")
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
@staticmethod
|
| 117 |
+
async def update_address(customer_id: str, address_id: str, update_data: Dict[str, Any]) -> bool:
|
| 118 |
+
"""Update an existing embedded address"""
|
| 119 |
+
try:
|
| 120 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 121 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 122 |
+
if not user:
|
| 123 |
+
return False
|
| 124 |
+
|
| 125 |
+
addresses = user.get("addresses", [])
|
| 126 |
+
|
| 127 |
+
updated = False
|
| 128 |
+
for a in addresses:
|
| 129 |
+
if a.get("address_id") == address_id:
|
| 130 |
+
allowed_fields = [
|
| 131 |
+
"address_line_1", "address_line_2", "city", "state", "postal_code",
|
| 132 |
+
"country", "address_type", "is_default", "landmark"
|
| 133 |
+
]
|
| 134 |
+
for field in allowed_fields:
|
| 135 |
+
if field in update_data:
|
| 136 |
+
a[field] = update_data[field]
|
| 137 |
+
a["updated_at"] = datetime.utcnow()
|
| 138 |
+
updated = True
|
| 139 |
+
break
|
| 140 |
+
|
| 141 |
+
if not updated:
|
| 142 |
+
return False
|
| 143 |
+
|
| 144 |
+
# If setting as default, unset other defaults
|
| 145 |
+
if update_data.get("is_default"):
|
| 146 |
+
for a in addresses:
|
| 147 |
+
if a.get("address_id") != address_id and a.get("is_default"):
|
| 148 |
+
a["is_default"] = False
|
| 149 |
+
a["updated_at"] = datetime.utcnow()
|
| 150 |
+
|
| 151 |
+
result = await BookMyServiceUserModel.collection.update_one(
|
| 152 |
+
{"customer_id": customer_id},
|
| 153 |
+
{"$set": {"addresses": addresses}}
|
| 154 |
+
)
|
| 155 |
+
return result.modified_count > 0
|
| 156 |
+
|
| 157 |
+
except Exception as e:
|
| 158 |
+
logger.error(f"Error updating embedded address {address_id} for user {customer_id}: {str(e)}")
|
| 159 |
+
return False
|
| 160 |
+
|
| 161 |
+
@staticmethod
|
| 162 |
+
async def delete_address(customer_id: str, address_id: str) -> bool:
|
| 163 |
+
"""Delete an embedded address"""
|
| 164 |
+
try:
|
| 165 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 166 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 167 |
+
if not user:
|
| 168 |
+
return False
|
| 169 |
+
|
| 170 |
+
addresses = user.get("addresses", [])
|
| 171 |
+
# Filter out by new domain id field 'address_id'
|
| 172 |
+
new_addresses = [a for a in addresses if a.get("address_id") != address_id]
|
| 173 |
+
|
| 174 |
+
result = await BookMyServiceUserModel.collection.update_one(
|
| 175 |
+
{"customer_id": customer_id},
|
| 176 |
+
{"$set": {"addresses": new_addresses}}
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
logger.info(f"Deleted embedded address {address_id} for user {customer_id}")
|
| 180 |
+
return result.modified_count > 0
|
| 181 |
+
|
| 182 |
+
except Exception as e:
|
| 183 |
+
logger.error(f"Error deleting embedded address {address_id} for user {customer_id}: {str(e)}")
|
| 184 |
+
return False
|
| 185 |
+
|
| 186 |
+
@staticmethod
|
| 187 |
+
async def get_default_address(customer_id: str) -> Optional[Dict[str, Any]]:
|
| 188 |
+
"""Get the default embedded address for a user"""
|
| 189 |
+
try:
|
| 190 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 191 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 192 |
+
if not user:
|
| 193 |
+
return None
|
| 194 |
+
|
| 195 |
+
addresses = user.get("addresses", [])
|
| 196 |
+
for a in addresses:
|
| 197 |
+
if a.get("is_default"):
|
| 198 |
+
a_copy = dict(a)
|
| 199 |
+
a_copy["customer_id"] = customer_id
|
| 200 |
+
return a_copy
|
| 201 |
+
return None
|
| 202 |
+
|
| 203 |
+
except Exception as e:
|
| 204 |
+
logger.error(f"Error getting default embedded address for user {customer_id}: {str(e)}")
|
| 205 |
+
return None
|
| 206 |
+
|
| 207 |
+
@staticmethod
|
| 208 |
+
async def set_default_address(customer_id: str, address_id: str) -> bool:
|
| 209 |
+
"""Set an embedded address as default and unset others"""
|
| 210 |
+
try:
|
| 211 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 212 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 213 |
+
if not user:
|
| 214 |
+
return False
|
| 215 |
+
|
| 216 |
+
addresses = user.get("addresses", [])
|
| 217 |
+
|
| 218 |
+
changed = False
|
| 219 |
+
for a in addresses:
|
| 220 |
+
if a.get("address_id") == address_id:
|
| 221 |
+
if not a.get("is_default"):
|
| 222 |
+
a["is_default"] = True
|
| 223 |
+
a["updated_at"] = datetime.utcnow()
|
| 224 |
+
changed = True
|
| 225 |
+
else:
|
| 226 |
+
if a.get("is_default"):
|
| 227 |
+
a["is_default"] = False
|
| 228 |
+
a["updated_at"] = datetime.utcnow()
|
| 229 |
+
changed = True
|
| 230 |
+
|
| 231 |
+
if not changed:
|
| 232 |
+
# Even if nothing changed, ensure persistence
|
| 233 |
+
pass
|
| 234 |
+
|
| 235 |
+
result = await BookMyServiceUserModel.collection.update_one(
|
| 236 |
+
{"customer_id": customer_id},
|
| 237 |
+
{"$set": {"addresses": addresses}}
|
| 238 |
+
)
|
| 239 |
+
return result.modified_count >= 0
|
| 240 |
+
|
| 241 |
+
except Exception as e:
|
| 242 |
+
logger.error(f"Error setting default embedded address {address_id} for user {customer_id}: {str(e)}")
|
| 243 |
+
return False
|
app/models/favorite_model.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import HTTPException
|
| 2 |
+
from app.core.nosql_client import db
|
| 3 |
+
import logging
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import List, Optional, Dict, Any
|
| 6 |
+
from app.utils.db import prepare_for_db
|
| 7 |
+
import uuid
|
| 8 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger("favorite_model")
|
| 11 |
+
|
| 12 |
+
class BookMyServiceFavoriteModel:
|
| 13 |
+
|
| 14 |
+
@staticmethod
|
| 15 |
+
async def create_favorite(
|
| 16 |
+
customer_id: str,
|
| 17 |
+
favorite_data:dict
|
| 18 |
+
):
|
| 19 |
+
"""Create a new favorite merchant entry"""
|
| 20 |
+
logger.info(f"Creating favorite for customer {customer_id}, merchant {favorite_data['merchant_id']}")
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
# Check if favorite already exists
|
| 24 |
+
user = await BookMyServiceUserModel.collection.find_one({
|
| 25 |
+
"customer_id": customer_id
|
| 26 |
+
})
|
| 27 |
+
|
| 28 |
+
if not user:
|
| 29 |
+
logger.error(f"User not found for customer_id {customer_id}")
|
| 30 |
+
return None
|
| 31 |
+
|
| 32 |
+
favorites = user.get("favorites", [])
|
| 33 |
+
|
| 34 |
+
# Create favorite document
|
| 35 |
+
favorite_doc = {
|
| 36 |
+
"merchant_id":favorite_data["merchant_id"],
|
| 37 |
+
"merchant_category": favorite_data["merchant_category"],
|
| 38 |
+
"merchant_name":favorite_data["merchant_name"],
|
| 39 |
+
"source": favorite_data[ "source"],
|
| 40 |
+
"added_at": datetime.utcnow(),
|
| 41 |
+
"notes": favorite_data.get("notes")
|
| 42 |
+
}
|
| 43 |
+
favorites.append(favorite_doc)
|
| 44 |
+
sanitized_favorite = prepare_for_db(favorites)
|
| 45 |
+
|
| 46 |
+
update_res = await BookMyServiceUserModel.collection.update_one(
|
| 47 |
+
{"customer_id": customer_id},
|
| 48 |
+
{"$set": {"favorites": sanitized_favorite}}
|
| 49 |
+
)
|
| 50 |
+
if update_res.modified_count > 0:
|
| 51 |
+
logger.info(f"favourites created successfully: {favorite_data['merchant_id']} for user: {customer_id}")
|
| 52 |
+
else:
|
| 53 |
+
logger.info(f"favourites creation attempted with no modified_count for user: {customer_id}")
|
| 54 |
+
|
| 55 |
+
return favorite_data["merchant_id"]
|
| 56 |
+
|
| 57 |
+
except HTTPException:
|
| 58 |
+
raise
|
| 59 |
+
except Exception as e:
|
| 60 |
+
logger.error(f"Error creating favorite: {str(e)}", exc_info=True)
|
| 61 |
+
raise HTTPException(status_code=500, detail="Failed to create favorite")
|
| 62 |
+
|
| 63 |
+
@staticmethod
|
| 64 |
+
async def delete_favorite(customer_id: str, merchant_id: str):
|
| 65 |
+
"""Remove a merchant from favorites"""
|
| 66 |
+
logger.info(f"Deleting favorite for customer {customer_id}, merchant {merchant_id}")
|
| 67 |
+
|
| 68 |
+
try:
|
| 69 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 70 |
+
if not user:
|
| 71 |
+
return False
|
| 72 |
+
|
| 73 |
+
favorites = user.get("favorites", [])
|
| 74 |
+
filterd_favorites = [favorite for favorite in favorites if favorite.get("merchant_id") != merchant_id]
|
| 75 |
+
if len(filterd_favorites) == len(favorites):
|
| 76 |
+
logger.warning(f"Embedded favorite not found for deletion: {merchant_id} (user {customer_id})")
|
| 77 |
+
return False
|
| 78 |
+
|
| 79 |
+
# Sanitize for MongoDB before write
|
| 80 |
+
sanitized_filterd_favorites = prepare_for_db(filterd_favorites)
|
| 81 |
+
result = await BookMyServiceUserModel.collection.update_one(
|
| 82 |
+
{"customer_id": customer_id},
|
| 83 |
+
{"$set": {"favorites": sanitized_filterd_favorites}}
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
if result.modified_count > 0:
|
| 87 |
+
logger.info(f"Embedded favorite deleted successfully: {merchant_id} (user {customer_id})")
|
| 88 |
+
return True
|
| 89 |
+
else:
|
| 90 |
+
logger.info(f"Embedded favorite deletion applied with no modified_count: {merchant_id} (user {customer_id})")
|
| 91 |
+
return True
|
| 92 |
+
|
| 93 |
+
except HTTPException:
|
| 94 |
+
raise
|
| 95 |
+
except Exception as e:
|
| 96 |
+
logger.error(f"Error deleting favorite: {str(e)}", exc_info=True)
|
| 97 |
+
raise HTTPException(status_code=500, detail="Failed to delete favorite")
|
| 98 |
+
|
| 99 |
+
@staticmethod
|
| 100 |
+
async def get_favorites( customer_id: str, limit:int=50 )-> List[Dict[str, Any]]:
|
| 101 |
+
"""Get user's favorite merchants, optionally filtered by category"""
|
| 102 |
+
logger.info(f"Getting favorite merchant for customer {customer_id}")
|
| 103 |
+
|
| 104 |
+
try:
|
| 105 |
+
# Build query
|
| 106 |
+
|
| 107 |
+
# Check if favorite already exists
|
| 108 |
+
user = await BookMyServiceUserModel.collection.find_one({
|
| 109 |
+
"customer_id": customer_id
|
| 110 |
+
})
|
| 111 |
+
|
| 112 |
+
if not user:
|
| 113 |
+
return []
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
favorites = user.get("favorites", [])
|
| 117 |
+
favorites.sort(key=lambda x: x["added_at"], reverse=True)
|
| 118 |
+
favorite_data = favorites[:limit]
|
| 119 |
+
|
| 120 |
+
# Get total count for pagination
|
| 121 |
+
|
| 122 |
+
logger.info(f"Found {len(favorites)} favorites for customer {customer_id}")
|
| 123 |
+
|
| 124 |
+
return {
|
| 125 |
+
"favorites": favorite_data,
|
| 126 |
+
"total_count": len(favorites),
|
| 127 |
+
"limit": limit
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
except Exception as e:
|
| 131 |
+
logger.error(f"Error getting favorites: {str(e)}", exc_info=True)
|
| 132 |
+
raise HTTPException(status_code=500, detail="Failed to get favorites")
|
| 133 |
+
|
| 134 |
+
@staticmethod
|
| 135 |
+
async def get_favorite(customer_id: str, merchant_id: str):
|
| 136 |
+
"""Get a specific favorite entry"""
|
| 137 |
+
logger.info(f"Getting favorite for customer {customer_id}, merchant {merchant_id}")
|
| 138 |
+
|
| 139 |
+
try:
|
| 140 |
+
|
| 141 |
+
user = await BookMyServiceUserModel.collection.find_one({
|
| 142 |
+
"customer_id": customer_id
|
| 143 |
+
})
|
| 144 |
+
|
| 145 |
+
if not user:
|
| 146 |
+
return []
|
| 147 |
+
|
| 148 |
+
favorites = user.get("favorites", [])
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
for favorite in favorites:
|
| 152 |
+
if favorite.get("merchant_id") == merchant_id:
|
| 153 |
+
return favorite
|
| 154 |
+
logger.warning(f"Embedded favorite merchant not found: {merchant_id} for user: {customer_id}")
|
| 155 |
+
return None
|
| 156 |
+
|
| 157 |
+
except HTTPException:
|
| 158 |
+
raise
|
| 159 |
+
except Exception as e:
|
| 160 |
+
logger.error(f"Error getting favorite: {str(e)}", exc_info=True)
|
| 161 |
+
raise HTTPException(status_code=500, detail="Failed to get favorite")
|
| 162 |
+
|
| 163 |
+
@staticmethod
|
| 164 |
+
async def update_favorite_notes(customer_id: str, merchant_id: str, notes: str):
|
| 165 |
+
"""Update notes for a favorite merchant"""
|
| 166 |
+
logger.info(f"Updating notes for customer {customer_id}, merchant {merchant_id}")
|
| 167 |
+
|
| 168 |
+
try:
|
| 169 |
+
user = await BookMyServiceUserModel.collection.find_one({
|
| 170 |
+
"customer_id": customer_id
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
if not user:
|
| 174 |
+
return False
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
result = await BookMyServiceUserModel.collection.update_one({"customer_id": customer_id, "favorites.merchant_id": merchant_id},
|
| 178 |
+
{"$set": {"favorites.$.notes": notes}})
|
| 179 |
+
|
| 180 |
+
print("result.matched_count",result.matched_count)
|
| 181 |
+
if result.matched_count == 0 :
|
| 182 |
+
logger.warning(f"Favorite not found for customer {customer_id}, merchant {merchant_id}")
|
| 183 |
+
raise HTTPException(status_code=404, detail="Favorite not found")
|
| 184 |
+
|
| 185 |
+
logger.info(f"Favorite merchant notes updated successfully")
|
| 186 |
+
return True
|
| 187 |
+
|
| 188 |
+
except HTTPException:
|
| 189 |
+
raise
|
| 190 |
+
except Exception as e:
|
| 191 |
+
logger.error(f"Error updating favorite merchant notes: {str(e)}", exc_info=True)
|
| 192 |
+
raise HTTPException(status_code=500, detail="Failed to update favorite notes")
|
| 193 |
+
|
| 194 |
+
@staticmethod
|
| 195 |
+
async def is_favorite(customer_id: str, merchant_id: str) -> bool:
|
| 196 |
+
"""Check if a merchant is in user's favorites"""
|
| 197 |
+
logger.info(f"Checking if merchant {merchant_id} is favorite for customer {customer_id}")
|
| 198 |
+
|
| 199 |
+
try:
|
| 200 |
+
favorite = await BookMyServiceFavoriteModel.collection.find_one({
|
| 201 |
+
"customer_id": customer_id,
|
| 202 |
+
"merchant_id": merchant_id
|
| 203 |
+
})
|
| 204 |
+
|
| 205 |
+
return favorite is not None
|
| 206 |
+
|
| 207 |
+
except Exception as e:
|
| 208 |
+
logger.error(f"Error checking favorite status: {str(e)}", exc_info=True)
|
| 209 |
+
return False
|
app/models/guest_model.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.core.nosql_client import db
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import List, Optional, Dict, Any
|
| 4 |
+
import uuid
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
from app.utils.db import prepare_for_db
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
class GuestModel:
|
| 12 |
+
"""Model for managing guest profiles embedded under customer documents"""
|
| 13 |
+
|
| 14 |
+
@staticmethod
|
| 15 |
+
async def create_guest(customer_id: str, guest_data: dict) -> Optional[str]:
|
| 16 |
+
"""Create a new embedded guest profile under a user in customers collection"""
|
| 17 |
+
try:
|
| 18 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 19 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 20 |
+
if not user:
|
| 21 |
+
logger.error(f"User not found for customer_id {customer_id}")
|
| 22 |
+
return None
|
| 23 |
+
|
| 24 |
+
guest_id = str(uuid.uuid4())
|
| 25 |
+
current_time = datetime.utcnow()
|
| 26 |
+
|
| 27 |
+
guest_doc = {
|
| 28 |
+
"guest_id": guest_id,
|
| 29 |
+
"first_name": guest_data.get("first_name"),
|
| 30 |
+
"last_name": guest_data.get("last_name"),
|
| 31 |
+
"email": guest_data.get("email"),
|
| 32 |
+
"phone_number": guest_data.get("phone_number"),
|
| 33 |
+
"gender": getattr(guest_data.get("gender"), "value", guest_data.get("gender")),
|
| 34 |
+
"date_of_birth": guest_data.get("date_of_birth"),
|
| 35 |
+
"relationship": getattr(guest_data.get("relationship"), "value", guest_data.get("relationship")),
|
| 36 |
+
"notes": guest_data.get("notes"),
|
| 37 |
+
"is_default": guest_data.get("is_default", False),
|
| 38 |
+
"created_at": current_time,
|
| 39 |
+
"updated_at": current_time,
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
guests = user.get("guests", [])
|
| 43 |
+
# Handle default semantics: if setting this guest as default, unset others.
|
| 44 |
+
if guest_doc.get("is_default"):
|
| 45 |
+
for existing in guests:
|
| 46 |
+
if existing.get("is_default"):
|
| 47 |
+
existing["is_default"] = False
|
| 48 |
+
existing["updated_at"] = current_time
|
| 49 |
+
else:
|
| 50 |
+
# If this is the first guest, make it default by default
|
| 51 |
+
if len(guests) == 0:
|
| 52 |
+
guest_doc["is_default"] = True
|
| 53 |
+
guests.append(guest_doc)
|
| 54 |
+
|
| 55 |
+
# Sanitize for MongoDB (convert date to datetime, strip tzinfo, etc.)
|
| 56 |
+
sanitized_guests = prepare_for_db(guests)
|
| 57 |
+
update_res = await BookMyServiceUserModel.collection.update_one(
|
| 58 |
+
{"customer_id": customer_id},
|
| 59 |
+
{"$set": {"guests": sanitized_guests}}
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
if update_res.modified_count > 0:
|
| 63 |
+
logger.info(f"Guest created successfully: {guest_id} for user: {customer_id}")
|
| 64 |
+
return guest_id
|
| 65 |
+
else:
|
| 66 |
+
logger.info(f"Guest creation attempted with no modified_count for user: {customer_id}")
|
| 67 |
+
return guest_id
|
| 68 |
+
|
| 69 |
+
except Exception as e:
|
| 70 |
+
logger.error(f"Error creating embedded guest for user {customer_id}: {str(e)}")
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
@staticmethod
|
| 74 |
+
async def get_user_guests(customer_id: str) -> List[Dict[str, Any]]:
|
| 75 |
+
"""Get all embedded guests for a specific user from customers collection"""
|
| 76 |
+
try:
|
| 77 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 78 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 79 |
+
if not user:
|
| 80 |
+
return []
|
| 81 |
+
|
| 82 |
+
guests = user.get("guests", [])
|
| 83 |
+
guests.sort(key=lambda x: x.get("created_at", datetime.utcnow()), reverse=True)
|
| 84 |
+
for g in guests:
|
| 85 |
+
g["customer_id"] = customer_id
|
| 86 |
+
logger.info(f"Retrieved {len(guests)} embedded guests for user: {customer_id}")
|
| 87 |
+
return guests
|
| 88 |
+
|
| 89 |
+
except Exception as e:
|
| 90 |
+
logger.error(f"Error getting embedded guests for user {customer_id}: {str(e)}")
|
| 91 |
+
return []
|
| 92 |
+
|
| 93 |
+
@staticmethod
|
| 94 |
+
async def get_guest_by_id(customer_id: str, guest_id: str) -> Optional[Dict[str, Any]]:
|
| 95 |
+
"""Get a specific embedded guest by ID for a user"""
|
| 96 |
+
try:
|
| 97 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 98 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 99 |
+
if not user:
|
| 100 |
+
return None
|
| 101 |
+
|
| 102 |
+
guests = user.get("guests", [])
|
| 103 |
+
for g in guests:
|
| 104 |
+
if g.get("guest_id") == guest_id:
|
| 105 |
+
g_copy = dict(g)
|
| 106 |
+
g_copy["customer_id"] = customer_id
|
| 107 |
+
logger.info(f"Embedded guest found: {guest_id} for user: {customer_id}")
|
| 108 |
+
return g_copy
|
| 109 |
+
logger.warning(f"Embedded guest not found: {guest_id} for user: {customer_id}")
|
| 110 |
+
return None
|
| 111 |
+
|
| 112 |
+
except Exception as e:
|
| 113 |
+
logger.error(f"Error getting embedded guest {guest_id} for user {customer_id}: {str(e)}")
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
@staticmethod
|
| 117 |
+
async def update_guest(customer_id: str, guest_id: str, update_fields: Dict[str, Any]) -> bool:
|
| 118 |
+
"""Update an embedded guest's information under a user"""
|
| 119 |
+
try:
|
| 120 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 121 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 122 |
+
if not user:
|
| 123 |
+
return False
|
| 124 |
+
|
| 125 |
+
guests = user.get("guests", [])
|
| 126 |
+
updated = False
|
| 127 |
+
|
| 128 |
+
for idx, g in enumerate(guests):
|
| 129 |
+
if g.get("guest_id") == guest_id:
|
| 130 |
+
normalized_updates: Dict[str, Any] = {}
|
| 131 |
+
for k, v in update_fields.items():
|
| 132 |
+
if hasattr(v, "value"):
|
| 133 |
+
normalized_updates[k] = v.value
|
| 134 |
+
else:
|
| 135 |
+
normalized_updates[k] = v
|
| 136 |
+
|
| 137 |
+
normalized_updates["updated_at"] = datetime.utcnow()
|
| 138 |
+
guests[idx] = {**g, **normalized_updates}
|
| 139 |
+
updated = True
|
| 140 |
+
# If is_default is being set to True, unset default for others
|
| 141 |
+
if update_fields.get("is_default"):
|
| 142 |
+
for j, other in enumerate(guests):
|
| 143 |
+
if other.get("guest_id") != guest_id and other.get("is_default"):
|
| 144 |
+
other["is_default"] = False
|
| 145 |
+
other["updated_at"] = datetime.utcnow()
|
| 146 |
+
guests[j] = other
|
| 147 |
+
break
|
| 148 |
+
|
| 149 |
+
if not updated:
|
| 150 |
+
logger.warning(f"Embedded guest not found for update: {guest_id} (user {customer_id})")
|
| 151 |
+
return False
|
| 152 |
+
|
| 153 |
+
# Sanitize for MongoDB before write
|
| 154 |
+
sanitized_guests = prepare_for_db(guests)
|
| 155 |
+
result = await BookMyServiceUserModel.collection.update_one(
|
| 156 |
+
{"customer_id": customer_id},
|
| 157 |
+
{"$set": {"guests": sanitized_guests}}
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
if result.modified_count > 0:
|
| 161 |
+
logger.info(f"Embedded guest updated successfully: {guest_id} (user {customer_id})")
|
| 162 |
+
return True
|
| 163 |
+
else:
|
| 164 |
+
logger.info(f"Embedded guest update applied with no modified_count: {guest_id} (user {customer_id})")
|
| 165 |
+
return True
|
| 166 |
+
|
| 167 |
+
except Exception as e:
|
| 168 |
+
logger.error(f"Error updating embedded guest {guest_id} for user {customer_id}: {str(e)}")
|
| 169 |
+
return False
|
| 170 |
+
|
| 171 |
+
@staticmethod
|
| 172 |
+
async def delete_guest(customer_id: str, guest_id: str) -> bool:
|
| 173 |
+
"""Delete an embedded guest profile under a user"""
|
| 174 |
+
try:
|
| 175 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 176 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 177 |
+
if not user:
|
| 178 |
+
return False
|
| 179 |
+
|
| 180 |
+
guests = user.get("guests", [])
|
| 181 |
+
new_guests = [g for g in guests if g.get("guest_id") != guest_id]
|
| 182 |
+
if len(new_guests) == len(guests):
|
| 183 |
+
logger.warning(f"Embedded guest not found for deletion: {guest_id} (user {customer_id})")
|
| 184 |
+
return False
|
| 185 |
+
|
| 186 |
+
# Sanitize for MongoDB before write
|
| 187 |
+
sanitized_new_guests = prepare_for_db(new_guests)
|
| 188 |
+
result = await BookMyServiceUserModel.collection.update_one(
|
| 189 |
+
{"customer_id": customer_id},
|
| 190 |
+
{"$set": {"guests": sanitized_new_guests}}
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
if result.modified_count > 0:
|
| 194 |
+
logger.info(f"Embedded guest deleted successfully: {guest_id} (user {customer_id})")
|
| 195 |
+
return True
|
| 196 |
+
else:
|
| 197 |
+
logger.info(f"Embedded guest deletion applied with no modified_count: {guest_id} (user {customer_id})")
|
| 198 |
+
return True
|
| 199 |
+
|
| 200 |
+
except Exception as e:
|
| 201 |
+
logger.error(f"Error deleting embedded guest {guest_id} for user {customer_id}: {str(e)}")
|
| 202 |
+
return False
|
| 203 |
+
|
| 204 |
+
@staticmethod
|
| 205 |
+
async def get_guest_count_for_user(customer_id: str) -> int:
|
| 206 |
+
"""Get the total number of embedded guests for a user"""
|
| 207 |
+
try:
|
| 208 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 209 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 210 |
+
if not user:
|
| 211 |
+
return 0
|
| 212 |
+
return len(user.get("guests", []))
|
| 213 |
+
except Exception as e:
|
| 214 |
+
logger.error(f"Error counting embedded guests for user {customer_id}: {str(e)}")
|
| 215 |
+
return 0
|
| 216 |
+
|
| 217 |
+
@staticmethod
|
| 218 |
+
async def check_guest_ownership(guest_id: str, customer_id: str) -> bool:
|
| 219 |
+
"""
|
| 220 |
+
Check if a guest belongs to a specific user.
|
| 221 |
+
|
| 222 |
+
Args:
|
| 223 |
+
guest_id: ID of the guest
|
| 224 |
+
customer_id: ID of the user
|
| 225 |
+
|
| 226 |
+
Returns:
|
| 227 |
+
True if guest belongs to user, False otherwise
|
| 228 |
+
"""
|
| 229 |
+
try:
|
| 230 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 231 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 232 |
+
if not user:
|
| 233 |
+
return False
|
| 234 |
+
guests = user.get("guests", [])
|
| 235 |
+
return any(g.get("guest_id") == guest_id for g in guests)
|
| 236 |
+
except Exception as e:
|
| 237 |
+
logger.error(f"Error checking embedded guest ownership {guest_id} for user {customer_id}: {str(e)}")
|
| 238 |
+
return False
|
| 239 |
+
|
| 240 |
+
@staticmethod
|
| 241 |
+
async def get_default_guest(customer_id: str) -> Optional[Dict[str, Any]]:
|
| 242 |
+
"""Get the default guest for a user"""
|
| 243 |
+
try:
|
| 244 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 245 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 246 |
+
if not user:
|
| 247 |
+
return None
|
| 248 |
+
guests = user.get("guests", [])
|
| 249 |
+
for g in guests:
|
| 250 |
+
if g.get("is_default"):
|
| 251 |
+
g_copy = dict(g)
|
| 252 |
+
g_copy["customer_id"] = customer_id
|
| 253 |
+
return g_copy
|
| 254 |
+
return None
|
| 255 |
+
except Exception as e:
|
| 256 |
+
logger.error(f"Error getting default guest for user {customer_id}: {str(e)}")
|
| 257 |
+
return None
|
| 258 |
+
|
| 259 |
+
@staticmethod
|
| 260 |
+
async def set_default_guest(customer_id: str, guest_id: str) -> bool:
|
| 261 |
+
"""Set a guest as default for a user, unsetting any existing default"""
|
| 262 |
+
try:
|
| 263 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 264 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 265 |
+
if not user:
|
| 266 |
+
return False
|
| 267 |
+
guests = user.get("guests", [])
|
| 268 |
+
found = False
|
| 269 |
+
now = datetime.utcnow()
|
| 270 |
+
for g in guests:
|
| 271 |
+
if g.get("guest_id") == guest_id:
|
| 272 |
+
g["is_default"] = True
|
| 273 |
+
g["updated_at"] = now
|
| 274 |
+
found = True
|
| 275 |
+
else:
|
| 276 |
+
if g.get("is_default"):
|
| 277 |
+
g["is_default"] = False
|
| 278 |
+
g["updated_at"] = now
|
| 279 |
+
if not found:
|
| 280 |
+
return False
|
| 281 |
+
# Sanitize for MongoDB before write
|
| 282 |
+
sanitized_guests = prepare_for_db(guests)
|
| 283 |
+
res = await BookMyServiceUserModel.collection.update_one(
|
| 284 |
+
{"customer_id": customer_id},
|
| 285 |
+
{"$set": {"guests": sanitized_guests}}
|
| 286 |
+
)
|
| 287 |
+
return True
|
| 288 |
+
except Exception as e:
|
| 289 |
+
logger.error(f"Error setting default guest {guest_id} for user {customer_id}: {str(e)}")
|
| 290 |
+
return False
|
app/models/otp_model.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import HTTPException
|
| 2 |
+
from app.core.cache_client import get_redis
|
| 3 |
+
from app.utils.sms_utils import send_sms_otp
|
| 4 |
+
from app.utils.email_utils import send_email_otp
|
| 5 |
+
from app.utils.common_utils import is_email
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger("otp_model")
|
| 9 |
+
|
| 10 |
+
class BookMyServiceOTPModel:
|
| 11 |
+
OTP_TTL = 300 # 5 minutes
|
| 12 |
+
RATE_LIMIT_MAX = 3
|
| 13 |
+
RATE_LIMIT_WINDOW = 600 # 10 minutes
|
| 14 |
+
IP_RATE_LIMIT_MAX = 10 # Max 10 OTPs per IP per hour
|
| 15 |
+
IP_RATE_LIMIT_WINDOW = 3600 # 1 hour
|
| 16 |
+
FAILED_ATTEMPTS_MAX = 5 # Max 5 failed attempts before lock
|
| 17 |
+
FAILED_ATTEMPTS_WINDOW = 3600 # 1 hour
|
| 18 |
+
ACCOUNT_LOCK_DURATION = 1800 # 30 minutes
|
| 19 |
+
|
| 20 |
+
@staticmethod
|
| 21 |
+
async def store_otp(identifier: str, phone: str, otp: str, ttl: int = OTP_TTL, client_ip: str = None):
|
| 22 |
+
logger.info(f"Storing OTP for identifier: {identifier}, IP: {client_ip}")
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
redis = await get_redis()
|
| 26 |
+
logger.info(f"Redis connection established for OTP storage")
|
| 27 |
+
|
| 28 |
+
# Check if account is locked
|
| 29 |
+
if await BookMyServiceOTPModel.is_account_locked(identifier):
|
| 30 |
+
logger.warning(f"Account locked for identifier: {identifier}")
|
| 31 |
+
raise HTTPException(status_code=423, detail="Account temporarily locked due to too many failed attempts")
|
| 32 |
+
|
| 33 |
+
# Rate limit: max 3 OTPs per identifier per 10 minutes
|
| 34 |
+
rate_key = f"otp_rate_limit:{identifier}"
|
| 35 |
+
logger.info(f"Checking rate limit with key: {rate_key}")
|
| 36 |
+
|
| 37 |
+
attempts = await redis.incr(rate_key)
|
| 38 |
+
logger.info(f"Current OTP attempts for {identifier}: {attempts}")
|
| 39 |
+
|
| 40 |
+
if attempts == 1:
|
| 41 |
+
await redis.expire(rate_key, BookMyServiceOTPModel.RATE_LIMIT_WINDOW)
|
| 42 |
+
logger.info(f"Set rate limit expiry for {identifier}")
|
| 43 |
+
elif attempts > BookMyServiceOTPModel.RATE_LIMIT_MAX:
|
| 44 |
+
logger.warning(f"Rate limit exceeded for {identifier}: {attempts} attempts")
|
| 45 |
+
raise HTTPException(status_code=429, detail="Too many OTP requests. Try again later.")
|
| 46 |
+
|
| 47 |
+
# IP-based rate limiting
|
| 48 |
+
if client_ip:
|
| 49 |
+
ip_rate_key = f"otp_ip_rate_limit:{client_ip}"
|
| 50 |
+
ip_attempts = await redis.incr(ip_rate_key)
|
| 51 |
+
|
| 52 |
+
if ip_attempts == 1:
|
| 53 |
+
await redis.expire(ip_rate_key, BookMyServiceOTPModel.IP_RATE_LIMIT_WINDOW)
|
| 54 |
+
elif ip_attempts > BookMyServiceOTPModel.IP_RATE_LIMIT_MAX:
|
| 55 |
+
logger.warning(f"IP rate limit exceeded for {client_ip}: {ip_attempts} attempts")
|
| 56 |
+
raise HTTPException(status_code=429, detail="Too many OTP requests from this IP address")
|
| 57 |
+
|
| 58 |
+
# Store OTP
|
| 59 |
+
otp_key = f"bms_otp:{identifier}"
|
| 60 |
+
await redis.setex(otp_key, ttl, otp)
|
| 61 |
+
logger.info(f"OTP stored successfully for {identifier} with key: {otp_key}, TTL: {ttl}")
|
| 62 |
+
|
| 63 |
+
except HTTPException as e:
|
| 64 |
+
logger.error(f"HTTP error storing OTP for {identifier}: {e.status_code} - {e.detail}")
|
| 65 |
+
raise e
|
| 66 |
+
except Exception as e:
|
| 67 |
+
logger.error(f"Unexpected error storing OTP for {identifier}: {str(e)}", exc_info=True)
|
| 68 |
+
raise HTTPException(status_code=500, detail="Failed to store OTP")
|
| 69 |
+
'''
|
| 70 |
+
# Send OTP via SMS, fallback to Email if identifier is email
|
| 71 |
+
try:
|
| 72 |
+
sid = send_sms_otp(phone, otp)
|
| 73 |
+
print(f"OTP {otp} sent to {phone} via SMS. SID: {sid}")
|
| 74 |
+
except Exception as sms_error:
|
| 75 |
+
print(f"⚠️ SMS failed: {sms_error}")
|
| 76 |
+
if is_email(identifier):
|
| 77 |
+
try:
|
| 78 |
+
await send_email_otp(identifier, otp)
|
| 79 |
+
print(f"✅ OTP {otp} sent to {identifier} via email fallback.")
|
| 80 |
+
except Exception as email_error:
|
| 81 |
+
raise HTTPException(status_code=500, detail=f"SMS and email both failed: {email_error}")
|
| 82 |
+
else:
|
| 83 |
+
raise HTTPException(status_code=500, detail="SMS failed and no email fallback available.")
|
| 84 |
+
'''
|
| 85 |
+
@staticmethod
|
| 86 |
+
async def verify_otp(identifier: str, otp: str, client_ip: str = None):
|
| 87 |
+
logger.info(f"Verifying OTP for identifier: {identifier}, IP: {client_ip}")
|
| 88 |
+
logger.info(f"Provided OTP: {otp}")
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
redis = await get_redis()
|
| 92 |
+
logger.info("Redis connection established for OTP verification")
|
| 93 |
+
|
| 94 |
+
# Check if account is locked
|
| 95 |
+
if await BookMyServiceOTPModel.is_account_locked(identifier):
|
| 96 |
+
logger.warning(f"Account locked for identifier: {identifier}")
|
| 97 |
+
raise HTTPException(status_code=423, detail="Account temporarily locked due to too many failed attempts")
|
| 98 |
+
|
| 99 |
+
key = f"bms_otp:{identifier}"
|
| 100 |
+
logger.info(f"Looking up OTP with key: {key}")
|
| 101 |
+
|
| 102 |
+
stored = await redis.get(key)
|
| 103 |
+
logger.info(f"Stored OTP value: {stored}")
|
| 104 |
+
|
| 105 |
+
if stored:
|
| 106 |
+
logger.info(f"OTP found in Redis. Comparing: provided='{otp}' vs stored='{stored}'")
|
| 107 |
+
if stored == otp:
|
| 108 |
+
logger.info(f"OTP verification successful for {identifier}")
|
| 109 |
+
await redis.delete(key)
|
| 110 |
+
# Clear failed attempts on successful verification
|
| 111 |
+
await BookMyServiceOTPModel.clear_failed_attempts(identifier)
|
| 112 |
+
logger.info(f"OTP deleted from Redis after successful verification")
|
| 113 |
+
return True
|
| 114 |
+
else:
|
| 115 |
+
logger.warning(f"OTP mismatch for {identifier}: provided='{otp}' vs stored='{stored}'")
|
| 116 |
+
# Track failed attempt
|
| 117 |
+
await BookMyServiceOTPModel.track_failed_attempt(identifier, client_ip)
|
| 118 |
+
return False
|
| 119 |
+
else:
|
| 120 |
+
logger.warning(f"No OTP found in Redis for identifier: {identifier} with key: {key}")
|
| 121 |
+
# Track failed attempt for expired/non-existent OTP
|
| 122 |
+
await BookMyServiceOTPModel.track_failed_attempt(identifier, client_ip)
|
| 123 |
+
return False
|
| 124 |
+
|
| 125 |
+
except HTTPException as e:
|
| 126 |
+
logger.error(f"HTTP error verifying OTP for {identifier}: {e.status_code} - {e.detail}")
|
| 127 |
+
raise e
|
| 128 |
+
except Exception as e:
|
| 129 |
+
logger.error(f"Error verifying OTP for {identifier}: {str(e)}", exc_info=True)
|
| 130 |
+
return False
|
| 131 |
+
|
| 132 |
+
@staticmethod
|
| 133 |
+
async def read_otp(identifier: str):
|
| 134 |
+
redis = await get_redis()
|
| 135 |
+
key = f"bms_otp:{identifier}"
|
| 136 |
+
otp = await redis.get(key)
|
| 137 |
+
if otp:
|
| 138 |
+
return otp
|
| 139 |
+
raise HTTPException(status_code=404, detail="OTP not found or expired")
|
| 140 |
+
|
| 141 |
+
@staticmethod
|
| 142 |
+
async def track_failed_attempt(identifier: str, client_ip: str = None):
|
| 143 |
+
"""Track failed OTP verification attempts"""
|
| 144 |
+
logger.info(f"Tracking failed attempt for identifier: {identifier}, IP: {client_ip}")
|
| 145 |
+
|
| 146 |
+
try:
|
| 147 |
+
redis = await get_redis()
|
| 148 |
+
|
| 149 |
+
# Track failed attempts for identifier
|
| 150 |
+
failed_key = f"failed_otp:{identifier}"
|
| 151 |
+
attempts = await redis.incr(failed_key)
|
| 152 |
+
|
| 153 |
+
if attempts == 1:
|
| 154 |
+
await redis.expire(failed_key, BookMyServiceOTPModel.FAILED_ATTEMPTS_WINDOW)
|
| 155 |
+
|
| 156 |
+
# Lock account if too many failed attempts
|
| 157 |
+
if attempts >= BookMyServiceOTPModel.FAILED_ATTEMPTS_MAX:
|
| 158 |
+
await BookMyServiceOTPModel.lock_account(identifier)
|
| 159 |
+
logger.warning(f"Account locked for {identifier} after {attempts} failed attempts")
|
| 160 |
+
|
| 161 |
+
# Track IP-based failed attempts
|
| 162 |
+
if client_ip:
|
| 163 |
+
ip_failed_key = f"failed_otp_ip:{client_ip}"
|
| 164 |
+
ip_attempts = await redis.incr(ip_failed_key)
|
| 165 |
+
|
| 166 |
+
if ip_attempts == 1:
|
| 167 |
+
await redis.expire(ip_failed_key, BookMyServiceOTPModel.FAILED_ATTEMPTS_WINDOW)
|
| 168 |
+
|
| 169 |
+
logger.info(f"IP {client_ip} failed attempts: {ip_attempts}")
|
| 170 |
+
|
| 171 |
+
except Exception as e:
|
| 172 |
+
logger.error(f"Error tracking failed attempt for {identifier}: {str(e)}", exc_info=True)
|
| 173 |
+
|
| 174 |
+
@staticmethod
|
| 175 |
+
async def clear_failed_attempts(identifier: str):
|
| 176 |
+
"""Clear failed attempts counter on successful verification"""
|
| 177 |
+
try:
|
| 178 |
+
redis = await get_redis()
|
| 179 |
+
failed_key = f"failed_otp:{identifier}"
|
| 180 |
+
await redis.delete(failed_key)
|
| 181 |
+
logger.info(f"Cleared failed attempts for {identifier}")
|
| 182 |
+
except Exception as e:
|
| 183 |
+
logger.error(f"Error clearing failed attempts for {identifier}: {str(e)}", exc_info=True)
|
| 184 |
+
|
| 185 |
+
@staticmethod
|
| 186 |
+
async def lock_account(identifier: str):
|
| 187 |
+
"""Lock account temporarily"""
|
| 188 |
+
try:
|
| 189 |
+
redis = await get_redis()
|
| 190 |
+
lock_key = f"account_locked:{identifier}"
|
| 191 |
+
await redis.setex(lock_key, BookMyServiceOTPModel.ACCOUNT_LOCK_DURATION, "locked")
|
| 192 |
+
logger.info(f"Account locked for {identifier} for {BookMyServiceOTPModel.ACCOUNT_LOCK_DURATION} seconds")
|
| 193 |
+
except Exception as e:
|
| 194 |
+
logger.error(f"Error locking account for {identifier}: {str(e)}", exc_info=True)
|
| 195 |
+
|
| 196 |
+
@staticmethod
|
| 197 |
+
async def is_account_locked(identifier: str) -> bool:
|
| 198 |
+
"""Check if account is currently locked"""
|
| 199 |
+
try:
|
| 200 |
+
redis = await get_redis()
|
| 201 |
+
lock_key = f"account_locked:{identifier}"
|
| 202 |
+
locked = await redis.get(lock_key)
|
| 203 |
+
return locked is not None
|
| 204 |
+
except Exception as e:
|
| 205 |
+
logger.error(f"Error checking account lock for {identifier}: {str(e)}", exc_info=True)
|
| 206 |
+
return False
|
| 207 |
+
|
| 208 |
+
@staticmethod
|
| 209 |
+
async def get_rate_limit_count(rate_key: str) -> int:
|
| 210 |
+
"""Get current rate limit count for a key"""
|
| 211 |
+
try:
|
| 212 |
+
redis = await get_redis()
|
| 213 |
+
count = await redis.get(rate_key)
|
| 214 |
+
return int(count) if count else 0
|
| 215 |
+
except Exception as e:
|
| 216 |
+
logger.error(f"Error getting rate limit count for {rate_key}: {str(e)}", exc_info=True)
|
| 217 |
+
return 0
|
| 218 |
+
|
| 219 |
+
@staticmethod
|
| 220 |
+
async def increment_rate_limit(rate_key: str, window: int) -> int:
|
| 221 |
+
"""Increment rate limit counter with expiry"""
|
| 222 |
+
try:
|
| 223 |
+
redis = await get_redis()
|
| 224 |
+
count = await redis.incr(rate_key)
|
| 225 |
+
if count == 1:
|
| 226 |
+
await redis.expire(rate_key, window)
|
| 227 |
+
return count
|
| 228 |
+
except Exception as e:
|
| 229 |
+
logger.error(f"Error incrementing rate limit for {rate_key}: {str(e)}", exc_info=True)
|
| 230 |
+
return 0
|
app/models/pet_model.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.core.nosql_client import db
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import List, Optional, Dict, Any
|
| 4 |
+
import uuid
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
from app.utils.db import prepare_for_db
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
class PetModel:
|
| 12 |
+
"""Model for managing pet profiles embedded under customer documents"""
|
| 13 |
+
|
| 14 |
+
@staticmethod
|
| 15 |
+
async def create_pet(
|
| 16 |
+
customer_id: str,
|
| 17 |
+
pet_data: dict
|
| 18 |
+
) -> Optional[str]:
|
| 19 |
+
"""Create a new embedded pet profile under a user in customers collection"""
|
| 20 |
+
try:
|
| 21 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 22 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 23 |
+
if not user:
|
| 24 |
+
logger.error(f"User not found for customer_id {customer_id}")
|
| 25 |
+
return None
|
| 26 |
+
|
| 27 |
+
pet_id = str(uuid.uuid4())
|
| 28 |
+
current_time = datetime.utcnow()
|
| 29 |
+
|
| 30 |
+
pet_doc = {
|
| 31 |
+
"pet_id": pet_id,
|
| 32 |
+
"pet_name": pet_data.get('pet_name'),
|
| 33 |
+
"species": getattr(pet_data.get('species'), 'value', pet_data.get('species')),
|
| 34 |
+
"breed": pet_data.get('breed'),
|
| 35 |
+
"date_of_birth": pet_data.get('date_of_birth'),
|
| 36 |
+
"age": pet_data.get('age'),
|
| 37 |
+
"weight": pet_data.get('weight'),
|
| 38 |
+
"gender": getattr(pet_data.get('gender'), 'value', pet_data.get('gender')),
|
| 39 |
+
"temperament": getattr(pet_data.get('temperament'), 'value', pet_data.get('temperament')),
|
| 40 |
+
"health_notes": pet_data.get('health_notes'),
|
| 41 |
+
"is_vaccinated": pet_data.get('is_vaccinated'),
|
| 42 |
+
"pet_photo_url": pet_data.get('pet_photo_url'),
|
| 43 |
+
"is_default": pet_data.get('is_default', False),
|
| 44 |
+
"created_at": current_time,
|
| 45 |
+
"updated_at": current_time
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
pets = user.get("pets", [])
|
| 49 |
+
if pet_doc.get("is_default"):
|
| 50 |
+
for existing in pets:
|
| 51 |
+
if existing.get("is_default"):
|
| 52 |
+
existing["is_default"] = False
|
| 53 |
+
existing["updated_at"] = current_time
|
| 54 |
+
else:
|
| 55 |
+
if len(pets) == 0:
|
| 56 |
+
pet_doc["is_default"] = True
|
| 57 |
+
pets.append(pet_doc)
|
| 58 |
+
|
| 59 |
+
# Sanitize pets list for MongoDB (convert date to datetime, etc.)
|
| 60 |
+
sanitized_pets = prepare_for_db(pets)
|
| 61 |
+
update_res = await BookMyServiceUserModel.collection.update_one(
|
| 62 |
+
{"customer_id": customer_id},
|
| 63 |
+
{"$set": {"pets": sanitized_pets}}
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
if update_res.modified_count > 0:
|
| 67 |
+
logger.info(f"Embedded pet created successfully: {pet_id} for user: {customer_id}")
|
| 68 |
+
return pet_id
|
| 69 |
+
else:
|
| 70 |
+
logger.info(f"Embedded pet creation attempted with no modified_count for user: {customer_id}")
|
| 71 |
+
return pet_id
|
| 72 |
+
|
| 73 |
+
except Exception as e:
|
| 74 |
+
logger.error(f"Error creating embedded pet for user {customer_id}: {str(e)}")
|
| 75 |
+
return None
|
| 76 |
+
|
| 77 |
+
@staticmethod
|
| 78 |
+
async def get_user_pets(customer_id: str) -> List[Dict[str, Any]]:
|
| 79 |
+
"""Get all embedded pets for a specific user from customers collection"""
|
| 80 |
+
try:
|
| 81 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 82 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 83 |
+
if not user:
|
| 84 |
+
return []
|
| 85 |
+
|
| 86 |
+
pets = user.get("pets", [])
|
| 87 |
+
pets.sort(key=lambda x: x.get("created_at", datetime.utcnow()), reverse=True)
|
| 88 |
+
for p in pets:
|
| 89 |
+
p["customer_id"] = customer_id
|
| 90 |
+
logger.info(f"Retrieved {len(pets)} embedded pets for user: {customer_id}")
|
| 91 |
+
return pets
|
| 92 |
+
|
| 93 |
+
except Exception as e:
|
| 94 |
+
logger.error(f"Error getting embedded pets for user {customer_id}: {str(e)}")
|
| 95 |
+
return []
|
| 96 |
+
|
| 97 |
+
@staticmethod
|
| 98 |
+
async def get_pet_by_id(customer_id: str, pet_id: str) -> Optional[Dict[str, Any]]:
|
| 99 |
+
"""Get a specific embedded pet by ID for a user"""
|
| 100 |
+
try:
|
| 101 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 102 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 103 |
+
if not user:
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
pets = user.get("pets", [])
|
| 107 |
+
for p in pets:
|
| 108 |
+
if p.get("pet_id") == pet_id:
|
| 109 |
+
p_copy = dict(p)
|
| 110 |
+
p_copy["customer_id"] = customer_id
|
| 111 |
+
logger.info(f"Embedded pet found: {pet_id} for user: {customer_id}")
|
| 112 |
+
return p_copy
|
| 113 |
+
logger.warning(f"Embedded pet not found: {pet_id} for user: {customer_id}")
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
except Exception as e:
|
| 117 |
+
logger.error(f"Error getting embedded pet {pet_id} for user {customer_id}: {str(e)}")
|
| 118 |
+
return None
|
| 119 |
+
|
| 120 |
+
@staticmethod
|
| 121 |
+
async def update_pet(customer_id: str, pet_id: str, update_fields: Dict[str, Any]) -> bool:
|
| 122 |
+
"""Update an embedded pet's information under a user"""
|
| 123 |
+
try:
|
| 124 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 125 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 126 |
+
if not user:
|
| 127 |
+
return False
|
| 128 |
+
|
| 129 |
+
pets = user.get("pets", [])
|
| 130 |
+
updated = False
|
| 131 |
+
|
| 132 |
+
for idx, p in enumerate(pets):
|
| 133 |
+
if p.get("pet_id") == pet_id:
|
| 134 |
+
normalized_updates: Dict[str, Any] = {}
|
| 135 |
+
for k, v in update_fields.items():
|
| 136 |
+
if hasattr(v, "value"):
|
| 137 |
+
normalized_updates[k] = v.value
|
| 138 |
+
else:
|
| 139 |
+
normalized_updates[k] = v
|
| 140 |
+
|
| 141 |
+
normalized_updates["updated_at"] = datetime.utcnow()
|
| 142 |
+
pets[idx] = {**p, **normalized_updates}
|
| 143 |
+
updated = True
|
| 144 |
+
if update_fields.get("is_default"):
|
| 145 |
+
for j, other in enumerate(pets):
|
| 146 |
+
if other.get("pet_id") != pet_id and other.get("is_default"):
|
| 147 |
+
other["is_default"] = False
|
| 148 |
+
other["updated_at"] = datetime.utcnow()
|
| 149 |
+
pets[j] = other
|
| 150 |
+
break
|
| 151 |
+
|
| 152 |
+
if not updated:
|
| 153 |
+
logger.warning(f"Embedded pet not found for update: {pet_id} (user {customer_id})")
|
| 154 |
+
return False
|
| 155 |
+
|
| 156 |
+
# Sanitize pets list for MongoDB
|
| 157 |
+
sanitized_pets = prepare_for_db(pets)
|
| 158 |
+
result = await BookMyServiceUserModel.collection.update_one(
|
| 159 |
+
{"customer_id": customer_id},
|
| 160 |
+
{"$set": {"pets": sanitized_pets}}
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
if result.modified_count > 0:
|
| 164 |
+
logger.info(f"Embedded pet updated successfully: {pet_id} (user {customer_id})")
|
| 165 |
+
return True
|
| 166 |
+
else:
|
| 167 |
+
logger.info(f"Embedded pet update applied with no modified_count: {pet_id} (user {customer_id})")
|
| 168 |
+
return True
|
| 169 |
+
|
| 170 |
+
except Exception as e:
|
| 171 |
+
logger.error(f"Error updating embedded pet {pet_id} for user {customer_id}: {str(e)}")
|
| 172 |
+
return False
|
| 173 |
+
|
| 174 |
+
@staticmethod
|
| 175 |
+
async def delete_pet(customer_id: str, pet_id: str) -> bool:
|
| 176 |
+
"""Delete an embedded pet profile under a user"""
|
| 177 |
+
try:
|
| 178 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 179 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 180 |
+
if not user:
|
| 181 |
+
return False
|
| 182 |
+
|
| 183 |
+
pets = user.get("pets", [])
|
| 184 |
+
new_pets = [p for p in pets if p.get("pet_id") != pet_id]
|
| 185 |
+
if len(new_pets) == len(pets):
|
| 186 |
+
logger.warning(f"Embedded pet not found for deletion: {pet_id} (user {customer_id})")
|
| 187 |
+
return False
|
| 188 |
+
|
| 189 |
+
# Sanitize pets list for MongoDB
|
| 190 |
+
sanitized_new_pets = prepare_for_db(new_pets)
|
| 191 |
+
result = await BookMyServiceUserModel.collection.update_one(
|
| 192 |
+
{"customer_id": customer_id},
|
| 193 |
+
{"$set": {"pets": sanitized_new_pets}}
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
if result.modified_count > 0:
|
| 197 |
+
logger.info(f"Embedded pet deleted successfully: {pet_id} (user {customer_id})")
|
| 198 |
+
return True
|
| 199 |
+
else:
|
| 200 |
+
logger.info(f"Embedded pet deletion applied with no modified_count: {pet_id} (user {customer_id})")
|
| 201 |
+
return True
|
| 202 |
+
|
| 203 |
+
except Exception as e:
|
| 204 |
+
logger.error(f"Error deleting embedded pet {pet_id} for user {customer_id}: {str(e)}")
|
| 205 |
+
return False
|
| 206 |
+
|
| 207 |
+
@staticmethod
|
| 208 |
+
async def get_pet_count_for_user(customer_id: str) -> int:
|
| 209 |
+
"""Get the total number of embedded pets for a user"""
|
| 210 |
+
try:
|
| 211 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 212 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 213 |
+
if not user:
|
| 214 |
+
return 0
|
| 215 |
+
return len(user.get("pets", []))
|
| 216 |
+
except Exception as e:
|
| 217 |
+
logger.error(f"Error counting embedded pets for user {customer_id}: {str(e)}")
|
| 218 |
+
return 0
|
| 219 |
+
|
| 220 |
+
@staticmethod
|
| 221 |
+
async def get_default_pet(customer_id: str) -> Optional[Dict[str, Any]]:
|
| 222 |
+
"""Get the default pet for a user"""
|
| 223 |
+
try:
|
| 224 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 225 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 226 |
+
if not user:
|
| 227 |
+
return None
|
| 228 |
+
pets = user.get("pets", [])
|
| 229 |
+
for p in pets:
|
| 230 |
+
if p.get("is_default"):
|
| 231 |
+
p_copy = dict(p)
|
| 232 |
+
p_copy["customer_id"] = customer_id
|
| 233 |
+
return p_copy
|
| 234 |
+
return None
|
| 235 |
+
except Exception as e:
|
| 236 |
+
logger.error(f"Error getting default pet for user {customer_id}: {str(e)}")
|
| 237 |
+
return None
|
| 238 |
+
|
| 239 |
+
@staticmethod
|
| 240 |
+
async def set_default_pet(customer_id: str, pet_id: str) -> bool:
|
| 241 |
+
"""Set a pet as default for a user, unsetting any existing default"""
|
| 242 |
+
try:
|
| 243 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 244 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 245 |
+
if not user:
|
| 246 |
+
return False
|
| 247 |
+
pets = user.get("pets", [])
|
| 248 |
+
found = False
|
| 249 |
+
now = datetime.utcnow()
|
| 250 |
+
for p in pets:
|
| 251 |
+
if p.get("pet_id") == pet_id:
|
| 252 |
+
p["is_default"] = True
|
| 253 |
+
p["updated_at"] = now
|
| 254 |
+
found = True
|
| 255 |
+
else:
|
| 256 |
+
if p.get("is_default"):
|
| 257 |
+
p["is_default"] = False
|
| 258 |
+
p["updated_at"] = now
|
| 259 |
+
if not found:
|
| 260 |
+
return False
|
| 261 |
+
# Sanitize pets list for MongoDB
|
| 262 |
+
sanitized_pets = prepare_for_db(pets)
|
| 263 |
+
res = await BookMyServiceUserModel.collection.update_one(
|
| 264 |
+
{"customer_id": customer_id},
|
| 265 |
+
{"$set": {"pets": sanitized_pets}}
|
| 266 |
+
)
|
| 267 |
+
return True
|
| 268 |
+
except Exception as e:
|
| 269 |
+
logger.error(f"Error setting default pet {pet_id} for user {customer_id}: {str(e)}")
|
| 270 |
+
return False
|
app/models/refresh_token_model.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta
|
| 2 |
+
from typing import Optional
|
| 3 |
+
import uuid
|
| 4 |
+
import logging
|
| 5 |
+
from app.core.nosql_client import db
|
| 6 |
+
from app.core.cache_client import get_redis
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger("refresh_token_model")
|
| 9 |
+
|
| 10 |
+
class RefreshTokenModel:
|
| 11 |
+
"""Model for managing refresh tokens with rotation support"""
|
| 12 |
+
|
| 13 |
+
collection = db["refresh_tokens"]
|
| 14 |
+
|
| 15 |
+
# Token family tracking for rotation
|
| 16 |
+
TOKEN_FAMILY_TTL = 30 * 24 * 3600 # 30 days in seconds
|
| 17 |
+
|
| 18 |
+
@staticmethod
|
| 19 |
+
async def create_token_family(customer_id: str, device_info: Optional[str] = None) -> str:
|
| 20 |
+
"""Create a new token family for refresh token rotation"""
|
| 21 |
+
family_id = str(uuid.uuid4())
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
redis = await get_redis()
|
| 25 |
+
family_key = f"token_family:{family_id}"
|
| 26 |
+
|
| 27 |
+
family_data = {
|
| 28 |
+
"customer_id": customer_id,
|
| 29 |
+
"device_info": device_info,
|
| 30 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 31 |
+
"rotation_count": 0
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
import json
|
| 35 |
+
await redis.setex(family_key, RefreshTokenModel.TOKEN_FAMILY_TTL, json.dumps(family_data))
|
| 36 |
+
|
| 37 |
+
logger.info(f"Created token family {family_id} for user {customer_id}")
|
| 38 |
+
return family_id
|
| 39 |
+
|
| 40 |
+
except Exception as e:
|
| 41 |
+
logger.error(f"Error creating token family: {str(e)}", exc_info=True)
|
| 42 |
+
raise
|
| 43 |
+
|
| 44 |
+
@staticmethod
|
| 45 |
+
async def get_token_family(family_id: str) -> Optional[dict]:
|
| 46 |
+
"""Get token family data"""
|
| 47 |
+
try:
|
| 48 |
+
redis = await get_redis()
|
| 49 |
+
family_key = f"token_family:{family_id}"
|
| 50 |
+
|
| 51 |
+
data = await redis.get(family_key)
|
| 52 |
+
if data:
|
| 53 |
+
import json
|
| 54 |
+
return json.loads(data)
|
| 55 |
+
return None
|
| 56 |
+
|
| 57 |
+
except Exception as e:
|
| 58 |
+
logger.error(f"Error getting token family: {str(e)}", exc_info=True)
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
@staticmethod
|
| 62 |
+
async def increment_rotation_count(family_id: str) -> int:
|
| 63 |
+
"""Increment rotation count for a token family"""
|
| 64 |
+
try:
|
| 65 |
+
redis = await get_redis()
|
| 66 |
+
family_key = f"token_family:{family_id}"
|
| 67 |
+
|
| 68 |
+
family_data = await RefreshTokenModel.get_token_family(family_id)
|
| 69 |
+
if not family_data:
|
| 70 |
+
return 0
|
| 71 |
+
|
| 72 |
+
family_data["rotation_count"] = family_data.get("rotation_count", 0) + 1
|
| 73 |
+
family_data["last_rotated"] = datetime.utcnow().isoformat()
|
| 74 |
+
|
| 75 |
+
import json
|
| 76 |
+
ttl = await redis.ttl(family_key)
|
| 77 |
+
if ttl > 0:
|
| 78 |
+
await redis.setex(family_key, ttl, json.dumps(family_data))
|
| 79 |
+
|
| 80 |
+
logger.info(f"Incremented rotation count for family {family_id} to {family_data['rotation_count']}")
|
| 81 |
+
return family_data["rotation_count"]
|
| 82 |
+
|
| 83 |
+
except Exception as e:
|
| 84 |
+
logger.error(f"Error incrementing rotation count: {str(e)}", exc_info=True)
|
| 85 |
+
return 0
|
| 86 |
+
|
| 87 |
+
@staticmethod
|
| 88 |
+
async def store_refresh_token(
|
| 89 |
+
token_id: str,
|
| 90 |
+
customer_id: str,
|
| 91 |
+
family_id: str,
|
| 92 |
+
expires_at: datetime,
|
| 93 |
+
remember_me: bool = False,
|
| 94 |
+
device_info: Optional[str] = None,
|
| 95 |
+
ip_address: Optional[str] = None
|
| 96 |
+
):
|
| 97 |
+
"""Store refresh token metadata"""
|
| 98 |
+
try:
|
| 99 |
+
token_doc = {
|
| 100 |
+
"token_id": token_id,
|
| 101 |
+
"customer_id": customer_id,
|
| 102 |
+
"family_id": family_id,
|
| 103 |
+
"expires_at": expires_at,
|
| 104 |
+
"remember_me": remember_me,
|
| 105 |
+
"device_info": device_info,
|
| 106 |
+
"ip_address": ip_address,
|
| 107 |
+
"created_at": datetime.utcnow(),
|
| 108 |
+
"revoked": False,
|
| 109 |
+
"used": False
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
await RefreshTokenModel.collection.insert_one(token_doc)
|
| 113 |
+
logger.info(f"Stored refresh token {token_id} for user {customer_id}")
|
| 114 |
+
|
| 115 |
+
except Exception as e:
|
| 116 |
+
logger.error(f"Error storing refresh token: {str(e)}", exc_info=True)
|
| 117 |
+
raise
|
| 118 |
+
|
| 119 |
+
@staticmethod
|
| 120 |
+
async def mark_token_as_used(token_id: str) -> bool:
|
| 121 |
+
"""Mark a refresh token as used (for rotation)"""
|
| 122 |
+
try:
|
| 123 |
+
result = await RefreshTokenModel.collection.update_one(
|
| 124 |
+
{"token_id": token_id},
|
| 125 |
+
{
|
| 126 |
+
"$set": {
|
| 127 |
+
"used": True,
|
| 128 |
+
"used_at": datetime.utcnow()
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
if result.modified_count > 0:
|
| 134 |
+
logger.info(f"Marked token {token_id} as used")
|
| 135 |
+
return True
|
| 136 |
+
return False
|
| 137 |
+
|
| 138 |
+
except Exception as e:
|
| 139 |
+
logger.error(f"Error marking token as used: {str(e)}", exc_info=True)
|
| 140 |
+
return False
|
| 141 |
+
|
| 142 |
+
@staticmethod
|
| 143 |
+
async def is_token_valid(token_id: str) -> bool:
|
| 144 |
+
"""Check if a refresh token is valid (not revoked or used)"""
|
| 145 |
+
try:
|
| 146 |
+
token = await RefreshTokenModel.collection.find_one({"token_id": token_id})
|
| 147 |
+
|
| 148 |
+
if not token:
|
| 149 |
+
logger.warning(f"Token {token_id} not found")
|
| 150 |
+
return False
|
| 151 |
+
|
| 152 |
+
if token.get("revoked"):
|
| 153 |
+
logger.warning(f"Token {token_id} is revoked")
|
| 154 |
+
return False
|
| 155 |
+
|
| 156 |
+
if token.get("used"):
|
| 157 |
+
logger.warning(f"Token {token_id} already used - possible replay attack")
|
| 158 |
+
# Revoke entire token family on reuse attempt
|
| 159 |
+
await RefreshTokenModel.revoke_token_family(token.get("family_id"))
|
| 160 |
+
return False
|
| 161 |
+
|
| 162 |
+
if token.get("expires_at") < datetime.utcnow():
|
| 163 |
+
logger.warning(f"Token {token_id} is expired")
|
| 164 |
+
return False
|
| 165 |
+
|
| 166 |
+
return True
|
| 167 |
+
|
| 168 |
+
except Exception as e:
|
| 169 |
+
logger.error(f"Error checking token validity: {str(e)}", exc_info=True)
|
| 170 |
+
return False
|
| 171 |
+
|
| 172 |
+
@staticmethod
|
| 173 |
+
async def get_token_metadata(token_id: str) -> Optional[dict]:
|
| 174 |
+
"""Get refresh token metadata"""
|
| 175 |
+
try:
|
| 176 |
+
token = await RefreshTokenModel.collection.find_one({"token_id": token_id})
|
| 177 |
+
return token
|
| 178 |
+
except Exception as e:
|
| 179 |
+
logger.error(f"Error getting token metadata: {str(e)}", exc_info=True)
|
| 180 |
+
return None
|
| 181 |
+
|
| 182 |
+
@staticmethod
|
| 183 |
+
async def revoke_token(token_id: str) -> bool:
|
| 184 |
+
"""Revoke a specific refresh token"""
|
| 185 |
+
try:
|
| 186 |
+
result = await RefreshTokenModel.collection.update_one(
|
| 187 |
+
{"token_id": token_id},
|
| 188 |
+
{
|
| 189 |
+
"$set": {
|
| 190 |
+
"revoked": True,
|
| 191 |
+
"revoked_at": datetime.utcnow()
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
if result.modified_count > 0:
|
| 197 |
+
logger.info(f"Revoked token {token_id}")
|
| 198 |
+
return True
|
| 199 |
+
return False
|
| 200 |
+
|
| 201 |
+
except Exception as e:
|
| 202 |
+
logger.error(f"Error revoking token: {str(e)}", exc_info=True)
|
| 203 |
+
return False
|
| 204 |
+
|
| 205 |
+
@staticmethod
|
| 206 |
+
async def revoke_token_family(family_id: str) -> int:
|
| 207 |
+
"""Revoke all tokens in a family (security breach detection)"""
|
| 208 |
+
try:
|
| 209 |
+
result = await RefreshTokenModel.collection.update_many(
|
| 210 |
+
{"family_id": family_id, "revoked": False},
|
| 211 |
+
{
|
| 212 |
+
"$set": {
|
| 213 |
+
"revoked": True,
|
| 214 |
+
"revoked_at": datetime.utcnow(),
|
| 215 |
+
"revoke_reason": "token_reuse_detected"
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
# Also delete the family from Redis
|
| 221 |
+
redis = await get_redis()
|
| 222 |
+
await redis.delete(f"token_family:{family_id}")
|
| 223 |
+
|
| 224 |
+
logger.warning(f"Revoked {result.modified_count} tokens in family {family_id}")
|
| 225 |
+
return result.modified_count
|
| 226 |
+
|
| 227 |
+
except Exception as e:
|
| 228 |
+
logger.error(f"Error revoking token family: {str(e)}", exc_info=True)
|
| 229 |
+
return 0
|
| 230 |
+
|
| 231 |
+
@staticmethod
|
| 232 |
+
async def revoke_all_user_tokens(customer_id: str) -> int:
|
| 233 |
+
"""Revoke all refresh tokens for a user (logout from all devices)"""
|
| 234 |
+
try:
|
| 235 |
+
result = await RefreshTokenModel.collection.update_many(
|
| 236 |
+
{"customer_id": customer_id, "revoked": False},
|
| 237 |
+
{
|
| 238 |
+
"$set": {
|
| 239 |
+
"revoked": True,
|
| 240 |
+
"revoked_at": datetime.utcnow(),
|
| 241 |
+
"revoke_reason": "user_logout_all"
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
logger.info(f"Revoked {result.modified_count} tokens for user {customer_id}")
|
| 247 |
+
return result.modified_count
|
| 248 |
+
|
| 249 |
+
except Exception as e:
|
| 250 |
+
logger.error(f"Error revoking all user tokens: {str(e)}", exc_info=True)
|
| 251 |
+
return 0
|
| 252 |
+
|
| 253 |
+
@staticmethod
|
| 254 |
+
async def get_active_sessions(customer_id: str) -> list:
|
| 255 |
+
"""Get all active sessions (valid refresh tokens) for a user"""
|
| 256 |
+
try:
|
| 257 |
+
tokens = await RefreshTokenModel.collection.find({
|
| 258 |
+
"customer_id": customer_id,
|
| 259 |
+
"revoked": False,
|
| 260 |
+
"expires_at": {"$gt": datetime.utcnow()}
|
| 261 |
+
}).to_list(length=100)
|
| 262 |
+
|
| 263 |
+
return tokens
|
| 264 |
+
|
| 265 |
+
except Exception as e:
|
| 266 |
+
logger.error(f"Error getting active sessions: {str(e)}", exc_info=True)
|
| 267 |
+
return []
|
| 268 |
+
|
| 269 |
+
@staticmethod
|
| 270 |
+
async def cleanup_expired_tokens():
|
| 271 |
+
"""Cleanup expired tokens (run periodically)"""
|
| 272 |
+
try:
|
| 273 |
+
result = await RefreshTokenModel.collection.delete_many({
|
| 274 |
+
"expires_at": {"$lt": datetime.utcnow()}
|
| 275 |
+
})
|
| 276 |
+
|
| 277 |
+
logger.info(f"Cleaned up {result.deleted_count} expired tokens")
|
| 278 |
+
return result.deleted_count
|
| 279 |
+
|
| 280 |
+
except Exception as e:
|
| 281 |
+
logger.error(f"Error cleaning up expired tokens: {str(e)}", exc_info=True)
|
| 282 |
+
return 0
|
app/models/review_model.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import HTTPException
|
| 2 |
+
from app.core.nosql_client import db
|
| 3 |
+
import logging
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
from app.utils.db import prepare_for_db
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
class ReviewModel:
|
| 11 |
+
collection = db["merchant_reviews"]
|
| 12 |
+
|
| 13 |
+
@staticmethod
|
| 14 |
+
async def create_review(
|
| 15 |
+
review_data:dict
|
| 16 |
+
)->dict:
|
| 17 |
+
logger.info(f"Creating review for merchant {review_data['merchant_id']}")
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
|
| 21 |
+
# Creating review document
|
| 22 |
+
review_doc = {
|
| 23 |
+
"merchant_id":review_data["merchant_id"],
|
| 24 |
+
"location_id": review_data["location_id"],
|
| 25 |
+
"user_name":review_data["user_name"],
|
| 26 |
+
"rating": review_data[ "rating"],
|
| 27 |
+
"review_text":review_data["review_text"],
|
| 28 |
+
"review_date": datetime.utcnow(),
|
| 29 |
+
"verified_purchase":review_data["verified_purchase"]
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
sanitized_review = prepare_for_db(review_doc)
|
| 33 |
+
|
| 34 |
+
result = await ReviewModel.collection.insert_one(sanitized_review)
|
| 35 |
+
|
| 36 |
+
logger.info(f"review data inserted successfully: {result}")
|
| 37 |
+
|
| 38 |
+
if result.inserted_id:
|
| 39 |
+
return review_doc
|
| 40 |
+
|
| 41 |
+
except HTTPException:
|
| 42 |
+
raise
|
| 43 |
+
except Exception as e:
|
| 44 |
+
logger.error(f"Error while adding review: {str(e)}", exc_info=True)
|
| 45 |
+
raise HTTPException(status_code=500, detail="Failed to add review details")
|
app/models/social_account_model.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import HTTPException
|
| 2 |
+
from app.core.nosql_client import db
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import Optional, List, Dict, Any
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger("social_account_model")
|
| 8 |
+
|
| 9 |
+
class SocialAccountModel:
|
| 10 |
+
"""Model for managing social login accounts and linking"""
|
| 11 |
+
|
| 12 |
+
collection = db["social_accounts"]
|
| 13 |
+
|
| 14 |
+
@staticmethod
|
| 15 |
+
async def create_social_account(customer_id: str, provider: str, provider_customer_id: str, user_info: Dict[str, Any]) -> str:
|
| 16 |
+
"""Create a new social account record"""
|
| 17 |
+
try:
|
| 18 |
+
social_account = {
|
| 19 |
+
"customer_id": customer_id,
|
| 20 |
+
"provider": provider,
|
| 21 |
+
"provider_customer_id": provider_customer_id,
|
| 22 |
+
"email": user_info.get("email"),
|
| 23 |
+
"name": user_info.get("name"),
|
| 24 |
+
"picture": user_info.get("picture"),
|
| 25 |
+
"profile_data": user_info,
|
| 26 |
+
"created_at": datetime.utcnow(),
|
| 27 |
+
"updated_at": datetime.utcnow(),
|
| 28 |
+
"is_active": True,
|
| 29 |
+
"last_login": datetime.utcnow()
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
result = await SocialAccountModel.collection.insert_one(social_account)
|
| 33 |
+
logger.info(f"Created social account for user {customer_id} with provider {provider}")
|
| 34 |
+
return str(result.inserted_id)
|
| 35 |
+
|
| 36 |
+
except Exception as e:
|
| 37 |
+
logger.error(f"Error creating social account: {str(e)}", exc_info=True)
|
| 38 |
+
raise HTTPException(status_code=500, detail="Failed to create social account")
|
| 39 |
+
|
| 40 |
+
@staticmethod
|
| 41 |
+
async def find_by_provider_and_customer_id(provider: str, provider_customer_id: str) -> Optional[Dict[str, Any]]:
|
| 42 |
+
"""Find social account by provider and provider user ID"""
|
| 43 |
+
try:
|
| 44 |
+
account = await SocialAccountModel.collection.find_one({
|
| 45 |
+
"provider": provider,
|
| 46 |
+
"provider_customer_id": provider_customer_id,
|
| 47 |
+
"is_active": True
|
| 48 |
+
})
|
| 49 |
+
return account
|
| 50 |
+
except Exception as e:
|
| 51 |
+
logger.error(f"Error finding social account: {str(e)}", exc_info=True)
|
| 52 |
+
return None
|
| 53 |
+
|
| 54 |
+
@staticmethod
|
| 55 |
+
async def find_by_customer_id(customer_id: str) -> List[Dict[str, Any]]:
|
| 56 |
+
"""Find all social accounts for a user"""
|
| 57 |
+
try:
|
| 58 |
+
cursor = SocialAccountModel.collection.find({
|
| 59 |
+
"customer_id": customer_id,
|
| 60 |
+
"is_active": True
|
| 61 |
+
})
|
| 62 |
+
accounts = await cursor.to_list(length=None)
|
| 63 |
+
return accounts
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.error(f"Error finding social accounts for user {customer_id}: {str(e)}", exc_info=True)
|
| 66 |
+
return []
|
| 67 |
+
|
| 68 |
+
@staticmethod
|
| 69 |
+
async def update_social_account(provider: str, provider_customer_id: str, user_info: Dict[str, Any]) -> bool:
|
| 70 |
+
"""Update social account with latest user info"""
|
| 71 |
+
try:
|
| 72 |
+
update_data = {
|
| 73 |
+
"email": user_info.get("email"),
|
| 74 |
+
"name": user_info.get("name"),
|
| 75 |
+
"picture": user_info.get("picture"),
|
| 76 |
+
"profile_data": user_info,
|
| 77 |
+
"updated_at": datetime.utcnow(),
|
| 78 |
+
"last_login": datetime.utcnow()
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
result = await SocialAccountModel.collection.update_one(
|
| 82 |
+
{
|
| 83 |
+
"provider": provider,
|
| 84 |
+
"provider_customer_id": provider_customer_id,
|
| 85 |
+
"is_active": True
|
| 86 |
+
},
|
| 87 |
+
{"$set": update_data}
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
return result.modified_count > 0
|
| 91 |
+
|
| 92 |
+
except Exception as e:
|
| 93 |
+
logger.error(f"Error updating social account: {str(e)}", exc_info=True)
|
| 94 |
+
return False
|
| 95 |
+
|
| 96 |
+
@staticmethod
|
| 97 |
+
async def link_social_account(customer_id: str, provider: str, provider_customer_id: str, user_info: Dict[str, Any]) -> bool:
|
| 98 |
+
"""Link a social account to an existing user"""
|
| 99 |
+
try:
|
| 100 |
+
# Check if this social account is already linked to another user
|
| 101 |
+
existing_account = await SocialAccountModel.find_by_provider_and_customer_id(provider, provider_customer_id)
|
| 102 |
+
|
| 103 |
+
if existing_account and existing_account["customer_id"] != customer_id:
|
| 104 |
+
logger.warning(f"Social account {provider}:{provider_customer_id} already linked to user {existing_account['customer_id']}")
|
| 105 |
+
raise HTTPException(
|
| 106 |
+
status_code=409,
|
| 107 |
+
detail=f"This {provider} account is already linked to another user"
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
if existing_account and existing_account["customer_id"] == customer_id:
|
| 111 |
+
# Update existing account
|
| 112 |
+
await SocialAccountModel.update_social_account(provider, provider_customer_id, user_info)
|
| 113 |
+
return True
|
| 114 |
+
|
| 115 |
+
# Create new social account link
|
| 116 |
+
await SocialAccountModel.create_social_account(customer_id, provider, provider_customer_id, user_info)
|
| 117 |
+
return True
|
| 118 |
+
|
| 119 |
+
except HTTPException:
|
| 120 |
+
raise
|
| 121 |
+
except Exception as e:
|
| 122 |
+
logger.error(f"Error linking social account: {str(e)}", exc_info=True)
|
| 123 |
+
raise HTTPException(status_code=500, detail="Failed to link social account")
|
| 124 |
+
|
| 125 |
+
@staticmethod
|
| 126 |
+
async def unlink_social_account(customer_id: str, provider: str) -> bool:
|
| 127 |
+
"""Unlink a social account from a user"""
|
| 128 |
+
try:
|
| 129 |
+
result = await SocialAccountModel.collection.update_one(
|
| 130 |
+
{
|
| 131 |
+
"customer_id": customer_id,
|
| 132 |
+
"provider": provider,
|
| 133 |
+
"is_active": True
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"$set": {
|
| 137 |
+
"is_active": False,
|
| 138 |
+
"updated_at": datetime.utcnow()
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
if result.modified_count > 0:
|
| 144 |
+
logger.info(f"Unlinked {provider} account for user {customer_id}")
|
| 145 |
+
return True
|
| 146 |
+
else:
|
| 147 |
+
logger.warning(f"No active {provider} account found for user {customer_id}")
|
| 148 |
+
return False
|
| 149 |
+
|
| 150 |
+
except Exception as e:
|
| 151 |
+
logger.error(f"Error unlinking social account: {str(e)}", exc_info=True)
|
| 152 |
+
return False
|
| 153 |
+
|
| 154 |
+
@staticmethod
|
| 155 |
+
async def get_profile_picture(customer_id: str, preferred_provider: str = None) -> Optional[str]:
|
| 156 |
+
"""Get user's profile picture from social accounts"""
|
| 157 |
+
try:
|
| 158 |
+
query = {"customer_id": customer_id, "is_active": True}
|
| 159 |
+
|
| 160 |
+
# If preferred provider specified, try that first
|
| 161 |
+
if preferred_provider:
|
| 162 |
+
account = await SocialAccountModel.collection.find_one({
|
| 163 |
+
**query,
|
| 164 |
+
"provider": preferred_provider,
|
| 165 |
+
"picture": {"$exists": True, "$ne": None}
|
| 166 |
+
})
|
| 167 |
+
if account and account.get("picture"):
|
| 168 |
+
return account["picture"]
|
| 169 |
+
|
| 170 |
+
# Otherwise, get any account with a profile picture
|
| 171 |
+
account = await SocialAccountModel.collection.find_one({
|
| 172 |
+
**query,
|
| 173 |
+
"picture": {"$exists": True, "$ne": None}
|
| 174 |
+
})
|
| 175 |
+
|
| 176 |
+
return account.get("picture") if account else None
|
| 177 |
+
|
| 178 |
+
except Exception as e:
|
| 179 |
+
logger.error(f"Error getting profile picture for user {customer_id}: {str(e)}", exc_info=True)
|
| 180 |
+
return None
|
| 181 |
+
|
| 182 |
+
@staticmethod
|
| 183 |
+
async def get_social_account_summary(customer_id: str) -> Dict[str, Any]:
|
| 184 |
+
"""Get summary of all linked social accounts for a user"""
|
| 185 |
+
try:
|
| 186 |
+
accounts = await SocialAccountModel.find_by_customer_id(customer_id)
|
| 187 |
+
|
| 188 |
+
summary = {
|
| 189 |
+
"linked_accounts": [],
|
| 190 |
+
"total_accounts": len(accounts),
|
| 191 |
+
"profile_picture": None
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
for account in accounts:
|
| 195 |
+
summary["linked_accounts"].append({
|
| 196 |
+
"provider": account["provider"],
|
| 197 |
+
"email": account.get("email"),
|
| 198 |
+
"name": account.get("name"),
|
| 199 |
+
"linked_at": account["created_at"],
|
| 200 |
+
"last_login": account.get("last_login")
|
| 201 |
+
})
|
| 202 |
+
|
| 203 |
+
# Set profile picture if available
|
| 204 |
+
if not summary["profile_picture"] and account.get("picture"):
|
| 205 |
+
summary["profile_picture"] = account["picture"]
|
| 206 |
+
|
| 207 |
+
return summary
|
| 208 |
+
|
| 209 |
+
except Exception as e:
|
| 210 |
+
logger.error(f"Error getting social account summary for user {customer_id}: {str(e)}", exc_info=True)
|
| 211 |
+
return {"linked_accounts": [], "total_accounts": 0, "profile_picture": None}
|
| 212 |
+
|
| 213 |
+
@staticmethod
|
| 214 |
+
async def merge_social_accounts(primary_customer_id: str, secondary_customer_id: str) -> bool:
|
| 215 |
+
"""Merge social accounts from secondary user to primary user"""
|
| 216 |
+
try:
|
| 217 |
+
# Get all social accounts from secondary user
|
| 218 |
+
secondary_accounts = await SocialAccountModel.find_by_customer_id(secondary_customer_id)
|
| 219 |
+
|
| 220 |
+
for account in secondary_accounts:
|
| 221 |
+
# Check if primary user already has this provider linked
|
| 222 |
+
existing = await SocialAccountModel.collection.find_one({
|
| 223 |
+
"customer_id": primary_customer_id,
|
| 224 |
+
"provider": account["provider"],
|
| 225 |
+
"is_active": True
|
| 226 |
+
})
|
| 227 |
+
|
| 228 |
+
if not existing:
|
| 229 |
+
# Transfer the account to primary user
|
| 230 |
+
await SocialAccountModel.collection.update_one(
|
| 231 |
+
{"_id": account["_id"]},
|
| 232 |
+
{
|
| 233 |
+
"$set": {
|
| 234 |
+
"customer_id": primary_customer_id,
|
| 235 |
+
"updated_at": datetime.utcnow()
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
)
|
| 239 |
+
logger.info(f"Transferred {account['provider']} account from user {secondary_customer_id} to {primary_customer_id}")
|
| 240 |
+
else:
|
| 241 |
+
# Deactivate the secondary account
|
| 242 |
+
await SocialAccountModel.collection.update_one(
|
| 243 |
+
{"_id": account["_id"]},
|
| 244 |
+
{
|
| 245 |
+
"$set": {
|
| 246 |
+
"is_active": False,
|
| 247 |
+
"updated_at": datetime.utcnow()
|
| 248 |
+
}
|
| 249 |
+
}
|
| 250 |
+
)
|
| 251 |
+
logger.info(f"Deactivated duplicate {account['provider']} account for user {secondary_customer_id}")
|
| 252 |
+
|
| 253 |
+
return True
|
| 254 |
+
|
| 255 |
+
except Exception as e:
|
| 256 |
+
logger.error(f"Error merging social accounts: {str(e)}", exc_info=True)
|
| 257 |
+
return False
|
app/models/social_security_model.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta
|
| 2 |
+
import logging
|
| 3 |
+
from app.core.cache_client import get_redis
|
| 4 |
+
from fastapi import HTTPException
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
class SocialSecurityModel:
|
| 9 |
+
"""Model for handling social login security features"""
|
| 10 |
+
|
| 11 |
+
# Rate limiting constants
|
| 12 |
+
OAUTH_RATE_LIMIT_MAX = 5 # Max OAuth attempts per IP per hour
|
| 13 |
+
OAUTH_RATE_LIMIT_WINDOW = 3600 # 1 hour in seconds
|
| 14 |
+
|
| 15 |
+
# Failed attempt tracking
|
| 16 |
+
OAUTH_FAILED_ATTEMPTS_MAX = 3 # Max failed OAuth attempts per IP
|
| 17 |
+
OAUTH_FAILED_ATTEMPTS_WINDOW = 1800 # 30 minutes
|
| 18 |
+
OAUTH_IP_LOCK_DURATION = 3600 # 1 hour lock for IP
|
| 19 |
+
|
| 20 |
+
@staticmethod
|
| 21 |
+
async def check_oauth_rate_limit(client_ip: str, provider: str) -> bool:
|
| 22 |
+
"""Check if OAuth rate limit is exceeded for IP and provider"""
|
| 23 |
+
if not client_ip:
|
| 24 |
+
return True # Allow if no IP provided
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
redis = await get_redis()
|
| 28 |
+
rate_key = f"oauth_rate:{client_ip}:{provider}"
|
| 29 |
+
|
| 30 |
+
current_count = await redis.get(rate_key)
|
| 31 |
+
if current_count and int(current_count) >= SocialSecurityModel.OAUTH_RATE_LIMIT_MAX:
|
| 32 |
+
logger.warning(f"OAuth rate limit exceeded for IP {client_ip} and provider {provider}")
|
| 33 |
+
return False
|
| 34 |
+
|
| 35 |
+
return True
|
| 36 |
+
|
| 37 |
+
except Exception as e:
|
| 38 |
+
logger.error(f"Error checking OAuth rate limit: {str(e)}", exc_info=True)
|
| 39 |
+
return True # Allow on error to avoid blocking legitimate users
|
| 40 |
+
|
| 41 |
+
@staticmethod
|
| 42 |
+
async def increment_oauth_rate_limit(client_ip: str, provider: str):
|
| 43 |
+
"""Increment OAuth rate limit counter"""
|
| 44 |
+
if not client_ip:
|
| 45 |
+
return
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
redis = await get_redis()
|
| 49 |
+
rate_key = f"oauth_rate:{client_ip}:{provider}"
|
| 50 |
+
|
| 51 |
+
count = await redis.incr(rate_key)
|
| 52 |
+
if count == 1:
|
| 53 |
+
await redis.expire(rate_key, SocialSecurityModel.OAUTH_RATE_LIMIT_WINDOW)
|
| 54 |
+
|
| 55 |
+
logger.info(f"OAuth rate limit count for {client_ip}:{provider} = {count}")
|
| 56 |
+
|
| 57 |
+
except Exception as e:
|
| 58 |
+
logger.error(f"Error incrementing OAuth rate limit: {str(e)}", exc_info=True)
|
| 59 |
+
|
| 60 |
+
@staticmethod
|
| 61 |
+
async def track_oauth_failed_attempt(client_ip: str, provider: str):
|
| 62 |
+
"""Track failed OAuth verification attempts"""
|
| 63 |
+
if not client_ip:
|
| 64 |
+
return
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
redis = await get_redis()
|
| 68 |
+
failed_key = f"oauth_failed:{client_ip}:{provider}"
|
| 69 |
+
|
| 70 |
+
attempts = await redis.incr(failed_key)
|
| 71 |
+
if attempts == 1:
|
| 72 |
+
await redis.expire(failed_key, SocialSecurityModel.OAUTH_FAILED_ATTEMPTS_WINDOW)
|
| 73 |
+
|
| 74 |
+
# Lock IP if too many failed attempts
|
| 75 |
+
if attempts >= SocialSecurityModel.OAUTH_FAILED_ATTEMPTS_MAX:
|
| 76 |
+
await SocialSecurityModel.lock_oauth_ip(client_ip, provider)
|
| 77 |
+
logger.warning(f"IP {client_ip} locked for provider {provider} after {attempts} failed attempts")
|
| 78 |
+
|
| 79 |
+
logger.info(f"OAuth failed attempts for {client_ip}:{provider} = {attempts}")
|
| 80 |
+
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logger.error(f"Error tracking OAuth failed attempt: {str(e)}", exc_info=True)
|
| 83 |
+
|
| 84 |
+
@staticmethod
|
| 85 |
+
async def lock_oauth_ip(client_ip: str, provider: str):
|
| 86 |
+
"""Lock IP for OAuth attempts on specific provider"""
|
| 87 |
+
try:
|
| 88 |
+
redis = await get_redis()
|
| 89 |
+
lock_key = f"oauth_ip_locked:{client_ip}:{provider}"
|
| 90 |
+
await redis.setex(lock_key, SocialSecurityModel.OAUTH_IP_LOCK_DURATION, "locked")
|
| 91 |
+
logger.info(f"IP {client_ip} locked for OAuth provider {provider}")
|
| 92 |
+
except Exception as e:
|
| 93 |
+
logger.error(f"Error locking OAuth IP: {str(e)}", exc_info=True)
|
| 94 |
+
|
| 95 |
+
@staticmethod
|
| 96 |
+
async def is_oauth_ip_locked(client_ip: str, provider: str) -> bool:
|
| 97 |
+
"""Check if IP is locked for OAuth attempts on specific provider"""
|
| 98 |
+
if not client_ip:
|
| 99 |
+
return False
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
redis = await get_redis()
|
| 103 |
+
lock_key = f"oauth_ip_locked:{client_ip}:{provider}"
|
| 104 |
+
locked = await redis.get(lock_key)
|
| 105 |
+
return locked is not None
|
| 106 |
+
except Exception as e:
|
| 107 |
+
logger.error(f"Error checking OAuth IP lock: {str(e)}", exc_info=True)
|
| 108 |
+
return False
|
| 109 |
+
|
| 110 |
+
@staticmethod
|
| 111 |
+
async def clear_oauth_failed_attempts(client_ip: str, provider: str):
|
| 112 |
+
"""Clear failed OAuth attempts on successful verification"""
|
| 113 |
+
if not client_ip:
|
| 114 |
+
return
|
| 115 |
+
|
| 116 |
+
try:
|
| 117 |
+
redis = await get_redis()
|
| 118 |
+
failed_key = f"oauth_failed:{client_ip}:{provider}"
|
| 119 |
+
await redis.delete(failed_key)
|
| 120 |
+
logger.info(f"Cleared OAuth failed attempts for {client_ip}:{provider}")
|
| 121 |
+
except Exception as e:
|
| 122 |
+
logger.error(f"Error clearing OAuth failed attempts: {str(e)}", exc_info=True)
|
| 123 |
+
|
| 124 |
+
@staticmethod
|
| 125 |
+
async def validate_oauth_token_format(token: str, provider: str) -> bool:
|
| 126 |
+
"""Basic validation of OAuth token format"""
|
| 127 |
+
# In local test mode, accept any non-empty string to facilitate testing
|
| 128 |
+
try:
|
| 129 |
+
from app.core.config import settings
|
| 130 |
+
if getattr(settings, "OAUTH_TEST_MODE", False):
|
| 131 |
+
return bool(token)
|
| 132 |
+
except Exception:
|
| 133 |
+
pass
|
| 134 |
+
|
| 135 |
+
if not token or not isinstance(token, str):
|
| 136 |
+
return False
|
| 137 |
+
|
| 138 |
+
# Basic length and format checks
|
| 139 |
+
if provider == "google":
|
| 140 |
+
# Normalize optional Bearer prefix
|
| 141 |
+
t = token.strip()
|
| 142 |
+
if t.lower().startswith("bearer "):
|
| 143 |
+
t = t[7:]
|
| 144 |
+
# Accept Google ID tokens (JWT)
|
| 145 |
+
if len(t) > 100 and t.count('.') == 2:
|
| 146 |
+
return True
|
| 147 |
+
# Accept Google OAuth access tokens (commonly start with 'ya29.') and are shorter
|
| 148 |
+
if t.startswith("ya29.") or (len(t) >= 20 and len(t) <= 4096 and t.count('.') < 2):
|
| 149 |
+
return True
|
| 150 |
+
return False
|
| 151 |
+
elif provider == "apple":
|
| 152 |
+
# Apple ID tokens are also JWT format
|
| 153 |
+
return len(token) > 100 and token.count('.') == 2
|
| 154 |
+
elif provider == "facebook":
|
| 155 |
+
# Facebook access tokens are typically shorter
|
| 156 |
+
return len(token) > 20 and len(token) < 500
|
| 157 |
+
|
| 158 |
+
return True # Allow unknown providers
|
| 159 |
+
|
| 160 |
+
@staticmethod
|
| 161 |
+
async def log_oauth_attempt(client_ip: str, provider: str, success: bool, customer_id: str = None):
|
| 162 |
+
"""Log OAuth authentication attempts for security monitoring"""
|
| 163 |
+
try:
|
| 164 |
+
redis = await get_redis()
|
| 165 |
+
log_key = f"oauth_log:{datetime.utcnow().strftime('%Y-%m-%d')}"
|
| 166 |
+
|
| 167 |
+
log_entry = {
|
| 168 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 169 |
+
"ip": client_ip,
|
| 170 |
+
"provider": provider,
|
| 171 |
+
"success": success,
|
| 172 |
+
"customer_id": customer_id
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
# Store as JSON string in Redis list
|
| 176 |
+
import json
|
| 177 |
+
await redis.lpush(log_key, json.dumps(log_entry))
|
| 178 |
+
|
| 179 |
+
# Keep only last 1000 entries per day
|
| 180 |
+
await redis.ltrim(log_key, 0, 999)
|
| 181 |
+
|
| 182 |
+
# Set expiry for 30 days
|
| 183 |
+
await redis.expire(log_key, 30 * 24 * 3600)
|
| 184 |
+
|
| 185 |
+
logger.info(f"OAuth attempt logged: {provider} from {client_ip} - {'success' if success else 'failed'}")
|
| 186 |
+
|
| 187 |
+
except Exception as e:
|
| 188 |
+
logger.error(f"Error logging OAuth attempt: {str(e)}", exc_info=True)
|
app/models/user_model.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import HTTPException
|
| 2 |
+
from app.core.nosql_client import db
|
| 3 |
+
from app.utils.common_utils import is_email, is_phone, validate_identifier # Updated imports
|
| 4 |
+
from app.schemas.user_schema import UserRegisterRequest
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger("user_model")
|
| 8 |
+
|
| 9 |
+
class BookMyServiceUserModel:
|
| 10 |
+
collection = db["customers"]
|
| 11 |
+
|
| 12 |
+
@staticmethod
|
| 13 |
+
async def find_by_email(email: str):
|
| 14 |
+
logger.info(f"Searching for user by email: {email}")
|
| 15 |
+
try:
|
| 16 |
+
user = await BookMyServiceUserModel.collection.find_one({"email": email})
|
| 17 |
+
if user:
|
| 18 |
+
logger.info(f"User found by email: {email}")
|
| 19 |
+
else:
|
| 20 |
+
logger.info(f"No user found with email: {email}")
|
| 21 |
+
return user
|
| 22 |
+
except Exception as e:
|
| 23 |
+
logger.error(f"Error finding user by email {email}: {str(e)}", exc_info=True)
|
| 24 |
+
return None
|
| 25 |
+
|
| 26 |
+
@staticmethod
|
| 27 |
+
async def find_by_phone(phone: str):
|
| 28 |
+
logger.info(f"Searching for user by phone: {phone}")
|
| 29 |
+
try:
|
| 30 |
+
user = await BookMyServiceUserModel.collection.find_one({"phone": phone})
|
| 31 |
+
if user:
|
| 32 |
+
logger.info(f"User found by phone: {phone}")
|
| 33 |
+
else:
|
| 34 |
+
logger.info(f"No user found with phone: {phone}")
|
| 35 |
+
return user
|
| 36 |
+
except Exception as e:
|
| 37 |
+
logger.error(f"Error finding user by phone {phone}: {str(e)}", exc_info=True)
|
| 38 |
+
return None
|
| 39 |
+
|
| 40 |
+
@staticmethod
|
| 41 |
+
async def find_by_mobile(mobile: str):
|
| 42 |
+
"""Legacy method for backward compatibility - redirects to find_by_phone"""
|
| 43 |
+
logger.info(f"Legacy find_by_mobile called, redirecting to find_by_phone for: {mobile}")
|
| 44 |
+
return await BookMyServiceUserModel.find_by_phone(mobile)
|
| 45 |
+
|
| 46 |
+
@staticmethod
|
| 47 |
+
async def find_by_identifier(identifier: str):
|
| 48 |
+
logger.info(f"Finding user by identifier: {identifier}")
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
# Validate and determine identifier type
|
| 52 |
+
identifier_type = validate_identifier(identifier)
|
| 53 |
+
logger.info(f"Identifier type determined: {identifier_type}")
|
| 54 |
+
|
| 55 |
+
if identifier_type == "email":
|
| 56 |
+
logger.info(f"Searching by email for identifier: {identifier}")
|
| 57 |
+
user = await BookMyServiceUserModel.find_by_email(identifier)
|
| 58 |
+
elif identifier_type == "phone":
|
| 59 |
+
logger.info(f"Searching by phone for identifier: {identifier}")
|
| 60 |
+
user = await BookMyServiceUserModel.find_by_phone(identifier)
|
| 61 |
+
else:
|
| 62 |
+
logger.error(f"Invalid identifier type: {identifier_type}")
|
| 63 |
+
raise HTTPException(status_code=400, detail="Invalid identifier format")
|
| 64 |
+
|
| 65 |
+
if not user:
|
| 66 |
+
logger.warning(f"User not found with identifier: {identifier}")
|
| 67 |
+
raise HTTPException(status_code=404, detail="User not found with this email or phone")
|
| 68 |
+
|
| 69 |
+
logger.info(f"User found successfully for identifier: {identifier}")
|
| 70 |
+
logger.info(f"User data keys: {list(user.keys()) if user else 'None'}")
|
| 71 |
+
return user
|
| 72 |
+
|
| 73 |
+
except ValueError as ve:
|
| 74 |
+
logger.error(f"Validation error for identifier {identifier}: {str(ve)}")
|
| 75 |
+
raise HTTPException(status_code=400, detail=str(ve))
|
| 76 |
+
except HTTPException as e:
|
| 77 |
+
logger.error(f"HTTP error finding user by identifier {identifier}: {e.status_code} - {e.detail}")
|
| 78 |
+
raise e
|
| 79 |
+
except Exception as e:
|
| 80 |
+
logger.error(f"Unexpected error finding user by identifier {identifier}: {str(e)}", exc_info=True)
|
| 81 |
+
raise HTTPException(status_code=500, detail="Failed to find user")
|
| 82 |
+
|
| 83 |
+
@staticmethod
|
| 84 |
+
async def exists_by_email_or_phone(email: str = None, phone: str = None) -> bool:
|
| 85 |
+
"""Check if user exists by email or phone"""
|
| 86 |
+
query_conditions = []
|
| 87 |
+
|
| 88 |
+
if email:
|
| 89 |
+
query_conditions.append({"email": email})
|
| 90 |
+
if phone:
|
| 91 |
+
query_conditions.append({"phone": phone})
|
| 92 |
+
|
| 93 |
+
if not query_conditions:
|
| 94 |
+
return False
|
| 95 |
+
|
| 96 |
+
query = {"$or": query_conditions} if len(query_conditions) > 1 else query_conditions[0]
|
| 97 |
+
|
| 98 |
+
result = await BookMyServiceUserModel.collection.find_one(query)
|
| 99 |
+
return result is not None
|
| 100 |
+
|
| 101 |
+
@staticmethod
|
| 102 |
+
async def create(user_data: UserRegisterRequest):
|
| 103 |
+
user_dict = user_data.dict()
|
| 104 |
+
result = await BookMyServiceUserModel.collection.insert_one(user_dict)
|
| 105 |
+
return result.inserted_id
|
| 106 |
+
|
| 107 |
+
@staticmethod
|
| 108 |
+
async def update_by_identifier(identifier: str, update_fields: dict):
|
| 109 |
+
try:
|
| 110 |
+
identifier_type = validate_identifier(identifier)
|
| 111 |
+
|
| 112 |
+
if identifier_type == "email":
|
| 113 |
+
query = {"email": identifier}
|
| 114 |
+
elif identifier_type == "phone":
|
| 115 |
+
query = {"phone": identifier}
|
| 116 |
+
else:
|
| 117 |
+
raise HTTPException(status_code=400, detail="Invalid identifier format")
|
| 118 |
+
|
| 119 |
+
result = await BookMyServiceUserModel.collection.update_one(query, {"$set": update_fields})
|
| 120 |
+
if result.matched_count == 0:
|
| 121 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 122 |
+
return result.modified_count > 0
|
| 123 |
+
|
| 124 |
+
except ValueError as ve:
|
| 125 |
+
logger.error(f"Validation error for identifier {identifier}: {str(ve)}")
|
| 126 |
+
raise HTTPException(status_code=400, detail=str(ve))
|
| 127 |
+
|
| 128 |
+
@staticmethod
|
| 129 |
+
async def update_profile(customer_id: str, update_fields: dict):
|
| 130 |
+
"""Update user profile by customer_id"""
|
| 131 |
+
try:
|
| 132 |
+
from datetime import datetime
|
| 133 |
+
|
| 134 |
+
# Add updated_at timestamp
|
| 135 |
+
update_fields["updated_at"] = datetime.utcnow()
|
| 136 |
+
|
| 137 |
+
result = await BookMyServiceUserModel.collection.update_one(
|
| 138 |
+
{"customer_id": customer_id},
|
| 139 |
+
{"$set": update_fields}
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
if result.matched_count == 0:
|
| 143 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 144 |
+
|
| 145 |
+
return result.modified_count > 0
|
| 146 |
+
|
| 147 |
+
except Exception as e:
|
| 148 |
+
logger.error(f"Error updating profile for user {customer_id}: {str(e)}")
|
| 149 |
+
raise HTTPException(status_code=500, detail="Failed to update profile")
|
| 150 |
+
|
| 151 |
+
@staticmethod
|
| 152 |
+
async def find_by_id(customer_id: str):
|
| 153 |
+
"""Find user by customer_id"""
|
| 154 |
+
try:
|
| 155 |
+
user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 156 |
+
return user
|
| 157 |
+
except Exception as e:
|
| 158 |
+
logger.error(f"Error finding user by ID {customer_id}: {str(e)}")
|
| 159 |
+
return None
|
app/models/wallet_model.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import Optional, List, Dict, Any
|
| 3 |
+
from bson import ObjectId
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
from app.core.nosql_client import db
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
class WalletModel:
|
| 11 |
+
"""Model for managing user wallet operations"""
|
| 12 |
+
|
| 13 |
+
wallet_collection = db["user_wallets"]
|
| 14 |
+
transaction_collection = db["wallet_transactions"]
|
| 15 |
+
|
| 16 |
+
@staticmethod
|
| 17 |
+
async def get_wallet_balance(customer_id: str) -> float:
|
| 18 |
+
"""Get current wallet balance for a user"""
|
| 19 |
+
try:
|
| 20 |
+
wallet = await WalletModel.wallet_collection.find_one({"customer_id": customer_id})
|
| 21 |
+
if wallet:
|
| 22 |
+
return wallet.get("balance", 0.0)
|
| 23 |
+
else:
|
| 24 |
+
# Create wallet if doesn't exist
|
| 25 |
+
await WalletModel.create_wallet(customer_id)
|
| 26 |
+
return 0.0
|
| 27 |
+
except Exception as e:
|
| 28 |
+
logger.error(f"Error getting wallet balance for user {customer_id}: {str(e)}")
|
| 29 |
+
return 0.0
|
| 30 |
+
|
| 31 |
+
@staticmethod
|
| 32 |
+
async def create_wallet(customer_id: str, initial_balance: float = 0.0) -> bool:
|
| 33 |
+
"""Create a new wallet for a user"""
|
| 34 |
+
try:
|
| 35 |
+
wallet_doc = {
|
| 36 |
+
"customer_id": customer_id,
|
| 37 |
+
"balance": initial_balance,
|
| 38 |
+
"created_at": datetime.utcnow(),
|
| 39 |
+
"updated_at": datetime.utcnow()
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
result = await WalletModel.wallet_collection.insert_one(wallet_doc)
|
| 43 |
+
logger.info(f"Created wallet for user {customer_id} with balance {initial_balance}")
|
| 44 |
+
return result.inserted_id is not None
|
| 45 |
+
except Exception as e:
|
| 46 |
+
logger.error(f"Error creating wallet for user {customer_id}: {str(e)}")
|
| 47 |
+
return False
|
| 48 |
+
|
| 49 |
+
@staticmethod
|
| 50 |
+
async def update_balance(customer_id: str, amount: float, transaction_type: str,
|
| 51 |
+
description: str = "", reference_id: str = None) -> bool:
|
| 52 |
+
"""Update wallet balance and create transaction record"""
|
| 53 |
+
try:
|
| 54 |
+
# Get current balance
|
| 55 |
+
current_balance = await WalletModel.get_wallet_balance(customer_id)
|
| 56 |
+
|
| 57 |
+
# Calculate new balance
|
| 58 |
+
if transaction_type in ["credit", "refund", "cashback"]:
|
| 59 |
+
new_balance = current_balance + amount
|
| 60 |
+
elif transaction_type in ["debit", "payment", "withdrawal"]:
|
| 61 |
+
if current_balance < amount:
|
| 62 |
+
logger.warning(f"Insufficient balance for user {customer_id}. Current: {current_balance}, Required: {amount}")
|
| 63 |
+
return False
|
| 64 |
+
new_balance = current_balance - amount
|
| 65 |
+
else:
|
| 66 |
+
logger.error(f"Invalid transaction type: {transaction_type}")
|
| 67 |
+
return False
|
| 68 |
+
|
| 69 |
+
# Update wallet balance
|
| 70 |
+
update_result = await WalletModel.wallet_collection.update_one(
|
| 71 |
+
{"customer_id": customer_id},
|
| 72 |
+
{
|
| 73 |
+
"$set": {
|
| 74 |
+
"balance": new_balance,
|
| 75 |
+
"updated_at": datetime.utcnow()
|
| 76 |
+
}
|
| 77 |
+
},
|
| 78 |
+
upsert=True
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
# Create transaction record
|
| 82 |
+
transaction_doc = {
|
| 83 |
+
"customer_id": customer_id,
|
| 84 |
+
"amount": amount,
|
| 85 |
+
"transaction_type": transaction_type,
|
| 86 |
+
"description": description,
|
| 87 |
+
"reference_id": reference_id,
|
| 88 |
+
"balance_before": current_balance,
|
| 89 |
+
"balance_after": new_balance,
|
| 90 |
+
"timestamp": datetime.utcnow(),
|
| 91 |
+
"status": "completed"
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
await WalletModel.transaction_collection.insert_one(transaction_doc)
|
| 95 |
+
|
| 96 |
+
logger.info(f"Updated wallet for user {customer_id}: {transaction_type} of {amount}, new balance: {new_balance}")
|
| 97 |
+
return True
|
| 98 |
+
|
| 99 |
+
except Exception as e:
|
| 100 |
+
logger.error(f"Error updating wallet balance for user {customer_id}: {str(e)}")
|
| 101 |
+
return False
|
| 102 |
+
|
| 103 |
+
@staticmethod
|
| 104 |
+
async def get_transaction_history(customer_id: str, page: int = 1, per_page: int = 20) -> Dict[str, Any]:
|
| 105 |
+
"""Get paginated transaction history for a user"""
|
| 106 |
+
try:
|
| 107 |
+
skip = (page - 1) * per_page
|
| 108 |
+
|
| 109 |
+
# Get transactions with pagination
|
| 110 |
+
cursor = WalletModel.transaction_collection.find(
|
| 111 |
+
{"customer_id": customer_id}
|
| 112 |
+
).sort("timestamp", -1).skip(skip).limit(per_page)
|
| 113 |
+
|
| 114 |
+
transactions = []
|
| 115 |
+
async for transaction in cursor:
|
| 116 |
+
# Convert ObjectId to string for JSON serialization
|
| 117 |
+
transaction["_id"] = str(transaction["_id"])
|
| 118 |
+
transactions.append(transaction)
|
| 119 |
+
|
| 120 |
+
# Get total count
|
| 121 |
+
total_count = await WalletModel.transaction_collection.count_documents({"customer_id": customer_id})
|
| 122 |
+
|
| 123 |
+
return {
|
| 124 |
+
"transactions": transactions,
|
| 125 |
+
"total_count": total_count,
|
| 126 |
+
"page": page,
|
| 127 |
+
"per_page": per_page,
|
| 128 |
+
"total_pages": (total_count + per_page - 1) // per_page
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger.error(f"Error getting transaction history for user {customer_id}: {str(e)}")
|
| 133 |
+
return {
|
| 134 |
+
"transactions": [],
|
| 135 |
+
"total_count": 0,
|
| 136 |
+
"page": page,
|
| 137 |
+
"per_page": per_page,
|
| 138 |
+
"total_pages": 0
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
@staticmethod
|
| 142 |
+
async def get_wallet_summary(customer_id: str) -> Dict[str, Any]:
|
| 143 |
+
"""Get wallet summary including balance and recent transactions"""
|
| 144 |
+
try:
|
| 145 |
+
balance = await WalletModel.get_wallet_balance(customer_id)
|
| 146 |
+
recent_transactions = await WalletModel.get_transaction_history(customer_id, page=1, per_page=5)
|
| 147 |
+
|
| 148 |
+
return {
|
| 149 |
+
"balance": balance,
|
| 150 |
+
"recent_transactions": recent_transactions["transactions"]
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
except Exception as e:
|
| 154 |
+
logger.error(f"Error getting wallet summary for user {customer_id}: {str(e)}")
|
| 155 |
+
return {
|
| 156 |
+
"balance": 0.0,
|
| 157 |
+
"recent_transactions": []
|
| 158 |
+
}
|
app/routers/__init__.py
CHANGED
|
@@ -1,2 +1,11 @@
|
|
| 1 |
-
__all__ = [
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__all__ = [
|
| 2 |
+
"user_router",
|
| 3 |
+
"profile_router",
|
| 4 |
+
"account_router",
|
| 5 |
+
"wallet_router",
|
| 6 |
+
"address_router",
|
| 7 |
+
"pet_router",
|
| 8 |
+
"guest_router",
|
| 9 |
+
"favorite_router",
|
| 10 |
+
"review_router"
|
| 11 |
+
]
|
app/routers/account_router.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, Query
|
| 2 |
+
from fastapi.security import HTTPBearer
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
from app.schemas.user_schema import (
|
| 8 |
+
LinkSocialAccountRequest, UnlinkSocialAccountRequest,
|
| 9 |
+
SocialAccountSummary, LoginHistoryResponse, SecuritySettingsResponse,
|
| 10 |
+
TokenResponse
|
| 11 |
+
)
|
| 12 |
+
from app.services.account_service import AccountService
|
| 13 |
+
from app.utils.jwt import decode_token
|
| 14 |
+
|
| 15 |
+
# Configure logging
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
router = APIRouter()
|
| 19 |
+
security = HTTPBearer()
|
| 20 |
+
|
| 21 |
+
def get_current_user(token: str = Depends(security)):
|
| 22 |
+
"""Extract user ID from JWT token"""
|
| 23 |
+
try:
|
| 24 |
+
payload = decode_token(token.credentials)
|
| 25 |
+
customer_id = payload.get("sub")
|
| 26 |
+
if not customer_id:
|
| 27 |
+
raise HTTPException(status_code=401, detail="Invalid token")
|
| 28 |
+
return customer_id
|
| 29 |
+
except Exception as e:
|
| 30 |
+
logger.error(f"Token validation error: {str(e)}")
|
| 31 |
+
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
| 32 |
+
|
| 33 |
+
def get_client_ip(request: Request) -> str:
|
| 34 |
+
"""Extract client IP from request"""
|
| 35 |
+
forwarded_for = request.headers.get("X-Forwarded-For")
|
| 36 |
+
if forwarded_for:
|
| 37 |
+
return forwarded_for.split(",")[0].strip()
|
| 38 |
+
|
| 39 |
+
real_ip = request.headers.get("X-Real-IP")
|
| 40 |
+
if real_ip:
|
| 41 |
+
return real_ip
|
| 42 |
+
|
| 43 |
+
return request.client.host if request.client else "unknown"
|
| 44 |
+
|
| 45 |
+
@router.get("/social-accounts", response_model=SocialAccountSummary)
|
| 46 |
+
async def get_social_accounts(customer_id: str = Depends(get_current_user)):
|
| 47 |
+
"""Get all linked social accounts for the current user"""
|
| 48 |
+
try:
|
| 49 |
+
account_service = AccountService()
|
| 50 |
+
summary = await account_service.get_social_account_summary(customer_id)
|
| 51 |
+
return summary
|
| 52 |
+
except Exception as e:
|
| 53 |
+
logger.error(f"Error fetching social accounts for user {customer_id}: {str(e)}")
|
| 54 |
+
raise HTTPException(status_code=500, detail="Failed to fetch social accounts")
|
| 55 |
+
|
| 56 |
+
@router.post("/link-social-account", response_model=dict)
|
| 57 |
+
async def link_social_account(
|
| 58 |
+
request: LinkSocialAccountRequest,
|
| 59 |
+
req: Request,
|
| 60 |
+
customer_id: str = Depends(get_current_user)
|
| 61 |
+
):
|
| 62 |
+
"""Link a new social account to the current user"""
|
| 63 |
+
try:
|
| 64 |
+
client_ip = get_client_ip(req)
|
| 65 |
+
account_service = AccountService()
|
| 66 |
+
|
| 67 |
+
result = await account_service.link_social_account(
|
| 68 |
+
customer_id=customer_id,
|
| 69 |
+
provider=request.provider,
|
| 70 |
+
token=request.token,
|
| 71 |
+
client_ip=client_ip
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
return {"message": f"Successfully linked {request.provider} account", "result": result}
|
| 75 |
+
except ValueError as e:
|
| 76 |
+
logger.warning(f"Invalid link request for user {customer_id}: {str(e)}")
|
| 77 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 78 |
+
except Exception as e:
|
| 79 |
+
logger.error(f"Error linking social account for user {customer_id}: {str(e)}")
|
| 80 |
+
raise HTTPException(status_code=500, detail="Failed to link social account")
|
| 81 |
+
|
| 82 |
+
@router.delete("/unlink-social-account", response_model=dict)
|
| 83 |
+
async def unlink_social_account(
|
| 84 |
+
request: UnlinkSocialAccountRequest,
|
| 85 |
+
customer_id: str = Depends(get_current_user)
|
| 86 |
+
):
|
| 87 |
+
"""Unlink a social account from the current user"""
|
| 88 |
+
try:
|
| 89 |
+
account_service = AccountService()
|
| 90 |
+
|
| 91 |
+
result = await account_service.unlink_social_account(
|
| 92 |
+
customer_id=customer_id,
|
| 93 |
+
provider=request.provider
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
return {"message": f"Successfully unlinked {request.provider} account", "result": result}
|
| 97 |
+
except ValueError as e:
|
| 98 |
+
logger.warning(f"Invalid unlink request for user {customer_id}: {str(e)}")
|
| 99 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 100 |
+
except Exception as e:
|
| 101 |
+
logger.error(f"Error unlinking social account for user {customer_id}: {str(e)}")
|
| 102 |
+
raise HTTPException(status_code=500, detail="Failed to unlink social account")
|
| 103 |
+
|
| 104 |
+
@router.get("/login-history", response_model=LoginHistoryResponse)
|
| 105 |
+
async def get_login_history(
|
| 106 |
+
page: int = Query(1, ge=1, description="Page number"),
|
| 107 |
+
per_page: int = Query(10, ge=1, le=50, description="Items per page"),
|
| 108 |
+
days: int = Query(30, ge=1, le=365, description="Number of days to look back"),
|
| 109 |
+
customer_id: str = Depends(get_current_user)
|
| 110 |
+
):
|
| 111 |
+
"""Get login history for the current user"""
|
| 112 |
+
try:
|
| 113 |
+
account_service = AccountService()
|
| 114 |
+
|
| 115 |
+
history = await account_service.get_login_history(
|
| 116 |
+
customer_id=customer_id,
|
| 117 |
+
page=page,
|
| 118 |
+
per_page=per_page,
|
| 119 |
+
days=days
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
return history
|
| 123 |
+
except Exception as e:
|
| 124 |
+
logger.error(f"Error fetching login history for user {customer_id}: {str(e)}")
|
| 125 |
+
raise HTTPException(status_code=500, detail="Failed to fetch login history")
|
| 126 |
+
|
| 127 |
+
@router.get("/security-settings", response_model=SecuritySettingsResponse)
|
| 128 |
+
async def get_security_settings(customer_id: str = Depends(get_current_user)):
|
| 129 |
+
"""Get security settings and status for the current user"""
|
| 130 |
+
try:
|
| 131 |
+
account_service = AccountService()
|
| 132 |
+
|
| 133 |
+
settings = await account_service.get_security_settings(customer_id)
|
| 134 |
+
|
| 135 |
+
return settings
|
| 136 |
+
except Exception as e:
|
| 137 |
+
logger.error(f"Error fetching security settings for user {customer_id}: {str(e)}")
|
| 138 |
+
raise HTTPException(status_code=500, detail="Failed to fetch security settings")
|
| 139 |
+
|
| 140 |
+
@router.post("/merge-accounts", response_model=dict)
|
| 141 |
+
async def merge_social_accounts(
|
| 142 |
+
target_customer_id: str,
|
| 143 |
+
req: Request,
|
| 144 |
+
customer_id: str = Depends(get_current_user)
|
| 145 |
+
):
|
| 146 |
+
"""Merge social accounts from another user (admin function or user-initiated)"""
|
| 147 |
+
try:
|
| 148 |
+
# For security, only allow users to merge their own accounts or implement admin check
|
| 149 |
+
if customer_id != target_customer_id:
|
| 150 |
+
# In a real implementation, you'd check if the current user is an admin
|
| 151 |
+
# or if they have proper authorization to merge accounts
|
| 152 |
+
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
| 153 |
+
|
| 154 |
+
client_ip = get_client_ip(req)
|
| 155 |
+
account_service = AccountService()
|
| 156 |
+
|
| 157 |
+
result = await account_service.merge_social_accounts(
|
| 158 |
+
primary_customer_id=customer_id,
|
| 159 |
+
secondary_customer_id=target_customer_id,
|
| 160 |
+
client_ip=client_ip
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
return {"message": "Successfully merged social accounts", "result": result}
|
| 164 |
+
except ValueError as e:
|
| 165 |
+
logger.warning(f"Invalid merge request for user {customer_id}: {str(e)}")
|
| 166 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 167 |
+
except Exception as e:
|
| 168 |
+
logger.error(f"Error merging social accounts for user {customer_id}: {str(e)}")
|
| 169 |
+
raise HTTPException(status_code=500, detail="Failed to merge social accounts")
|
| 170 |
+
|
| 171 |
+
@router.delete("/revoke-all-sessions", response_model=dict)
|
| 172 |
+
async def revoke_all_sessions(
|
| 173 |
+
req: Request,
|
| 174 |
+
customer_id: str = Depends(get_current_user)
|
| 175 |
+
):
|
| 176 |
+
"""Revoke all active sessions for security purposes"""
|
| 177 |
+
try:
|
| 178 |
+
client_ip = get_client_ip(req)
|
| 179 |
+
account_service = AccountService()
|
| 180 |
+
|
| 181 |
+
result = await account_service.revoke_all_sessions(customer_id, client_ip)
|
| 182 |
+
|
| 183 |
+
return {"message": "All sessions have been revoked", "result": result}
|
| 184 |
+
except Exception as e:
|
| 185 |
+
logger.error(f"Error revoking sessions for user {customer_id}: {str(e)}")
|
| 186 |
+
raise HTTPException(status_code=500, detail="Failed to revoke sessions")
|
| 187 |
+
|
| 188 |
+
@router.get("/trusted-devices", response_model=dict)
|
| 189 |
+
async def get_trusted_devices(customer_id: str = Depends(get_current_user)):
|
| 190 |
+
"""Get list of trusted devices for the current user"""
|
| 191 |
+
try:
|
| 192 |
+
account_service = AccountService()
|
| 193 |
+
|
| 194 |
+
devices = await account_service.get_trusted_devices(customer_id)
|
| 195 |
+
|
| 196 |
+
return {"devices": devices}
|
| 197 |
+
except Exception as e:
|
| 198 |
+
logger.error(f"Error fetching trusted devices for user {customer_id}: {str(e)}")
|
| 199 |
+
raise HTTPException(status_code=500, detail="Failed to fetch trusted devices")
|
| 200 |
+
|
| 201 |
+
@router.delete("/trusted-devices/{device_id}", response_model=dict)
|
| 202 |
+
async def remove_trusted_device(
|
| 203 |
+
device_id: str,
|
| 204 |
+
customer_id: str = Depends(get_current_user)
|
| 205 |
+
):
|
| 206 |
+
"""Remove a trusted device"""
|
| 207 |
+
try:
|
| 208 |
+
account_service = AccountService()
|
| 209 |
+
|
| 210 |
+
result = await account_service.remove_trusted_device(customer_id, device_id)
|
| 211 |
+
|
| 212 |
+
return {"message": "Trusted device removed successfully", "result": result}
|
| 213 |
+
except ValueError as e:
|
| 214 |
+
logger.warning(f"Invalid device removal request for user {customer_id}: {str(e)}")
|
| 215 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 216 |
+
except Exception as e:
|
| 217 |
+
logger.error(f"Error removing trusted device for user {customer_id}: {str(e)}")
|
| 218 |
+
raise HTTPException(status_code=500, detail="Failed to remove trusted device")
|
app/routers/address_router.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from typing import List
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
from app.utils.jwt import get_current_customer_id
|
| 6 |
+
from app.models.address_model import AddressModel
|
| 7 |
+
from app.schemas.address_schema import (
|
| 8 |
+
AddressCreateRequest, AddressUpdateRequest, AddressResponse,
|
| 9 |
+
AddressListResponse, SetDefaultAddressRequest, AddressOperationResponse
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
|
| 16 |
+
@router.get("/", response_model=AddressListResponse)
|
| 17 |
+
async def get_user_addresses(current_customer_id: str = Depends(get_current_customer_id)):
|
| 18 |
+
"""
|
| 19 |
+
Get all delivery addresses for the current user.
|
| 20 |
+
|
| 21 |
+
This endpoint is JWT protected and requires a valid Bearer token.
|
| 22 |
+
"""
|
| 23 |
+
try:
|
| 24 |
+
logger.info(f"Get addresses request for user: {current_customer_id}")
|
| 25 |
+
|
| 26 |
+
addresses = await AddressModel.get_user_addresses(current_customer_id)
|
| 27 |
+
|
| 28 |
+
address_responses = []
|
| 29 |
+
for addr in addresses:
|
| 30 |
+
address_responses.append(AddressResponse(
|
| 31 |
+
address_id=addr["address_id"], # Use the new address_id field
|
| 32 |
+
address_type=addr["address_type"],
|
| 33 |
+
address_line_1=addr["address_line_1"],
|
| 34 |
+
address_line_2=addr.get("address_line_2", ""),
|
| 35 |
+
city=addr["city"],
|
| 36 |
+
state=addr["state"],
|
| 37 |
+
postal_code=addr["postal_code"],
|
| 38 |
+
country=addr.get("country", "India"),
|
| 39 |
+
landmark=addr.get("landmark", ""),
|
| 40 |
+
is_default=addr.get("is_default", False),
|
| 41 |
+
created_at=addr.get("created_at"),
|
| 42 |
+
updated_at=addr.get("updated_at")
|
| 43 |
+
))
|
| 44 |
+
|
| 45 |
+
if not address_responses:
|
| 46 |
+
return AddressListResponse(
|
| 47 |
+
success=False,
|
| 48 |
+
message="Failed to retrieve address"
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
else:
|
| 52 |
+
return AddressListResponse(
|
| 53 |
+
success=True,
|
| 54 |
+
message="Addresses retrieved successfully",
|
| 55 |
+
addresses=address_responses,
|
| 56 |
+
total_count=len(address_responses)
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
except Exception as e:
|
| 61 |
+
logger.error(f"Error getting addresses for user {current_customer_id}: {str(e)}")
|
| 62 |
+
raise HTTPException(
|
| 63 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 64 |
+
detail="Failed to retrieve addresses"
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
@router.post("/", response_model=AddressOperationResponse)
|
| 68 |
+
async def create_address(
|
| 69 |
+
address_data: AddressCreateRequest,
|
| 70 |
+
current_customer_id: str = Depends(get_current_customer_id)
|
| 71 |
+
):
|
| 72 |
+
"""
|
| 73 |
+
Create a new delivery address for the current user.
|
| 74 |
+
"""
|
| 75 |
+
try:
|
| 76 |
+
logger.info(f"Create address request for user: {current_customer_id}")
|
| 77 |
+
|
| 78 |
+
# Check if user already has 5 addresses (limit)
|
| 79 |
+
existing_addresses = await AddressModel.get_user_addresses(current_customer_id)
|
| 80 |
+
if len(existing_addresses) >= 5:
|
| 81 |
+
raise HTTPException(
|
| 82 |
+
status_code=400,
|
| 83 |
+
detail="Maximum of 5 addresses allowed per user"
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# If this is the first address, make it default
|
| 87 |
+
is_default = len(existing_addresses) == 0 or address_data.is_default
|
| 88 |
+
|
| 89 |
+
address_id = await AddressModel.create_address(current_customer_id, address_data.dict())
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
if address_id:
|
| 93 |
+
# Get the created address
|
| 94 |
+
created_address = await AddressModel.get_address_by_id(current_customer_id,address_id)
|
| 95 |
+
|
| 96 |
+
address_response = AddressResponse(
|
| 97 |
+
address_id=created_address["address_id"], # Use the new address_id field
|
| 98 |
+
address_type=created_address["address_type"],
|
| 99 |
+
address_line_1=created_address["address_line_1"],
|
| 100 |
+
address_line_2=created_address.get("address_line_2", ""),
|
| 101 |
+
city=created_address["city"],
|
| 102 |
+
state=created_address["state"],
|
| 103 |
+
postal_code=created_address["postal_code"],
|
| 104 |
+
country=created_address.get("country", "India"),
|
| 105 |
+
is_default=created_address.get("is_default", False),
|
| 106 |
+
landmark=created_address.get("landmark", ""),
|
| 107 |
+
created_at=created_address.get("created_at"),
|
| 108 |
+
updated_at=created_address.get("updated_at")
|
| 109 |
+
)
|
| 110 |
+
return AddressOperationResponse(
|
| 111 |
+
success=True,
|
| 112 |
+
message="Address created successfully",
|
| 113 |
+
address=address_response
|
| 114 |
+
)
|
| 115 |
+
else:
|
| 116 |
+
return AddressOperationResponse(
|
| 117 |
+
success=False,
|
| 118 |
+
message="Failed to create address"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
except HTTPException:
|
| 122 |
+
raise
|
| 123 |
+
except Exception as e:
|
| 124 |
+
logger.error(f"Error creating address for user {current_customer_id}: {str(e)}")
|
| 125 |
+
raise HTTPException(
|
| 126 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 127 |
+
detail="Failed to create address"
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
@router.put("/{address_id}", response_model=AddressOperationResponse)
|
| 131 |
+
async def update_address(
|
| 132 |
+
address_id: str,
|
| 133 |
+
address_data: AddressUpdateRequest,
|
| 134 |
+
current_customer_id: str = Depends(get_current_customer_id)
|
| 135 |
+
):
|
| 136 |
+
"""
|
| 137 |
+
Update an existing delivery address.
|
| 138 |
+
"""
|
| 139 |
+
try:
|
| 140 |
+
logger.info(f"Update address request for user: {current_customer_id}, address: {address_id}")
|
| 141 |
+
|
| 142 |
+
# Check if address exists and belongs to user
|
| 143 |
+
existing_address = await AddressModel.get_address_by_id(current_customer_id,address_id)
|
| 144 |
+
if not existing_address:
|
| 145 |
+
raise HTTPException(status_code=404, detail="Address not found")
|
| 146 |
+
|
| 147 |
+
if existing_address["customer_id"] != current_customer_id:
|
| 148 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 149 |
+
|
| 150 |
+
# Prepare update fields
|
| 151 |
+
update_fields = {}
|
| 152 |
+
|
| 153 |
+
if address_data.address_type is not None:
|
| 154 |
+
update_fields["address_type"] = address_data.address_type
|
| 155 |
+
if address_data.address_line_1 is not None:
|
| 156 |
+
update_fields["address_line_1"] = address_data.address_line_1
|
| 157 |
+
if address_data.address_line_2 is not None:
|
| 158 |
+
update_fields["address_line_2"] = address_data.address_line_2
|
| 159 |
+
if address_data.city is not None:
|
| 160 |
+
update_fields["city"] = address_data.city
|
| 161 |
+
if address_data.state is not None:
|
| 162 |
+
update_fields["state"] = address_data.state
|
| 163 |
+
if address_data.is_default is not None:
|
| 164 |
+
update_fields['is_default']=address_data.is_default
|
| 165 |
+
if address_data.landmark is not None:
|
| 166 |
+
update_fields["landmark"] = address_data.landmark
|
| 167 |
+
if address_data.postal_code is not None:
|
| 168 |
+
update_fields["postal_code"] = address_data.postal_code
|
| 169 |
+
if address_data.country is not None:
|
| 170 |
+
update_fields["country"] = address_data.country
|
| 171 |
+
if not update_fields:
|
| 172 |
+
raise HTTPException(status_code=400, detail="No fields to update")
|
| 173 |
+
|
| 174 |
+
success = await AddressModel.update_address(current_customer_id,address_id, update_fields)
|
| 175 |
+
|
| 176 |
+
if success:
|
| 177 |
+
# Get updated address
|
| 178 |
+
updated_address = await AddressModel.get_address_by_id(current_customer_id,address_id)
|
| 179 |
+
|
| 180 |
+
address_response = AddressResponse(
|
| 181 |
+
address_id=updated_address["address_id"], # Use the new address_id field
|
| 182 |
+
address_type=updated_address["address_type"],
|
| 183 |
+
address_line_1=updated_address["address_line_1"],
|
| 184 |
+
address_line_2=updated_address.get("address_line_2", ""),
|
| 185 |
+
city=updated_address["city"],
|
| 186 |
+
state=updated_address["state"],
|
| 187 |
+
postal_code=updated_address["postal_code"],
|
| 188 |
+
country=updated_address.get("country", "India"),
|
| 189 |
+
landmark=updated_address.get("landmark", ""),
|
| 190 |
+
is_default=updated_address.get("is_default", False),
|
| 191 |
+
created_at=updated_address.get("created_at"),
|
| 192 |
+
updated_at=updated_address.get("updated_at")
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
return AddressOperationResponse(
|
| 196 |
+
success=True,
|
| 197 |
+
message="Address updated successfully",
|
| 198 |
+
address=address_response
|
| 199 |
+
)
|
| 200 |
+
else:
|
| 201 |
+
return AddressOperationResponse(
|
| 202 |
+
success=False,
|
| 203 |
+
message="Failed to update address"
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
except HTTPException:
|
| 207 |
+
raise
|
| 208 |
+
except Exception as e:
|
| 209 |
+
logger.error(f"Error updating address {address_id} for user {current_customer_id}: {str(e)}")
|
| 210 |
+
raise HTTPException(
|
| 211 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 212 |
+
detail="Failed to update address"
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
@router.delete("/{address_id}", response_model=AddressOperationResponse)
|
| 216 |
+
async def delete_address(
|
| 217 |
+
address_id: str,
|
| 218 |
+
current_customer_id: str = Depends(get_current_customer_id)
|
| 219 |
+
):
|
| 220 |
+
"""
|
| 221 |
+
Delete a delivery address.
|
| 222 |
+
"""
|
| 223 |
+
try:
|
| 224 |
+
logger.info(f"Delete address request for user: {current_customer_id}, address: {address_id}")
|
| 225 |
+
|
| 226 |
+
# Check if address exists and belongs to user
|
| 227 |
+
existing_address = await AddressModel.get_address_by_id(current_customer_id,address_id)
|
| 228 |
+
if not existing_address:
|
| 229 |
+
raise HTTPException(status_code=404, detail="Address not found")
|
| 230 |
+
|
| 231 |
+
if existing_address["customer_id"] != current_customer_id:
|
| 232 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 233 |
+
|
| 234 |
+
# Check if this is the default address
|
| 235 |
+
if existing_address.get("is_default", False):
|
| 236 |
+
# Get other addresses to potentially set a new default
|
| 237 |
+
user_addresses = await AddressModel.get_user_addresses(current_customer_id)
|
| 238 |
+
# Compare by new domain id field 'address_id'
|
| 239 |
+
other_addresses = [addr for addr in user_addresses if addr.get("address_id") != address_id]
|
| 240 |
+
|
| 241 |
+
if other_addresses:
|
| 242 |
+
# Set the first other address as default
|
| 243 |
+
await AddressModel.set_default_address(current_customer_id, other_addresses[0]["address_id"])
|
| 244 |
+
|
| 245 |
+
success = await AddressModel.delete_address(current_customer_id,address_id)
|
| 246 |
+
|
| 247 |
+
if success:
|
| 248 |
+
return AddressOperationResponse(
|
| 249 |
+
success=True,
|
| 250 |
+
message="Address deleted successfully"
|
| 251 |
+
)
|
| 252 |
+
else:
|
| 253 |
+
return AddressOperationResponse(
|
| 254 |
+
success=False,
|
| 255 |
+
message="Failed to delete address"
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
except HTTPException:
|
| 259 |
+
raise
|
| 260 |
+
except Exception as e:
|
| 261 |
+
logger.error(f"Error deleting address {address_id} for user {current_customer_id}: {str(e)}")
|
| 262 |
+
raise HTTPException(
|
| 263 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 264 |
+
detail="Failed to delete address"
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
@router.post("/set-default", response_model=AddressOperationResponse)
|
| 268 |
+
async def set_default_address(
|
| 269 |
+
request: SetDefaultAddressRequest,
|
| 270 |
+
current_customer_id: str = Depends(get_current_customer_id)
|
| 271 |
+
):
|
| 272 |
+
"""
|
| 273 |
+
Set an address as the default delivery address.
|
| 274 |
+
"""
|
| 275 |
+
try:
|
| 276 |
+
logger.info(f"Set default address request for user: {current_customer_id}, address: {request.address_id}")
|
| 277 |
+
|
| 278 |
+
# Check if address exists and belongs to user
|
| 279 |
+
existing_address = await AddressModel.get_address_by_id(current_customer_id,request.address_id)
|
| 280 |
+
if not existing_address:
|
| 281 |
+
raise HTTPException(status_code=404, detail="Address not found")
|
| 282 |
+
|
| 283 |
+
if existing_address["customer_id"] != current_customer_id:
|
| 284 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 285 |
+
|
| 286 |
+
success = await AddressModel.set_default_address(current_customer_id, request.address_id)
|
| 287 |
+
|
| 288 |
+
if success:
|
| 289 |
+
# Get updated address
|
| 290 |
+
updated_address = await AddressModel.get_address_by_id(current_customer_id,request.address_id)
|
| 291 |
+
|
| 292 |
+
address_response = AddressResponse(
|
| 293 |
+
address_id=request.address_id,
|
| 294 |
+
address_type=updated_address["address_type"],
|
| 295 |
+
contact_name=updated_address.get("contact_name", ""),
|
| 296 |
+
contact_phone=updated_address.get("contact_phone", ""),
|
| 297 |
+
address_line_1=updated_address["address_line_1"],
|
| 298 |
+
address_line_2=updated_address.get("address_line_2", ""),
|
| 299 |
+
city=updated_address["city"],
|
| 300 |
+
state=updated_address["state"],
|
| 301 |
+
postal_code=updated_address["postal_code"],
|
| 302 |
+
country=updated_address.get("country", "India"),
|
| 303 |
+
landmark=updated_address.get("landmark", ""),
|
| 304 |
+
is_default=updated_address.get("is_default", False),
|
| 305 |
+
created_at=updated_address.get("created_at"),
|
| 306 |
+
updated_at=updated_address.get("updated_at")
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
return AddressOperationResponse(
|
| 310 |
+
success=True,
|
| 311 |
+
message="Default address set successfully",
|
| 312 |
+
address=address_response
|
| 313 |
+
)
|
| 314 |
+
else:
|
| 315 |
+
return AddressOperationResponse(
|
| 316 |
+
success=False,
|
| 317 |
+
message="Failed to set default address"
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
except HTTPException:
|
| 321 |
+
raise
|
| 322 |
+
except Exception as e:
|
| 323 |
+
logger.error(f"Error setting default address for user {current_customer_id}: {str(e)}")
|
| 324 |
+
raise HTTPException(
|
| 325 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 326 |
+
detail="Failed to set default address"
|
| 327 |
+
)
|
app/routers/favorite_router.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from app.schemas.favorite_schema import (
|
| 4 |
+
FavoriteCreateRequest,
|
| 5 |
+
FavoriteUpdateRequest,
|
| 6 |
+
FavoriteResponse,
|
| 7 |
+
FavoritesListResponse,
|
| 8 |
+
FavoriteStatusResponse,
|
| 9 |
+
FavoriteSuccessResponse,
|
| 10 |
+
FavoriteDataResponse
|
| 11 |
+
)
|
| 12 |
+
from app.services.favorite_service import FavoriteService
|
| 13 |
+
from app.services.user_service import UserService
|
| 14 |
+
from app.utils.jwt import get_current_user
|
| 15 |
+
import logging
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger("favorite_router")
|
| 18 |
+
|
| 19 |
+
router = APIRouter(
|
| 20 |
+
prefix="/favorites",
|
| 21 |
+
tags=["favorites"],
|
| 22 |
+
responses={404: {"description": "Not found"}},
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
@router.post(
|
| 26 |
+
"",
|
| 27 |
+
response_model=FavoriteSuccessResponse,
|
| 28 |
+
status_code=status.HTTP_201_CREATED,
|
| 29 |
+
summary="Add merchant to favorites",
|
| 30 |
+
description="Add a merchant to the user's list of favorite merchants"
|
| 31 |
+
)
|
| 32 |
+
async def add_favorite(
|
| 33 |
+
favorite_data: FavoriteCreateRequest,
|
| 34 |
+
current_user: dict = Depends(get_current_user)
|
| 35 |
+
):
|
| 36 |
+
"""
|
| 37 |
+
Add a merchant to user's favorites.
|
| 38 |
+
|
| 39 |
+
- **merchant_id**: ID of the merchant to favorite
|
| 40 |
+
- **merchant_category**: Category of the merchant (salon, spa, pet_spa, etc.)
|
| 41 |
+
- **merchant_name**: Name of the merchant for quick display
|
| 42 |
+
- **notes**: Optional note about this favorite
|
| 43 |
+
"""
|
| 44 |
+
try:
|
| 45 |
+
return await FavoriteService.add_favorite(
|
| 46 |
+
customer_id=current_user["sub"],
|
| 47 |
+
favorite_data=favorite_data
|
| 48 |
+
)
|
| 49 |
+
except HTTPException as e:
|
| 50 |
+
raise e
|
| 51 |
+
except Exception as e:
|
| 52 |
+
logger.error(f"Error in add_favorite endpoint: {str(e)}", exc_info=True)
|
| 53 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
| 54 |
+
|
| 55 |
+
@router.delete(
|
| 56 |
+
"/{merchant_id}",
|
| 57 |
+
response_model=FavoriteSuccessResponse,
|
| 58 |
+
status_code=status.HTTP_200_OK,
|
| 59 |
+
summary="Remove merchant from favorites",
|
| 60 |
+
description="Remove a merchant from the user's list of favorite merchants"
|
| 61 |
+
)
|
| 62 |
+
async def remove_favorite(
|
| 63 |
+
merchant_id: str,
|
| 64 |
+
current_user: dict = Depends(get_current_user)
|
| 65 |
+
):
|
| 66 |
+
"""
|
| 67 |
+
Remove a merchant from user's favorites.
|
| 68 |
+
|
| 69 |
+
- **merchant_id**: ID of the merchant to remove from favorites
|
| 70 |
+
"""
|
| 71 |
+
try:
|
| 72 |
+
|
| 73 |
+
customer_id=current_user.get("sub")
|
| 74 |
+
|
| 75 |
+
# Check if favorite merchant already exists and belongs to user
|
| 76 |
+
existing_favorite = await FavoriteService.get_favorite_details(customer_id, merchant_id)
|
| 77 |
+
|
| 78 |
+
if not existing_favorite.success:
|
| 79 |
+
raise HTTPException(
|
| 80 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 81 |
+
detail="favorite not found"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
return await FavoriteService.remove_favorite(
|
| 85 |
+
customer_id,
|
| 86 |
+
merchant_id
|
| 87 |
+
)
|
| 88 |
+
except HTTPException as e:
|
| 89 |
+
raise e
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.error(f"Error in remove_favorite endpoint: {str(e)}", exc_info=True)
|
| 92 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
| 93 |
+
|
| 94 |
+
@router.get(
|
| 95 |
+
"",
|
| 96 |
+
response_model=FavoritesListResponse,
|
| 97 |
+
status_code=status.HTTP_200_OK,
|
| 98 |
+
summary="List favorite merchants",
|
| 99 |
+
description="Get user's list of favorite merchants, optionally filtered by category"
|
| 100 |
+
)
|
| 101 |
+
async def list_favorites(
|
| 102 |
+
limit: int = Query(50, ge=1, le=100, description="Maximum number of items to return"),
|
| 103 |
+
current_user: dict = Depends(get_current_user)
|
| 104 |
+
):
|
| 105 |
+
"""
|
| 106 |
+
Get user's favorite merchants.
|
| 107 |
+
|
| 108 |
+
- **category**: Optional filter by merchant category
|
| 109 |
+
- **skip**: Number of items to skip (for pagination)
|
| 110 |
+
- **limit**: Maximum number of items to return (max 100)
|
| 111 |
+
"""
|
| 112 |
+
try:
|
| 113 |
+
return await FavoriteService.get_favorites(
|
| 114 |
+
customer_id=current_user["sub"],
|
| 115 |
+
limit=limit
|
| 116 |
+
)
|
| 117 |
+
except HTTPException as e:
|
| 118 |
+
raise e
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logger.error(f"Error in list_favorites endpoint: {str(e)}", exc_info=True)
|
| 121 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
| 122 |
+
|
| 123 |
+
@router.get(
|
| 124 |
+
"/{merchant_id}/status",
|
| 125 |
+
response_model=FavoriteStatusResponse,
|
| 126 |
+
status_code=status.HTTP_200_OK,
|
| 127 |
+
summary="Check favorite status",
|
| 128 |
+
description="Check if a merchant is in user's favorites"
|
| 129 |
+
)
|
| 130 |
+
async def check_favorite_status(
|
| 131 |
+
merchant_id: str,
|
| 132 |
+
current_user: dict = Depends(get_current_user)
|
| 133 |
+
):
|
| 134 |
+
"""
|
| 135 |
+
Check if a merchant is in user's favorites.
|
| 136 |
+
|
| 137 |
+
- **merchant_id**: ID of the merchant to check
|
| 138 |
+
"""
|
| 139 |
+
try:
|
| 140 |
+
return await FavoriteService.check_favorite_status(
|
| 141 |
+
customer_id=current_user["sub"],
|
| 142 |
+
merchant_id=merchant_id
|
| 143 |
+
)
|
| 144 |
+
except HTTPException as e:
|
| 145 |
+
raise e
|
| 146 |
+
except Exception as e:
|
| 147 |
+
logger.error(f"Error in check_favorite_status endpoint: {str(e)}", exc_info=True)
|
| 148 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
| 149 |
+
|
| 150 |
+
@router.get(
|
| 151 |
+
"/{merchant_id}",
|
| 152 |
+
response_model=FavoriteDataResponse,
|
| 153 |
+
status_code=status.HTTP_200_OK,
|
| 154 |
+
summary="Get favorite details",
|
| 155 |
+
description="Get detailed information about a specific favorite merchant"
|
| 156 |
+
)
|
| 157 |
+
async def get_favorite_details(
|
| 158 |
+
merchant_id: str,
|
| 159 |
+
current_user: dict = Depends(get_current_user)
|
| 160 |
+
):
|
| 161 |
+
"""
|
| 162 |
+
Get detailed information about a specific favorite merchant.
|
| 163 |
+
|
| 164 |
+
- **merchant_id**: ID of the merchant
|
| 165 |
+
"""
|
| 166 |
+
try:
|
| 167 |
+
return await FavoriteService.get_favorite_details(
|
| 168 |
+
customer_id=current_user["sub"],
|
| 169 |
+
merchant_id=merchant_id
|
| 170 |
+
)
|
| 171 |
+
except HTTPException as e:
|
| 172 |
+
raise e
|
| 173 |
+
except Exception as e:
|
| 174 |
+
logger.error(f"Error in get_favorite_details endpoint: {str(e)}", exc_info=True)
|
| 175 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
| 176 |
+
|
| 177 |
+
@router.patch(
|
| 178 |
+
"/{merchant_id}/notes",
|
| 179 |
+
response_model=FavoriteSuccessResponse,
|
| 180 |
+
status_code=status.HTTP_200_OK,
|
| 181 |
+
summary="Update favorite notes",
|
| 182 |
+
description="Update the notes for a favorite merchant"
|
| 183 |
+
)
|
| 184 |
+
async def update_favorite_notes(
|
| 185 |
+
merchant_id: str,
|
| 186 |
+
notes_data: FavoriteUpdateRequest,
|
| 187 |
+
current_user: dict = Depends(get_current_user)
|
| 188 |
+
):
|
| 189 |
+
"""
|
| 190 |
+
Update the notes for a favorite merchant.
|
| 191 |
+
|
| 192 |
+
- **merchant_id**: ID of the merchant
|
| 193 |
+
- **notes**: Updated note about this favorite
|
| 194 |
+
"""
|
| 195 |
+
try:
|
| 196 |
+
return await FavoriteService.update_favorite_notes(
|
| 197 |
+
customer_id=current_user["sub"],
|
| 198 |
+
merchant_id=merchant_id,
|
| 199 |
+
notes_data=notes_data
|
| 200 |
+
)
|
| 201 |
+
except HTTPException as e:
|
| 202 |
+
raise e
|
| 203 |
+
except Exception as e:
|
| 204 |
+
logger.error(f"Error in update_favorite_notes endpoint: {str(e)}", exc_info=True)
|
| 205 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
app/routers/guest_router.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends, status
|
| 2 |
+
from fastapi.security import HTTPBearer
|
| 3 |
+
from app.models.guest_model import GuestModel
|
| 4 |
+
from app.schemas.guest_schema import (
|
| 5 |
+
GuestCreateRequest,
|
| 6 |
+
GuestUpdateRequest,
|
| 7 |
+
GuestResponse,
|
| 8 |
+
GuestListResponse,
|
| 9 |
+
GuestDeleteResponse,
|
| 10 |
+
SetDefaultGuestRequest
|
| 11 |
+
)
|
| 12 |
+
from app.utils.jwt import verify_token
|
| 13 |
+
from typing import Dict, Any
|
| 14 |
+
import logging
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
router = APIRouter()
|
| 19 |
+
security = HTTPBearer()
|
| 20 |
+
|
| 21 |
+
async def get_current_user(token: str = Depends(security)) -> Dict[str, Any]:
|
| 22 |
+
"""
|
| 23 |
+
Dependency to get current authenticated user from JWT token
|
| 24 |
+
"""
|
| 25 |
+
try:
|
| 26 |
+
payload = verify_token(token.credentials)
|
| 27 |
+
if not payload:
|
| 28 |
+
raise HTTPException(
|
| 29 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 30 |
+
detail="Invalid or expired token"
|
| 31 |
+
)
|
| 32 |
+
return payload
|
| 33 |
+
except Exception as e:
|
| 34 |
+
logger.error(f"Token verification failed: {str(e)}")
|
| 35 |
+
raise HTTPException(
|
| 36 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 37 |
+
detail="Invalid or expired token"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
@router.get("/guests", response_model=GuestListResponse)
|
| 41 |
+
async def get_user_guests(
|
| 42 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 43 |
+
):
|
| 44 |
+
"""
|
| 45 |
+
Get all guests for a specific user.
|
| 46 |
+
|
| 47 |
+
- **customer_id**: ID of the user
|
| 48 |
+
- Returns list of guests with total count
|
| 49 |
+
"""
|
| 50 |
+
try:
|
| 51 |
+
# Verify user can only access their own guests
|
| 52 |
+
customer_id=current_user.get("sub")
|
| 53 |
+
|
| 54 |
+
guests_data = await GuestModel.get_user_guests(customer_id)
|
| 55 |
+
|
| 56 |
+
guests = [GuestResponse(**guest) for guest in guests_data]
|
| 57 |
+
|
| 58 |
+
return GuestListResponse(
|
| 59 |
+
guests=guests,
|
| 60 |
+
total_count=len(guests)
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
except HTTPException:
|
| 64 |
+
raise
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.error(f"Error getting guests for user {customer_id}: {str(e)}")
|
| 67 |
+
raise HTTPException(
|
| 68 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 69 |
+
detail="Failed to retrieve guests"
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
@router.post("/guests", response_model=GuestResponse, status_code=status.HTTP_201_CREATED)
|
| 73 |
+
async def create_guest(
|
| 74 |
+
guest_data: GuestCreateRequest,
|
| 75 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 76 |
+
):
|
| 77 |
+
"""
|
| 78 |
+
Create a new guest profile for a user.
|
| 79 |
+
|
| 80 |
+
- **customer_id**: ID of the user creating the guest profile
|
| 81 |
+
- **guest_data**: Guest information including name, contact details, etc.
|
| 82 |
+
- Returns the created guest profile
|
| 83 |
+
"""
|
| 84 |
+
try:
|
| 85 |
+
# Verify user can only create guests for themselves
|
| 86 |
+
customer_id=current_user.get("sub")
|
| 87 |
+
|
| 88 |
+
# Create guest in database
|
| 89 |
+
|
| 90 |
+
guest_id=await GuestModel.create_guest(customer_id,guest_data.dict())
|
| 91 |
+
|
| 92 |
+
if not guest_id:
|
| 93 |
+
raise HTTPException(
|
| 94 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 95 |
+
detail="Failed to create guest profile"
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# Retrieve and return the created guest
|
| 99 |
+
created_guest = await GuestModel.get_guest_by_id(customer_id, guest_id)
|
| 100 |
+
if not created_guest:
|
| 101 |
+
raise HTTPException(
|
| 102 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 103 |
+
detail="Guest created but failed to retrieve"
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
return GuestResponse(**created_guest)
|
| 107 |
+
|
| 108 |
+
except HTTPException:
|
| 109 |
+
raise
|
| 110 |
+
except Exception as e:
|
| 111 |
+
logger.error(f"Error creating guest for user {customer_id}: {str(e)}")
|
| 112 |
+
raise HTTPException(
|
| 113 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 114 |
+
detail="Failed to create guest profile"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
@router.get("/guests/default", response_model=GuestResponse)
|
| 118 |
+
async def get_default_guest(
|
| 119 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 120 |
+
):
|
| 121 |
+
"""Get the default guest for the current user"""
|
| 122 |
+
try:
|
| 123 |
+
customer_id = current_user.get("sub")
|
| 124 |
+
default_guest = await GuestModel.get_default_guest(customer_id)
|
| 125 |
+
if not default_guest:
|
| 126 |
+
raise HTTPException(
|
| 127 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 128 |
+
detail="Default guest not set"
|
| 129 |
+
)
|
| 130 |
+
return GuestResponse(**default_guest)
|
| 131 |
+
except HTTPException:
|
| 132 |
+
raise
|
| 133 |
+
except Exception as e:
|
| 134 |
+
logger.error(f"Error getting default guest for user {customer_id}: {str(e)}")
|
| 135 |
+
raise HTTPException(
|
| 136 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 137 |
+
detail="Failed to retrieve default guest"
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
@router.post("/guests/set-default", response_model=GuestResponse)
|
| 141 |
+
async def set_default_guest(
|
| 142 |
+
req: SetDefaultGuestRequest,
|
| 143 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 144 |
+
):
|
| 145 |
+
"""Set a guest as default for the current user"""
|
| 146 |
+
try:
|
| 147 |
+
customer_id = current_user.get("sub")
|
| 148 |
+
# Verify guest exists and belongs to user
|
| 149 |
+
existing_guest = await GuestModel.get_guest_by_id(customer_id, req.guest_id)
|
| 150 |
+
if not existing_guest:
|
| 151 |
+
raise HTTPException(
|
| 152 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 153 |
+
detail="Guest not found"
|
| 154 |
+
)
|
| 155 |
+
if existing_guest.get("customer_id") != customer_id:
|
| 156 |
+
raise HTTPException(
|
| 157 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 158 |
+
detail="Access denied. This guest doesn't belong to you."
|
| 159 |
+
)
|
| 160 |
+
success = await GuestModel.set_default_guest(customer_id, req.guest_id)
|
| 161 |
+
if not success:
|
| 162 |
+
raise HTTPException(
|
| 163 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 164 |
+
detail="Failed to set default guest"
|
| 165 |
+
)
|
| 166 |
+
updated_guest = await GuestModel.get_guest_by_id(customer_id, req.guest_id)
|
| 167 |
+
if not updated_guest:
|
| 168 |
+
raise HTTPException(
|
| 169 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 170 |
+
detail="Default set but failed to retrieve guest"
|
| 171 |
+
)
|
| 172 |
+
return GuestResponse(**updated_guest)
|
| 173 |
+
except HTTPException:
|
| 174 |
+
raise
|
| 175 |
+
except Exception as e:
|
| 176 |
+
logger.error(f"Error setting default guest {req.guest_id} for user {customer_id}: {str(e)}")
|
| 177 |
+
raise HTTPException(
|
| 178 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 179 |
+
detail="Failed to set default guest"
|
| 180 |
+
)
|
| 181 |
+
@router.put("/guests/{guest_id}", response_model=GuestResponse)
|
| 182 |
+
async def update_guest(
|
| 183 |
+
guest_id: str,
|
| 184 |
+
guest_data: GuestUpdateRequest,
|
| 185 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 186 |
+
):
|
| 187 |
+
"""
|
| 188 |
+
Update an existing guest profile.
|
| 189 |
+
|
| 190 |
+
- **customer_id**: ID of the user who owns the guest profile
|
| 191 |
+
- **guest_id**: ID of the guest to update
|
| 192 |
+
- **guest_data**: Updated guest information
|
| 193 |
+
- Returns the updated guest profile
|
| 194 |
+
"""
|
| 195 |
+
try:
|
| 196 |
+
# Verify user can only update their own guests
|
| 197 |
+
customer_id=current_user.get("sub")
|
| 198 |
+
|
| 199 |
+
# Check if guest exists and belongs to user
|
| 200 |
+
existing_guest = await GuestModel.get_guest_by_id(customer_id, guest_id)
|
| 201 |
+
if not existing_guest:
|
| 202 |
+
raise HTTPException(
|
| 203 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 204 |
+
detail="Guest not found"
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
if existing_guest.get("customer_id") != customer_id:
|
| 208 |
+
raise HTTPException(
|
| 209 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 210 |
+
detail="Access denied. This guest doesn't belong to you."
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
# Prepare update fields (only include non-None values)
|
| 214 |
+
update_fields = {}
|
| 215 |
+
for field, value in guest_data.dict(exclude_unset=True).items():
|
| 216 |
+
if value is not None:
|
| 217 |
+
if hasattr(value, 'value'): # Handle enum values
|
| 218 |
+
update_fields[field] = value.value
|
| 219 |
+
else:
|
| 220 |
+
update_fields[field] = value
|
| 221 |
+
|
| 222 |
+
if not update_fields:
|
| 223 |
+
raise HTTPException(
|
| 224 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 225 |
+
detail="No valid fields provided for update"
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
# Update guest in database
|
| 229 |
+
success = await GuestModel.update_guest(customer_id, guest_id, update_fields)
|
| 230 |
+
if not success:
|
| 231 |
+
raise HTTPException(
|
| 232 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 233 |
+
detail="Failed to update guest profile"
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
# Retrieve and return updated guest
|
| 237 |
+
updated_guest = await GuestModel.get_guest_by_id(customer_id, guest_id)
|
| 238 |
+
if not updated_guest:
|
| 239 |
+
raise HTTPException(
|
| 240 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 241 |
+
detail="Guest updated but failed to retrieve"
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
return GuestResponse(**updated_guest)
|
| 245 |
+
|
| 246 |
+
except HTTPException:
|
| 247 |
+
raise
|
| 248 |
+
except Exception as e:
|
| 249 |
+
logger.error(f"Error updating guest {guest_id} for user {customer_id}: {str(e)}")
|
| 250 |
+
raise HTTPException(
|
| 251 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 252 |
+
detail="Failed to update guest profile"
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
@router.delete("/guests/{guest_id}", response_model=GuestDeleteResponse)
|
| 256 |
+
async def delete_guest(
|
| 257 |
+
guest_id: str,
|
| 258 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 259 |
+
):
|
| 260 |
+
"""
|
| 261 |
+
Delete a guest profile.
|
| 262 |
+
|
| 263 |
+
- **customer_id**: ID of the user who owns the guest profile
|
| 264 |
+
- **guest_id**: ID of the guest to delete
|
| 265 |
+
- Returns confirmation of deletion
|
| 266 |
+
"""
|
| 267 |
+
try:
|
| 268 |
+
# Verify user can only delete their own guests
|
| 269 |
+
customer_id=current_user.get("sub")
|
| 270 |
+
|
| 271 |
+
# Check if guest exists and belongs to user
|
| 272 |
+
existing_guest = await GuestModel.get_guest_by_id(customer_id, guest_id)
|
| 273 |
+
if not existing_guest:
|
| 274 |
+
raise HTTPException(
|
| 275 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 276 |
+
detail="Guest not found"
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
if existing_guest.get("customer_id") != customer_id:
|
| 280 |
+
raise HTTPException(
|
| 281 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 282 |
+
detail="Access denied. This guest doesn't belong to you."
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
# Delete guest from database
|
| 286 |
+
success = await GuestModel.delete_guest(customer_id, guest_id)
|
| 287 |
+
if not success:
|
| 288 |
+
raise HTTPException(
|
| 289 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 290 |
+
detail="Failed to delete guest profile"
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
guest_name = existing_guest.get('first_name', 'Guest')
|
| 294 |
+
if existing_guest.get('last_name'):
|
| 295 |
+
guest_name += f" {existing_guest.get('last_name')}"
|
| 296 |
+
|
| 297 |
+
return GuestDeleteResponse(
|
| 298 |
+
message=f"Guest '{guest_name}' has been successfully deleted",
|
| 299 |
+
guest_id=guest_id
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
except HTTPException:
|
| 303 |
+
raise
|
| 304 |
+
except Exception as e:
|
| 305 |
+
logger.error(f"Error deleting guest {guest_id} for user {customer_id}: {str(e)}")
|
| 306 |
+
raise HTTPException(
|
| 307 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 308 |
+
detail="Failed to delete guest profile"
|
| 309 |
+
)
|
app/routers/pet_router.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends, status
|
| 2 |
+
from fastapi.security import HTTPBearer
|
| 3 |
+
from app.models.pet_model import PetModel
|
| 4 |
+
from app.schemas.pet_schema import (
|
| 5 |
+
PetCreateRequest,
|
| 6 |
+
PetUpdateRequest,
|
| 7 |
+
PetResponse,
|
| 8 |
+
PetListResponse,
|
| 9 |
+
PetDeleteResponse,
|
| 10 |
+
SetDefaultPetRequest
|
| 11 |
+
)
|
| 12 |
+
from app.utils.jwt import verify_token
|
| 13 |
+
from typing import Dict, Any
|
| 14 |
+
import logging
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
router = APIRouter()
|
| 19 |
+
security = HTTPBearer()
|
| 20 |
+
|
| 21 |
+
async def get_current_user(token: str = Depends(security)) -> Dict[str, Any]:
|
| 22 |
+
"""
|
| 23 |
+
Dependency to get current authenticated user from JWT token
|
| 24 |
+
"""
|
| 25 |
+
try:
|
| 26 |
+
payload = verify_token(token.credentials)
|
| 27 |
+
if not payload:
|
| 28 |
+
raise HTTPException(
|
| 29 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 30 |
+
detail="Invalid or expired token"
|
| 31 |
+
)
|
| 32 |
+
return payload
|
| 33 |
+
except Exception as e:
|
| 34 |
+
logger.error(f"Token verification failed: {str(e)}")
|
| 35 |
+
raise HTTPException(
|
| 36 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 37 |
+
detail="Invalid or expired token"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
@router.get("/pets", response_model=PetListResponse)
|
| 41 |
+
async def get_user_pets(
|
| 42 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 43 |
+
):
|
| 44 |
+
"""
|
| 45 |
+
Get all pets for a specific user.
|
| 46 |
+
|
| 47 |
+
- **customer_id**: ID of the pet owner
|
| 48 |
+
- Returns list of pets with total count
|
| 49 |
+
"""
|
| 50 |
+
try:
|
| 51 |
+
# Verify user can only access their own pets
|
| 52 |
+
customer_id=current_user.get("sub")
|
| 53 |
+
|
| 54 |
+
pets_data = await PetModel.get_user_pets(customer_id)
|
| 55 |
+
|
| 56 |
+
pets = [PetResponse(**pet) for pet in pets_data]
|
| 57 |
+
|
| 58 |
+
return PetListResponse(
|
| 59 |
+
pets=pets,
|
| 60 |
+
total_count=len(pets)
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
except HTTPException:
|
| 64 |
+
raise
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.error(f"Error getting pets for user {customer_id}: {str(e)}")
|
| 67 |
+
raise HTTPException(
|
| 68 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 69 |
+
detail="Failed to retrieve pets"
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
@router.post("/pets", response_model=PetResponse, status_code=status.HTTP_201_CREATED)
|
| 73 |
+
async def create_pet(
|
| 74 |
+
pet_data: PetCreateRequest,
|
| 75 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 76 |
+
):
|
| 77 |
+
"""
|
| 78 |
+
Create a new pet profile for a user.
|
| 79 |
+
|
| 80 |
+
- **customer_id**: ID of the pet owner
|
| 81 |
+
- **pet_data**: Pet information including name, species, breed, etc.
|
| 82 |
+
- Returns the created pet profile
|
| 83 |
+
"""
|
| 84 |
+
try:
|
| 85 |
+
# Verify user can only create pets for themselves
|
| 86 |
+
customer_id=current_user.get("sub")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
pet_id = await PetModel.create_pet(customer_id,pet_data.dict())
|
| 90 |
+
# Create pet in database
|
| 91 |
+
|
| 92 |
+
if not pet_id:
|
| 93 |
+
raise HTTPException(
|
| 94 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 95 |
+
detail="Failed to create pet profile"
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# Retrieve and return the created pet
|
| 99 |
+
created_pet = await PetModel.get_pet_by_id(customer_id, pet_id)
|
| 100 |
+
if not created_pet:
|
| 101 |
+
raise HTTPException(
|
| 102 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 103 |
+
detail="Pet created but failed to retrieve"
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
return PetResponse(**created_pet)
|
| 107 |
+
|
| 108 |
+
except HTTPException:
|
| 109 |
+
raise
|
| 110 |
+
except Exception as e:
|
| 111 |
+
logger.error(f"Error creating pet for user {customer_id}: {str(e)}")
|
| 112 |
+
raise HTTPException(
|
| 113 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 114 |
+
detail="Failed to create pet profile"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
@router.get("/pets/default", response_model=PetResponse)
|
| 118 |
+
async def get_default_pet(
|
| 119 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 120 |
+
):
|
| 121 |
+
"""Get the default pet for the current user"""
|
| 122 |
+
try:
|
| 123 |
+
customer_id = current_user.get("sub")
|
| 124 |
+
default_pet = await PetModel.get_default_pet(customer_id)
|
| 125 |
+
if not default_pet:
|
| 126 |
+
raise HTTPException(
|
| 127 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 128 |
+
detail="Default pet not set"
|
| 129 |
+
)
|
| 130 |
+
return PetResponse(**default_pet)
|
| 131 |
+
except HTTPException:
|
| 132 |
+
raise
|
| 133 |
+
except Exception as e:
|
| 134 |
+
logger.error(f"Error getting default pet for user {customer_id}: {str(e)}")
|
| 135 |
+
raise HTTPException(
|
| 136 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 137 |
+
detail="Failed to retrieve default pet"
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
@router.post("/pets/set-default", response_model=PetResponse)
|
| 141 |
+
async def set_default_pet(
|
| 142 |
+
req: SetDefaultPetRequest,
|
| 143 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 144 |
+
):
|
| 145 |
+
"""Set a pet as default for the current user"""
|
| 146 |
+
try:
|
| 147 |
+
customer_id = current_user.get("sub")
|
| 148 |
+
# Verify pet exists and belongs to user
|
| 149 |
+
existing_pet = await PetModel.get_pet_by_id(customer_id, req.pet_id)
|
| 150 |
+
if not existing_pet:
|
| 151 |
+
raise HTTPException(
|
| 152 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 153 |
+
detail="Pet not found"
|
| 154 |
+
)
|
| 155 |
+
if existing_pet.get("customer_id") != customer_id:
|
| 156 |
+
raise HTTPException(
|
| 157 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 158 |
+
detail="Access denied. This pet doesn't belong to you."
|
| 159 |
+
)
|
| 160 |
+
success = await PetModel.set_default_pet(customer_id, req.pet_id)
|
| 161 |
+
if not success:
|
| 162 |
+
raise HTTPException(
|
| 163 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 164 |
+
detail="Failed to set default pet"
|
| 165 |
+
)
|
| 166 |
+
updated_pet = await PetModel.get_pet_by_id(customer_id, req.pet_id)
|
| 167 |
+
if not updated_pet:
|
| 168 |
+
raise HTTPException(
|
| 169 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 170 |
+
detail="Default set but failed to retrieve pet"
|
| 171 |
+
)
|
| 172 |
+
return PetResponse(**updated_pet)
|
| 173 |
+
except HTTPException:
|
| 174 |
+
raise
|
| 175 |
+
except Exception as e:
|
| 176 |
+
logger.error(f"Error setting default pet {req.pet_id} for user {customer_id}: {str(e)}")
|
| 177 |
+
raise HTTPException(
|
| 178 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 179 |
+
detail="Failed to set default pet"
|
| 180 |
+
)
|
| 181 |
+
@router.put("/pets/{pet_id}", response_model=PetResponse)
|
| 182 |
+
async def update_pet(
|
| 183 |
+
pet_id: str,
|
| 184 |
+
pet_data: PetUpdateRequest,
|
| 185 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 186 |
+
):
|
| 187 |
+
"""
|
| 188 |
+
Update an existing pet profile.
|
| 189 |
+
|
| 190 |
+
- **customer_id**: ID of the pet owner
|
| 191 |
+
- **pet_id**: ID of the pet to update
|
| 192 |
+
- **pet_data**: Updated pet information
|
| 193 |
+
- Returns the updated pet profile
|
| 194 |
+
"""
|
| 195 |
+
try:
|
| 196 |
+
# Verify user can only update their own pets
|
| 197 |
+
customer_id=current_user.get("sub")
|
| 198 |
+
|
| 199 |
+
# Check if pet exists and belongs to user
|
| 200 |
+
existing_pet = await PetModel.get_pet_by_id(customer_id, pet_id)
|
| 201 |
+
if not existing_pet:
|
| 202 |
+
raise HTTPException(
|
| 203 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 204 |
+
detail="Pet not found"
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
if existing_pet.get("customer_id") != customer_id:
|
| 208 |
+
raise HTTPException(
|
| 209 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 210 |
+
detail="Access denied. This pet doesn't belong to you."
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
# Prepare update fields (only include non-None values)
|
| 214 |
+
update_fields = {}
|
| 215 |
+
for field, value in pet_data.dict(exclude_unset=True).items():
|
| 216 |
+
if value is not None:
|
| 217 |
+
if hasattr(value, 'value'): # Handle enum values
|
| 218 |
+
update_fields[field] = value.value
|
| 219 |
+
else:
|
| 220 |
+
update_fields[field] = value
|
| 221 |
+
|
| 222 |
+
if not update_fields:
|
| 223 |
+
raise HTTPException(
|
| 224 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 225 |
+
detail="No valid fields provided for update"
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
# Update pet in database
|
| 229 |
+
success = await PetModel.update_pet(customer_id, pet_id, update_fields)
|
| 230 |
+
if not success:
|
| 231 |
+
raise HTTPException(
|
| 232 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 233 |
+
detail="Failed to update pet profile"
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
# Retrieve and return updated pet
|
| 237 |
+
updated_pet = await PetModel.get_pet_by_id(customer_id, pet_id)
|
| 238 |
+
if not updated_pet:
|
| 239 |
+
raise HTTPException(
|
| 240 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 241 |
+
detail="Pet updated but failed to retrieve"
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
return PetResponse(**updated_pet)
|
| 245 |
+
|
| 246 |
+
except HTTPException:
|
| 247 |
+
raise
|
| 248 |
+
except Exception as e:
|
| 249 |
+
logger.error(f"Error updating pet {pet_id} for user {customer_id}: {str(e)}")
|
| 250 |
+
raise HTTPException(
|
| 251 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 252 |
+
detail="Failed to update pet profile"
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
@router.delete("/pets/{pet_id}", response_model=PetDeleteResponse)
|
| 256 |
+
async def delete_pet(
|
| 257 |
+
pet_id: str,
|
| 258 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 259 |
+
):
|
| 260 |
+
"""
|
| 261 |
+
Delete a pet profile.
|
| 262 |
+
|
| 263 |
+
- **customer_id**: ID of the pet owner
|
| 264 |
+
- **pet_id**: ID of the pet to delete
|
| 265 |
+
- Returns confirmation of deletion
|
| 266 |
+
"""
|
| 267 |
+
try:
|
| 268 |
+
# Verify user can only delete their own pets
|
| 269 |
+
customer_id=current_user.get("sub")
|
| 270 |
+
|
| 271 |
+
# Check if pet exists and belongs to user
|
| 272 |
+
existing_pet = await PetModel.get_pet_by_id(customer_id, pet_id)
|
| 273 |
+
if not existing_pet:
|
| 274 |
+
raise HTTPException(
|
| 275 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 276 |
+
detail="Pet not found"
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
if existing_pet.get("customer_id") != customer_id:
|
| 280 |
+
raise HTTPException(
|
| 281 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 282 |
+
detail="Access denied. This pet doesn't belong to you."
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
# Delete pet from database
|
| 286 |
+
success = await PetModel.delete_pet(customer_id, pet_id)
|
| 287 |
+
if not success:
|
| 288 |
+
raise HTTPException(
|
| 289 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 290 |
+
detail="Failed to delete pet profile"
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
return PetDeleteResponse(
|
| 294 |
+
message=f"Pet '{existing_pet.get('pet_name')}' has been successfully deleted",
|
| 295 |
+
pet_id=pet_id
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
except HTTPException:
|
| 299 |
+
raise
|
| 300 |
+
except Exception as e:
|
| 301 |
+
logger.error(f"Error deleting pet {pet_id} for user {customer_id}: {str(e)}")
|
| 302 |
+
raise HTTPException(
|
| 303 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 304 |
+
detail="Failed to delete pet profile"
|
| 305 |
+
)
|
app/routers/profile_router.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
## bookmyservice-ums/app/routers/profile_router.py
|
| 3 |
+
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 5 |
+
from typing import Dict, Any
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
from app.utils.jwt import get_current_customer_id
|
| 9 |
+
from app.services.profile_service import profile_service
|
| 10 |
+
from app.services.wallet_service import WalletService
|
| 11 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 12 |
+
from app.models.address_model import AddressModel
|
| 13 |
+
from app.schemas.profile_schema import (
|
| 14 |
+
ProfileUpdateRequest, ProfileResponse, ProfileOperationResponse,
|
| 15 |
+
PersonalDetailsResponse, WalletDisplayResponse, ProfileDashboardResponse
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
router = APIRouter()
|
| 21 |
+
|
| 22 |
+
@router.get("/me", response_model=Dict[str, Any])
|
| 23 |
+
async def get_profile(current_customer_id: str = Depends(get_current_customer_id)):
|
| 24 |
+
"""
|
| 25 |
+
Get current user's profile from customers collection.
|
| 26 |
+
|
| 27 |
+
This endpoint is JWT protected and requires a valid Bearer token.
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
current_customer_id (str): User ID extracted from JWT token
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
Dict[str, Any]: Customer profile data
|
| 34 |
+
|
| 35 |
+
Raises:
|
| 36 |
+
HTTPException: 401 if token is invalid, 404 if profile not found
|
| 37 |
+
"""
|
| 38 |
+
try:
|
| 39 |
+
logger.info(f"Profile request for user: {current_customer_id}")
|
| 40 |
+
|
| 41 |
+
# Fetch customer profile using the service
|
| 42 |
+
profile_data = await profile_service.get_customer_profile(current_customer_id)
|
| 43 |
+
|
| 44 |
+
return {
|
| 45 |
+
"success": True,
|
| 46 |
+
"message": "Profile retrieved successfully",
|
| 47 |
+
"data": profile_data
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
except HTTPException:
|
| 51 |
+
# Re-raise HTTP exceptions from service
|
| 52 |
+
raise
|
| 53 |
+
except Exception as e:
|
| 54 |
+
logger.error(f"Unexpected error in get_profile: {str(e)}")
|
| 55 |
+
raise HTTPException(
|
| 56 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 57 |
+
detail="Internal server error"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
@router.get("/dashboard", response_model=ProfileDashboardResponse)
|
| 61 |
+
async def get_profile_dashboard(current_customer_id: str = Depends(get_current_customer_id)):
|
| 62 |
+
"""
|
| 63 |
+
Get complete profile dashboard with personal details, wallet, and address info.
|
| 64 |
+
|
| 65 |
+
This endpoint matches the screenshot requirements showing:
|
| 66 |
+
- Personal details (name, email, phone, DOB)
|
| 67 |
+
- Wallet balance
|
| 68 |
+
- Address management info
|
| 69 |
+
"""
|
| 70 |
+
try:
|
| 71 |
+
logger.info(f"Dashboard request for user: {current_customer_id}")
|
| 72 |
+
|
| 73 |
+
# Get user profile
|
| 74 |
+
user = await BookMyServiceUserModel.find_by_id(current_customer_id)
|
| 75 |
+
if not user:
|
| 76 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 77 |
+
|
| 78 |
+
# Parse name into first and last name
|
| 79 |
+
name_parts = user.get("name", "").split(" ", 1)
|
| 80 |
+
first_name = name_parts[0] if name_parts else ""
|
| 81 |
+
last_name = name_parts[1] if len(name_parts) > 1 else ""
|
| 82 |
+
|
| 83 |
+
# Get wallet balance
|
| 84 |
+
wallet_balance = await WalletService.get_wallet_balance(current_customer_id)
|
| 85 |
+
|
| 86 |
+
# Get address count and default address status
|
| 87 |
+
addresses = await AddressModel.get_user_addresses(current_customer_id)
|
| 88 |
+
address_count = len(addresses)
|
| 89 |
+
has_default_address = any(addr.get("is_default", False) for addr in addresses)
|
| 90 |
+
|
| 91 |
+
# Build response
|
| 92 |
+
personal_details = PersonalDetailsResponse(
|
| 93 |
+
first_name=first_name,
|
| 94 |
+
last_name=last_name,
|
| 95 |
+
email=user.get("email", ""),
|
| 96 |
+
phone=user.get("phone", ""),
|
| 97 |
+
date_of_birth=user.get("date_of_birth")
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
wallet_display = WalletDisplayResponse(
|
| 101 |
+
balance=wallet_balance.balance,
|
| 102 |
+
formatted_balance=wallet_balance.formatted_balance,
|
| 103 |
+
currency=wallet_balance.currency
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
return ProfileDashboardResponse(
|
| 107 |
+
personal_details=personal_details,
|
| 108 |
+
wallet=wallet_display,
|
| 109 |
+
address_count=address_count,
|
| 110 |
+
has_default_address=has_default_address
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
except HTTPException:
|
| 114 |
+
raise
|
| 115 |
+
except Exception as e:
|
| 116 |
+
logger.error(f"Error getting profile dashboard for user {current_customer_id}: {str(e)}")
|
| 117 |
+
raise HTTPException(
|
| 118 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 119 |
+
detail="Internal server error"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
@router.put("/update", response_model=ProfileOperationResponse)
|
| 123 |
+
async def update_profile(
|
| 124 |
+
profile_data: ProfileUpdateRequest,
|
| 125 |
+
current_customer_id: str = Depends(get_current_customer_id)
|
| 126 |
+
):
|
| 127 |
+
"""
|
| 128 |
+
Update user profile information including personal details and DOB.
|
| 129 |
+
"""
|
| 130 |
+
try:
|
| 131 |
+
logger.info(f"Profile update request for user: {current_customer_id}")
|
| 132 |
+
|
| 133 |
+
# Prepare update fields
|
| 134 |
+
update_fields = {}
|
| 135 |
+
|
| 136 |
+
if profile_data.name is not None:
|
| 137 |
+
update_fields["name"] = profile_data.name
|
| 138 |
+
|
| 139 |
+
if profile_data.email is not None:
|
| 140 |
+
# Check if email is already used by another user
|
| 141 |
+
existing_user = await BookMyServiceUserModel.find_by_email(str(profile_data.email))
|
| 142 |
+
if existing_user and existing_user.get("customer_id") != current_customer_id:
|
| 143 |
+
raise HTTPException(status_code=409, detail="Email already in use by another account")
|
| 144 |
+
update_fields["email"] = str(profile_data.email)
|
| 145 |
+
|
| 146 |
+
if profile_data.phone is not None:
|
| 147 |
+
# Check if phone is already used by another user
|
| 148 |
+
existing_user = await BookMyServiceUserModel.find_by_phone(profile_data.phone)
|
| 149 |
+
if existing_user and existing_user.get("customer_id") != current_customer_id:
|
| 150 |
+
raise HTTPException(status_code=409, detail="Phone number already in use by another account")
|
| 151 |
+
update_fields["phone"] = profile_data.phone
|
| 152 |
+
|
| 153 |
+
if profile_data.date_of_birth is not None:
|
| 154 |
+
update_fields["date_of_birth"] = profile_data.date_of_birth
|
| 155 |
+
|
| 156 |
+
if profile_data.profile_picture is not None:
|
| 157 |
+
update_fields["profile_picture"] = profile_data.profile_picture
|
| 158 |
+
|
| 159 |
+
if not update_fields:
|
| 160 |
+
raise HTTPException(status_code=400, detail="No fields to update")
|
| 161 |
+
|
| 162 |
+
# Update profile
|
| 163 |
+
success = await BookMyServiceUserModel.update_profile(current_customer_id, update_fields)
|
| 164 |
+
|
| 165 |
+
if success:
|
| 166 |
+
# Get updated profile
|
| 167 |
+
updated_user = await BookMyServiceUserModel.find_by_id(current_customer_id)
|
| 168 |
+
|
| 169 |
+
profile_response = ProfileResponse(
|
| 170 |
+
customer_id=updated_user["customer_id"],
|
| 171 |
+
name=updated_user["name"],
|
| 172 |
+
email=updated_user.get("email"),
|
| 173 |
+
phone=updated_user.get("phone"),
|
| 174 |
+
date_of_birth=updated_user.get("date_of_birth"),
|
| 175 |
+
profile_picture=updated_user.get("profile_picture"),
|
| 176 |
+
auth_method=updated_user.get("auth_mode", "unknown"),
|
| 177 |
+
created_at=updated_user.get("created_at"),
|
| 178 |
+
updated_at=updated_user.get("updated_at")
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
return ProfileOperationResponse(
|
| 182 |
+
success=True,
|
| 183 |
+
message="Profile updated successfully",
|
| 184 |
+
profile=profile_response
|
| 185 |
+
)
|
| 186 |
+
else:
|
| 187 |
+
return ProfileOperationResponse(
|
| 188 |
+
success=False,
|
| 189 |
+
message="Failed to update profile"
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
except HTTPException:
|
| 193 |
+
raise
|
| 194 |
+
except Exception as e:
|
| 195 |
+
logger.error(f"Error updating profile for user {current_customer_id}: {str(e)}")
|
| 196 |
+
raise HTTPException(
|
| 197 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 198 |
+
detail="Internal server error"
|
| 199 |
+
)
|
| 200 |
+
|
app/routers/review_router.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
import logging
|
| 3 |
+
from app.utils.jwt import get_current_user
|
| 4 |
+
from typing import Dict, Any
|
| 5 |
+
|
| 6 |
+
from app.schemas.review_schema import ReviewCreateRequest,ReviewResponse
|
| 7 |
+
from app.models.review_model import ReviewModel
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
@router.post("/add", response_model=ReviewResponse,
|
| 14 |
+
status_code=status.HTTP_201_CREATED)
|
| 15 |
+
async def create_review(
|
| 16 |
+
review_data: ReviewCreateRequest,
|
| 17 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 18 |
+
):
|
| 19 |
+
try:
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
review_reponse=await ReviewModel.create_review(review_data.dict())
|
| 23 |
+
|
| 24 |
+
if not review_reponse:
|
| 25 |
+
raise HTTPException(
|
| 26 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 27 |
+
detail="failed to add review details"
|
| 28 |
+
)
|
| 29 |
+
return ReviewResponse(
|
| 30 |
+
merchant_id=review_reponse["merchant_id"],
|
| 31 |
+
location_id=review_reponse["location_id"],
|
| 32 |
+
user_name=review_reponse["user_name"],
|
| 33 |
+
rating=review_reponse["rating"],
|
| 34 |
+
review_text=review_reponse["review_text"],
|
| 35 |
+
review_date=review_reponse["review_date"],
|
| 36 |
+
verified_purchase=review_reponse["verified_purchase"]
|
| 37 |
+
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
except HTTPException:
|
| 41 |
+
raise
|
| 42 |
+
except Exception as e:
|
| 43 |
+
logger.error(f"Error while adding review: {str(e)}")
|
| 44 |
+
raise HTTPException(
|
| 45 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 46 |
+
detail="Failed to add review"
|
| 47 |
+
)
|
| 48 |
+
|
app/routers/user_router.py
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends, Security, status
|
| 2 |
+
from fastapi.security import APIKeyHeader
|
| 3 |
+
|
| 4 |
+
from app.schemas.user_schema import (
|
| 5 |
+
OTPRequest,
|
| 6 |
+
OTPRequestWithLogin,
|
| 7 |
+
OTPVerifyRequest,
|
| 8 |
+
UserRegisterRequest,
|
| 9 |
+
UserLoginRequest,
|
| 10 |
+
OAuthLoginRequest,
|
| 11 |
+
TokenResponse,
|
| 12 |
+
OTPSendResponse,
|
| 13 |
+
)
|
| 14 |
+
from app.services.user_service import UserService
|
| 15 |
+
from app.utils.jwt import create_temp_token, decode_token, create_refresh_token, get_current_customer_id
|
| 16 |
+
from app.utils.social_utils import verify_google_token, verify_google_access_token, verify_apple_token, verify_facebook_token
|
| 17 |
+
from app.utils.common_utils import validate_identifier
|
| 18 |
+
from app.models.social_security_model import SocialSecurityModel
|
| 19 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 20 |
+
from fastapi import Request
|
| 21 |
+
import logging
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger("user_router")
|
| 24 |
+
|
| 25 |
+
router = APIRouter()
|
| 26 |
+
|
| 27 |
+
# 🔐 Declare API key header scheme (Swagger shows a single token input box)
|
| 28 |
+
api_key_scheme = APIKeyHeader(name="Authorization", auto_error=False)
|
| 29 |
+
|
| 30 |
+
# 🔍 Bearer token parser
|
| 31 |
+
# More flexible bearer token parser
|
| 32 |
+
def get_bearer_token(api_key: str = Security(api_key_scheme)) -> str:
|
| 33 |
+
try:
|
| 34 |
+
# Check if Authorization header is missing
|
| 35 |
+
if not api_key:
|
| 36 |
+
logger.warning("Missing Authorization header")
|
| 37 |
+
raise HTTPException(
|
| 38 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 39 |
+
detail="Missing Authorization header"
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# If "Bearer " prefix is included, strip it
|
| 43 |
+
logger.info(f"Received Authorization header: {api_key}")
|
| 44 |
+
if api_key.lower().startswith("bearer "):
|
| 45 |
+
return api_key[7:] # Remove "Bearer " prefix
|
| 46 |
+
|
| 47 |
+
# Else, assume it's already a raw JWT
|
| 48 |
+
return api_key
|
| 49 |
+
|
| 50 |
+
except HTTPException:
|
| 51 |
+
# Re-raise HTTP exceptions
|
| 52 |
+
raise
|
| 53 |
+
except Exception as e:
|
| 54 |
+
logger.error(f"Error processing Authorization header: {str(e)}")
|
| 55 |
+
raise HTTPException(
|
| 56 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 57 |
+
detail="Invalid Authorization header format"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# 📧📱 Send OTP using single login input (preferred endpoint)
|
| 62 |
+
@router.post("/send-otp-login", response_model=OTPSendResponse)
|
| 63 |
+
async def send_otp_login_handler(payload: OTPRequestWithLogin):
|
| 64 |
+
logger.info(f"OTP login request started - login_input: {payload.login_input}")
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
# Validate identifier format
|
| 68 |
+
try:
|
| 69 |
+
identifier_type = validate_identifier(payload.login_input)
|
| 70 |
+
logger.info(f"Login input type: {identifier_type}")
|
| 71 |
+
except ValueError as ve:
|
| 72 |
+
logger.error(f"Invalid login input format: {str(ve)}")
|
| 73 |
+
raise HTTPException(status_code=400, detail=str(ve))
|
| 74 |
+
|
| 75 |
+
# Check if user already exists
|
| 76 |
+
user_exists = False
|
| 77 |
+
if identifier_type == "email":
|
| 78 |
+
user_exists = await BookMyServiceUserModel.exists_by_email_or_phone(email=payload.login_input)
|
| 79 |
+
elif identifier_type == "phone":
|
| 80 |
+
user_exists = await BookMyServiceUserModel.exists_by_email_or_phone(phone=payload.login_input)
|
| 81 |
+
|
| 82 |
+
logger.info(f"User existence check result: {user_exists}")
|
| 83 |
+
|
| 84 |
+
# Send OTP via service
|
| 85 |
+
logger.info(f"Calling UserService.send_otp with identifier: {payload.login_input}")
|
| 86 |
+
await UserService.send_otp(payload.login_input)
|
| 87 |
+
logger.info(f"OTP sent successfully to: {payload.login_input}")
|
| 88 |
+
|
| 89 |
+
# Create temporary token
|
| 90 |
+
logger.info("Creating temporary token for OTP verification")
|
| 91 |
+
temp_token = create_temp_token({
|
| 92 |
+
"sub": payload.login_input,
|
| 93 |
+
"type": "otp_verification"
|
| 94 |
+
}, expires_minutes=10)
|
| 95 |
+
|
| 96 |
+
logger.info(f"Temporary token created for: {payload.login_input}")
|
| 97 |
+
logger.info(f"Temp token (first 20 chars): {temp_token[:20]}...")
|
| 98 |
+
|
| 99 |
+
return {
|
| 100 |
+
"message": "OTP sent",
|
| 101 |
+
"temp_token": temp_token,
|
| 102 |
+
"user_exists": user_exists
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
except HTTPException as e:
|
| 106 |
+
logger.error(f"OTP login request failed - HTTP {e.status_code}: {e.detail}")
|
| 107 |
+
raise e
|
| 108 |
+
except Exception as e:
|
| 109 |
+
logger.error(f"Unexpected error during OTP login request: {str(e)}", exc_info=True)
|
| 110 |
+
raise HTTPException(status_code=500, detail="Internal server error during OTP login request")
|
| 111 |
+
|
| 112 |
+
# 🔐 OTP Login using temporary token
|
| 113 |
+
@router.post("/otp-login", response_model=TokenResponse)
|
| 114 |
+
async def otp_login_handler(
|
| 115 |
+
payload: OTPVerifyRequest,
|
| 116 |
+
request: Request,
|
| 117 |
+
temp_token: str = Depends(get_bearer_token)
|
| 118 |
+
):
|
| 119 |
+
logger.info(f"OTP login attempt started - login_input: {payload.login_input}, remember_me: {payload.remember_me}")
|
| 120 |
+
logger.info(f"Received temp_token: {temp_token[:20]}..." if temp_token else "No temp_token received")
|
| 121 |
+
|
| 122 |
+
# Get client IP
|
| 123 |
+
client_ip = request.client.host if request.client else None
|
| 124 |
+
|
| 125 |
+
try:
|
| 126 |
+
# Decode and validate temporary token
|
| 127 |
+
logger.info("Attempting to decode temporary token")
|
| 128 |
+
decoded = decode_token(temp_token)
|
| 129 |
+
logger.info(f"Decoded token payload: {decoded}")
|
| 130 |
+
|
| 131 |
+
if not decoded:
|
| 132 |
+
logger.warning("Failed to decode temporary token - token is invalid or expired")
|
| 133 |
+
raise HTTPException(status_code=401, detail="Invalid or expired OTP session token")
|
| 134 |
+
|
| 135 |
+
# Validate token subject matches login input
|
| 136 |
+
token_sub = decoded.get("sub")
|
| 137 |
+
token_type = decoded.get("type")
|
| 138 |
+
logger.info(f"Token subject: {token_sub}, Token type: {token_type}")
|
| 139 |
+
|
| 140 |
+
if token_sub != payload.login_input:
|
| 141 |
+
logger.warning(f"Token subject mismatch - token_sub: {token_sub}, login_input: {payload.login_input}")
|
| 142 |
+
raise HTTPException(status_code=401, detail="Invalid or expired OTP session token")
|
| 143 |
+
|
| 144 |
+
if token_type != "otp_verification":
|
| 145 |
+
logger.warning(f"Invalid token type - expected: otp_verification, got: {token_type}")
|
| 146 |
+
raise HTTPException(status_code=401, detail="Invalid or expired OTP session token")
|
| 147 |
+
|
| 148 |
+
logger.info(f"Temporary token validation successful for: {payload.login_input}")
|
| 149 |
+
|
| 150 |
+
# Call user service for OTP verification and login
|
| 151 |
+
logger.info(f"Calling UserService.otp_login_handler with identifier: {payload.login_input}, otp: {payload.otp}")
|
| 152 |
+
result = await UserService.otp_login_handler(
|
| 153 |
+
payload.login_input,
|
| 154 |
+
payload.otp,
|
| 155 |
+
client_ip=client_ip,
|
| 156 |
+
remember_me=payload.remember_me,
|
| 157 |
+
device_info=payload.device_info
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
logger.info(f"OTP login successful for: {payload.login_input}")
|
| 161 |
+
return result
|
| 162 |
+
|
| 163 |
+
except HTTPException as e:
|
| 164 |
+
logger.error(f"OTP login failed for {payload.login_input} - HTTP {e.status_code}: {e.detail}")
|
| 165 |
+
raise e
|
| 166 |
+
except Exception as e:
|
| 167 |
+
logger.error(f"Unexpected error during OTP login for {payload.login_input}: {str(e)}", exc_info=True)
|
| 168 |
+
raise HTTPException(status_code=500, detail="Internal server error during OTP login")
|
| 169 |
+
|
| 170 |
+
# 🌐 OAuth Login for Google / Apple
|
| 171 |
+
@router.post("/oauth-login", response_model=TokenResponse)
|
| 172 |
+
async def oauth_login_handler(payload: OAuthLoginRequest, request: Request):
|
| 173 |
+
from app.core.config import settings
|
| 174 |
+
|
| 175 |
+
# Get client IP
|
| 176 |
+
client_ip = request.client.host if request.client else None
|
| 177 |
+
|
| 178 |
+
# Check if IP is locked for this provider
|
| 179 |
+
if await SocialSecurityModel.is_oauth_ip_locked(client_ip, payload.provider):
|
| 180 |
+
await SocialSecurityModel.log_oauth_attempt(client_ip, payload.provider, False)
|
| 181 |
+
raise HTTPException(
|
| 182 |
+
status_code=429,
|
| 183 |
+
detail=f"Too many failed attempts. IP temporarily locked for {payload.provider} OAuth."
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
# Check rate limiting
|
| 187 |
+
if not await SocialSecurityModel.check_oauth_rate_limit(client_ip, payload.provider):
|
| 188 |
+
await SocialSecurityModel.log_oauth_attempt(client_ip, payload.provider, False)
|
| 189 |
+
raise HTTPException(
|
| 190 |
+
status_code=429,
|
| 191 |
+
detail=f"Rate limit exceeded for {payload.provider} OAuth. Please try again later."
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
# Validate token format (allow Google access tokens too)
|
| 195 |
+
if not await SocialSecurityModel.validate_oauth_token_format(payload.token, payload.provider):
|
| 196 |
+
await SocialSecurityModel.track_oauth_failed_attempt(client_ip, payload.provider)
|
| 197 |
+
await SocialSecurityModel.log_oauth_attempt(client_ip, payload.provider, False)
|
| 198 |
+
raise HTTPException(status_code=400, detail="Invalid token format")
|
| 199 |
+
|
| 200 |
+
# Increment rate limit counter
|
| 201 |
+
await SocialSecurityModel.increment_oauth_rate_limit(client_ip, payload.provider)
|
| 202 |
+
|
| 203 |
+
try:
|
| 204 |
+
if payload.provider == "google":
|
| 205 |
+
# Accept both ID tokens (JWT) and access tokens
|
| 206 |
+
token = payload.token
|
| 207 |
+
is_jwt = token.count('.') == 2
|
| 208 |
+
if is_jwt:
|
| 209 |
+
# ID token verification requires the configured client id
|
| 210 |
+
if not settings.GOOGLE_CLIENT_ID:
|
| 211 |
+
raise HTTPException(status_code=500, detail="Google OAuth not configured")
|
| 212 |
+
user_info = await verify_google_token(token, settings.GOOGLE_CLIENT_ID)
|
| 213 |
+
else:
|
| 214 |
+
# Access token verification via UserInfo does not require client id
|
| 215 |
+
if token.lower().startswith("bearer "):
|
| 216 |
+
token = token[7:]
|
| 217 |
+
user_info = await verify_google_access_token(token)
|
| 218 |
+
provider_customer_id = user_info.get('sub', user_info.get('id'))
|
| 219 |
+
customer_id = f"google_{provider_customer_id}"
|
| 220 |
+
|
| 221 |
+
elif payload.provider == "apple":
|
| 222 |
+
if not settings.APPLE_AUDIENCE:
|
| 223 |
+
raise HTTPException(status_code=500, detail="Apple OAuth not configured")
|
| 224 |
+
user_info = await verify_apple_token(payload.token, settings.APPLE_AUDIENCE)
|
| 225 |
+
provider_customer_id = user_info.get('sub', user_info.get('id'))
|
| 226 |
+
customer_id = f"apple_{provider_customer_id}"
|
| 227 |
+
|
| 228 |
+
elif payload.provider == "facebook":
|
| 229 |
+
if not settings.FACEBOOK_APP_ID or not settings.FACEBOOK_APP_SECRET:
|
| 230 |
+
raise HTTPException(status_code=500, detail="Facebook OAuth not configured")
|
| 231 |
+
user_info = await verify_facebook_token(payload.token, settings.FACEBOOK_APP_ID, settings.FACEBOOK_APP_SECRET)
|
| 232 |
+
provider_customer_id = user_info.get('id')
|
| 233 |
+
customer_id = f"facebook_{provider_customer_id}"
|
| 234 |
+
|
| 235 |
+
else:
|
| 236 |
+
raise HTTPException(status_code=400, detail="Unsupported OAuth provider")
|
| 237 |
+
|
| 238 |
+
# Clear failed attempts on successful verification
|
| 239 |
+
await SocialSecurityModel.clear_oauth_failed_attempts(client_ip, payload.provider)
|
| 240 |
+
|
| 241 |
+
# Log successful attempt
|
| 242 |
+
await SocialSecurityModel.log_oauth_attempt(client_ip, payload.provider, True, customer_id)
|
| 243 |
+
|
| 244 |
+
# Resolve existing UUID via social linkage if available
|
| 245 |
+
user_exists = False
|
| 246 |
+
resolved_customer_uuid = None
|
| 247 |
+
try:
|
| 248 |
+
from app.models.social_account_model import SocialAccountModel
|
| 249 |
+
linked_account = await SocialAccountModel.find_by_provider_and_customer_id(payload.provider, provider_customer_id)
|
| 250 |
+
if linked_account and linked_account.get("customer_id"):
|
| 251 |
+
resolved_customer_uuid = linked_account["customer_id"]
|
| 252 |
+
user_exists = True
|
| 253 |
+
else:
|
| 254 |
+
# Backward compatibility: some records may still have provider-prefixed customer_id
|
| 255 |
+
existing_user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
|
| 256 |
+
if existing_user:
|
| 257 |
+
resolved_customer_uuid = existing_user["customer_id"]
|
| 258 |
+
user_exists = True
|
| 259 |
+
except Exception:
|
| 260 |
+
# Do not block login on existence check errors; default to False
|
| 261 |
+
user_exists = False
|
| 262 |
+
|
| 263 |
+
temp_token = create_temp_token({
|
| 264 |
+
"sub": resolved_customer_uuid or customer_id,
|
| 265 |
+
"type": "oauth_session",
|
| 266 |
+
"verified": True,
|
| 267 |
+
"provider": payload.provider,
|
| 268 |
+
"user_info": user_info
|
| 269 |
+
})
|
| 270 |
+
|
| 271 |
+
# Log temporary OAuth session token (truncated)
|
| 272 |
+
logger.info(f"OAuth temp token generated (first 25 chars): {temp_token[:25]}...")
|
| 273 |
+
|
| 274 |
+
# Populate response with available customer details for frontend convenience
|
| 275 |
+
return {
|
| 276 |
+
"access_token": temp_token,
|
| 277 |
+
"token_type": "bearer",
|
| 278 |
+
"expires_in": settings.JWT_TEMP_TOKEN_EXPIRE_MINUTES * 60,
|
| 279 |
+
"refresh_token": None,
|
| 280 |
+
"customer_id": resolved_customer_uuid if user_exists else None,
|
| 281 |
+
"name": user_info.get("name"),
|
| 282 |
+
"email": user_info.get("email"),
|
| 283 |
+
"profile_picture": user_info.get("picture"),
|
| 284 |
+
"auth_method": "oauth",
|
| 285 |
+
"provider": payload.provider,
|
| 286 |
+
"user_exists": user_exists,
|
| 287 |
+
"security_info": {
|
| 288 |
+
"verified": user_info.get("email_verified"),
|
| 289 |
+
"provider_user_id": provider_customer_id
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
except HTTPException:
|
| 294 |
+
# Re-raise HTTP exceptions (configuration errors, etc.)
|
| 295 |
+
raise
|
| 296 |
+
except Exception as e:
|
| 297 |
+
# Track failed attempt for token verification failures
|
| 298 |
+
await SocialSecurityModel.track_oauth_failed_attempt(client_ip, payload.provider)
|
| 299 |
+
await SocialSecurityModel.log_oauth_attempt(client_ip, payload.provider, False)
|
| 300 |
+
logger.error(f"OAuth verification failed for {payload.provider}: {str(e)}", exc_info=True)
|
| 301 |
+
raise HTTPException(status_code=401, detail="OAuth token verification failed")
|
| 302 |
+
|
| 303 |
+
# 👤 Final user registration after OTP or OAuth
|
| 304 |
+
@router.post("/register", response_model=TokenResponse)
|
| 305 |
+
async def register_user(
|
| 306 |
+
payload: UserRegisterRequest,
|
| 307 |
+
temp_token: str = Depends(get_bearer_token)
|
| 308 |
+
):
|
| 309 |
+
logger.info(f"Received registration request with payload: {payload}")
|
| 310 |
+
|
| 311 |
+
decoded = decode_token(temp_token)
|
| 312 |
+
if not decoded or decoded.get("type") not in ["otp_verification", "oauth_session"]:
|
| 313 |
+
raise HTTPException(status_code=401, detail="Invalid or expired registration token")
|
| 314 |
+
|
| 315 |
+
logger.info(f"Registering user with payload: {payload}")
|
| 316 |
+
|
| 317 |
+
result = await UserService.register(payload, decoded)
|
| 318 |
+
# Log tokens returned on register (truncated)
|
| 319 |
+
if result and isinstance(result, dict):
|
| 320 |
+
at = result.get("access_token")
|
| 321 |
+
rt = result.get("refresh_token")
|
| 322 |
+
if at:
|
| 323 |
+
logger.info(f"Register access token (first 25 chars): {at[:25]}...")
|
| 324 |
+
if rt:
|
| 325 |
+
logger.info(f"Register refresh token (first 25 chars): {rt[:25]}...")
|
| 326 |
+
return result
|
| 327 |
+
|
| 328 |
+
# 🔄 Refresh access token using refresh token with rotation
|
| 329 |
+
@router.post("/refresh-token", response_model=TokenResponse)
|
| 330 |
+
async def refresh_token_handler(
|
| 331 |
+
request: Request,
|
| 332 |
+
refresh_token: str = Depends(get_bearer_token)
|
| 333 |
+
):
|
| 334 |
+
from app.models.refresh_token_model import RefreshTokenModel
|
| 335 |
+
from app.utils.jwt import create_access_token
|
| 336 |
+
|
| 337 |
+
logger.info("Refresh token request received")
|
| 338 |
+
|
| 339 |
+
# Get client IP
|
| 340 |
+
client_ip = request.client.host if request.client else None
|
| 341 |
+
|
| 342 |
+
try:
|
| 343 |
+
# Decode and validate refresh token
|
| 344 |
+
decoded = decode_token(refresh_token)
|
| 345 |
+
logger.info(f"Decoded refresh token payload: {decoded}")
|
| 346 |
+
|
| 347 |
+
if not decoded:
|
| 348 |
+
logger.warning("Failed to decode refresh token - token is invalid or expired")
|
| 349 |
+
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
| 350 |
+
|
| 351 |
+
# Validate token type
|
| 352 |
+
token_type = decoded.get("type")
|
| 353 |
+
if token_type != "refresh":
|
| 354 |
+
logger.warning(f"Invalid token type for refresh - expected: refresh, got: {token_type}")
|
| 355 |
+
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
| 356 |
+
|
| 357 |
+
# Extract token information
|
| 358 |
+
customer_id = decoded.get("sub")
|
| 359 |
+
token_id = decoded.get("jti")
|
| 360 |
+
family_id = decoded.get("family_id")
|
| 361 |
+
remember_me = decoded.get("remember_me", False)
|
| 362 |
+
|
| 363 |
+
if not customer_id or not token_id:
|
| 364 |
+
logger.warning("Refresh token missing required claims")
|
| 365 |
+
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
| 366 |
+
|
| 367 |
+
# Validate token hasn't been revoked or reused
|
| 368 |
+
if not await RefreshTokenModel.is_token_valid(token_id):
|
| 369 |
+
logger.error(f"Token {token_id} is invalid - possible security breach")
|
| 370 |
+
raise HTTPException(
|
| 371 |
+
status_code=401,
|
| 372 |
+
detail="Invalid refresh token. Please login again."
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
# Mark current token as used
|
| 376 |
+
await RefreshTokenModel.mark_token_as_used(token_id)
|
| 377 |
+
|
| 378 |
+
# Increment rotation count
|
| 379 |
+
if family_id:
|
| 380 |
+
await RefreshTokenModel.increment_rotation_count(family_id)
|
| 381 |
+
|
| 382 |
+
logger.info(f"Refresh token validation successful for user: {customer_id}")
|
| 383 |
+
|
| 384 |
+
# Create new access token
|
| 385 |
+
access_token = create_access_token({
|
| 386 |
+
"sub": customer_id
|
| 387 |
+
})
|
| 388 |
+
|
| 389 |
+
# Create new refresh token (rotation)
|
| 390 |
+
new_refresh_token, new_token_id, new_expires_at = create_refresh_token(
|
| 391 |
+
{"sub": customer_id},
|
| 392 |
+
remember_me=remember_me,
|
| 393 |
+
family_id=family_id
|
| 394 |
+
)
|
| 395 |
+
|
| 396 |
+
# Store new refresh token metadata
|
| 397 |
+
token_metadata = await RefreshTokenModel.get_token_metadata(token_id)
|
| 398 |
+
await RefreshTokenModel.store_refresh_token(
|
| 399 |
+
token_id=new_token_id,
|
| 400 |
+
customer_id=customer_id,
|
| 401 |
+
family_id=family_id,
|
| 402 |
+
expires_at=new_expires_at,
|
| 403 |
+
remember_me=remember_me,
|
| 404 |
+
device_info=token_metadata.get("device_info") if token_metadata else None,
|
| 405 |
+
ip_address=client_ip
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
logger.info(f"New tokens generated for user: {customer_id} (rotation)")
|
| 409 |
+
|
| 410 |
+
return {
|
| 411 |
+
"access_token": access_token,
|
| 412 |
+
"token_type": "bearer",
|
| 413 |
+
"expires_in": settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
| 414 |
+
"refresh_token": new_refresh_token,
|
| 415 |
+
"customer_id": customer_id
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
except HTTPException as e:
|
| 419 |
+
logger.error(f"Refresh token failed - HTTP {e.status_code}: {e.detail}")
|
| 420 |
+
raise e
|
| 421 |
+
except Exception as e:
|
| 422 |
+
logger.error(f"Unexpected error during refresh token: {str(e)}", exc_info=True)
|
| 423 |
+
raise HTTPException(status_code=500, detail="Internal server error during token refresh")
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
# 🚪 Logout - Revoke refresh token
|
| 427 |
+
@router.post("/logout")
|
| 428 |
+
async def logout_handler(
|
| 429 |
+
refresh_token: str = Depends(get_bearer_token)
|
| 430 |
+
):
|
| 431 |
+
from app.models.refresh_token_model import RefreshTokenModel
|
| 432 |
+
|
| 433 |
+
logger.info("Logout request received")
|
| 434 |
+
|
| 435 |
+
try:
|
| 436 |
+
# Decode refresh token to get token ID
|
| 437 |
+
decoded = decode_token(refresh_token)
|
| 438 |
+
|
| 439 |
+
if decoded and decoded.get("type") == "refresh":
|
| 440 |
+
token_id = decoded.get("jti")
|
| 441 |
+
customer_id = decoded.get("sub")
|
| 442 |
+
|
| 443 |
+
if token_id:
|
| 444 |
+
# Revoke the refresh token
|
| 445 |
+
await RefreshTokenModel.revoke_token(token_id)
|
| 446 |
+
logger.info(f"Revoked refresh token {token_id} for user {customer_id}")
|
| 447 |
+
|
| 448 |
+
return {
|
| 449 |
+
"message": "Logged out successfully",
|
| 450 |
+
"success": True
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
return {
|
| 454 |
+
"message": "Invalid token",
|
| 455 |
+
"success": False
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
except Exception as e:
|
| 459 |
+
logger.error(f"Error during logout: {str(e)}", exc_info=True)
|
| 460 |
+
raise HTTPException(status_code=500, detail="Internal server error during logout")
|
| 461 |
+
|
| 462 |
+
# 🚪 Logout from all devices - Revoke all refresh tokens
|
| 463 |
+
@router.post("/logout-all")
|
| 464 |
+
async def logout_all_handler(
|
| 465 |
+
customer_id: str = Depends(get_current_customer_id)
|
| 466 |
+
):
|
| 467 |
+
from app.models.refresh_token_model import RefreshTokenModel
|
| 468 |
+
|
| 469 |
+
logger.info(f"Logout all devices request for user: {customer_id}")
|
| 470 |
+
|
| 471 |
+
try:
|
| 472 |
+
# Revoke all refresh tokens for the user
|
| 473 |
+
revoked_count = await RefreshTokenModel.revoke_all_user_tokens(customer_id)
|
| 474 |
+
|
| 475 |
+
logger.info(f"Revoked {revoked_count} tokens for user {customer_id}")
|
| 476 |
+
|
| 477 |
+
return {
|
| 478 |
+
"message": f"Logged out from {revoked_count} device(s) successfully",
|
| 479 |
+
"success": True,
|
| 480 |
+
"revoked_count": revoked_count
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
except Exception as e:
|
| 484 |
+
logger.error(f"Error during logout all: {str(e)}", exc_info=True)
|
| 485 |
+
raise HTTPException(status_code=500, detail="Internal server error during logout")
|
| 486 |
+
|
| 487 |
+
# 📱 Get active sessions
|
| 488 |
+
@router.get("/sessions")
|
| 489 |
+
async def get_active_sessions_handler(
|
| 490 |
+
customer_id: str = Depends(get_current_customer_id)
|
| 491 |
+
):
|
| 492 |
+
from app.models.refresh_token_model import RefreshTokenModel
|
| 493 |
+
|
| 494 |
+
logger.info(f"Get active sessions request for user: {customer_id}")
|
| 495 |
+
|
| 496 |
+
try:
|
| 497 |
+
sessions = await RefreshTokenModel.get_active_sessions(customer_id)
|
| 498 |
+
|
| 499 |
+
# Format session data for response
|
| 500 |
+
formatted_sessions = []
|
| 501 |
+
for session in sessions:
|
| 502 |
+
formatted_sessions.append({
|
| 503 |
+
"token_id": session.get("token_id"),
|
| 504 |
+
"device_info": session.get("device_info"),
|
| 505 |
+
"ip_address": session.get("ip_address"),
|
| 506 |
+
"created_at": session.get("created_at"),
|
| 507 |
+
"expires_at": session.get("expires_at"),
|
| 508 |
+
"remember_me": session.get("remember_me", False),
|
| 509 |
+
"last_used": session.get("used_at")
|
| 510 |
+
})
|
| 511 |
+
|
| 512 |
+
return {
|
| 513 |
+
"sessions": formatted_sessions,
|
| 514 |
+
"total": len(formatted_sessions)
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
except Exception as e:
|
| 518 |
+
logger.error(f"Error getting active sessions: {str(e)}", exc_info=True)
|
| 519 |
+
raise HTTPException(status_code=500, detail="Internal server error getting sessions")
|
| 520 |
+
|
| 521 |
+
# 🗑️ Revoke specific session
|
| 522 |
+
@router.delete("/sessions/{token_id}")
|
| 523 |
+
async def revoke_session_handler(
|
| 524 |
+
token_id: str,
|
| 525 |
+
customer_id: str = Depends(get_current_customer_id)
|
| 526 |
+
):
|
| 527 |
+
from app.models.refresh_token_model import RefreshTokenModel
|
| 528 |
+
|
| 529 |
+
logger.info(f"Revoke session request for token: {token_id} by user: {customer_id}")
|
| 530 |
+
|
| 531 |
+
try:
|
| 532 |
+
# Verify the token belongs to the user
|
| 533 |
+
token_metadata = await RefreshTokenModel.get_token_metadata(token_id)
|
| 534 |
+
|
| 535 |
+
if not token_metadata:
|
| 536 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 537 |
+
|
| 538 |
+
if token_metadata.get("customer_id") != customer_id:
|
| 539 |
+
raise HTTPException(status_code=403, detail="Unauthorized to revoke this session")
|
| 540 |
+
|
| 541 |
+
# Revoke the token
|
| 542 |
+
success = await RefreshTokenModel.revoke_token(token_id)
|
| 543 |
+
|
| 544 |
+
if success:
|
| 545 |
+
return {
|
| 546 |
+
"message": "Session revoked successfully",
|
| 547 |
+
"success": True
|
| 548 |
+
}
|
| 549 |
+
else:
|
| 550 |
+
raise HTTPException(status_code=404, detail="Session not found or already revoked")
|
| 551 |
+
|
| 552 |
+
except HTTPException:
|
| 553 |
+
raise
|
| 554 |
+
except Exception as e:
|
| 555 |
+
logger.error(f"Error revoking session: {str(e)}", exc_info=True)
|
| 556 |
+
raise HTTPException(status_code=500, detail="Internal server error revoking session")
|
app/routers/wallet_router.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
from app.utils.jwt import get_current_customer_id
|
| 6 |
+
from app.services.wallet_service import WalletService
|
| 7 |
+
from app.schemas.wallet_schema import (
|
| 8 |
+
WalletBalanceResponse, TransactionHistoryResponse, WalletSummaryResponse,
|
| 9 |
+
AddMoneyRequest, WithdrawMoneyRequest, TransactionRequest, TransactionResponse
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
|
| 16 |
+
@router.get("/balance", response_model=WalletBalanceResponse)
|
| 17 |
+
async def get_wallet_balance(current_customer_id: str = Depends(get_current_customer_id)):
|
| 18 |
+
"""
|
| 19 |
+
Get current user's wallet balance.
|
| 20 |
+
|
| 21 |
+
This endpoint is JWT protected and requires a valid Bearer token.
|
| 22 |
+
"""
|
| 23 |
+
try:
|
| 24 |
+
logger.info(f"Wallet balance request for user: {current_customer_id}")
|
| 25 |
+
|
| 26 |
+
balance_info = await WalletService.get_wallet_balance(current_customer_id)
|
| 27 |
+
return balance_info
|
| 28 |
+
|
| 29 |
+
except Exception as e:
|
| 30 |
+
logger.error(f"Error getting wallet balance for user {current_customer_id}: {str(e)}")
|
| 31 |
+
raise HTTPException(
|
| 32 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 33 |
+
detail="Failed to retrieve wallet balance"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
@router.get("/summary", response_model=WalletSummaryResponse)
|
| 37 |
+
async def get_wallet_summary(current_customer_id: str = Depends(get_current_customer_id)):
|
| 38 |
+
"""
|
| 39 |
+
Get wallet summary including balance and recent transaction stats.
|
| 40 |
+
"""
|
| 41 |
+
try:
|
| 42 |
+
logger.info(f"Wallet summary request for user: {current_customer_id}")
|
| 43 |
+
|
| 44 |
+
summary = await WalletService.get_wallet_summary(current_customer_id)
|
| 45 |
+
return summary
|
| 46 |
+
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.error(f"Error getting wallet summary for user {current_customer_id}: {str(e)}")
|
| 49 |
+
raise HTTPException(
|
| 50 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 51 |
+
detail="Failed to retrieve wallet summary"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
@router.get("/transactions", response_model=TransactionHistoryResponse)
|
| 55 |
+
async def get_transaction_history(
|
| 56 |
+
current_customer_id: str = Depends(get_current_customer_id),
|
| 57 |
+
page: int = Query(1, ge=1, description="Page number"),
|
| 58 |
+
limit: int = Query(10, ge=1, le=100, description="Number of transactions per page"),
|
| 59 |
+
transaction_type: Optional[str] = Query(None, description="Filter by transaction type")
|
| 60 |
+
):
|
| 61 |
+
"""
|
| 62 |
+
Get paginated transaction history for the current user.
|
| 63 |
+
|
| 64 |
+
Query parameters:
|
| 65 |
+
- page: Page number (default: 1)
|
| 66 |
+
- limit: Number of transactions per page (default: 10, max: 100)
|
| 67 |
+
- transaction_type: Filter by type (credit, debit, refund, etc.)
|
| 68 |
+
"""
|
| 69 |
+
try:
|
| 70 |
+
logger.info(f"Transaction history request for user: {current_customer_id}, page: {page}, limit: {limit}")
|
| 71 |
+
|
| 72 |
+
history = await WalletService.get_transaction_history(
|
| 73 |
+
current_customer_id,
|
| 74 |
+
page=page,
|
| 75 |
+
limit=limit,
|
| 76 |
+
transaction_type=transaction_type
|
| 77 |
+
)
|
| 78 |
+
return history
|
| 79 |
+
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger.error(f"Error getting transaction history for user {current_customer_id}: {str(e)}")
|
| 82 |
+
raise HTTPException(
|
| 83 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 84 |
+
detail="Failed to retrieve transaction history"
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
@router.post("/add-money", response_model=TransactionResponse)
|
| 88 |
+
async def add_money_to_wallet(
|
| 89 |
+
request: AddMoneyRequest,
|
| 90 |
+
current_customer_id: str = Depends(get_current_customer_id)
|
| 91 |
+
):
|
| 92 |
+
"""
|
| 93 |
+
Add money to user's wallet.
|
| 94 |
+
|
| 95 |
+
This would typically integrate with a payment gateway.
|
| 96 |
+
For now, it simulates adding money to the wallet.
|
| 97 |
+
"""
|
| 98 |
+
try:
|
| 99 |
+
logger.info(f"Add money request for user: {current_customer_id}, amount: {request.amount}")
|
| 100 |
+
|
| 101 |
+
if request.amount <= 0:
|
| 102 |
+
raise HTTPException(status_code=400, detail="Amount must be greater than zero")
|
| 103 |
+
|
| 104 |
+
transaction = await WalletService.add_money(
|
| 105 |
+
current_customer_id,
|
| 106 |
+
request.amount,
|
| 107 |
+
request.payment_method,
|
| 108 |
+
request.reference_id,
|
| 109 |
+
request.description
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
return TransactionResponse(
|
| 113 |
+
success=True,
|
| 114 |
+
message="Money added successfully",
|
| 115 |
+
transaction=transaction,
|
| 116 |
+
new_balance=transaction.balance_after
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
except HTTPException:
|
| 120 |
+
raise
|
| 121 |
+
except Exception as e:
|
| 122 |
+
logger.error(f"Error adding money for user {current_customer_id}: {str(e)}")
|
| 123 |
+
raise HTTPException(
|
| 124 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 125 |
+
detail="Failed to add money to wallet"
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
@router.post("/withdraw", response_model=TransactionResponse)
|
| 129 |
+
async def withdraw_money(
|
| 130 |
+
request: WithdrawMoneyRequest,
|
| 131 |
+
current_customer_id: str = Depends(get_current_customer_id)
|
| 132 |
+
):
|
| 133 |
+
"""
|
| 134 |
+
Withdraw money from user's wallet.
|
| 135 |
+
|
| 136 |
+
This would typically integrate with a payment gateway for bank transfers.
|
| 137 |
+
"""
|
| 138 |
+
try:
|
| 139 |
+
logger.info(f"Withdraw request for user: {current_customer_id}, amount: {request.amount}")
|
| 140 |
+
|
| 141 |
+
if request.amount <= 0:
|
| 142 |
+
raise HTTPException(status_code=400, detail="Amount must be greater than zero")
|
| 143 |
+
|
| 144 |
+
# Check if user has sufficient balance
|
| 145 |
+
balance_info = await WalletService.get_wallet_balance(current_customer_id)
|
| 146 |
+
if balance_info.balance < request.amount:
|
| 147 |
+
raise HTTPException(status_code=400, detail="Insufficient wallet balance")
|
| 148 |
+
|
| 149 |
+
transaction = await WalletService.deduct_money(
|
| 150 |
+
current_customer_id,
|
| 151 |
+
request.amount,
|
| 152 |
+
"withdrawal",
|
| 153 |
+
request.bank_account_id,
|
| 154 |
+
request.description or "Wallet withdrawal"
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
return TransactionResponse(
|
| 158 |
+
success=True,
|
| 159 |
+
message="Withdrawal processed successfully",
|
| 160 |
+
transaction=transaction,
|
| 161 |
+
new_balance=transaction.balance_after
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
except HTTPException:
|
| 165 |
+
raise
|
| 166 |
+
except Exception as e:
|
| 167 |
+
logger.error(f"Error processing withdrawal for user {current_customer_id}: {str(e)}")
|
| 168 |
+
raise HTTPException(
|
| 169 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 170 |
+
detail="Failed to process withdrawal"
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
@router.post("/transaction", response_model=TransactionResponse)
|
| 174 |
+
async def create_transaction(
|
| 175 |
+
request: TransactionRequest,
|
| 176 |
+
current_customer_id: str = Depends(get_current_customer_id)
|
| 177 |
+
):
|
| 178 |
+
"""
|
| 179 |
+
Create a generic transaction (for internal use, service bookings, etc.).
|
| 180 |
+
"""
|
| 181 |
+
try:
|
| 182 |
+
logger.info(f"Transaction request for user: {current_customer_id}, type: {request.transaction_type}, amount: {request.amount}")
|
| 183 |
+
|
| 184 |
+
if request.amount <= 0:
|
| 185 |
+
raise HTTPException(status_code=400, detail="Amount must be greater than zero")
|
| 186 |
+
|
| 187 |
+
if request.transaction_type == "debit":
|
| 188 |
+
# Check if user has sufficient balance for debit transactions
|
| 189 |
+
balance_info = await WalletService.get_wallet_balance(current_customer_id)
|
| 190 |
+
if balance_info.balance < request.amount:
|
| 191 |
+
raise HTTPException(status_code=400, detail="Insufficient wallet balance")
|
| 192 |
+
|
| 193 |
+
transaction = await WalletService.deduct_money(
|
| 194 |
+
current_customer_id,
|
| 195 |
+
request.amount,
|
| 196 |
+
request.category or "service",
|
| 197 |
+
request.reference_id,
|
| 198 |
+
request.description
|
| 199 |
+
)
|
| 200 |
+
else: # credit or refund
|
| 201 |
+
transaction = await WalletService.add_money(
|
| 202 |
+
current_customer_id,
|
| 203 |
+
request.amount,
|
| 204 |
+
request.category or "refund",
|
| 205 |
+
request.reference_id,
|
| 206 |
+
request.description
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
return TransactionResponse(
|
| 210 |
+
success=True,
|
| 211 |
+
message=f"Transaction processed successfully",
|
| 212 |
+
transaction=transaction,
|
| 213 |
+
new_balance=transaction.balance_after
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
except HTTPException:
|
| 217 |
+
raise
|
| 218 |
+
except Exception as e:
|
| 219 |
+
logger.error(f"Error creating transaction for user {current_customer_id}: {str(e)}")
|
| 220 |
+
raise HTTPException(
|
| 221 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 222 |
+
detail="Failed to process transaction"
|
| 223 |
+
)
|
app/schemas/__init__.py
ADDED
|
File without changes
|
app/schemas/address_schema.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field, validator
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import List, Optional, Literal
|
| 4 |
+
|
| 5 |
+
class AddressCreateRequest(BaseModel):
|
| 6 |
+
"""Request model for creating a new address"""
|
| 7 |
+
address_line_1: str = Field(..., min_length=5, max_length=200, description="Primary address line")
|
| 8 |
+
address_line_2: Optional[str] = Field("", max_length=200, description="Secondary address line")
|
| 9 |
+
city: str = Field(..., min_length=2, max_length=100, description="City name")
|
| 10 |
+
state: str = Field(..., min_length=2, max_length=100, description="State name")
|
| 11 |
+
postal_code: str = Field(..., min_length=5, max_length=10, description="Postal/ZIP code")
|
| 12 |
+
country: str = Field(default="India", max_length=100, description="Country name")
|
| 13 |
+
address_type: Literal["home", "work", "other"] = Field(default="home", description="Type of address")
|
| 14 |
+
is_default: bool = Field(default=False, description="Set as default address")
|
| 15 |
+
landmark: Optional[str] = Field("", max_length=200, description="Nearby landmark")
|
| 16 |
+
|
| 17 |
+
@validator('postal_code')
|
| 18 |
+
def validate_postal_code(cls, v):
|
| 19 |
+
if not v.isdigit():
|
| 20 |
+
raise ValueError('Postal code must contain only digits')
|
| 21 |
+
return v
|
| 22 |
+
|
| 23 |
+
class AddressUpdateRequest(BaseModel):
|
| 24 |
+
"""Request model for updating an existing address"""
|
| 25 |
+
address_line_1: Optional[str] = Field(None, min_length=5, max_length=200, description="Primary address line")
|
| 26 |
+
address_line_2: Optional[str] = Field(None, max_length=200, description="Secondary address line")
|
| 27 |
+
city: Optional[str] = Field(None, min_length=2, max_length=100, description="City name")
|
| 28 |
+
state: Optional[str] = Field(None, min_length=2, max_length=100, description="State name")
|
| 29 |
+
postal_code: Optional[str] = Field(None, min_length=5, max_length=10, description="Postal/ZIP code")
|
| 30 |
+
country: Optional[str] = Field(None, max_length=100, description="Country name")
|
| 31 |
+
address_type: Optional[Literal["home", "work", "other"]] = Field(None, description="Type of address")
|
| 32 |
+
is_default: Optional[bool] = Field(None, description="Set as default address")
|
| 33 |
+
landmark: Optional[str] = Field(None, max_length=200, description="Nearby landmark")
|
| 34 |
+
|
| 35 |
+
@validator('postal_code')
|
| 36 |
+
def validate_postal_code(cls, v):
|
| 37 |
+
if v and not v.isdigit():
|
| 38 |
+
raise ValueError('Postal code must contain only digits')
|
| 39 |
+
return v
|
| 40 |
+
|
| 41 |
+
class AddressResponse(BaseModel):
|
| 42 |
+
"""Response model for address data"""
|
| 43 |
+
address_id: str = Field(..., description="Unique address ID")
|
| 44 |
+
address_line_1: str = Field(..., description="Primary address line")
|
| 45 |
+
address_line_2: str = Field(..., description="Secondary address line")
|
| 46 |
+
city: str = Field(..., description="City name")
|
| 47 |
+
state: str = Field(..., description="State name")
|
| 48 |
+
postal_code: str = Field(..., description="Postal/ZIP code")
|
| 49 |
+
country: str = Field(..., description="Country name")
|
| 50 |
+
address_type: str = Field(..., description="Type of address")
|
| 51 |
+
is_default: bool = Field(..., description="Is default address")
|
| 52 |
+
landmark: str = Field(..., description="Nearby landmark")
|
| 53 |
+
created_at: datetime = Field(..., description="Address creation timestamp")
|
| 54 |
+
updated_at: datetime = Field(..., description="Address last update timestamp")
|
| 55 |
+
|
| 56 |
+
class Config:
|
| 57 |
+
from_attributes = True
|
| 58 |
+
|
| 59 |
+
class AddressListResponse(BaseModel):
|
| 60 |
+
"""Response model for list of addresses"""
|
| 61 |
+
success: bool = Field(..., description="Operation success status")
|
| 62 |
+
message: str = Field(..., description="Response message")
|
| 63 |
+
addresses: Optional[List[AddressResponse]]= Field(None, description="List of user addresses")
|
| 64 |
+
total_count: Optional[int] = Field(0, description="Total number of addresses")
|
| 65 |
+
|
| 66 |
+
class SetDefaultAddressRequest(BaseModel):
|
| 67 |
+
"""Request model for setting default address"""
|
| 68 |
+
address_id: str = Field(..., description="Address ID to set as default")
|
| 69 |
+
|
| 70 |
+
class AddressOperationResponse(BaseModel):
|
| 71 |
+
"""Response model for address operations"""
|
| 72 |
+
success: bool = Field(..., description="Operation success status")
|
| 73 |
+
message: str = Field(..., description="Response message")
|
| 74 |
+
address: Optional[AddressResponse] = Field(None, description="Address ID if applicable")
|
app/schemas/favorite_schema.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
class FavoriteCreateRequest(BaseModel):
|
| 6 |
+
"""Request schema for creating a favorite"""
|
| 7 |
+
merchant_id: str = Field(..., description="ID of the merchant to favorite")
|
| 8 |
+
source: str = Field(..., description="Source of the favorite action")
|
| 9 |
+
merchant_category: str = Field(..., description="Category of the merchant (salon, spa, pet_spa, etc.)")
|
| 10 |
+
merchant_name: str = Field(..., description="Name of the merchant for quick display")
|
| 11 |
+
notes: Optional[str] = Field(None, description="Optional note about this favorite")
|
| 12 |
+
class FavoriteUpdateRequest(BaseModel):
|
| 13 |
+
"""Request schema for updating favorite notes"""
|
| 14 |
+
notes: Optional[str] = Field(None, description="Updated note about this favorite")
|
| 15 |
+
|
| 16 |
+
class FavoriteResponse(BaseModel):
|
| 17 |
+
"""Response schema for a favorite merchant"""
|
| 18 |
+
merchant_id: str = Field(..., description="ID of the merchant")
|
| 19 |
+
source: str = Field(..., description="Source of the favorite action")
|
| 20 |
+
merchant_category: str = Field(..., description="Category of the merchant")
|
| 21 |
+
merchant_name: str = Field(..., description="Name of the merchant")
|
| 22 |
+
added_at: datetime = Field(..., description="When the merchant was favorited")
|
| 23 |
+
notes: Optional[str] = Field(None, description="Optional note about this favorite")
|
| 24 |
+
|
| 25 |
+
class FavoritesListResponse(BaseModel):
|
| 26 |
+
"""Response schema for listing favorites"""
|
| 27 |
+
favorites: List[FavoriteResponse] = Field(..., description="List of favorite merchants")
|
| 28 |
+
total_count: int = Field(..., description="Total number of favorites")
|
| 29 |
+
limit: int = Field(..., description="Maximum number of items returned")
|
| 30 |
+
|
| 31 |
+
class FavoriteStatusResponse(BaseModel):
|
| 32 |
+
"""Response schema for checking favorite status"""
|
| 33 |
+
is_favorite: bool = Field(..., description="Whether the merchant is in favorites")
|
| 34 |
+
merchant_id: Optional[str] = Field(None, description="ID of the merchant if exists")
|
| 35 |
+
|
| 36 |
+
class FavoriteSuccessResponse(BaseModel):
|
| 37 |
+
"""Response schema for successful favorite operations"""
|
| 38 |
+
success: bool = Field(..., description="Operation success status")
|
| 39 |
+
message: str = Field(..., description="Operation result message")
|
| 40 |
+
merchant_id: Optional[str] = Field(None, description="ID of the merchant")
|
| 41 |
+
|
| 42 |
+
class FavoriteDataResponse(BaseModel):
|
| 43 |
+
success: bool = Field(..., description="Operation success status")
|
| 44 |
+
message: str = Field(..., description="Operation result message")
|
| 45 |
+
favorite_data: Optional[FavoriteResponse]=Field(None, description="List of favorite merchants")
|
app/schemas/guest_schema.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field, validator, EmailStr
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
from datetime import datetime,date
|
| 4 |
+
from enum import Enum
|
| 5 |
+
|
| 6 |
+
class GenderEnum(str, Enum):
|
| 7 |
+
MALE = "Male"
|
| 8 |
+
FEMALE = "Female"
|
| 9 |
+
OTHER = "Other"
|
| 10 |
+
|
| 11 |
+
class RelationshipEnum(str, Enum):
|
| 12 |
+
FAMILY = "Family"
|
| 13 |
+
FRIEND = "Friend"
|
| 14 |
+
COLLEAGUE = "Colleague"
|
| 15 |
+
OTHER = "Other"
|
| 16 |
+
|
| 17 |
+
class GuestCreateRequest(BaseModel):
|
| 18 |
+
"""Schema for creating a new guest profile"""
|
| 19 |
+
first_name: str = Field(..., min_length=1, max_length=100, description="Guest's first name")
|
| 20 |
+
last_name: Optional[str] = Field(None, max_length=100, description="Guest's last name")
|
| 21 |
+
email: Optional[EmailStr] = Field(None, description="Guest's email address")
|
| 22 |
+
phone_number: Optional[str] = Field(None, max_length=20, description="Guest's phone number")
|
| 23 |
+
gender: Optional[GenderEnum] = Field(None, description="Guest's gender")
|
| 24 |
+
date_of_birth: Optional[date] = Field(None, description="Guest's date of birth for age calculation")
|
| 25 |
+
relationship: Optional[RelationshipEnum] = Field(None, description="Relationship to the user")
|
| 26 |
+
notes: Optional[str] = Field(None, max_length=500, description="Additional notes about the guest")
|
| 27 |
+
is_default: Optional[bool] = Field(None, description="Mark as default guest")
|
| 28 |
+
|
| 29 |
+
@validator('email', pre=True)
|
| 30 |
+
def optional_email_empty_to_none(cls, v):
|
| 31 |
+
if v is None:
|
| 32 |
+
return None
|
| 33 |
+
if isinstance(v, str) and v.strip() == '':
|
| 34 |
+
return None
|
| 35 |
+
return v
|
| 36 |
+
|
| 37 |
+
@validator('phone_number', pre=True)
|
| 38 |
+
def optional_phone_empty_to_none(cls, v):
|
| 39 |
+
if v is None:
|
| 40 |
+
return None
|
| 41 |
+
if isinstance(v, str) and v.strip() == '':
|
| 42 |
+
return None
|
| 43 |
+
return v
|
| 44 |
+
|
| 45 |
+
@validator('date_of_birth', pre=True)
|
| 46 |
+
def coerce_date_of_birth(cls, v):
|
| 47 |
+
if v is None:
|
| 48 |
+
return None
|
| 49 |
+
if isinstance(v, str):
|
| 50 |
+
s = v.strip()
|
| 51 |
+
if s == '':
|
| 52 |
+
return None
|
| 53 |
+
try:
|
| 54 |
+
# Accept ISO date or datetime strings; convert to date
|
| 55 |
+
if 'T' in s or 'Z' in s or '+' in s:
|
| 56 |
+
return datetime.fromisoformat(s.replace('Z', '+00:00')).date()
|
| 57 |
+
return date.fromisoformat(s)
|
| 58 |
+
except Exception:
|
| 59 |
+
return v
|
| 60 |
+
if isinstance(v, datetime):
|
| 61 |
+
return v.date()
|
| 62 |
+
return v
|
| 63 |
+
|
| 64 |
+
@validator('first_name')
|
| 65 |
+
def validate_first_name(cls, v):
|
| 66 |
+
if not v or not v.strip():
|
| 67 |
+
raise ValueError('First name cannot be empty')
|
| 68 |
+
return v.strip()
|
| 69 |
+
|
| 70 |
+
@validator('last_name')
|
| 71 |
+
def validate_last_name(cls, v):
|
| 72 |
+
if v is not None and v.strip() == '':
|
| 73 |
+
return None
|
| 74 |
+
return v.strip() if v else v
|
| 75 |
+
|
| 76 |
+
@validator('phone_number')
|
| 77 |
+
def validate_phone_number(cls, v):
|
| 78 |
+
if v is not None:
|
| 79 |
+
# Remove spaces and special characters for validation
|
| 80 |
+
cleaned = ''.join(filter(str.isdigit, v))
|
| 81 |
+
if len(cleaned) < 10 or len(cleaned) > 15:
|
| 82 |
+
raise ValueError('Phone number must be between 10 and 15 digits')
|
| 83 |
+
return v
|
| 84 |
+
|
| 85 |
+
@validator('date_of_birth')
|
| 86 |
+
def validate_date_of_birth(cls, v):
|
| 87 |
+
if v is not None:
|
| 88 |
+
if v > date.today():
|
| 89 |
+
raise ValueError('Date of birth cannot be in the future')
|
| 90 |
+
# Check if age would be reasonable (not more than 120 years old)
|
| 91 |
+
age = (date.today() - v).days // 365
|
| 92 |
+
if age > 120:
|
| 93 |
+
raise ValueError('Date of birth indicates unrealistic age')
|
| 94 |
+
return v
|
| 95 |
+
|
| 96 |
+
class GuestUpdateRequest(BaseModel):
|
| 97 |
+
"""Schema for updating a guest profile"""
|
| 98 |
+
first_name: Optional[str] = Field(None, min_length=1, max_length=100, description="Guest's first name")
|
| 99 |
+
last_name: Optional[str] = Field(None, max_length=100, description="Guest's last name")
|
| 100 |
+
email: Optional[EmailStr] = Field(None, description="Guest's email address")
|
| 101 |
+
phone_number: Optional[str] = Field(None, max_length=20, description="Guest's phone number")
|
| 102 |
+
gender: Optional[GenderEnum] = Field(None, description="Guest's gender")
|
| 103 |
+
date_of_birth: Optional[date] = Field(None, description="Guest's date of birth for age calculation")
|
| 104 |
+
relationship: Optional[RelationshipEnum] = Field(None, description="Relationship to the user")
|
| 105 |
+
notes: Optional[str] = Field(None, max_length=500, description="Additional notes about the guest")
|
| 106 |
+
is_default: Optional[bool] = Field(None, description="Mark as default guest")
|
| 107 |
+
|
| 108 |
+
@validator('email', pre=True)
|
| 109 |
+
def optional_email_empty_to_none_update(cls, v):
|
| 110 |
+
if v is None:
|
| 111 |
+
return None
|
| 112 |
+
if isinstance(v, str) and v.strip() == '':
|
| 113 |
+
return None
|
| 114 |
+
return v
|
| 115 |
+
|
| 116 |
+
@validator('phone_number', pre=True)
|
| 117 |
+
def optional_phone_empty_to_none_update(cls, v):
|
| 118 |
+
if v is None:
|
| 119 |
+
return None
|
| 120 |
+
if isinstance(v, str) and v.strip() == '':
|
| 121 |
+
return None
|
| 122 |
+
return v
|
| 123 |
+
|
| 124 |
+
@validator('date_of_birth', pre=True)
|
| 125 |
+
def coerce_date_of_birth_update(cls, v):
|
| 126 |
+
if v is None:
|
| 127 |
+
return None
|
| 128 |
+
if isinstance(v, str):
|
| 129 |
+
s = v.strip()
|
| 130 |
+
if s == '':
|
| 131 |
+
return None
|
| 132 |
+
try:
|
| 133 |
+
if 'T' in s or 'Z' in s or '+' in s:
|
| 134 |
+
return datetime.fromisoformat(s.replace('Z', '+00:00')).date()
|
| 135 |
+
return date.fromisoformat(s)
|
| 136 |
+
except Exception:
|
| 137 |
+
return v
|
| 138 |
+
if isinstance(v, datetime):
|
| 139 |
+
return v.date()
|
| 140 |
+
return v
|
| 141 |
+
|
| 142 |
+
@validator('first_name')
|
| 143 |
+
def validate_first_name(cls, v):
|
| 144 |
+
if v is not None and (not v or not v.strip()):
|
| 145 |
+
raise ValueError('First name cannot be empty')
|
| 146 |
+
return v.strip() if v else v
|
| 147 |
+
|
| 148 |
+
@validator('last_name')
|
| 149 |
+
def validate_last_name(cls, v):
|
| 150 |
+
if v is not None and v.strip() == '':
|
| 151 |
+
return None
|
| 152 |
+
return v.strip() if v else v
|
| 153 |
+
|
| 154 |
+
@validator('phone_number')
|
| 155 |
+
def validate_phone_number(cls, v):
|
| 156 |
+
if v is not None:
|
| 157 |
+
# Remove spaces and special characters for validation
|
| 158 |
+
cleaned = ''.join(filter(str.isdigit, v))
|
| 159 |
+
if len(cleaned) < 10 or len(cleaned) > 15:
|
| 160 |
+
raise ValueError('Phone number must be between 10 and 15 digits')
|
| 161 |
+
return v
|
| 162 |
+
|
| 163 |
+
@validator('date_of_birth')
|
| 164 |
+
def validate_date_of_birth(cls, v):
|
| 165 |
+
if v is not None:
|
| 166 |
+
if v > date.today():
|
| 167 |
+
raise ValueError('Date of birth cannot be in the future')
|
| 168 |
+
# Check if age would be reasonable (not more than 120 years old)
|
| 169 |
+
age = (date.today() - v).days // 365
|
| 170 |
+
if age > 120:
|
| 171 |
+
raise ValueError('Date of birth indicates unrealistic age')
|
| 172 |
+
return v
|
| 173 |
+
|
| 174 |
+
class GuestResponse(BaseModel):
|
| 175 |
+
"""Schema for guest profile response"""
|
| 176 |
+
guest_id: str = Field(..., description="Unique guest identifier")
|
| 177 |
+
customer_id: str = Field(..., description="User ID who created this guest profile")
|
| 178 |
+
first_name: str = Field(..., description="Guest's first name")
|
| 179 |
+
last_name: Optional[str] = Field(None, description="Guest's last name")
|
| 180 |
+
email: Optional[str] = Field(None, description="Guest's email address")
|
| 181 |
+
phone_number: Optional[str] = Field(None, description="Guest's phone number")
|
| 182 |
+
gender: Optional[str] = Field(None, description="Guest's gender")
|
| 183 |
+
date_of_birth: Optional[date] = Field(None, description="Guest's date of birth")
|
| 184 |
+
relationship: Optional[str] = Field(None, description="Relationship to the user")
|
| 185 |
+
notes: Optional[str] = Field(None, description="Additional notes about the guest")
|
| 186 |
+
is_default: bool = Field(..., description="Is default guest")
|
| 187 |
+
created_at: datetime = Field(..., description="Guest profile creation timestamp")
|
| 188 |
+
updated_at: datetime = Field(..., description="Guest profile last update timestamp")
|
| 189 |
+
|
| 190 |
+
@property
|
| 191 |
+
def full_name(self) -> str:
|
| 192 |
+
"""Get the full name of the guest"""
|
| 193 |
+
if self.last_name:
|
| 194 |
+
return f"{self.first_name} {self.last_name}"
|
| 195 |
+
return self.first_name
|
| 196 |
+
|
| 197 |
+
@property
|
| 198 |
+
def age(self) -> Optional[int]:
|
| 199 |
+
"""Calculate age from date of birth"""
|
| 200 |
+
if self.date_of_birth:
|
| 201 |
+
today = datetime.now()
|
| 202 |
+
return today.year - self.date_of_birth.year - (
|
| 203 |
+
(today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day)
|
| 204 |
+
)
|
| 205 |
+
return None
|
| 206 |
+
|
| 207 |
+
class Config:
|
| 208 |
+
from_attributes = True
|
| 209 |
+
json_encoders = {
|
| 210 |
+
datetime: lambda v: v.isoformat()
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
class GuestListResponse(BaseModel):
|
| 214 |
+
"""Schema for list of guests response"""
|
| 215 |
+
guests: List[GuestResponse] = Field(..., description="List of user's guests")
|
| 216 |
+
total_count: int = Field(..., description="Total number of guests")
|
| 217 |
+
|
| 218 |
+
class Config:
|
| 219 |
+
from_attributes = True
|
| 220 |
+
|
| 221 |
+
class GuestDeleteResponse(BaseModel):
|
| 222 |
+
"""Schema for guest deletion response"""
|
| 223 |
+
message: str = Field(..., description="Deletion confirmation message")
|
| 224 |
+
guest_id: str = Field(..., description="ID of the deleted guest")
|
| 225 |
+
|
| 226 |
+
class Config:
|
| 227 |
+
from_attributes = True
|
| 228 |
+
|
| 229 |
+
class SetDefaultGuestRequest(BaseModel):
|
| 230 |
+
"""Request model for setting default guest"""
|
| 231 |
+
guest_id: str = Field(..., description="Guest ID to set as default")
|
app/schemas/pet_schema.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field, validator
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
from datetime import datetime,date
|
| 4 |
+
from enum import Enum
|
| 5 |
+
|
| 6 |
+
class SpeciesEnum(str, Enum):
|
| 7 |
+
DOG = "Dog"
|
| 8 |
+
CAT = "Cat"
|
| 9 |
+
OTHER = "Other"
|
| 10 |
+
|
| 11 |
+
class GenderEnum(str, Enum):
|
| 12 |
+
MALE = "Male"
|
| 13 |
+
FEMALE = "Female"
|
| 14 |
+
OTHER = "Other"
|
| 15 |
+
|
| 16 |
+
class TemperamentEnum(str, Enum):
|
| 17 |
+
CALM = "Calm"
|
| 18 |
+
NERVOUS = "Nervous"
|
| 19 |
+
AGGRESSIVE = "Aggressive"
|
| 20 |
+
SOCIAL = "Social"
|
| 21 |
+
|
| 22 |
+
class PetCreateRequest(BaseModel):
|
| 23 |
+
"""Schema for creating a new pet profile"""
|
| 24 |
+
pet_name: str = Field(..., min_length=1, max_length=100, description="Name of the pet")
|
| 25 |
+
species: SpeciesEnum = Field(..., description="Species of the pet")
|
| 26 |
+
breed: Optional[str] = Field(None, max_length=100, description="Breed of the pet")
|
| 27 |
+
date_of_birth: Optional[date] = Field(None, description="Pet's date of birth")
|
| 28 |
+
age: Optional[int] = Field(None, ge=0, le=50, description="Pet's age in years")
|
| 29 |
+
weight: Optional[float] = Field(None, ge=0, le=200, description="Pet's weight in kg")
|
| 30 |
+
gender: Optional[GenderEnum] = Field(None, description="Pet's gender")
|
| 31 |
+
temperament: Optional[TemperamentEnum] = Field(None, description="Pet's temperament")
|
| 32 |
+
health_notes: Optional[str] = Field(None, max_length=1000, description="Health notes, allergies, medications")
|
| 33 |
+
is_vaccinated: bool = Field(False, description="Vaccination status")
|
| 34 |
+
pet_photo_url: Optional[str] = Field(None, max_length=500, description="URL to pet's photo")
|
| 35 |
+
|
| 36 |
+
@validator('pet_name')
|
| 37 |
+
def validate_pet_name(cls, v):
|
| 38 |
+
if not v or not v.strip():
|
| 39 |
+
raise ValueError('Pet name cannot be empty')
|
| 40 |
+
return v.strip()
|
| 41 |
+
|
| 42 |
+
@validator('age', 'date_of_birth')
|
| 43 |
+
def validate_age_or_dob(cls, v, values):
|
| 44 |
+
# At least one of age or date_of_birth should be provided
|
| 45 |
+
if 'age' in values and 'date_of_birth' in values:
|
| 46 |
+
if not values.get('age') and not values.get('date_of_birth'):
|
| 47 |
+
raise ValueError('Either age or date of birth must be provided')
|
| 48 |
+
return v
|
| 49 |
+
|
| 50 |
+
class PetUpdateRequest(BaseModel):
|
| 51 |
+
"""Schema for updating a pet profile"""
|
| 52 |
+
pet_name: Optional[str] = Field(None, min_length=1, max_length=100, description="Name of the pet")
|
| 53 |
+
species: Optional[SpeciesEnum] = Field(None, description="Species of the pet")
|
| 54 |
+
breed: Optional[str] = Field(None, max_length=100, description="Breed of the pet")
|
| 55 |
+
date_of_birth: Optional[date] = Field(None, description="Pet's date of birth")
|
| 56 |
+
age: Optional[int] = Field(None, ge=0, le=50, description="Pet's age in years")
|
| 57 |
+
weight: Optional[float] = Field(None, ge=0, le=200, description="Pet's weight in kg")
|
| 58 |
+
gender: Optional[GenderEnum] = Field(None, description="Pet's gender")
|
| 59 |
+
temperament: Optional[TemperamentEnum] = Field(None, description="Pet's temperament")
|
| 60 |
+
health_notes: Optional[str] = Field(None, max_length=1000, description="Health notes, allergies, medications")
|
| 61 |
+
is_vaccinated: Optional[bool] = Field(None, description="Vaccination status")
|
| 62 |
+
pet_photo_url: Optional[str] = Field(None, max_length=500, description="URL to pet's photo")
|
| 63 |
+
is_default: Optional[bool] = Field(None, description="Mark as default pet")
|
| 64 |
+
|
| 65 |
+
@validator('pet_name')
|
| 66 |
+
def validate_pet_name(cls, v):
|
| 67 |
+
if v is not None and (not v or not v.strip()):
|
| 68 |
+
raise ValueError('Pet name cannot be empty')
|
| 69 |
+
return v.strip() if v else v
|
| 70 |
+
|
| 71 |
+
class PetResponse(BaseModel):
|
| 72 |
+
"""Schema for pet profile response"""
|
| 73 |
+
pet_id: str = Field(..., description="Unique pet identifier")
|
| 74 |
+
customer_id: str = Field(..., description="Owner's user ID")
|
| 75 |
+
pet_name: str = Field(..., description="Name of the pet")
|
| 76 |
+
species: str = Field(..., description="Species of the pet")
|
| 77 |
+
breed: Optional[str] = Field(None, description="Breed of the pet")
|
| 78 |
+
date_of_birth: Optional[datetime] = Field(None, description="Pet's date of birth")
|
| 79 |
+
age: Optional[int] = Field(None, description="Pet's age in years")
|
| 80 |
+
weight: Optional[float] = Field(None, description="Pet's weight in kg")
|
| 81 |
+
gender: Optional[str] = Field(None, description="Pet's gender")
|
| 82 |
+
temperament: Optional[str] = Field(None, description="Pet's temperament")
|
| 83 |
+
health_notes: Optional[str] = Field(None, description="Health notes, allergies, medications")
|
| 84 |
+
is_vaccinated: bool = Field(..., description="Vaccination status")
|
| 85 |
+
pet_photo_url: Optional[str] = Field(None, description="URL to pet's photo")
|
| 86 |
+
is_default: bool = Field(..., description="Is default pet")
|
| 87 |
+
created_at: datetime = Field(..., description="Pet profile creation timestamp")
|
| 88 |
+
updated_at: datetime = Field(..., description="Pet profile last update timestamp")
|
| 89 |
+
|
| 90 |
+
class Config:
|
| 91 |
+
from_attributes = True
|
| 92 |
+
json_encoders = {
|
| 93 |
+
datetime: lambda v: v.isoformat()
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
class PetListResponse(BaseModel):
|
| 97 |
+
"""Schema for list of pets response"""
|
| 98 |
+
pets: List[PetResponse] = Field(..., description="List of user's pets")
|
| 99 |
+
total_count: int = Field(..., description="Total number of pets")
|
| 100 |
+
|
| 101 |
+
class Config:
|
| 102 |
+
from_attributes = True
|
| 103 |
+
|
| 104 |
+
class PetDeleteResponse(BaseModel):
|
| 105 |
+
"""Schema for pet deletion response"""
|
| 106 |
+
message: str = Field(..., description="Deletion confirmation message")
|
| 107 |
+
pet_id: str = Field(..., description="ID of the deleted pet")
|
| 108 |
+
|
| 109 |
+
class Config:
|
| 110 |
+
from_attributes = True
|
| 111 |
+
|
| 112 |
+
class SetDefaultPetRequest(BaseModel):
|
| 113 |
+
"""Request model for setting default pet"""
|
| 114 |
+
pet_id: str = Field(..., description="Pet ID to set as default")
|
app/schemas/profile_schema.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr, Field, validator
|
| 2 |
+
from datetime import datetime, date
|
| 3 |
+
from typing import Optional, Dict, Any
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
class ProfileUpdateRequest(BaseModel):
|
| 7 |
+
"""Request model for updating user profile"""
|
| 8 |
+
name: Optional[str] = Field(None, min_length=2, max_length=100, description="User's full name")
|
| 9 |
+
email: Optional[EmailStr] = Field(None, description="User's email address")
|
| 10 |
+
phone: Optional[str] = Field(None, description="User's phone number")
|
| 11 |
+
date_of_birth: Optional[str] = Field(None, description="Date of birth in DD/MM/YYYY format")
|
| 12 |
+
profile_picture: Optional[str] = Field(None, description="Profile picture URL")
|
| 13 |
+
|
| 14 |
+
@validator('phone')
|
| 15 |
+
def validate_phone(cls, v):
|
| 16 |
+
if v is not None:
|
| 17 |
+
# Remove any non-digit characters
|
| 18 |
+
phone_digits = re.sub(r'\D', '', v)
|
| 19 |
+
if len(phone_digits) != 10:
|
| 20 |
+
raise ValueError('Phone number must be exactly 10 digits')
|
| 21 |
+
return phone_digits
|
| 22 |
+
return v
|
| 23 |
+
|
| 24 |
+
@validator('date_of_birth')
|
| 25 |
+
def validate_date_of_birth(cls, v):
|
| 26 |
+
if v is not None:
|
| 27 |
+
try:
|
| 28 |
+
# Parse DD/MM/YYYY format
|
| 29 |
+
day, month, year = map(int, v.split('/'))
|
| 30 |
+
birth_date = date(year, month, day)
|
| 31 |
+
|
| 32 |
+
# Check if date is not in the future
|
| 33 |
+
if birth_date > date.today():
|
| 34 |
+
raise ValueError('Date of birth cannot be in the future')
|
| 35 |
+
|
| 36 |
+
# Check if age is reasonable (not more than 120 years)
|
| 37 |
+
age = (date.today() - birth_date).days // 365
|
| 38 |
+
if age > 120:
|
| 39 |
+
raise ValueError('Invalid date of birth')
|
| 40 |
+
|
| 41 |
+
return v
|
| 42 |
+
except ValueError as e:
|
| 43 |
+
if "Invalid date of birth" in str(e) or "Date of birth cannot be in the future" in str(e):
|
| 44 |
+
raise e
|
| 45 |
+
raise ValueError('Date of birth must be in DD/MM/YYYY format')
|
| 46 |
+
return v
|
| 47 |
+
|
| 48 |
+
class ProfileResponse(BaseModel):
|
| 49 |
+
"""Response model for user profile"""
|
| 50 |
+
customer_id: str = Field(..., description="Unique user identifier")
|
| 51 |
+
name: str = Field(..., description="User's full name")
|
| 52 |
+
email: Optional[str] = Field(None, description="User's email address")
|
| 53 |
+
phone: Optional[str] = Field(None, description="User's phone number")
|
| 54 |
+
date_of_birth: Optional[str] = Field(None, description="Date of birth in DD/MM/YYYY format")
|
| 55 |
+
profile_picture: Optional[str] = Field(None, description="Profile picture URL")
|
| 56 |
+
auth_method: str = Field(..., description="Authentication method used")
|
| 57 |
+
created_at: datetime = Field(..., description="Account creation timestamp")
|
| 58 |
+
updated_at: Optional[datetime] = Field(None, description="Last profile update timestamp")
|
| 59 |
+
|
| 60 |
+
class Config:
|
| 61 |
+
from_attributes = True
|
| 62 |
+
|
| 63 |
+
class PersonalDetailsResponse(BaseModel):
|
| 64 |
+
"""Response model for personal details section"""
|
| 65 |
+
first_name: str = Field(..., description="User's first name")
|
| 66 |
+
last_name: str = Field(..., description="User's last name")
|
| 67 |
+
email: str = Field(..., description="User's email address")
|
| 68 |
+
phone: str = Field(..., description="User's phone number")
|
| 69 |
+
date_of_birth: Optional[str] = Field(None, description="Date of birth in DD/MM/YYYY format")
|
| 70 |
+
|
| 71 |
+
class Config:
|
| 72 |
+
from_attributes = True
|
| 73 |
+
|
| 74 |
+
class ProfileOperationResponse(BaseModel):
|
| 75 |
+
"""Response model for profile operations"""
|
| 76 |
+
success: bool = Field(..., description="Operation success status")
|
| 77 |
+
message: str = Field(..., description="Response message")
|
| 78 |
+
profile: Optional[ProfileResponse] = Field(None, description="Updated profile data")
|
| 79 |
+
|
| 80 |
+
class WalletDisplayResponse(BaseModel):
|
| 81 |
+
"""Response model for wallet display in profile"""
|
| 82 |
+
balance: float = Field(..., description="Current wallet balance")
|
| 83 |
+
formatted_balance: str = Field(..., description="Formatted balance with currency symbol")
|
| 84 |
+
currency: str = Field(default="INR", description="Currency code")
|
| 85 |
+
|
| 86 |
+
class ProfileDashboardResponse(BaseModel):
|
| 87 |
+
"""Complete response model for profile dashboard"""
|
| 88 |
+
personal_details: PersonalDetailsResponse = Field(..., description="Personal details")
|
| 89 |
+
wallet: WalletDisplayResponse = Field(..., description="Wallet information")
|
| 90 |
+
address_count: int = Field(..., description="Number of saved addresses")
|
| 91 |
+
has_default_address: bool = Field(..., description="Whether user has a default address set")
|
app/schemas/review_schema.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
class ReviewCreateRequest(BaseModel):
|
| 6 |
+
"""Request schema for creating a review"""
|
| 7 |
+
merchant_id: str = Field(..., description="Unique ID of the merchant")
|
| 8 |
+
location_id: str = Field(..., description="ID of the merchant location")
|
| 9 |
+
user_name: str = Field(..., description="Name of the user submitting the review")
|
| 10 |
+
rating: float = Field(..., description="Rating given by the user")
|
| 11 |
+
review_text: str = Field(..., description="review text provided by the user")
|
| 12 |
+
verified_purchase: bool = Field(..., description="Indicates if the review is from a verified purchase")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ReviewResponse(BaseModel):
|
| 16 |
+
"""Response schema for creating a review"""
|
| 17 |
+
merchant_id: str = Field(..., description="Unique ID of the merchant")
|
| 18 |
+
location_id: str = Field(..., description="ID of the merchant location")
|
| 19 |
+
user_name: str = Field(..., description="Name of the user submitting the review")
|
| 20 |
+
rating: float = Field(..., description="Rating given by the user")
|
| 21 |
+
review_text: str = Field(..., description="review text provided by the user")
|
| 22 |
+
review_date: datetime = Field(..., description="Date and time when the review was created")
|
| 23 |
+
verified_purchase: bool = Field(..., description="Indicates if the review is from a verified purchase")
|
| 24 |
+
|
app/schemas/user_schema.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr, validator
|
| 2 |
+
from typing import Optional, Literal, List, Dict, Any
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
# Used for OTP-based or OAuth-based user registration
|
| 7 |
+
class UserRegisterRequest(BaseModel):
|
| 8 |
+
name: str
|
| 9 |
+
email: EmailStr # Mandatory for all registration modes
|
| 10 |
+
phone: str # Mandatory for all registration modes (always used as OTP identifier)
|
| 11 |
+
otp: Optional[str] = None # Required for OTP mode
|
| 12 |
+
dob: Optional[str] = None # ISO format date string
|
| 13 |
+
oauth_token: Optional[str] = None # Required for OAuth mode
|
| 14 |
+
provider: Optional[Literal["google", "apple", "facebook"]] = None # Required for OAuth mode
|
| 15 |
+
mode: Literal["otp", "oauth"]
|
| 16 |
+
remember_me: Optional[bool] = False
|
| 17 |
+
device_info: Optional[str] = None
|
| 18 |
+
|
| 19 |
+
@validator('phone')
|
| 20 |
+
def validate_phone(cls, v):
|
| 21 |
+
if v is not None:
|
| 22 |
+
# Remove all non-digit characters except +
|
| 23 |
+
cleaned = re.sub(r'[^\d+]', '', v)
|
| 24 |
+
# Check if it's a valid phone number (8-15 digits, optionally starting with +)
|
| 25 |
+
if not re.match(r'^\+?[1-9]\d{7,14}$', cleaned):
|
| 26 |
+
raise ValueError('Invalid phone number format')
|
| 27 |
+
return v
|
| 28 |
+
|
| 29 |
+
@validator('mode')
|
| 30 |
+
def validate_mode_dependent_fields(cls, v, values):
|
| 31 |
+
if v == "otp":
|
| 32 |
+
if not values.get('otp'):
|
| 33 |
+
raise ValueError('OTP is required for OTP registration mode')
|
| 34 |
+
elif v == "oauth":
|
| 35 |
+
if not values.get('oauth_token'):
|
| 36 |
+
raise ValueError('OAuth token is required for OAuth registration mode')
|
| 37 |
+
if not values.get('provider'):
|
| 38 |
+
raise ValueError('Provider is required for OAuth registration mode')
|
| 39 |
+
return v
|
| 40 |
+
|
| 41 |
+
# Used in login form (optional display name prefilled from local storage)
|
| 42 |
+
class UserLoginRequest(BaseModel):
|
| 43 |
+
name: Optional[str] = None
|
| 44 |
+
login_input: str # Changed from email to support both email and phone
|
| 45 |
+
|
| 46 |
+
@validator('login_input')
|
| 47 |
+
def validate_login_input(cls, v):
|
| 48 |
+
# Check if it's either email or phone
|
| 49 |
+
email_pattern = r"[^@]+@[^@]+\.[^@]+"
|
| 50 |
+
phone_cleaned = re.sub(r'[^\d+]', '', v)
|
| 51 |
+
phone_pattern = r'^\+?[1-9]\d{7,14}$'
|
| 52 |
+
|
| 53 |
+
if not (re.match(email_pattern, v) or re.match(phone_pattern, phone_cleaned)):
|
| 54 |
+
raise ValueError('Login input must be a valid email address or phone number')
|
| 55 |
+
return v
|
| 56 |
+
|
| 57 |
+
# OTP request via email or phone - updated to ensure only one is provided
|
| 58 |
+
class OTPRequest(BaseModel):
|
| 59 |
+
email: Optional[EmailStr] = None
|
| 60 |
+
phone: Optional[str] = None
|
| 61 |
+
|
| 62 |
+
@validator('phone')
|
| 63 |
+
def validate_phone(cls, v):
|
| 64 |
+
if v is not None:
|
| 65 |
+
# Remove all non-digit characters except +
|
| 66 |
+
cleaned = re.sub(r'[^\d+]', '', v)
|
| 67 |
+
# Check if it's a valid phone number (8-15 digits, optionally starting with +)
|
| 68 |
+
if not re.match(r'^\+?[1-9]\d{7,14}$', cleaned):
|
| 69 |
+
raise ValueError('Invalid phone number format')
|
| 70 |
+
return v
|
| 71 |
+
|
| 72 |
+
@validator('phone')
|
| 73 |
+
def validate_only_one_identifier(cls, v, values):
|
| 74 |
+
if v is not None and values.get('email') is not None:
|
| 75 |
+
raise ValueError('Provide either email or phone, not both')
|
| 76 |
+
if v is None and values.get('email') is None:
|
| 77 |
+
raise ValueError('Either email or phone must be provided')
|
| 78 |
+
return v
|
| 79 |
+
|
| 80 |
+
# Generic OTP request using single login input
|
| 81 |
+
class OTPRequestWithLogin(BaseModel):
|
| 82 |
+
login_input: str # email or phone
|
| 83 |
+
|
| 84 |
+
@validator('login_input')
|
| 85 |
+
def validate_login_input(cls, v):
|
| 86 |
+
# Check if it's either email or phone
|
| 87 |
+
email_pattern = r"[^@]+@[^@]+\.[^@]+"
|
| 88 |
+
phone_cleaned = re.sub(r'[^\d+]', '', v)
|
| 89 |
+
phone_pattern = r'^\+?[1-9]\d{7,14}$'
|
| 90 |
+
|
| 91 |
+
if not (re.match(email_pattern, v) or re.match(phone_pattern, phone_cleaned)):
|
| 92 |
+
raise ValueError('Login input must be a valid email address or phone number')
|
| 93 |
+
return v
|
| 94 |
+
|
| 95 |
+
# OTP verification input
|
| 96 |
+
class OTPVerifyRequest(BaseModel):
|
| 97 |
+
login_input: str
|
| 98 |
+
otp: str
|
| 99 |
+
remember_me: Optional[bool] = False
|
| 100 |
+
device_info: Optional[str] = None
|
| 101 |
+
|
| 102 |
+
@validator('login_input')
|
| 103 |
+
def validate_login_input(cls, v):
|
| 104 |
+
# Check if it's either email or phone
|
| 105 |
+
email_pattern = r"[^@]+@[^@]+\.[^@]+"
|
| 106 |
+
phone_cleaned = re.sub(r'[^\d+]', '', v)
|
| 107 |
+
phone_pattern = r'^\+?[1-9]\d{7,14}$'
|
| 108 |
+
|
| 109 |
+
if not (re.match(email_pattern, v) or re.match(phone_pattern, phone_cleaned)):
|
| 110 |
+
raise ValueError('Login input must be a valid email address or phone number')
|
| 111 |
+
return v
|
| 112 |
+
|
| 113 |
+
# OTP send response with user existence flag
|
| 114 |
+
class OTPSendResponse(BaseModel):
|
| 115 |
+
message: str
|
| 116 |
+
temp_token: str
|
| 117 |
+
user_exists: bool = False
|
| 118 |
+
|
| 119 |
+
# OAuth login using Google/Apple
|
| 120 |
+
class OAuthLoginRequest(BaseModel):
|
| 121 |
+
provider: Literal["google", "apple", "facebook"]
|
| 122 |
+
token: str
|
| 123 |
+
remember_me: Optional[bool] = False
|
| 124 |
+
device_info: Optional[str] = None
|
| 125 |
+
|
| 126 |
+
# JWT Token response format with enhanced security info
|
| 127 |
+
class TokenResponse(BaseModel):
|
| 128 |
+
access_token: str
|
| 129 |
+
token_type: str = "bearer"
|
| 130 |
+
expires_in: Optional[int] = None
|
| 131 |
+
refresh_token: Optional[str] = None
|
| 132 |
+
customer_id: Optional[str] = None
|
| 133 |
+
name: Optional[str] = None
|
| 134 |
+
email: Optional[str] = None
|
| 135 |
+
profile_picture: Optional[str] = None
|
| 136 |
+
auth_method: Optional[str] = None # "otp" or "oauth"
|
| 137 |
+
provider: Optional[str] = None # For OAuth logins
|
| 138 |
+
user_exists: Optional[bool] = None # Indicates if user already exists for OAuth
|
| 139 |
+
security_info: Optional[Dict[str, Any]] = None
|
| 140 |
+
|
| 141 |
+
# Enhanced user profile response with social accounts
|
| 142 |
+
class UserProfileResponse(BaseModel):
|
| 143 |
+
customer_id: str
|
| 144 |
+
name: str
|
| 145 |
+
email: Optional[EmailStr] = None
|
| 146 |
+
phone: Optional[str] = None
|
| 147 |
+
profile_picture: Optional[str] = None
|
| 148 |
+
auth_method: str
|
| 149 |
+
created_at: datetime
|
| 150 |
+
social_accounts: Optional[List[Dict[str, Any]]] = None
|
| 151 |
+
security_info: Optional[Dict[str, Any]] = None
|
| 152 |
+
|
| 153 |
+
# Social account information
|
| 154 |
+
class SocialAccountInfo(BaseModel):
|
| 155 |
+
provider: str
|
| 156 |
+
email: Optional[str] = None
|
| 157 |
+
name: Optional[str] = None
|
| 158 |
+
linked_at: datetime
|
| 159 |
+
last_login: Optional[datetime] = None
|
| 160 |
+
|
| 161 |
+
# Social account summary response
|
| 162 |
+
class SocialAccountSummary(BaseModel):
|
| 163 |
+
linked_accounts: List[SocialAccountInfo]
|
| 164 |
+
total_accounts: int
|
| 165 |
+
profile_picture: Optional[str] = None
|
| 166 |
+
|
| 167 |
+
# Account linking request
|
| 168 |
+
class LinkSocialAccountRequest(BaseModel):
|
| 169 |
+
provider: Literal["google", "apple", "facebook"]
|
| 170 |
+
token: str
|
| 171 |
+
|
| 172 |
+
# Account unlinking request
|
| 173 |
+
class UnlinkSocialAccountRequest(BaseModel):
|
| 174 |
+
provider: Literal["google", "apple", "facebook"]
|
| 175 |
+
|
| 176 |
+
# Login history entry
|
| 177 |
+
class LoginHistoryEntry(BaseModel):
|
| 178 |
+
timestamp: datetime
|
| 179 |
+
method: str # "otp" or "oauth"
|
| 180 |
+
provider: Optional[str] = None
|
| 181 |
+
ip_address: Optional[str] = None
|
| 182 |
+
success: bool
|
| 183 |
+
device_info: Optional[str] = None
|
| 184 |
+
|
| 185 |
+
# Login history response
|
| 186 |
+
class LoginHistoryResponse(BaseModel):
|
| 187 |
+
entries: List[LoginHistoryEntry]
|
| 188 |
+
total_entries: int
|
| 189 |
+
page: int
|
| 190 |
+
per_page: int
|
| 191 |
+
|
| 192 |
+
# Security settings response
|
| 193 |
+
class SecuritySettingsResponse(BaseModel):
|
| 194 |
+
two_factor_enabled: bool = False
|
| 195 |
+
linked_social_accounts: int
|
| 196 |
+
last_password_change: Optional[datetime] = None
|
| 197 |
+
recent_login_attempts: int
|
| 198 |
+
account_locked: bool = False
|
app/schemas/wallet_schema.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import List, Optional, Literal
|
| 4 |
+
from decimal import Decimal
|
| 5 |
+
|
| 6 |
+
class WalletBalanceResponse(BaseModel):
|
| 7 |
+
"""Response model for wallet balance"""
|
| 8 |
+
balance: float = Field(..., description="Current wallet balance")
|
| 9 |
+
currency: str = Field(default="INR", description="Currency code")
|
| 10 |
+
formatted_balance: str = Field(..., description="Formatted balance with currency symbol")
|
| 11 |
+
|
| 12 |
+
class TransactionEntry(BaseModel):
|
| 13 |
+
"""Model for individual transaction entry"""
|
| 14 |
+
transaction_id: str = Field(..., description="Unique transaction ID")
|
| 15 |
+
amount: float = Field(..., description="Transaction amount")
|
| 16 |
+
transaction_type: Literal["credit", "debit", "refund", "cashback", "payment", "withdrawal"] = Field(..., description="Type of transaction")
|
| 17 |
+
description: str = Field(..., description="Transaction description")
|
| 18 |
+
reference_id: Optional[str] = Field(None, description="Reference ID for the transaction")
|
| 19 |
+
balance_before: float = Field(..., description="Balance before transaction")
|
| 20 |
+
balance_after: float = Field(..., description="Balance after transaction")
|
| 21 |
+
timestamp: datetime = Field(..., description="Transaction timestamp")
|
| 22 |
+
status: Literal["completed", "pending", "failed"] = Field(default="completed", description="Transaction status")
|
| 23 |
+
|
| 24 |
+
class TransactionHistoryResponse(BaseModel):
|
| 25 |
+
"""Response model for transaction history"""
|
| 26 |
+
transactions: List[TransactionEntry] = Field(..., description="List of transactions")
|
| 27 |
+
total_count: int = Field(..., description="Total number of transactions")
|
| 28 |
+
page: int = Field(..., description="Current page number")
|
| 29 |
+
per_page: int = Field(..., description="Number of items per page")
|
| 30 |
+
total_pages: int = Field(..., description="Total number of pages")
|
| 31 |
+
|
| 32 |
+
class WalletSummaryResponse(BaseModel):
|
| 33 |
+
"""Response model for wallet summary"""
|
| 34 |
+
balance: float = Field(..., description="Current wallet balance")
|
| 35 |
+
formatted_balance: str = Field(..., description="Formatted balance with currency symbol")
|
| 36 |
+
recent_transactions: List[TransactionEntry] = Field(..., description="Recent transactions")
|
| 37 |
+
|
| 38 |
+
class AddMoneyRequest(BaseModel):
|
| 39 |
+
"""Request model for adding money to wallet"""
|
| 40 |
+
amount: float = Field(..., gt=0, description="Amount to add (must be positive)")
|
| 41 |
+
payment_method: Literal["card", "upi", "netbanking"] = Field(..., description="Payment method")
|
| 42 |
+
reference_id: Optional[str] = Field(None, description="Payment reference ID")
|
| 43 |
+
description: Optional[str] = Field("Wallet top-up", description="Transaction description")
|
| 44 |
+
|
| 45 |
+
class WithdrawMoneyRequest(BaseModel):
|
| 46 |
+
"""Request model for withdrawing money from wallet"""
|
| 47 |
+
amount: float = Field(..., gt=0, description="Amount to withdraw (must be positive)")
|
| 48 |
+
bank_account_id: str = Field(..., description="Bank account ID for withdrawal")
|
| 49 |
+
description: Optional[str] = Field("Wallet withdrawal", description="Transaction description")
|
| 50 |
+
|
| 51 |
+
class TransactionRequest(BaseModel):
|
| 52 |
+
"""Generic transaction request model"""
|
| 53 |
+
amount: float = Field(..., gt=0, description="Transaction amount")
|
| 54 |
+
transaction_type: Literal["credit", "debit", "refund", "cashback", "payment"] = Field(..., description="Transaction type")
|
| 55 |
+
description: str = Field(..., description="Transaction description")
|
| 56 |
+
reference_id: Optional[str] = Field(None, description="Reference ID")
|
| 57 |
+
category: Optional[str] = Field(None, description="Transaction category")
|
| 58 |
+
|
| 59 |
+
class TransactionResponse(BaseModel):
|
| 60 |
+
"""Response model for transaction operations"""
|
| 61 |
+
success: bool = Field(..., description="Transaction success status")
|
| 62 |
+
message: str = Field(..., description="Response message")
|
| 63 |
+
transaction: Optional[TransactionEntry] = Field(None, description="Transaction details")
|
| 64 |
+
new_balance: Optional[float] = Field(None, description="New wallet balance after transaction")
|
| 65 |
+
|
| 66 |
+
class WalletTransactionResponse(BaseModel):
|
| 67 |
+
"""Response model for wallet transaction operations"""
|
| 68 |
+
success: bool = Field(..., description="Transaction success status")
|
| 69 |
+
message: str = Field(..., description="Response message")
|
| 70 |
+
transaction_id: Optional[str] = Field(None, description="Transaction ID if successful")
|
| 71 |
+
new_balance: Optional[float] = Field(None, description="New wallet balance after transaction")
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/account_service.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta
|
| 2 |
+
from typing import List, Dict, Any, Optional
|
| 3 |
+
import logging
|
| 4 |
+
from bson import ObjectId
|
| 5 |
+
|
| 6 |
+
from app.models.social_account_model import SocialAccountModel
|
| 7 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 8 |
+
from app.schemas.user_schema import (
|
| 9 |
+
SocialAccountSummary, SocialAccountInfo, LoginHistoryResponse,
|
| 10 |
+
LoginHistoryEntry, SecuritySettingsResponse
|
| 11 |
+
)
|
| 12 |
+
from app.utils.social_utils import verify_google_token, verify_apple_token, verify_facebook_token
|
| 13 |
+
from app.core.nosql_client import db
|
| 14 |
+
|
| 15 |
+
# Configure logging
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
class AccountService:
|
| 19 |
+
"""Service for managing user accounts, social accounts, and security settings"""
|
| 20 |
+
|
| 21 |
+
def __init__(self):
|
| 22 |
+
self.security_collection = db.get_collection("security_logs")
|
| 23 |
+
self.device_collection = db.get_collection("device_tracking")
|
| 24 |
+
self.session_collection = db.get_collection("user_sessions")
|
| 25 |
+
|
| 26 |
+
async def get_social_account_summary(self, customer_id: str) -> SocialAccountSummary:
|
| 27 |
+
"""Get summary of all linked social accounts for a user"""
|
| 28 |
+
try:
|
| 29 |
+
social_accounts = await SocialAccountModel.find_by_customer_id(customer_id)
|
| 30 |
+
|
| 31 |
+
linked_accounts = []
|
| 32 |
+
profile_picture = None
|
| 33 |
+
|
| 34 |
+
for account in social_accounts:
|
| 35 |
+
account_info = SocialAccountInfo(
|
| 36 |
+
provider=account["provider"],
|
| 37 |
+
email=account.get("email"),
|
| 38 |
+
name=account.get("name"),
|
| 39 |
+
linked_at=account["created_at"],
|
| 40 |
+
last_login=account.get("last_login")
|
| 41 |
+
)
|
| 42 |
+
linked_accounts.append(account_info)
|
| 43 |
+
|
| 44 |
+
# Use the first available profile picture
|
| 45 |
+
if not profile_picture and account.get("profile_picture"):
|
| 46 |
+
profile_picture = account["profile_picture"]
|
| 47 |
+
|
| 48 |
+
return SocialAccountSummary(
|
| 49 |
+
linked_accounts=linked_accounts,
|
| 50 |
+
total_accounts=len(linked_accounts),
|
| 51 |
+
profile_picture=profile_picture
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
except Exception as e:
|
| 55 |
+
logger.error(f"Error getting social account summary for user {customer_id}: {str(e)}")
|
| 56 |
+
raise
|
| 57 |
+
|
| 58 |
+
async def link_social_account(self, customer_id: str, provider: str, token: str, client_ip: str) -> Dict[str, Any]:
|
| 59 |
+
"""Link a new social account to an existing user"""
|
| 60 |
+
try:
|
| 61 |
+
# Verify the token and get user info
|
| 62 |
+
user_info = await self._verify_social_token(provider, token)
|
| 63 |
+
|
| 64 |
+
# Check if this social account is already linked to another user
|
| 65 |
+
existing_account = await SocialAccountModel.find_by_provider_id(
|
| 66 |
+
provider, user_info["id"]
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
if existing_account and existing_account["customer_id"] != customer_id:
|
| 70 |
+
raise ValueError(f"This {provider} account is already linked to another user")
|
| 71 |
+
|
| 72 |
+
# Check if user already has this provider linked
|
| 73 |
+
user_provider_account = await SocialAccountModel.find_by_user_and_provider(
|
| 74 |
+
customer_id, provider
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
if user_provider_account:
|
| 78 |
+
# Update existing account
|
| 79 |
+
await SocialAccountModel.update_social_account(
|
| 80 |
+
customer_id, provider, user_info, client_ip
|
| 81 |
+
)
|
| 82 |
+
action = "updated"
|
| 83 |
+
else:
|
| 84 |
+
# Create new social account link
|
| 85 |
+
await SocialAccountModel.create_social_account(
|
| 86 |
+
customer_id, provider, user_info, client_ip
|
| 87 |
+
)
|
| 88 |
+
action = "linked"
|
| 89 |
+
|
| 90 |
+
# Log the action
|
| 91 |
+
await self._log_account_action(
|
| 92 |
+
customer_id, f"social_account_{action}",
|
| 93 |
+
{"provider": provider, "client_ip": client_ip}
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
return {"action": action, "provider": provider, "user_info": user_info}
|
| 97 |
+
|
| 98 |
+
except Exception as e:
|
| 99 |
+
logger.error(f"Error linking social account for user {customer_id}: {str(e)}")
|
| 100 |
+
raise
|
| 101 |
+
|
| 102 |
+
async def unlink_social_account(self, customer_id: str, provider: str) -> Dict[str, Any]:
|
| 103 |
+
"""Unlink a social account from a user"""
|
| 104 |
+
try:
|
| 105 |
+
# Check if account exists
|
| 106 |
+
account = await SocialAccountModel.find_by_user_and_provider(customer_id, provider)
|
| 107 |
+
if not account:
|
| 108 |
+
raise ValueError(f"No {provider} account found for this user")
|
| 109 |
+
|
| 110 |
+
# Check if this is the only authentication method
|
| 111 |
+
user = await BookMyServiceUserModel.find_by_id(customer_id)
|
| 112 |
+
if not user:
|
| 113 |
+
raise ValueError("User not found")
|
| 114 |
+
|
| 115 |
+
# Count total social accounts
|
| 116 |
+
social_accounts = await SocialAccountModel.find_by_customer_id(customer_id)
|
| 117 |
+
|
| 118 |
+
# If user has no phone/email and this is their only social account, prevent unlinking
|
| 119 |
+
if (len(social_accounts) == 1 and
|
| 120 |
+
not user.get("phone") and not user.get("email")):
|
| 121 |
+
raise ValueError("Cannot unlink the only authentication method")
|
| 122 |
+
|
| 123 |
+
# Unlink the account
|
| 124 |
+
result = await SocialAccountModel.unlink_social_account(customer_id, provider)
|
| 125 |
+
|
| 126 |
+
# Log the action
|
| 127 |
+
await self._log_account_action(
|
| 128 |
+
customer_id, "social_account_unlinked",
|
| 129 |
+
{"provider": provider}
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
return {"action": "unlinked", "provider": provider, "result": result}
|
| 133 |
+
|
| 134 |
+
except Exception as e:
|
| 135 |
+
logger.error(f"Error unlinking social account for user {customer_id}: {str(e)}")
|
| 136 |
+
raise
|
| 137 |
+
|
| 138 |
+
async def get_login_history(self, customer_id: str, page: int = 1,
|
| 139 |
+
per_page: int = 10, days: int = 30) -> LoginHistoryResponse:
|
| 140 |
+
"""Get login history for a user"""
|
| 141 |
+
try:
|
| 142 |
+
# Calculate date range
|
| 143 |
+
end_date = datetime.utcnow()
|
| 144 |
+
start_date = end_date - timedelta(days=days)
|
| 145 |
+
|
| 146 |
+
# Query security logs for login events
|
| 147 |
+
skip = (page - 1) * per_page
|
| 148 |
+
|
| 149 |
+
pipeline = [
|
| 150 |
+
{
|
| 151 |
+
"$match": {
|
| 152 |
+
"customer_id": customer_id,
|
| 153 |
+
"timestamp": {"$gte": start_date, "$lte": end_date},
|
| 154 |
+
"$or": [
|
| 155 |
+
{"path": {"$regex": "/login"}},
|
| 156 |
+
{"path": {"$regex": "/oauth"}},
|
| 157 |
+
{"path": {"$regex": "/otp"}}
|
| 158 |
+
]
|
| 159 |
+
}
|
| 160 |
+
},
|
| 161 |
+
{"$sort": {"timestamp": -1}},
|
| 162 |
+
{"$skip": skip},
|
| 163 |
+
{"$limit": per_page}
|
| 164 |
+
]
|
| 165 |
+
|
| 166 |
+
cursor = self.security_collection.aggregate(pipeline)
|
| 167 |
+
logs = await cursor.to_list(length=per_page)
|
| 168 |
+
|
| 169 |
+
# Count total entries
|
| 170 |
+
total_count = await self.security_collection.count_documents({
|
| 171 |
+
"customer_id": customer_id,
|
| 172 |
+
"timestamp": {"$gte": start_date, "$lte": end_date},
|
| 173 |
+
"$or": [
|
| 174 |
+
{"path": {"$regex": "/login"}},
|
| 175 |
+
{"path": {"$regex": "/oauth"}},
|
| 176 |
+
{"path": {"$regex": "/otp"}}
|
| 177 |
+
]
|
| 178 |
+
})
|
| 179 |
+
|
| 180 |
+
# Convert to response format
|
| 181 |
+
entries = []
|
| 182 |
+
for log in logs:
|
| 183 |
+
method = "oauth" if "oauth" in log["path"] else "otp"
|
| 184 |
+
provider = None
|
| 185 |
+
|
| 186 |
+
# Extract provider from query params if available
|
| 187 |
+
if method == "oauth" and log.get("query_params"):
|
| 188 |
+
provider = log["query_params"].get("provider")
|
| 189 |
+
|
| 190 |
+
entry = LoginHistoryEntry(
|
| 191 |
+
timestamp=log["timestamp"],
|
| 192 |
+
method=method,
|
| 193 |
+
provider=provider,
|
| 194 |
+
ip_address=log.get("client_ip"),
|
| 195 |
+
success=log["status_code"] < 400,
|
| 196 |
+
device_info=log.get("device_info", {}).get("user_agent")
|
| 197 |
+
)
|
| 198 |
+
entries.append(entry)
|
| 199 |
+
|
| 200 |
+
return LoginHistoryResponse(
|
| 201 |
+
entries=entries,
|
| 202 |
+
total_entries=total_count,
|
| 203 |
+
page=page,
|
| 204 |
+
per_page=per_page
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
except Exception as e:
|
| 208 |
+
logger.error(f"Error getting login history for user {customer_id}: {str(e)}")
|
| 209 |
+
raise
|
| 210 |
+
|
| 211 |
+
async def get_security_settings(self, customer_id: str) -> SecuritySettingsResponse:
|
| 212 |
+
"""Get security settings and status for a user"""
|
| 213 |
+
try:
|
| 214 |
+
# Get user info
|
| 215 |
+
user = await BookMyServiceUserModel.find_by_id(customer_id)
|
| 216 |
+
if not user:
|
| 217 |
+
raise ValueError("User not found")
|
| 218 |
+
|
| 219 |
+
# Count linked social accounts
|
| 220 |
+
social_accounts = await SocialAccountModel.find_by_customer_id(customer_id)
|
| 221 |
+
linked_accounts_count = len(social_accounts)
|
| 222 |
+
|
| 223 |
+
# Get recent login attempts (last 24 hours)
|
| 224 |
+
yesterday = datetime.utcnow() - timedelta(days=1)
|
| 225 |
+
recent_attempts = await self.security_collection.count_documents({
|
| 226 |
+
"customer_id": customer_id,
|
| 227 |
+
"timestamp": {"$gte": yesterday},
|
| 228 |
+
"$or": [
|
| 229 |
+
{"path": {"$regex": "/login"}},
|
| 230 |
+
{"path": {"$regex": "/oauth"}},
|
| 231 |
+
{"path": {"$regex": "/otp"}}
|
| 232 |
+
]
|
| 233 |
+
})
|
| 234 |
+
|
| 235 |
+
# Check if account is locked (this would be implemented based on your locking logic)
|
| 236 |
+
account_locked = False # Implement based on your account locking mechanism
|
| 237 |
+
|
| 238 |
+
return SecuritySettingsResponse(
|
| 239 |
+
two_factor_enabled=False, # Implement 2FA if needed
|
| 240 |
+
linked_social_accounts=linked_accounts_count,
|
| 241 |
+
last_password_change=None, # Implement if you have password functionality
|
| 242 |
+
recent_login_attempts=recent_attempts,
|
| 243 |
+
account_locked=account_locked
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
except Exception as e:
|
| 247 |
+
logger.error(f"Error getting security settings for user {customer_id}: {str(e)}")
|
| 248 |
+
raise
|
| 249 |
+
|
| 250 |
+
async def merge_social_accounts(self, primary_customer_id: str, secondary_customer_id: str,
|
| 251 |
+
client_ip: str) -> Dict[str, Any]:
|
| 252 |
+
"""Merge social accounts from secondary user to primary user"""
|
| 253 |
+
try:
|
| 254 |
+
# Get social accounts from secondary user
|
| 255 |
+
secondary_accounts = await SocialAccountModel.find_by_customer_id(secondary_customer_id)
|
| 256 |
+
|
| 257 |
+
merged_count = 0
|
| 258 |
+
for account in secondary_accounts:
|
| 259 |
+
# Check if primary user already has this provider
|
| 260 |
+
existing = await SocialAccountModel.find_by_user_and_provider(
|
| 261 |
+
primary_customer_id, account["provider"]
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
if not existing:
|
| 265 |
+
# Transfer the account to primary user
|
| 266 |
+
await SocialAccountModel.update_customer_id(
|
| 267 |
+
account["_id"], primary_customer_id
|
| 268 |
+
)
|
| 269 |
+
merged_count += 1
|
| 270 |
+
|
| 271 |
+
# Log the merge action
|
| 272 |
+
await self._log_account_action(
|
| 273 |
+
primary_customer_id, "accounts_merged",
|
| 274 |
+
{
|
| 275 |
+
"secondary_customer_id": secondary_customer_id,
|
| 276 |
+
"merged_accounts": merged_count,
|
| 277 |
+
"client_ip": client_ip
|
| 278 |
+
}
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
return {
|
| 282 |
+
"merged_accounts": merged_count,
|
| 283 |
+
"primary_customer_id": primary_customer_id,
|
| 284 |
+
"secondary_customer_id": secondary_customer_id
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
except Exception as e:
|
| 288 |
+
logger.error(f"Error merging accounts {secondary_customer_id} -> {primary_customer_id}: {str(e)}")
|
| 289 |
+
raise
|
| 290 |
+
|
| 291 |
+
async def revoke_all_sessions(self, customer_id: str, client_ip: str) -> Dict[str, Any]:
|
| 292 |
+
"""Revoke all active sessions for a user"""
|
| 293 |
+
try:
|
| 294 |
+
# In a real implementation, you'd have a sessions collection
|
| 295 |
+
# For now, we'll just log the action
|
| 296 |
+
await self._log_account_action(
|
| 297 |
+
customer_id, "all_sessions_revoked",
|
| 298 |
+
{"client_ip": client_ip}
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
# Here you would typically:
|
| 302 |
+
# 1. Delete all session tokens from database
|
| 303 |
+
# 2. Add tokens to a blacklist
|
| 304 |
+
# 3. Force re-authentication on next request
|
| 305 |
+
|
| 306 |
+
return {"action": "revoked", "customer_id": customer_id}
|
| 307 |
+
|
| 308 |
+
except Exception as e:
|
| 309 |
+
logger.error(f"Error revoking sessions for user {customer_id}: {str(e)}")
|
| 310 |
+
raise
|
| 311 |
+
|
| 312 |
+
async def get_trusted_devices(self, customer_id: str) -> List[Dict[str, Any]]:
|
| 313 |
+
"""Get list of trusted devices for a user"""
|
| 314 |
+
try:
|
| 315 |
+
cursor = self.device_collection.find({
|
| 316 |
+
"customer_id": customer_id,
|
| 317 |
+
"is_trusted": True
|
| 318 |
+
}).sort("last_seen", -1)
|
| 319 |
+
|
| 320 |
+
devices = await cursor.to_list(length=None)
|
| 321 |
+
|
| 322 |
+
# Format device information
|
| 323 |
+
trusted_devices = []
|
| 324 |
+
for device in devices:
|
| 325 |
+
device_info = {
|
| 326 |
+
"device_id": str(device["_id"]),
|
| 327 |
+
"device_fingerprint": device["device_fingerprint"],
|
| 328 |
+
"platform": device.get("device_info", {}).get("platform", "Unknown"),
|
| 329 |
+
"browser": device.get("device_info", {}).get("browser", "Unknown"),
|
| 330 |
+
"first_seen": device["first_seen"],
|
| 331 |
+
"last_seen": device["last_seen"],
|
| 332 |
+
"access_count": device.get("access_count", 0)
|
| 333 |
+
}
|
| 334 |
+
trusted_devices.append(device_info)
|
| 335 |
+
|
| 336 |
+
return trusted_devices
|
| 337 |
+
|
| 338 |
+
except Exception as e:
|
| 339 |
+
logger.error(f"Error getting trusted devices for user {customer_id}: {str(e)}")
|
| 340 |
+
raise
|
| 341 |
+
|
| 342 |
+
async def remove_trusted_device(self, customer_id: str, device_id: str) -> Dict[str, Any]:
|
| 343 |
+
"""Remove a trusted device"""
|
| 344 |
+
try:
|
| 345 |
+
result = await self.device_collection.update_one(
|
| 346 |
+
{
|
| 347 |
+
"_id": ObjectId(device_id),
|
| 348 |
+
"customer_id": customer_id
|
| 349 |
+
},
|
| 350 |
+
{"$set": {"is_trusted": False}}
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
if result.matched_count == 0:
|
| 354 |
+
raise ValueError("Device not found or not owned by user")
|
| 355 |
+
|
| 356 |
+
await self._log_account_action(
|
| 357 |
+
customer_id, "trusted_device_removed",
|
| 358 |
+
{"device_id": device_id}
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
return {"action": "removed", "device_id": device_id}
|
| 362 |
+
|
| 363 |
+
except Exception as e:
|
| 364 |
+
logger.error(f"Error removing trusted device for user {customer_id}: {str(e)}")
|
| 365 |
+
raise
|
| 366 |
+
|
| 367 |
+
async def _verify_social_token(self, provider: str, token: str) -> Dict[str, Any]:
|
| 368 |
+
"""Verify social media token and return user info"""
|
| 369 |
+
try:
|
| 370 |
+
if provider == "google":
|
| 371 |
+
return await verify_google_token(token)
|
| 372 |
+
elif provider == "apple":
|
| 373 |
+
return await verify_apple_token(token)
|
| 374 |
+
elif provider == "facebook":
|
| 375 |
+
return await verify_facebook_token(token)
|
| 376 |
+
else:
|
| 377 |
+
raise ValueError(f"Unsupported provider: {provider}")
|
| 378 |
+
except Exception as e:
|
| 379 |
+
logger.error(f"Token verification failed for {provider}: {str(e)}")
|
| 380 |
+
raise ValueError(f"Invalid {provider} token")
|
| 381 |
+
|
| 382 |
+
async def _log_account_action(self, customer_id: str, action: str, details: Dict[str, Any]):
|
| 383 |
+
"""Log account-related actions for audit purposes"""
|
| 384 |
+
try:
|
| 385 |
+
log_entry = {
|
| 386 |
+
"timestamp": datetime.utcnow(),
|
| 387 |
+
"customer_id": customer_id,
|
| 388 |
+
"action": action,
|
| 389 |
+
"details": details,
|
| 390 |
+
"type": "account_management"
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
await self.security_collection.insert_one(log_entry)
|
| 394 |
+
|
| 395 |
+
except Exception as e:
|
| 396 |
+
logger.error(f"Failed to log account action: {str(e)}")
|
app/services/favorite_service.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import HTTPException, Depends
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from app.models.favorite_model import BookMyServiceFavoriteModel
|
| 4 |
+
from app.schemas.favorite_schema import (
|
| 5 |
+
FavoriteCreateRequest,
|
| 6 |
+
FavoriteUpdateRequest,
|
| 7 |
+
FavoriteResponse,
|
| 8 |
+
FavoritesListResponse,
|
| 9 |
+
FavoriteStatusResponse,
|
| 10 |
+
FavoriteSuccessResponse,
|
| 11 |
+
FavoriteDataResponse
|
| 12 |
+
)
|
| 13 |
+
from app.services.user_service import UserService
|
| 14 |
+
import logging
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("favorite_service")
|
| 17 |
+
|
| 18 |
+
class FavoriteService:
|
| 19 |
+
|
| 20 |
+
@staticmethod
|
| 21 |
+
async def add_favorite(
|
| 22 |
+
customer_id: str,
|
| 23 |
+
favorite_data: FavoriteCreateRequest
|
| 24 |
+
) -> FavoriteSuccessResponse:
|
| 25 |
+
"""Add a merchant to user's favorites"""
|
| 26 |
+
try:
|
| 27 |
+
# Create favorite in database
|
| 28 |
+
favorite_merchant_id= await BookMyServiceFavoriteModel.create_favorite(
|
| 29 |
+
customer_id,
|
| 30 |
+
favorite_data.dict()
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
return FavoriteSuccessResponse(
|
| 34 |
+
success=True,
|
| 35 |
+
message="Merchant added to favorites successfully",
|
| 36 |
+
merchant_id=favorite_merchant_id
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
except HTTPException as e:
|
| 40 |
+
raise e
|
| 41 |
+
except Exception as e:
|
| 42 |
+
logger.error(f"Error adding favorite: {str(e)}", exc_info=True)
|
| 43 |
+
raise HTTPException(status_code=500, detail="Failed to add favorite")
|
| 44 |
+
|
| 45 |
+
@staticmethod
|
| 46 |
+
async def remove_favorite(customer_id: str, merchant_id: str) -> FavoriteSuccessResponse:
|
| 47 |
+
"""Remove a merchant from user's favorites"""
|
| 48 |
+
try:
|
| 49 |
+
await BookMyServiceFavoriteModel.delete_favorite(customer_id, merchant_id)
|
| 50 |
+
|
| 51 |
+
return FavoriteSuccessResponse(
|
| 52 |
+
success=True,
|
| 53 |
+
message="Merchant removed from favorites successfully",
|
| 54 |
+
merchant_id=None
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
except HTTPException as e:
|
| 58 |
+
raise e
|
| 59 |
+
except Exception as e:
|
| 60 |
+
logger.error(f"Error removing favorite: {str(e)}", exc_info=True)
|
| 61 |
+
raise HTTPException(status_code=500, detail="Failed to remove favorite")
|
| 62 |
+
|
| 63 |
+
@staticmethod
|
| 64 |
+
async def get_favorites(
|
| 65 |
+
customer_id: str,
|
| 66 |
+
limit: int = 50
|
| 67 |
+
) -> FavoritesListResponse:
|
| 68 |
+
"""Get user's favorite merchants"""
|
| 69 |
+
try:
|
| 70 |
+
result = await BookMyServiceFavoriteModel.get_favorites(customer_id,limit )
|
| 71 |
+
|
| 72 |
+
# Convert MongoDB documents to response format
|
| 73 |
+
favorites = []
|
| 74 |
+
for fav in result["favorites"]:
|
| 75 |
+
favorites.append(FavoriteResponse(
|
| 76 |
+
merchant_id=fav["merchant_id"],
|
| 77 |
+
merchant_category=fav["merchant_category"],
|
| 78 |
+
merchant_name=fav["merchant_name"],
|
| 79 |
+
source=fav["source"],
|
| 80 |
+
added_at=fav["added_at"],
|
| 81 |
+
notes=fav.get("notes")
|
| 82 |
+
))
|
| 83 |
+
|
| 84 |
+
return FavoritesListResponse(
|
| 85 |
+
favorites=favorites,
|
| 86 |
+
total_count=result["total_count"],
|
| 87 |
+
limit=result["limit"]
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.error(f"Error getting favorites: {str(e)}", exc_info=True)
|
| 92 |
+
raise HTTPException(status_code=500, detail="Failed to get favorites")
|
| 93 |
+
|
| 94 |
+
@staticmethod
|
| 95 |
+
async def check_favorite_status(customer_id: str, merchant_id: str) -> FavoriteStatusResponse:
|
| 96 |
+
"""Check if a merchant is in user's favorites"""
|
| 97 |
+
try:
|
| 98 |
+
favorite_data = await BookMyServiceFavoriteModel.get_favorite(customer_id, merchant_id)
|
| 99 |
+
|
| 100 |
+
return FavoriteStatusResponse(
|
| 101 |
+
is_favorite=bool(favorite_data),
|
| 102 |
+
merchant_id=merchant_id
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
except Exception as e:
|
| 106 |
+
logger.error(f"Error checking favorite status: {str(e)}", exc_info=True)
|
| 107 |
+
raise HTTPException(status_code=500, detail="Failed to check favorite status")
|
| 108 |
+
|
| 109 |
+
@staticmethod
|
| 110 |
+
async def update_favorite_notes(
|
| 111 |
+
customer_id: str,
|
| 112 |
+
merchant_id: str,
|
| 113 |
+
notes_data: FavoriteUpdateRequest
|
| 114 |
+
) -> FavoriteSuccessResponse:
|
| 115 |
+
"""Update notes for a favorite merchant"""
|
| 116 |
+
try:
|
| 117 |
+
await BookMyServiceFavoriteModel.update_favorite_notes(
|
| 118 |
+
customer_id=customer_id,
|
| 119 |
+
merchant_id=merchant_id,
|
| 120 |
+
notes=notes_data.notes
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
return FavoriteSuccessResponse(
|
| 124 |
+
success=True,
|
| 125 |
+
message="Favorite notes updated successfully",
|
| 126 |
+
merchant_id=merchant_id
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
except HTTPException as e:
|
| 130 |
+
raise e
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger.error(f"Error updating favorite notes: {str(e)}", exc_info=True)
|
| 133 |
+
raise HTTPException(status_code=500, detail="Failed to update favorite notes")
|
| 134 |
+
|
| 135 |
+
@staticmethod
|
| 136 |
+
async def get_favorite_details(customer_id: str, merchant_id: str)->FavoriteDataResponse:
|
| 137 |
+
"""Get detailed information about a specific favorite"""
|
| 138 |
+
try:
|
| 139 |
+
favorite = await BookMyServiceFavoriteModel.get_favorite(customer_id, merchant_id)
|
| 140 |
+
|
| 141 |
+
if favorite:
|
| 142 |
+
return FavoriteDataResponse(
|
| 143 |
+
success=True,
|
| 144 |
+
message="Favorite found successfully",
|
| 145 |
+
favorite_data=favorite
|
| 146 |
+
)
|
| 147 |
+
else:
|
| 148 |
+
return FavoriteDataResponse(
|
| 149 |
+
success=False,
|
| 150 |
+
message="Favorite not found",
|
| 151 |
+
favorite_data=None
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
except HTTPException as e:
|
| 155 |
+
raise e
|
| 156 |
+
except Exception as e:
|
| 157 |
+
logger.error(f"Error getting favorite details: {str(e)}", exc_info=True)
|
| 158 |
+
raise HTTPException(status_code=500, detail="Failed to get favorite details")
|
app/services/otp_service.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import HTTPException
|
| 2 |
+
from app.core.cache_client import get_redis
|
| 3 |
+
from app.utils.sms_utils import send_sms_otp
|
| 4 |
+
from app.utils.email_utils import send_email_otp
|
| 5 |
+
from app.utils.common_utils import is_email
|
| 6 |
+
|
| 7 |
+
class BookMyServiceOTPModel:
|
| 8 |
+
OTP_TTL = 300 # 5 minutes
|
| 9 |
+
RATE_LIMIT_MAX = 3
|
| 10 |
+
RATE_LIMIT_WINDOW = 600 # 10 minutes
|
| 11 |
+
|
| 12 |
+
@staticmethod
|
| 13 |
+
async def store_otp(identifier: str, phone: str, otp: str, ttl: int = OTP_TTL):
|
| 14 |
+
redis = await get_redis()
|
| 15 |
+
|
| 16 |
+
rate_key = f"otp_rate_limit:{identifier}"
|
| 17 |
+
attempts = await redis.incr(rate_key)
|
| 18 |
+
if attempts == 1:
|
| 19 |
+
await redis.expire(rate_key, BookMyServiceOTPModel.RATE_LIMIT_WINDOW)
|
| 20 |
+
elif attempts > BookMyServiceOTPModel.RATE_LIMIT_MAX:
|
| 21 |
+
raise HTTPException(status_code=429, detail="Too many OTP requests. Try again later.")
|
| 22 |
+
|
| 23 |
+
await redis.setex(f"bms_otp:{identifier}", ttl, otp)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@staticmethod
|
| 29 |
+
async def verify_otp(identifier: str, otp: str):
|
| 30 |
+
redis = await get_redis()
|
| 31 |
+
key = f"bms_otp:{identifier}"
|
| 32 |
+
stored = await redis.get(key)
|
| 33 |
+
if stored and stored == otp:
|
| 34 |
+
await redis.delete(key)
|
| 35 |
+
return True
|
| 36 |
+
return False
|
app/services/profile_service.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Profile service for customer profile operations.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Optional, Dict, Any
|
| 7 |
+
from bson import ObjectId
|
| 8 |
+
from app.core.nosql_client import db
|
| 9 |
+
from fastapi import HTTPException, status
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
class ProfileService:
|
| 14 |
+
"""Service class for profile-related operations."""
|
| 15 |
+
|
| 16 |
+
@staticmethod
|
| 17 |
+
async def get_customer_profile(customer_id: str) -> Dict[str, Any]:
|
| 18 |
+
"""
|
| 19 |
+
Fetch customer profile from the customers collection.
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
customer_id (str): The user ID from JWT token
|
| 23 |
+
|
| 24 |
+
Returns:
|
| 25 |
+
Dict[str, Any]: Customer profile data
|
| 26 |
+
|
| 27 |
+
Raises:
|
| 28 |
+
HTTPException: If customer not found or database error
|
| 29 |
+
"""
|
| 30 |
+
try:
|
| 31 |
+
# Convert string ID to ObjectId if needed
|
| 32 |
+
if ObjectId.is_valid(customer_id):
|
| 33 |
+
query = {"_id": ObjectId(customer_id)}
|
| 34 |
+
else:
|
| 35 |
+
# If not a valid ObjectId, try searching by other fields
|
| 36 |
+
query = {"$or": [
|
| 37 |
+
{"customer_id": customer_id},
|
| 38 |
+
{"email": customer_id},
|
| 39 |
+
{"mobile": customer_id}
|
| 40 |
+
]}
|
| 41 |
+
|
| 42 |
+
logger.info(f"Fetching profile for user: {customer_id}")
|
| 43 |
+
|
| 44 |
+
# Fetch customer from customers collection
|
| 45 |
+
customer = await db.customers.find_one(query)
|
| 46 |
+
|
| 47 |
+
if not customer:
|
| 48 |
+
logger.warning(f"Customer not found for customer_id: {customer_id}")
|
| 49 |
+
raise HTTPException(
|
| 50 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 51 |
+
detail="Customer profile not found"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# Convert ObjectId to string for JSON serialization
|
| 55 |
+
if "_id" in customer:
|
| 56 |
+
customer["_id"] = str(customer["_id"])
|
| 57 |
+
|
| 58 |
+
logger.info(f"Successfully fetched profile for user: {customer_id}")
|
| 59 |
+
return customer
|
| 60 |
+
|
| 61 |
+
except HTTPException:
|
| 62 |
+
# Re-raise HTTP exceptions
|
| 63 |
+
raise
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.error(f"Error fetching customer profile for user {customer_id}: {str(e)}")
|
| 66 |
+
raise HTTPException(
|
| 67 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 68 |
+
detail="Internal server error while fetching profile"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
# Create service instance
|
| 72 |
+
profile_service = ProfileService()
|
app/services/user_service.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import uuid
|
| 3 |
+
from jose import jwt
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from fastapi import HTTPException
|
| 6 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 7 |
+
from app.models.otp_model import BookMyServiceOTPModel
|
| 8 |
+
from app.models.social_account_model import SocialAccountModel
|
| 9 |
+
from app.models.refresh_token_model import RefreshTokenModel
|
| 10 |
+
from app.core.config import settings
|
| 11 |
+
from app.utils.common_utils import is_email, validate_identifier
|
| 12 |
+
from app.utils.jwt import create_refresh_token
|
| 13 |
+
from app.schemas.user_schema import UserRegisterRequest
|
| 14 |
+
import logging
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("user_service")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class UserService:
|
| 22 |
+
@staticmethod
|
| 23 |
+
async def send_otp(identifier: str, phone: str = None, client_ip: str = None):
|
| 24 |
+
logger.info(f"UserService.send_otp called - identifier: {identifier}, phone: {phone}, ip: {client_ip}")
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
# Validate identifier format
|
| 28 |
+
identifier_type = validate_identifier(identifier)
|
| 29 |
+
logger.info(f"Identifier type: {identifier_type}")
|
| 30 |
+
|
| 31 |
+
# Enhanced rate limiting by IP and identifier
|
| 32 |
+
if client_ip:
|
| 33 |
+
ip_rate_key = f"otp_ip_rate:{client_ip}"
|
| 34 |
+
ip_attempts = await BookMyServiceOTPModel.get_rate_limit_count(ip_rate_key)
|
| 35 |
+
if ip_attempts >= 10: # Max 10 OTPs per IP per hour
|
| 36 |
+
logger.warning(f"IP rate limit exceeded for {client_ip}")
|
| 37 |
+
raise HTTPException(status_code=429, detail="Too many OTP requests from this IP")
|
| 38 |
+
|
| 39 |
+
# For phone identifiers, use the identifier itself as phone
|
| 40 |
+
# For email identifiers, use the provided phone parameter
|
| 41 |
+
if identifier_type == "phone":
|
| 42 |
+
phone_number = identifier
|
| 43 |
+
elif identifier_type == "email" and phone:
|
| 44 |
+
phone_number = phone
|
| 45 |
+
else:
|
| 46 |
+
# If email identifier but no phone provided, we'll send OTP via email
|
| 47 |
+
phone_number = None
|
| 48 |
+
|
| 49 |
+
# Generate OTP - hardcoded for testing purposes
|
| 50 |
+
otp = '777777'
|
| 51 |
+
logger.info(f"Generated hardcoded OTP for identifier: {identifier}")
|
| 52 |
+
|
| 53 |
+
await BookMyServiceOTPModel.store_otp(identifier, phone_number, otp)
|
| 54 |
+
|
| 55 |
+
# Track IP-based rate limiting
|
| 56 |
+
if client_ip:
|
| 57 |
+
await BookMyServiceOTPModel.increment_rate_limit(ip_rate_key, 3600) # 1 hour window
|
| 58 |
+
|
| 59 |
+
logger.info(f"OTP stored successfully for identifier: {identifier}")
|
| 60 |
+
logger.info(f"OTP sent to {identifier}")
|
| 61 |
+
|
| 62 |
+
except ValueError as ve:
|
| 63 |
+
logger.error(f"Validation error for identifier {identifier}: {str(ve)}")
|
| 64 |
+
raise HTTPException(status_code=400, detail=str(ve))
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.error(f"Error in send_otp for identifier {identifier}: {str(e)}", exc_info=True)
|
| 67 |
+
raise HTTPException(status_code=500, detail="Failed to send OTP")
|
| 68 |
+
|
| 69 |
+
@staticmethod
|
| 70 |
+
async def otp_login_handler(
|
| 71 |
+
identifier: str,
|
| 72 |
+
otp: str,
|
| 73 |
+
client_ip: str = None,
|
| 74 |
+
remember_me: bool = False,
|
| 75 |
+
device_info: str = None
|
| 76 |
+
):
|
| 77 |
+
logger.info(f"UserService.otp_login_handler called - identifier: {identifier}, otp: {otp}, ip: {client_ip}, remember_me: {remember_me}")
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
# Validate identifier format
|
| 81 |
+
identifier_type = validate_identifier(identifier)
|
| 82 |
+
logger.info(f"Identifier type: {identifier_type}")
|
| 83 |
+
|
| 84 |
+
# Check if account is locked
|
| 85 |
+
if await BookMyServiceOTPModel.is_account_locked(identifier):
|
| 86 |
+
logger.warning(f"Account locked for identifier: {identifier}")
|
| 87 |
+
raise HTTPException(status_code=423, detail="Account temporarily locked due to too many failed attempts")
|
| 88 |
+
|
| 89 |
+
# Verify OTP with client IP tracking
|
| 90 |
+
logger.info(f"Verifying OTP for identifier: {identifier}")
|
| 91 |
+
otp_valid = await BookMyServiceOTPModel.verify_otp(identifier, otp, client_ip)
|
| 92 |
+
logger.info(f"OTP verification result: {otp_valid}")
|
| 93 |
+
|
| 94 |
+
if not otp_valid:
|
| 95 |
+
logger.warning(f"Invalid or expired OTP for identifier: {identifier}")
|
| 96 |
+
# Track failed attempt
|
| 97 |
+
await BookMyServiceOTPModel.track_failed_attempt(identifier, client_ip)
|
| 98 |
+
raise HTTPException(status_code=400, detail="Invalid or expired OTP")
|
| 99 |
+
|
| 100 |
+
# Clear failed attempts on successful verification
|
| 101 |
+
await BookMyServiceOTPModel.clear_failed_attempts(identifier)
|
| 102 |
+
logger.info(f"OTP verification successful for identifier: {identifier}")
|
| 103 |
+
|
| 104 |
+
# Find user by identifier
|
| 105 |
+
logger.info(f"Looking up user by identifier: {identifier}")
|
| 106 |
+
user = await BookMyServiceUserModel.find_by_identifier(identifier)
|
| 107 |
+
logger.info(f"User lookup result: {user is not None}")
|
| 108 |
+
|
| 109 |
+
if not user:
|
| 110 |
+
logger.warning(f"No user found for identifier: {identifier}")
|
| 111 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 112 |
+
|
| 113 |
+
customer_id = user.get("customer_id")
|
| 114 |
+
logger.info(f"User found for identifier: {identifier}, customer_id: {customer_id}")
|
| 115 |
+
|
| 116 |
+
# Create token family for refresh token rotation
|
| 117 |
+
family_id = await RefreshTokenModel.create_token_family(customer_id, device_info)
|
| 118 |
+
|
| 119 |
+
# Create JWT access token
|
| 120 |
+
logger.info("Creating JWT token for authenticated user")
|
| 121 |
+
token_data = {
|
| 122 |
+
"sub": customer_id,
|
| 123 |
+
"exp": datetime.utcnow() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
access_token = jwt.encode(token_data, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
| 127 |
+
|
| 128 |
+
# Create refresh token with rotation support
|
| 129 |
+
refresh_token, token_id, expires_at = create_refresh_token(
|
| 130 |
+
{"sub": customer_id},
|
| 131 |
+
remember_me=remember_me,
|
| 132 |
+
family_id=family_id
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
# Store refresh token metadata
|
| 136 |
+
await RefreshTokenModel.store_refresh_token(
|
| 137 |
+
token_id=token_id,
|
| 138 |
+
customer_id=customer_id,
|
| 139 |
+
family_id=family_id,
|
| 140 |
+
expires_at=expires_at,
|
| 141 |
+
remember_me=remember_me,
|
| 142 |
+
device_info=device_info,
|
| 143 |
+
ip_address=client_ip
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
# Log generated tokens (truncated for security)
|
| 147 |
+
logger.info(f"Access token generated (first 25 chars): {access_token[:25]}...")
|
| 148 |
+
logger.info(f"Refresh token generated (first 25 chars): {refresh_token[:25]}...")
|
| 149 |
+
logger.info(f"JWT tokens created successfully for user: {customer_id}")
|
| 150 |
+
|
| 151 |
+
return {
|
| 152 |
+
"access_token": access_token,
|
| 153 |
+
"refresh_token": refresh_token,
|
| 154 |
+
"token_type": "bearer",
|
| 155 |
+
"expires_in": settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
| 156 |
+
"customer_id": customer_id,
|
| 157 |
+
"name": user.get("name"),
|
| 158 |
+
"email": user.get("email"),
|
| 159 |
+
"profile_picture": user.get("profile_picture"),
|
| 160 |
+
"auth_method": user.get("auth_mode"),
|
| 161 |
+
"provider": None,
|
| 162 |
+
"security_info": None
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
except ValueError as ve:
|
| 166 |
+
logger.error(f"Validation error for identifier {identifier}: {str(ve)}")
|
| 167 |
+
raise HTTPException(status_code=400, detail=str(ve))
|
| 168 |
+
except HTTPException as e:
|
| 169 |
+
logger.error(f"HTTP error in otp_login_handler for {identifier}: {e.status_code} - {e.detail}")
|
| 170 |
+
raise e
|
| 171 |
+
except Exception as e:
|
| 172 |
+
logger.error(f"Unexpected error in otp_login_handler for {identifier}: {str(e)}", exc_info=True)
|
| 173 |
+
raise HTTPException(status_code=500, detail="Internal server error during login")
|
| 174 |
+
|
| 175 |
+
@staticmethod
|
| 176 |
+
async def register(data: UserRegisterRequest, decoded):
|
| 177 |
+
logger.info(f"Registering user with data: {data}")
|
| 178 |
+
|
| 179 |
+
# Validate mandatory fields for all registration modes
|
| 180 |
+
if not data.name or not data.name.strip():
|
| 181 |
+
raise HTTPException(status_code=400, detail="Name is required")
|
| 182 |
+
|
| 183 |
+
if not data.email:
|
| 184 |
+
raise HTTPException(status_code=400, detail="Email is required")
|
| 185 |
+
|
| 186 |
+
if not data.phone or not data.phone.strip():
|
| 187 |
+
raise HTTPException(status_code=400, detail="Phone is required")
|
| 188 |
+
|
| 189 |
+
if data.mode == "otp":
|
| 190 |
+
# Always use phone as the OTP identifier as per documentation
|
| 191 |
+
identifier = data.phone
|
| 192 |
+
|
| 193 |
+
# Validate phone format
|
| 194 |
+
try:
|
| 195 |
+
identifier_type = validate_identifier(identifier)
|
| 196 |
+
if identifier_type != "phone":
|
| 197 |
+
raise ValueError("Phone number format is invalid")
|
| 198 |
+
logger.info(f"Registration identifier type: {identifier_type}")
|
| 199 |
+
except ValueError as ve:
|
| 200 |
+
logger.error(f"Invalid phone format during registration: {str(ve)}")
|
| 201 |
+
raise HTTPException(status_code=400, detail=str(ve))
|
| 202 |
+
|
| 203 |
+
redis_key = f"bms_otp:{identifier}"
|
| 204 |
+
logger.info(f"Verifying OTP for Redis key: {redis_key}")
|
| 205 |
+
|
| 206 |
+
if not data.otp:
|
| 207 |
+
raise HTTPException(status_code=400, detail="OTP is required")
|
| 208 |
+
|
| 209 |
+
if not await BookMyServiceOTPModel.verify_otp(identifier, data.otp):
|
| 210 |
+
raise HTTPException(status_code=400, detail="Invalid or expired OTP")
|
| 211 |
+
|
| 212 |
+
customer_id = str(uuid.uuid4())
|
| 213 |
+
|
| 214 |
+
elif data.mode == "oauth":
|
| 215 |
+
# Validate OAuth-specific mandatory fields
|
| 216 |
+
if not data.oauth_token or not data.provider:
|
| 217 |
+
raise HTTPException(status_code=400, detail="OAuth token and provider are required")
|
| 218 |
+
|
| 219 |
+
# Extract user info from decoded token
|
| 220 |
+
user_info = decoded.get("user_info", {})
|
| 221 |
+
provider_customer_id = user_info.get("sub") or user_info.get("id")
|
| 222 |
+
|
| 223 |
+
if not provider_customer_id:
|
| 224 |
+
raise HTTPException(status_code=400, detail="Invalid OAuth user information")
|
| 225 |
+
|
| 226 |
+
# Check if this social account already exists
|
| 227 |
+
existing_social_account = await SocialAccountModel.find_by_provider_and_customer_id(
|
| 228 |
+
data.provider, provider_customer_id
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
if existing_social_account:
|
| 232 |
+
# User already has this social account linked
|
| 233 |
+
existing_user = await BookMyServiceUserModel.collection.find_one({
|
| 234 |
+
"customer_id": existing_social_account["customer_id"]
|
| 235 |
+
})
|
| 236 |
+
if existing_user:
|
| 237 |
+
# Update social account with latest info and return existing user token
|
| 238 |
+
await SocialAccountModel.update_social_account(data.provider, provider_customer_id, user_info)
|
| 239 |
+
|
| 240 |
+
token_data = {
|
| 241 |
+
"sub": existing_user["customer_id"],
|
| 242 |
+
"exp": datetime.utcnow() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 243 |
+
}
|
| 244 |
+
access_token = jwt.encode(token_data, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
| 245 |
+
|
| 246 |
+
# Create refresh token
|
| 247 |
+
refresh_token_data = {
|
| 248 |
+
"sub": existing_user["customer_id"],
|
| 249 |
+
"exp": datetime.utcnow() + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
|
| 250 |
+
}
|
| 251 |
+
refresh_token = jwt.encode(refresh_token_data, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
| 252 |
+
|
| 253 |
+
# Log generated tokens for existing linked user (truncated)
|
| 254 |
+
logger.info(f"Access token for existing user (first 25 chars): {access_token[:25]}...")
|
| 255 |
+
logger.info(f"Refresh token for existing user (first 25 chars): {refresh_token[:25]}...")
|
| 256 |
+
|
| 257 |
+
return {
|
| 258 |
+
"access_token": access_token,
|
| 259 |
+
"refresh_token": refresh_token,
|
| 260 |
+
"token_type": "bearer",
|
| 261 |
+
"expires_in": settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
# Generate a new UUID for customer_id instead of provider-prefixed ID
|
| 265 |
+
customer_id = str(uuid.uuid4())
|
| 266 |
+
|
| 267 |
+
else:
|
| 268 |
+
raise HTTPException(status_code=400, detail="Unsupported registration mode")
|
| 269 |
+
|
| 270 |
+
# Check if user already exists
|
| 271 |
+
if await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id}):
|
| 272 |
+
raise HTTPException(status_code=409, detail="User already registered")
|
| 273 |
+
|
| 274 |
+
# Check for existing email or phone
|
| 275 |
+
existing_user = await BookMyServiceUserModel.exists_by_email_or_phone(
|
| 276 |
+
email=data.email,
|
| 277 |
+
phone=data.phone
|
| 278 |
+
)
|
| 279 |
+
if existing_user:
|
| 280 |
+
raise HTTPException(status_code=409, detail="User with this email or phone already exists")
|
| 281 |
+
|
| 282 |
+
# Create user document
|
| 283 |
+
user_doc = {
|
| 284 |
+
"customer_id": customer_id,
|
| 285 |
+
"name": data.name,
|
| 286 |
+
"email": data.email,
|
| 287 |
+
"phone": data.phone,
|
| 288 |
+
"auth_mode": data.mode,
|
| 289 |
+
"created_at": datetime.utcnow()
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
# Add profile picture from social account if available
|
| 293 |
+
if data.mode == "oauth" and user_info.get("picture"):
|
| 294 |
+
user_doc["profile_picture"] = user_info["picture"]
|
| 295 |
+
|
| 296 |
+
await BookMyServiceUserModel.collection.insert_one(user_doc)
|
| 297 |
+
logger.info(f"Created new user: {customer_id}")
|
| 298 |
+
|
| 299 |
+
# Create social account record for OAuth registration using UUID customer_id
|
| 300 |
+
if data.mode == "oauth":
|
| 301 |
+
await SocialAccountModel.create_social_account(
|
| 302 |
+
customer_id, data.provider, provider_customer_id, user_info
|
| 303 |
+
)
|
| 304 |
+
logger.info(f"Created social account link for {data.provider} -> {customer_id}")
|
| 305 |
+
|
| 306 |
+
# Create token family for refresh token rotation
|
| 307 |
+
family_id = await RefreshTokenModel.create_token_family(customer_id, data.device_info)
|
| 308 |
+
|
| 309 |
+
token_data = {
|
| 310 |
+
"sub": customer_id,
|
| 311 |
+
"exp": datetime.utcnow() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
access_token = jwt.encode(token_data, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
| 315 |
+
|
| 316 |
+
# Create refresh token with rotation support
|
| 317 |
+
refresh_token, token_id, expires_at = create_refresh_token(
|
| 318 |
+
{"sub": customer_id},
|
| 319 |
+
remember_me=data.remember_me,
|
| 320 |
+
family_id=family_id
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
# Store refresh token metadata
|
| 324 |
+
await RefreshTokenModel.store_refresh_token(
|
| 325 |
+
token_id=token_id,
|
| 326 |
+
customer_id=customer_id,
|
| 327 |
+
family_id=family_id,
|
| 328 |
+
expires_at=expires_at,
|
| 329 |
+
remember_me=data.remember_me,
|
| 330 |
+
device_info=data.device_info,
|
| 331 |
+
ip_address=None # Can be passed from router if needed
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
# Log generated tokens for new registration (truncated)
|
| 335 |
+
logger.info(f"Access token on register (first 25 chars): {access_token[:25]}...")
|
| 336 |
+
logger.info(f"Refresh token on register (first 25 chars): {refresh_token[:25]}...")
|
| 337 |
+
|
| 338 |
+
return {
|
| 339 |
+
"access_token": access_token,
|
| 340 |
+
"refresh_token": refresh_token,
|
| 341 |
+
"token_type": "bearer",
|
| 342 |
+
"expires_in": settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
| 343 |
+
"customer_id": customer_id,
|
| 344 |
+
"name": data.name,
|
| 345 |
+
"email": data.email,
|
| 346 |
+
"profile_picture": user_doc.get("profile_picture"),
|
| 347 |
+
"auth_method": data.mode,
|
| 348 |
+
"provider": data.provider if data.mode == "oauth" else None,
|
| 349 |
+
"security_info": None
|
| 350 |
+
}
|
app/services/wallet_service.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Any, Optional
|
| 2 |
+
import logging
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
from app.models.wallet_model import WalletModel
|
| 6 |
+
from app.schemas.wallet_schema import (
|
| 7 |
+
WalletBalanceResponse, WalletSummaryResponse, TransactionHistoryResponse,
|
| 8 |
+
TransactionEntry, WalletTransactionResponse
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
class WalletService:
|
| 14 |
+
"""Service for wallet operations"""
|
| 15 |
+
|
| 16 |
+
@staticmethod
|
| 17 |
+
async def get_wallet_balance(customer_id: str) -> WalletBalanceResponse:
|
| 18 |
+
"""Get formatted wallet balance for user"""
|
| 19 |
+
try:
|
| 20 |
+
balance = await WalletModel.get_wallet_balance(customer_id)
|
| 21 |
+
|
| 22 |
+
return WalletBalanceResponse(
|
| 23 |
+
balance=balance,
|
| 24 |
+
currency="INR",
|
| 25 |
+
formatted_balance=f"₹{balance:,.2f}"
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
except Exception as e:
|
| 29 |
+
logger.error(f"Error getting wallet balance for user {customer_id}: {str(e)}")
|
| 30 |
+
return WalletBalanceResponse(
|
| 31 |
+
balance=0.0,
|
| 32 |
+
currency="INR",
|
| 33 |
+
formatted_balance="₹0.00"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
@staticmethod
|
| 37 |
+
async def get_wallet_summary(customer_id: str) -> WalletSummaryResponse:
|
| 38 |
+
"""Get wallet summary with balance and recent transactions"""
|
| 39 |
+
try:
|
| 40 |
+
summary_data = await WalletModel.get_wallet_summary(customer_id)
|
| 41 |
+
|
| 42 |
+
# Convert transactions to schema format
|
| 43 |
+
recent_transactions = []
|
| 44 |
+
for transaction in summary_data.get("recent_transactions", []):
|
| 45 |
+
recent_transactions.append(TransactionEntry(
|
| 46 |
+
transaction_id=transaction["_id"],
|
| 47 |
+
amount=transaction["amount"],
|
| 48 |
+
transaction_type=transaction["transaction_type"],
|
| 49 |
+
description=transaction["description"],
|
| 50 |
+
reference_id=transaction.get("reference_id"),
|
| 51 |
+
balance_before=transaction["balance_before"],
|
| 52 |
+
balance_after=transaction["balance_after"],
|
| 53 |
+
timestamp=transaction["timestamp"],
|
| 54 |
+
status=transaction["status"]
|
| 55 |
+
))
|
| 56 |
+
|
| 57 |
+
balance = summary_data.get("balance", 0.0)
|
| 58 |
+
|
| 59 |
+
return WalletSummaryResponse(
|
| 60 |
+
balance=balance,
|
| 61 |
+
formatted_balance=f"₹{balance:,.2f}",
|
| 62 |
+
recent_transactions=recent_transactions
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.error(f"Error getting wallet summary for user {customer_id}: {str(e)}")
|
| 67 |
+
return WalletSummaryResponse(
|
| 68 |
+
balance=0.0,
|
| 69 |
+
formatted_balance="₹0.00",
|
| 70 |
+
recent_transactions=[]
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
@staticmethod
|
| 74 |
+
async def get_transaction_history(customer_id: str, page: int = 1, per_page: int = 20) -> TransactionHistoryResponse:
|
| 75 |
+
"""Get paginated transaction history"""
|
| 76 |
+
try:
|
| 77 |
+
history_data = await WalletModel.get_transaction_history(customer_id, page, per_page)
|
| 78 |
+
|
| 79 |
+
# Convert transactions to schema format
|
| 80 |
+
transactions = []
|
| 81 |
+
for transaction in history_data.get("transactions", []):
|
| 82 |
+
transactions.append(TransactionEntry(
|
| 83 |
+
transaction_id=transaction["_id"],
|
| 84 |
+
amount=transaction["amount"],
|
| 85 |
+
transaction_type=transaction["transaction_type"],
|
| 86 |
+
description=transaction["description"],
|
| 87 |
+
reference_id=transaction.get("reference_id"),
|
| 88 |
+
balance_before=transaction["balance_before"],
|
| 89 |
+
balance_after=transaction["balance_after"],
|
| 90 |
+
timestamp=transaction["timestamp"],
|
| 91 |
+
status=transaction["status"]
|
| 92 |
+
))
|
| 93 |
+
|
| 94 |
+
return TransactionHistoryResponse(
|
| 95 |
+
transactions=transactions,
|
| 96 |
+
total_count=history_data.get("total_count", 0),
|
| 97 |
+
page=page,
|
| 98 |
+
per_page=per_page,
|
| 99 |
+
total_pages=history_data.get("total_pages", 0)
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
except Exception as e:
|
| 103 |
+
logger.error(f"Error getting transaction history for user {customer_id}: {str(e)}")
|
| 104 |
+
return TransactionHistoryResponse(
|
| 105 |
+
transactions=[],
|
| 106 |
+
total_count=0,
|
| 107 |
+
page=page,
|
| 108 |
+
per_page=per_page,
|
| 109 |
+
total_pages=0
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
@staticmethod
|
| 113 |
+
async def add_money(customer_id: str, amount: float, payment_method: str,
|
| 114 |
+
description: str = "Wallet top-up", reference_id: str = None) -> WalletTransactionResponse:
|
| 115 |
+
"""Add money to wallet"""
|
| 116 |
+
try:
|
| 117 |
+
success = await WalletModel.update_balance(
|
| 118 |
+
customer_id=customer_id,
|
| 119 |
+
amount=amount,
|
| 120 |
+
transaction_type="credit",
|
| 121 |
+
description=f"{description} via {payment_method}",
|
| 122 |
+
reference_id=reference_id
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
if success:
|
| 126 |
+
new_balance = await WalletModel.get_wallet_balance(customer_id)
|
| 127 |
+
return WalletTransactionResponse(
|
| 128 |
+
success=True,
|
| 129 |
+
message=f"Successfully added ₹{amount:,.2f} to wallet",
|
| 130 |
+
transaction_id=reference_id,
|
| 131 |
+
new_balance=new_balance
|
| 132 |
+
)
|
| 133 |
+
else:
|
| 134 |
+
return WalletTransactionResponse(
|
| 135 |
+
success=False,
|
| 136 |
+
message="Failed to add money to wallet"
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
except Exception as e:
|
| 140 |
+
logger.error(f"Error adding money to wallet for user {customer_id}: {str(e)}")
|
| 141 |
+
return WalletTransactionResponse(
|
| 142 |
+
success=False,
|
| 143 |
+
message="Internal error occurred while adding money"
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
@staticmethod
|
| 147 |
+
async def deduct_money(customer_id: str, amount: float, description: str,
|
| 148 |
+
reference_id: str = None) -> WalletTransactionResponse:
|
| 149 |
+
"""Deduct money from wallet (for payments)"""
|
| 150 |
+
try:
|
| 151 |
+
success = await WalletModel.update_balance(
|
| 152 |
+
customer_id=customer_id,
|
| 153 |
+
amount=amount,
|
| 154 |
+
transaction_type="debit",
|
| 155 |
+
description=description,
|
| 156 |
+
reference_id=reference_id
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
if success:
|
| 160 |
+
new_balance = await WalletModel.get_wallet_balance(customer_id)
|
| 161 |
+
return WalletTransactionResponse(
|
| 162 |
+
success=True,
|
| 163 |
+
message=f"Successfully deducted ₹{amount:,.2f} from wallet",
|
| 164 |
+
transaction_id=reference_id,
|
| 165 |
+
new_balance=new_balance
|
| 166 |
+
)
|
| 167 |
+
else:
|
| 168 |
+
return WalletTransactionResponse(
|
| 169 |
+
success=False,
|
| 170 |
+
message="Insufficient balance or transaction failed"
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
except Exception as e:
|
| 174 |
+
logger.error(f"Error deducting money from wallet for user {customer_id}: {str(e)}")
|
| 175 |
+
return WalletTransactionResponse(
|
| 176 |
+
success=False,
|
| 177 |
+
message="Internal error occurred while processing payment"
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
@staticmethod
|
| 181 |
+
async def process_refund(customer_id: str, amount: float, description: str,
|
| 182 |
+
reference_id: str = None) -> WalletTransactionResponse:
|
| 183 |
+
"""Process refund to wallet"""
|
| 184 |
+
try:
|
| 185 |
+
success = await WalletModel.update_balance(
|
| 186 |
+
customer_id=customer_id,
|
| 187 |
+
amount=amount,
|
| 188 |
+
transaction_type="refund",
|
| 189 |
+
description=description,
|
| 190 |
+
reference_id=reference_id
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
if success:
|
| 194 |
+
new_balance = await WalletModel.get_wallet_balance(customer_id)
|
| 195 |
+
return WalletTransactionResponse(
|
| 196 |
+
success=True,
|
| 197 |
+
message=f"Refund of ₹{amount:,.2f} processed successfully",
|
| 198 |
+
transaction_id=reference_id,
|
| 199 |
+
new_balance=new_balance
|
| 200 |
+
)
|
| 201 |
+
else:
|
| 202 |
+
return WalletTransactionResponse(
|
| 203 |
+
success=False,
|
| 204 |
+
message="Failed to process refund"
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
except Exception as e:
|
| 208 |
+
logger.error(f"Error processing refund for user {customer_id}: {str(e)}")
|
| 209 |
+
return WalletTransactionResponse(
|
| 210 |
+
success=False,
|
| 211 |
+
message="Internal error occurred while processing refund"
|
| 212 |
+
)
|
app/utils/__init__.py
ADDED
|
File without changes
|
app/utils/common_utils.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
def is_email(identifier: str) -> bool:
|
| 4 |
+
return re.match(r"[^@]+@[^@]+\.[^@]+", identifier) is not None
|
| 5 |
+
|
| 6 |
+
def is_phone(identifier: str) -> bool:
|
| 7 |
+
"""
|
| 8 |
+
Validate phone number format. Supports:
|
| 9 |
+
- International format: +1234567890, +91-9876543210
|
| 10 |
+
- National format: 9876543210, (123) 456-7890
|
| 11 |
+
- With/without spaces, dashes, parentheses
|
| 12 |
+
"""
|
| 13 |
+
# Remove all non-digit characters except +
|
| 14 |
+
cleaned = re.sub(r'[^\d+]', '', identifier)
|
| 15 |
+
|
| 16 |
+
# Check if it's a valid phone number (8-15 digits, optionally starting with +)
|
| 17 |
+
if re.match(r'^\+?[1-9]\d{7,14}$', cleaned):
|
| 18 |
+
return True
|
| 19 |
+
return False
|
| 20 |
+
|
| 21 |
+
def validate_identifier(identifier: str) -> str:
|
| 22 |
+
"""
|
| 23 |
+
Validate and return the type of identifier (email or phone).
|
| 24 |
+
Raises ValueError if neither email nor phone format.
|
| 25 |
+
"""
|
| 26 |
+
if is_email(identifier):
|
| 27 |
+
return "email"
|
| 28 |
+
elif is_phone(identifier):
|
| 29 |
+
return "phone"
|
| 30 |
+
else:
|
| 31 |
+
raise ValueError("Identifier must be a valid email address or phone number")
|
app/utils/db.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime,date
|
| 2 |
+
from decimal import Decimal
|
| 3 |
+
from typing import Any
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
|
| 6 |
+
def prepare_for_db(obj: Any) -> Any:
|
| 7 |
+
"""
|
| 8 |
+
Recursively sanitizes the object to be MongoDB-compatible:
|
| 9 |
+
- Converts Decimal to float
|
| 10 |
+
- Converts datetime with tzinfo to naive datetime
|
| 11 |
+
- Converts Pydantic BaseModel to dict
|
| 12 |
+
"""
|
| 13 |
+
if isinstance(obj, Decimal):
|
| 14 |
+
return float(obj)
|
| 15 |
+
elif isinstance(obj, date) and not isinstance(obj, datetime):
|
| 16 |
+
return datetime(obj.year, obj.month, obj.day)
|
| 17 |
+
elif isinstance(obj, datetime):
|
| 18 |
+
return obj.replace(tzinfo=None)
|
| 19 |
+
elif isinstance(obj, BaseModel):
|
| 20 |
+
return prepare_for_db(obj.dict())
|
| 21 |
+
elif isinstance(obj, dict):
|
| 22 |
+
return {k: prepare_for_db(v) for k, v in obj.items()}
|
| 23 |
+
elif isinstance(obj, list):
|
| 24 |
+
return [prepare_for_db(v) for v in obj]
|
| 25 |
+
else:
|
| 26 |
+
return obj
|
app/utils/email_utils.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import smtplib
|
| 2 |
+
from email.mime.text import MIMEText
|
| 3 |
+
from app.core.config import settings
|
| 4 |
+
|
| 5 |
+
async def send_email_otp(to_email: str, otp: str, timeout: float = 10.0):
|
| 6 |
+
msg = MIMEText(f"Your OTP is {otp}. It is valid for 5 minutes.")
|
| 7 |
+
msg["Subject"] = "Your One-Time Password"
|
| 8 |
+
msg["From"] = settings.SMTP_FROM
|
| 9 |
+
msg["To"] = to_email
|
| 10 |
+
|
| 11 |
+
server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=timeout)
|
| 12 |
+
server.connect(settings.SMTP_HOST, settings.SMTP_PORT)
|
| 13 |
+
server.starttls()
|
| 14 |
+
server.login(settings.SMTP_USER, settings.SMTP_PASS)
|
| 15 |
+
server.send_message(msg)
|
| 16 |
+
server.quit()
|
app/utils/jwt.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
## bookmyservice-ums/app/utils/jwt.py
|
| 3 |
+
|
| 4 |
+
from jose import jwt, JWTError
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
from fastapi import Depends, HTTPException, status
|
| 7 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from app.core.config import settings
|
| 10 |
+
import logging
|
| 11 |
+
import uuid
|
| 12 |
+
|
| 13 |
+
SECRET_KEY = settings.JWT_SECRET_KEY
|
| 14 |
+
ALGORITHM = settings.JWT_ALGORITHM
|
| 15 |
+
ACCESS_EXPIRE_MINUTES_DEFAULT = settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
|
| 16 |
+
REFRESH_EXPIRE_DAYS_DEFAULT = settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
|
| 17 |
+
TEMP_EXPIRE_MINUTES_DEFAULT = settings.JWT_TEMP_TOKEN_EXPIRE_MINUTES
|
| 18 |
+
|
| 19 |
+
# Remember me settings
|
| 20 |
+
REMEMBER_ME_REFRESH_EXPIRE_DAYS = settings.JWT_REMEMBER_ME_EXPIRE_DAYS
|
| 21 |
+
|
| 22 |
+
# Security scheme
|
| 23 |
+
security = HTTPBearer()
|
| 24 |
+
|
| 25 |
+
# Module logger (app-level logging config applies)
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
def create_access_token(data: dict, expires_minutes: int = ACCESS_EXPIRE_MINUTES_DEFAULT):
|
| 29 |
+
to_encode = data.copy()
|
| 30 |
+
expire = datetime.utcnow() + timedelta(minutes=expires_minutes)
|
| 31 |
+
to_encode.update({"exp": expire})
|
| 32 |
+
|
| 33 |
+
# Avoid logging sensitive payload; log minimal context
|
| 34 |
+
logger.info(
|
| 35 |
+
"Creating access token",
|
| 36 |
+
)
|
| 37 |
+
logger.info(
|
| 38 |
+
"Access token claims keys=%s expires_at=%s",
|
| 39 |
+
list(to_encode.keys()),
|
| 40 |
+
expire.isoformat(),
|
| 41 |
+
)
|
| 42 |
+
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 43 |
+
|
| 44 |
+
def create_refresh_token(
|
| 45 |
+
data: dict,
|
| 46 |
+
expires_days: int = REFRESH_EXPIRE_DAYS_DEFAULT,
|
| 47 |
+
remember_me: bool = False,
|
| 48 |
+
family_id: Optional[str] = None
|
| 49 |
+
):
|
| 50 |
+
"""Create refresh token with rotation support"""
|
| 51 |
+
to_encode = data.copy()
|
| 52 |
+
|
| 53 |
+
# Use longer expiry for remember me
|
| 54 |
+
if remember_me:
|
| 55 |
+
expires_days = REMEMBER_ME_REFRESH_EXPIRE_DAYS
|
| 56 |
+
|
| 57 |
+
expire = datetime.utcnow() + timedelta(days=expires_days)
|
| 58 |
+
|
| 59 |
+
# Generate unique token ID for tracking
|
| 60 |
+
token_id = str(uuid.uuid4())
|
| 61 |
+
|
| 62 |
+
to_encode.update({
|
| 63 |
+
"exp": expire,
|
| 64 |
+
"type": "refresh",
|
| 65 |
+
"jti": token_id, # JWT ID for token tracking
|
| 66 |
+
"remember_me": remember_me
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
# Add family ID for rotation tracking
|
| 70 |
+
if family_id:
|
| 71 |
+
to_encode["family_id"] = family_id
|
| 72 |
+
|
| 73 |
+
logger.info("Creating refresh token")
|
| 74 |
+
logger.info(
|
| 75 |
+
"Refresh token claims keys=%s expires_at=%s remember_me=%s",
|
| 76 |
+
list(to_encode.keys()),
|
| 77 |
+
expire.isoformat(),
|
| 78 |
+
remember_me
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM), token_id, expire
|
| 82 |
+
|
| 83 |
+
def create_temp_token(data: dict, expires_minutes: int = TEMP_EXPIRE_MINUTES_DEFAULT):
|
| 84 |
+
logger.info("Creating temporary access token with short expiry")
|
| 85 |
+
return create_access_token(data, expires_minutes=expires_minutes)
|
| 86 |
+
|
| 87 |
+
def decode_token(token: str) -> dict:
|
| 88 |
+
try:
|
| 89 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 90 |
+
logger.info("Token decoded successfully")
|
| 91 |
+
logger.info("Decoded claims keys=%s", list(payload.keys()))
|
| 92 |
+
return payload
|
| 93 |
+
except JWTError as e:
|
| 94 |
+
logger.warning("Token decode failed: %s", str(e))
|
| 95 |
+
return {}
|
| 96 |
+
|
| 97 |
+
def verify_token(token: str) -> dict:
|
| 98 |
+
"""
|
| 99 |
+
Verify and decode JWT token, raise HTTPException if invalid.
|
| 100 |
+
"""
|
| 101 |
+
credentials_exception = HTTPException(
|
| 102 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 103 |
+
detail="Could not validate credentials",
|
| 104 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
try:
|
| 108 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 109 |
+
customer_id: str = payload.get("sub")
|
| 110 |
+
if customer_id is None:
|
| 111 |
+
logger.warning("Verified token missing 'sub' claim")
|
| 112 |
+
raise credentials_exception
|
| 113 |
+
logger.info("Token verified for subject")
|
| 114 |
+
logger.info("Verified claims keys=%s", list(payload.keys()))
|
| 115 |
+
return payload
|
| 116 |
+
except JWTError as e:
|
| 117 |
+
logger.error("Token verification failed: %s", str(e))
|
| 118 |
+
raise credentials_exception
|
| 119 |
+
|
| 120 |
+
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
|
| 121 |
+
"""
|
| 122 |
+
Dependency to get current authenticated user from JWT token.
|
| 123 |
+
"""
|
| 124 |
+
token = credentials.credentials
|
| 125 |
+
logger.info("Authenticating request with Bearer token")
|
| 126 |
+
# Don't log raw tokens; log minimal metadata
|
| 127 |
+
logger.info("Bearer token length=%d", len(token) if token else 0)
|
| 128 |
+
return verify_token(token)
|
| 129 |
+
|
| 130 |
+
async def get_current_customer_id(current_user: dict = Depends(get_current_user)) -> str:
|
| 131 |
+
"""
|
| 132 |
+
Dependency to get current user ID.
|
| 133 |
+
"""
|
| 134 |
+
customer_id = current_user.get("sub")
|
| 135 |
+
logger.info("Resolved current customer id")
|
| 136 |
+
logger.info("Current customer id=%s", customer_id)
|
| 137 |
+
return customer_id
|
app/utils/logger.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
def setup_logger(name):
|
| 5 |
+
"""
|
| 6 |
+
Set up a logger with consistent formatting and settings
|
| 7 |
+
|
| 8 |
+
Args:
|
| 9 |
+
name (str): The name for the logger, typically __name__
|
| 10 |
+
|
| 11 |
+
Returns:
|
| 12 |
+
logging.Logger: Configured logger instance
|
| 13 |
+
"""
|
| 14 |
+
logger = logging.getLogger(name)
|
| 15 |
+
|
| 16 |
+
# Only configure handlers if they don't exist
|
| 17 |
+
if not logger.handlers:
|
| 18 |
+
# Console handler
|
| 19 |
+
console_handler = logging.StreamHandler(sys.stdout)
|
| 20 |
+
console_format = logging.Formatter(
|
| 21 |
+
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 22 |
+
)
|
| 23 |
+
console_handler.setFormatter(console_format)
|
| 24 |
+
logger.addHandler(console_handler)
|
| 25 |
+
|
| 26 |
+
# Set level - could be read from environment variables
|
| 27 |
+
logger.setLevel(logging.INFO)
|
| 28 |
+
|
| 29 |
+
# Prevent propagation to root logger to avoid duplicate logs
|
| 30 |
+
logger.propagate = False
|
| 31 |
+
|
| 32 |
+
return logger
|
app/utils/sms_utils.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from twilio.rest import Client
|
| 2 |
+
from twilio.http.http_client import TwilioHttpClient
|
| 3 |
+
from app.core.config import settings
|
| 4 |
+
|
| 5 |
+
def send_sms_otp(phone: str, otp: str) -> str:
|
| 6 |
+
http_client = TwilioHttpClient(timeout=10) # 10 seconds timeout
|
| 7 |
+
client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN, http_client=http_client)
|
| 8 |
+
|
| 9 |
+
message = client.messages.create(
|
| 10 |
+
from_=settings.TWILIO_SMS_FROM,
|
| 11 |
+
body=f"Your OTP is {otp}",
|
| 12 |
+
to=phone
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
return message.sid
|