MukeshKapoor25 commited on
Commit
fd2ce9d
·
1 Parent(s): 25481c5

feat: add authentication, security middleware, and optimize JSON handling

Browse files

refactor: reorganize project structure and update dependencies
style: improve code formatting and add docstrings
test: add unit tests for cart, order, and payment services
fix: correct database connection issues and update SQL config
chore: update requirements.txt and add .env.example

.env CHANGED
@@ -14,4 +14,22 @@ CACHE_K=dLRZrhU1d5EP9N1CW6grUgsj7MyWIj2i
14
 
15
  RAZORPAY_KEY_ID=rzp_test_2UTAol2AFSV5VN
16
 
17
- RAZORPAY_KEY_SECRET=elb4JNjUw3eLqhVMiLFiRgki
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  RAZORPAY_KEY_ID=rzp_test_2UTAol2AFSV5VN
16
 
17
+ RAZORPAY_KEY_SECRET=elb4JNjUw3eLqhVMiLFiRgki
18
+
19
+ # JWT Configuration
20
+ JWT_SECRET_KEY=your-super-secret-jwt-key-change-in-production
21
+ JWT_ALGORITHM=HS256
22
+ JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
23
+
24
+ # Security Configuration
25
+ ALLOWED_HOSTS=localhost,127.0.0.1,bookmyservice.tech
26
+ CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://bookmyservice.tech
27
+
28
+ # Rate Limiting Configuration
29
+ RATE_LIMIT_CALLS=100
30
+ RATE_LIMIT_PERIOD=60
31
+
32
+ # Cache Key Prefixes (Optional - defaults provided)
33
+ CART_KEY_PREFIX=cart
34
+ ORDER_KEY_PREFIX=order
35
+ APPOINTMENTS_KEY_PREFIX=appointments
.env.example ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Database Configuration
2
+ DATABASE_URL=postgresql://user:password@localhost/dbname
3
+
4
+ # Redis/Cache Configuration
5
+ CACHE_URI=redis://localhost:6379
6
+ CACHE_K=your-redis-password
7
+
8
+ # Cache Key Prefixes (Optional - defaults provided)
9
+ CART_KEY_PREFIX=cart
10
+ ORDER_KEY_PREFIX=order
11
+ APPOINTMENTS_KEY_PREFIX=appointments
12
+
13
+ # JWT Configuration
14
+ JWT_SECRET_KEY=your-super-secret-jwt-key-change-in-production
15
+ JWT_ALGORITHM=HS256
16
+ JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
17
+
18
+ # Security Configuration
19
+ ALLOWED_HOSTS=localhost,127.0.0.1,yourdomain.com
20
+ CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://yourdomain.com
21
+
22
+ # Rate Limiting Configuration
23
+ RATE_LIMIT_CALLS=100
24
+ RATE_LIMIT_PERIOD=60
app/app.py CHANGED
@@ -1,7 +1,9 @@
1
  from fastapi import FastAPI
2
- from app.sql import connect_to_database, disconnect_from_database
 
3
  from app.routers.appointment import router as appointment_router
4
  from app.routers.cart import router as cart_router
 
5
  import logging
6
 
7
  # Configure logging
@@ -17,6 +19,18 @@ app = FastAPI(
17
  version="1.0.0"
18
  )
19
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  # Startup event to initialize database connection
21
  @app.on_event("startup")
22
  async def startup():
 
1
  from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from app.core.sql_config import connect_to_database, disconnect_from_database
4
  from app.routers.appointment import router as appointment_router
5
  from app.routers.cart import router as cart_router
6
+ from app.auth.middleware import add_security_middleware
7
  import logging
8
 
9
  # Configure logging
 
19
  version="1.0.0"
20
  )
21
 
22
+ # Add CORS middleware for testing phase - permissive settings
23
+ app.add_middleware(
24
+ CORSMiddleware,
25
+ allow_origins=["*"], # Allows all origins for testing
26
+ allow_credentials=True,
27
+ allow_methods=["*"], # Allows all methods (GET, POST, PUT, DELETE, etc.)
28
+ allow_headers=["*"], # Allows all headers
29
+ )
30
+
31
+ # Add security middleware
32
+ add_security_middleware(app)
33
+
34
  # Startup event to initialize database connection
35
  @app.on_event("startup")
36
  async def startup():
app/auth/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # Authentication and middleware modules
2
+ from .auth import get_current_user
3
+ from .middleware import add_security_middleware
4
+
5
+ __all__ = ["get_current_user", "add_security_middleware"]
app/auth/auth.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta
2
+ from typing import Optional, Dict, Any
3
+ from fastapi import Depends, HTTPException, status
4
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
5
+ from jose import JWTError, jwt
6
+ from passlib.context import CryptContext
7
+ from app.core.config import settings
8
+ import os
9
+ from dotenv import load_dotenv
10
+
11
+ load_dotenv()
12
+
13
+ # JWT Configuration
14
+ SECRET_KEY = settings.JWT_SECRET_KEY
15
+ ALGORITHM = settings.JWT_ALGORITHM
16
+ ACCESS_TOKEN_EXPIRE_MINUTES = settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
17
+
18
+ # Password hashing
19
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
20
+
21
+ # HTTP Bearer token scheme
22
+ security = HTTPBearer()
23
+
24
+ class AuthenticationError(HTTPException):
25
+ def __init__(self, detail: str = "Could not validate credentials"):
26
+ super().__init__(
27
+ status_code=status.HTTP_401_UNAUTHORIZED,
28
+ detail=detail,
29
+ headers={"WWW-Authenticate": "Bearer"},
30
+ )
31
+
32
+
33
+
34
+
35
+ def verify_token(token: str) -> dict:
36
+ """Verify and decode a JWT token."""
37
+ try:
38
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
39
+ user_id: str = payload.get("sub")
40
+ if user_id is None:
41
+ raise AuthenticationError("Invalid token: missing user ID")
42
+ return payload
43
+ except JWTError:
44
+ raise AuthenticationError("Invalid token")
45
+
46
+ async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
47
+ """Dependency to get the current authenticated user."""
48
+ token = credentials.credentials
49
+ payload = verify_token(token)
50
+
51
+ # Extract user information from token
52
+ user_id = payload.get("sub")
53
+
54
+ return {
55
+ "sub": user_id,
56
+ "payload": payload
57
+ }
58
+
59
+
app/auth/middleware.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, Request, HTTPException
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.middleware.trustedhost import TrustedHostMiddleware
4
+ from starlette.middleware.base import BaseHTTPMiddleware
5
+ from starlette.responses import Response
6
+ import time
7
+ from collections import defaultdict
8
+ import asyncio
9
+ from typing import Dict, List
10
+ from app.core.config import settings
11
+
12
+
13
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
14
+ """Add security headers to all responses"""
15
+
16
+ async def dispatch(self, request: Request, call_next):
17
+ response = await call_next(request)
18
+
19
+ # Security headers
20
+ response.headers["X-Content-Type-Options"] = "nosniff"
21
+ response.headers["X-Frame-Options"] = "DENY"
22
+ response.headers["X-XSS-Protection"] = "1; mode=block"
23
+ response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
24
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
25
+ response.headers["Content-Security-Policy"] = "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' data: https:"
26
+
27
+ return response
28
+
29
+
30
+ class RateLimitMiddleware(BaseHTTPMiddleware):
31
+ """Simple rate limiting middleware with memory cleanup"""
32
+
33
+ def __init__(self, app, calls: int = None, period: int = None, cleanup_interval: int = 300):
34
+ super().__init__(app)
35
+ self.calls = calls or settings.RATE_LIMIT_CALLS
36
+ self.period = period or settings.RATE_LIMIT_PERIOD
37
+ self.cleanup_interval = cleanup_interval # Cleanup every 5 minutes by default
38
+ self.clients: Dict[str, List[float]] = defaultdict(list)
39
+ self.last_cleanup = time.time()
40
+
41
+ def _cleanup_inactive_clients(self, now: float):
42
+ """Remove clients that haven't made requests within the cleanup interval"""
43
+ inactive_clients = []
44
+ for client_ip, request_times in self.clients.items():
45
+ if not request_times or (now - max(request_times)) > self.cleanup_interval:
46
+ inactive_clients.append(client_ip)
47
+
48
+ for client_ip in inactive_clients:
49
+ del self.clients[client_ip]
50
+
51
+ async def dispatch(self, request: Request, call_next):
52
+ client_ip = request.client.host
53
+ now = time.time()
54
+
55
+ # Periodic cleanup of inactive clients
56
+ if now - self.last_cleanup > self.cleanup_interval:
57
+ self._cleanup_inactive_clients(now)
58
+ self.last_cleanup = now
59
+
60
+ # Clean old requests for current client
61
+ self.clients[client_ip] = [
62
+ req_time for req_time in self.clients[client_ip]
63
+ if now - req_time < self.period
64
+ ]
65
+
66
+ # Check rate limit
67
+ if len(self.clients[client_ip]) >= self.calls:
68
+ raise HTTPException(
69
+ status_code=429,
70
+ detail="Rate limit exceeded. Please try again later."
71
+ )
72
+
73
+ # Add current request
74
+ self.clients[client_ip].append(now)
75
+
76
+ response = await call_next(request)
77
+ return response
78
+
79
+
80
+ def add_security_middleware(app: FastAPI):
81
+ """Add all security middleware to the FastAPI app"""
82
+
83
+ # CORS middleware
84
+ app.add_middleware(
85
+ CORSMiddleware,
86
+ allow_origins=settings.CORS_ORIGINS,
87
+ allow_credentials=True,
88
+ allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
89
+ allow_headers=["*"],
90
+ )
91
+
92
+ # Trusted host middleware
93
+ app.add_middleware(
94
+ TrustedHostMiddleware,
95
+ allowed_hosts=settings.ALLOWED_HOSTS
96
+ )
97
+
98
+ # Security headers middleware
99
+ app.add_middleware(SecurityHeadersMiddleware)
100
+
101
+ # Rate limiting middleware
102
+ app.add_middleware(RateLimitMiddleware)
app/controllers/appointment.py DELETED
@@ -1,153 +0,0 @@
1
- from fastapi import APIRouter, HTTPException, Depends, Query
2
-
3
- from app.services.appointment import (
4
- create_new_appointment,
5
- reschedule_appointment,
6
- cancel_appointment_service,
7
- get_appointments_by_customer_id
8
- )
9
- from app.models.appointment import Appointment, AppointmentListResponse
10
- from app.services.order import OrderController
11
-
12
- import logging
13
-
14
- from typing import Optional
15
-
16
- from app.repositories.cache import get_or_set_cache
17
-
18
- # Initialize router and logger
19
- router = APIRouter()
20
- logger = logging.getLogger(__name__)
21
-
22
- @router.post("/appointment")
23
- async def create_appointment(appointment: Appointment):
24
- """
25
- API endpoint to create a new appointment and generate a Razorpay order.
26
-
27
- Args:
28
- appointment (Appointment): The details of the appointment to create.
29
- razorpay_service (RazorpayService): Dependency injection for Razorpay service.
30
-
31
- Returns:
32
- dict: Confirmation message with payment details.
33
- """
34
- try:
35
- logger.info("Creating a new appointment")
36
- return await create_new_appointment(appointment)
37
- except HTTPException as e:
38
- logger.error(f"Failed to create appointment: {e.detail}")
39
- raise e
40
- except Exception as e:
41
- logger.error(f"Unexpected error while creating appointment: {e}")
42
- raise HTTPException(status_code=500, detail="Failed to create appointment")
43
-
44
-
45
- @router.put("/reschedule/{appointment_id}")
46
- async def reschedule(appointment_id: str, new_date: str, new_time: str):
47
- """
48
- API endpoint to reschedule an existing appointment.
49
-
50
- Args:
51
- appointment_id (str): The ID of the appointment to reschedule.
52
- new_date (str): The new date for the appointment (YYYY-MM-DD).
53
- new_time (str): The new time for the appointment (HH:MM:SS).
54
-
55
- Returns:
56
- dict: Confirmation message.
57
- """
58
- try:
59
- logger.info(f"Rescheduling appointment with ID: {appointment_id}")
60
- return await reschedule_appointment(appointment_id, new_date, new_time)
61
- except HTTPException as e:
62
- logger.error(f"Failed to reschedule appointment {appointment_id}: {e.detail}")
63
- raise e
64
- except ValueError as ve:
65
- logger.error(f"Invalid date or time format: {ve}")
66
- raise HTTPException(status_code=400, detail=f"Invalid date or time format: {ve}")
67
- except Exception as e:
68
- logger.error(f"Unexpected error while rescheduling appointment {appointment_id}: {e}")
69
- raise HTTPException(status_code=500, detail="Failed to reschedule appointment")
70
-
71
-
72
- @router.put("/cancel/{appointment_id}")
73
- async def cancel_appointment(appointment_id: str, cancle_reason: str):
74
- """
75
- API endpoint to cancel an appointment.
76
-
77
- Args:
78
- cancellation (AppointmentCancellation): The cancellation request details.
79
-
80
- Returns:
81
- dict: Confirmation message. need validate the user role
82
- """
83
- try:
84
- logger.info(f"Cancelling appointment with ID: {appointment_id}")
85
- return await cancel_appointment_service(
86
- appointment_id=appointment_id,
87
- cancel_reason=cancle_reason
88
- )
89
- except HTTPException as e:
90
- logger.error(f"Failed to cancel appointment {appointment_id}: {e.detail}")
91
- raise e
92
- except Exception as e:
93
- logger.error(f"Unexpected error while cancelling appointment {appointment_id}: {e}")
94
- raise HTTPException(status_code=500, detail="Failed to cancel appointment")
95
-
96
- @router.post("/order")
97
- async def create_order(customer_id: str, amount: float, currency : str ="INR", order_controller: OrderController = Depends()):
98
- """
99
- Creates a Razorpay order before booking an appointment, with caching in Redis.
100
-
101
- Args:
102
- customer_id (str): Unique customer ID.
103
- amount (float): Total amount for payment.
104
-
105
- Returns:
106
- dict: Razorpay order response.
107
- """
108
- try:
109
- return await order_controller.create_order(customer_id, amount, currency)
110
- except HTTPException as e:
111
- raise e
112
- except Exception as e:
113
- logger.error(f"❌ Failed to create Razorpay order: {e}")
114
- raise HTTPException(status_code=500, detail="Failed to create Razorpay order")
115
-
116
-
117
- @router.get(
118
- "/{customer_id}",
119
- response_model=dict,
120
- summary="Get customer appointments",
121
- description="Retrieve paginated list of appointments for a customer"
122
- )
123
- async def list_customer_appointments(
124
- customer_id: str,
125
- limit: int = Query(10, ge=1, le=50),
126
- offset: int = Query(0, ge=0),
127
- status: Optional[str] = Query(None, description="Either 'active' or 'past'")
128
- ):
129
- # ✅ Validate status input
130
- valid_statuses = {"active", "past"}
131
- if status and status.lower() not in valid_statuses:
132
- raise HTTPException(status_code=400, detail="Invalid status. Use 'active' or 'past'.")
133
-
134
- cache_key = f"appointments:{customer_id}:{status}:{limit}:{offset}"
135
-
136
- async def fetch_from_db():
137
- return await get_appointments_by_customer_id(
138
- customer_id=customer_id,
139
- limit=limit,
140
- offset=offset,
141
- status=status.lower() if status else None
142
- )
143
-
144
- try:
145
- response = await get_or_set_cache(cache_key, fetch_from_db)
146
- return {"data": response}
147
- except Exception as e:
148
- logger.error(f"Error in list_customer_appointments: {e}")
149
- raise HTTPException(
150
- status_code=500,
151
- detail={"message": "Failed to retrieve appointments", "error": str(e)}
152
- )
153
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/controllers/cart.py DELETED
@@ -1,33 +0,0 @@
1
- from fastapi import APIRouter, HTTPException
2
- from app.models.cart import AppointmentCart
3
- from app.services.cart import (
4
- add_appointment_to_cart,
5
- retrieve_appointment_from_cart,
6
- remove_appointment_from_cart,
7
- )
8
-
9
- router = APIRouter()
10
-
11
- @router.post("/appointment")
12
- async def add_to_cart(appointment_cart: AppointmentCart):
13
- try:
14
- return await add_appointment_to_cart(
15
- customer_id=appointment_cart.customer_id,
16
- appointment_data=appointment_cart.dict()
17
- )
18
- except Exception as e:
19
- raise HTTPException(status_code=500, detail=f"Failed to add to cart: {e}")
20
-
21
- @router.get("/appointment/{customer_id}")
22
- async def get_from_cart(customer_id: str):
23
- try:
24
- return await retrieve_appointment_from_cart(customer_id)
25
- except Exception as e:
26
- raise HTTPException(status_code=500, detail=f"Failed to retrieve from cart: {e}")
27
-
28
- @router.delete("/appointment/{customer_id}")
29
- async def delete_from_cart(customer_id: str):
30
- try:
31
- return await remove_appointment_from_cart(customer_id)
32
- except Exception as e:
33
- raise HTTPException(status_code=500, detail=f"Failed to delete from cart: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Core configuration and database modules
app/core/config.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import Optional
3
+
4
+
5
+ class Settings:
6
+ """Application configuration settings"""
7
+
8
+ # Database settings
9
+ DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql://user:password@localhost/dbname")
10
+
11
+ # Redis/Cache settings
12
+ CACHE_URI: str = os.getenv("CACHE_URI", "redis://localhost:6379")
13
+ CACHE_K: str = os.getenv("CACHE_K", "")
14
+
15
+ # Cache key prefixes (configurable via environment variables)
16
+ CART_KEY_PREFIX: str = os.getenv("CART_KEY_PREFIX", "cart")
17
+ ORDER_KEY_PREFIX: str = os.getenv("ORDER_KEY_PREFIX", "order")
18
+ APPOINTMENTS_KEY_PREFIX: str = os.getenv("APPOINTMENTS_KEY_PREFIX", "appointments")
19
+
20
+ # JWT settings
21
+ JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production")
22
+ JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256")
23
+ JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
24
+
25
+ # Security settings
26
+ ALLOWED_HOSTS: list = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
27
+ CORS_ORIGINS: list = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000").split(",")
28
+
29
+ # Rate limiting
30
+ RATE_LIMIT_CALLS: int = int(os.getenv("RATE_LIMIT_CALLS", "100"))
31
+ RATE_LIMIT_PERIOD: int = int(os.getenv("RATE_LIMIT_PERIOD", "60"))
32
+
33
+ @classmethod
34
+ def get_cache_key(cls, key_type: str, *args) -> str:
35
+ """
36
+ Generate cache keys using configurable prefixes
37
+
38
+ Args:
39
+ key_type: Type of cache key (cart, order, appointments)
40
+ *args: Additional arguments to include in the key
41
+
42
+ Returns:
43
+ str: Formatted cache key
44
+ """
45
+ if key_type == "cart":
46
+ return f"{cls.CART_KEY_PREFIX}:{':'.join(map(str, args))}"
47
+ elif key_type == "order":
48
+ return f"{cls.ORDER_KEY_PREFIX}:{':'.join(map(str, args))}"
49
+ elif key_type == "appointments":
50
+ return f"{cls.APPOINTMENTS_KEY_PREFIX}:{':'.join(map(str, args))}"
51
+ else:
52
+ raise ValueError(f"Unknown cache key type: {key_type}")
53
+
54
+
55
+ # Global settings instance
56
+ settings = Settings()
app/{nosql.py → core/database_config.py} RENAMED
File without changes
app/{sql.py → core/sql_config.py} RENAMED
@@ -28,7 +28,9 @@ if not DATABASE_URL:
28
 
29
  # Initialize the database connection and metadata
30
  try:
 
31
  database = databases.Database(DATABASE_URL)
 
32
  metadata = sqlalchemy.MetaData()
33
  logger.info("Database connection initialized successfully.")
34
  except Exception as e:
 
28
 
29
  # Initialize the database connection and metadata
30
  try:
31
+ # Create database connection
32
  database = databases.Database(DATABASE_URL)
33
+
34
  metadata = sqlalchemy.MetaData()
35
  logger.info("Database connection initialized successfully.")
36
  except Exception as e:
app/models/appointment.py CHANGED
@@ -3,7 +3,7 @@ from datetime import datetime, date
3
  from typing import List, Dict, Optional, Any
4
  import enum
5
  import sqlalchemy
6
- from app.sql import metadata
7
 
8
 
9
  # Enum for appointment statuses
 
3
  from typing import List, Dict, Optional, Any
4
  import enum
5
  import sqlalchemy
6
+ from app.core.sql_config import metadata
7
 
8
 
9
  # Enum for appointment statuses
app/repositories/appointment.py CHANGED
@@ -1,29 +1,20 @@
1
  from typing import Tuple, Optional, List, Dict, Union
2
- from app.models.appointment import appointment_table
3
- from app.sql import database
4
- from app.utils.sql import (
5
  serialize_appointment,
6
  validate_query_result,
7
- build_filter_query,
8
  )
9
  from fastapi import HTTPException
10
  from sqlalchemy.sql import select
11
  import logging
12
  from sqlalchemy import func, insert
13
 
14
- from app.models.appointment import appointment_table, Appointment
15
-
16
  # Configure logging
17
  logging.basicConfig(level=logging.INFO)
18
  logger = logging.getLogger(__name__)
19
 
20
- '''from sqlalchemy.dialects import postgresql
21
-
22
- def debug_sql(query):
23
- """Return compiled SQL string with literal parameters."""
24
- return str(query.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}))
25
- '''
26
-
27
  async def create_appointment(appointment: Appointment):
28
  """Creates an appointment, handling both online and offline payments."""
29
  try:
@@ -31,12 +22,10 @@ async def create_appointment(appointment: Appointment):
31
  appointment_data = appointment.dict()
32
 
33
  logger.info(f"🛠️ Pre-insert status: {appointment_data['status']} (Type: {type(appointment_data['status'])})")
34
-
35
-
36
  logger.info(f"📌 Creating appointment: {appointment_data}")
37
 
38
  # ✅ Insert into DB correctly
39
- await database.execute(insert(appointment_table).values(**appointment_data))
40
 
41
  logger.info(f"✅ Appointment stored in DB: {appointment.appointment_id}")
42
 
@@ -66,7 +55,9 @@ async def update_appointment(appointment_id: str, update_data: dict):
66
  dict: Confirmation message.
67
  """
68
  try:
69
- # Validate existing appointment
 
 
70
 
71
  query = (
72
  appointment_table.update()
@@ -78,7 +69,7 @@ async def update_appointment(appointment_id: str, update_data: dict):
78
 
79
  result = await database.execute(query)
80
 
81
- if result == 0:
82
  logger.warning(f"No rows updated for appointment ID: {appointment_id}")
83
  raise HTTPException(status_code=404, detail="Appointment not found.")
84
 
@@ -105,7 +96,8 @@ async def get_appointment_by_id(appointment_id: str):
105
 
106
  logger.info(f"Fetching appointment: {query}")
107
 
108
- result = await database.fetch_one(query)
 
109
 
110
  validate_query_result(result, "Appointment not found.")
111
  logger.info(f"Retrieved appointment: {appointment_id}")
@@ -128,7 +120,9 @@ async def get_appointments_by_customer(customer_id: str):
128
  """
129
  try:
130
  query = appointment_table.select().where(appointment_table.c.customer_id == customer_id)
131
- results = await database.fetch_all(query)
 
 
132
 
133
  if not results:
134
  logger.warning(f"No appointments found for customer ID: {customer_id}")
@@ -179,9 +173,15 @@ async def get_appointments_with_filters(filters: dict):
179
  list: List of serialized appointments.
180
  """
181
  try:
182
- filter_query = build_filter_query(filters)
183
- query = f"SELECT * FROM appointments WHERE {filter_query}" if filter_query else "SELECT * FROM appointments"
184
- results = await database.fetch_all(query, filters)
 
 
 
 
 
 
185
 
186
  if not results:
187
  logger.warning("No appointments found matching the filters.")
@@ -207,45 +207,38 @@ async def cancel_appointment(appointment_id: str, update_data: dict):
207
  Returns:
208
  dict: Confirmation message.
209
  """
 
 
210
 
211
- logger.info(f"Cancel appointment repos: {appointment_id}")
212
-
213
- query = (
214
- appointment_table.update()
215
- .where(appointment_table.c.appointment_id == appointment_id)
216
- .values(**update_data)
217
- )
218
-
219
- logger.info(f"Cancel appointment query: {query}")
220
-
221
-
222
- result = await database.execute(query)
223
- return result
224
-
225
-
226
- async def save_order_to_db(order_id: str, customer_id: str, amount: float, currency: str, status: str):
227
- query = """
228
- INSERT INTO razorpay_orders (order_id, customer_id, amount, currency, status)
229
- VALUES (:order_id, :customer_id, :amount, :currency, :status)
230
- ON CONFLICT (order_id) DO NOTHING;
231
- """
232
- logger.info(f"Saving order to database: {query}")
233
- await database.execute(query, values={"order_id": order_id, "customer_id": customer_id, "amount": amount, "currency": currency, "status": status})
234
-
235
-
236
- async def get_order_by_id(order_id: str):
237
- query = "SELECT * FROM razorpay_orders WHERE order_id = :order_id"
238
- return await database.fetch_one(query, values={"order_id": order_id})
239
 
 
 
 
 
 
240
 
241
- async def get_order_by_payment_id(payment_id: str):
242
- query = "SELECT * FROM razorpay_orders WHERE payment_id = :payment_id"
243
- return await database.fetch_one(query, values={"payment_id": payment_id})
244
 
245
- async def update_order_status(payment_id: str, status: str):
246
- query = "UPDATE razorpay_orders SET status = :status WHERE payment_id = :payment_id"
247
- await database.execute(query, values={"payment_id": payment_id, "status": status})
 
 
248
 
 
 
 
 
 
 
 
249
 
250
 
251
  async def fetch_appointments_from_db(
@@ -290,16 +283,10 @@ async def fetch_appointments_from_db(
290
  if status:
291
  query = query.where(appointment_table.c.status.in_(status))
292
 
293
- #logger.info(f"Fetching appointments from DB: {debug_sql(count_query)}")
294
-
295
-
296
  appointments = await database.fetch_all(query)
297
- #logger.info(f"Appointments fetched: {appointments}")
298
 
299
  appointments_dicts = [dict(appointment) for appointment in appointments]
300
 
301
- #logger.info(f"Retrieved appointments for customer ID {customer_id}: {appointments_dicts}")
302
-
303
  return appointments_dicts, total_count
304
 
305
  except Exception as e:
 
1
  from typing import Tuple, Optional, List, Dict, Union
2
+ from app.models.appointment import appointment_table, Appointment
3
+ from app.core.sql_config import database
4
+ from app.utils.database import (
5
  serialize_appointment,
6
  validate_query_result,
7
+ validate_existing_appointment,
8
  )
9
  from fastapi import HTTPException
10
  from sqlalchemy.sql import select
11
  import logging
12
  from sqlalchemy import func, insert
13
 
 
 
14
  # Configure logging
15
  logging.basicConfig(level=logging.INFO)
16
  logger = logging.getLogger(__name__)
17
 
 
 
 
 
 
 
 
18
  async def create_appointment(appointment: Appointment):
19
  """Creates an appointment, handling both online and offline payments."""
20
  try:
 
22
  appointment_data = appointment.dict()
23
 
24
  logger.info(f"🛠️ Pre-insert status: {appointment_data['status']} (Type: {type(appointment_data['status'])})")
 
 
25
  logger.info(f"📌 Creating appointment: {appointment_data}")
26
 
27
  # ✅ Insert into DB correctly
28
+ result = await database.execute(insert(appointment_table).values(**appointment_data))
29
 
30
  logger.info(f"✅ Appointment stored in DB: {appointment.appointment_id}")
31
 
 
55
  dict: Confirmation message.
56
  """
57
  try:
58
+ # First validate that the appointment exists and can be modified
59
+ existing_appointment = await get_appointment_by_id(appointment_id)
60
+ validate_existing_appointment(existing_appointment)
61
 
62
  query = (
63
  appointment_table.update()
 
69
 
70
  result = await database.execute(query)
71
 
72
+ if result.rowcount == 0:
73
  logger.warning(f"No rows updated for appointment ID: {appointment_id}")
74
  raise HTTPException(status_code=404, detail="Appointment not found.")
75
 
 
96
 
97
  logger.info(f"Fetching appointment: {query}")
98
 
99
+ async with monitor_db_operation("SELECT", "appointment"):
100
+ result = await database.fetch_one(query)
101
 
102
  validate_query_result(result, "Appointment not found.")
103
  logger.info(f"Retrieved appointment: {appointment_id}")
 
120
  """
121
  try:
122
  query = appointment_table.select().where(appointment_table.c.customer_id == customer_id)
123
+
124
+ async with monitor_db_operation("SELECT", "appointment"):
125
+ results = await database.fetch_all(query)
126
 
127
  if not results:
128
  logger.warning(f"No appointments found for customer ID: {customer_id}")
 
173
  list: List of serialized appointments.
174
  """
175
  try:
176
+ # Build secure query using SQLAlchemy query builder
177
+ query = appointment_table.select()
178
+
179
+ # Apply filters safely using SQLAlchemy's where clauses
180
+ for key, value in filters.items():
181
+ if value and hasattr(appointment_table.c, key):
182
+ query = query.where(getattr(appointment_table.c, key) == value)
183
+
184
+ results = await database.fetch_all(query)
185
 
186
  if not results:
187
  logger.warning("No appointments found matching the filters.")
 
207
  Returns:
208
  dict: Confirmation message.
209
  """
210
+ try:
211
+ logger.info(f"Cancel appointment repos: {appointment_id}")
212
 
213
+ # First validate that the appointment exists and can be cancelled
214
+ existing_appointment = await get_appointment_by_id(appointment_id)
215
+ validate_existing_appointment(existing_appointment)
216
+
217
+ # Additional validation for cancellation
218
+ if existing_appointment["status"] == "completed":
219
+ raise HTTPException(status_code=400, detail="Cannot cancel a completed appointment.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
 
221
+ query = (
222
+ appointment_table.update()
223
+ .where(appointment_table.c.appointment_id == appointment_id)
224
+ .values(**update_data)
225
+ )
226
 
227
+ logger.info(f"Cancel appointment query: {query}")
 
 
228
 
229
+ result = await database.execute(query)
230
+
231
+ if result.rowcount == 0:
232
+ logger.warning(f"No rows updated for appointment ID: {appointment_id}")
233
+ raise HTTPException(status_code=404, detail="Appointment not found.")
234
 
235
+ logger.info(f"Appointment cancelled successfully: {appointment_id}")
236
+ return {"message": "Appointment cancelled successfully"}
237
+ except HTTPException as http_exc:
238
+ raise http_exc
239
+ except Exception as e:
240
+ logger.error(f"Failed to cancel appointment {appointment_id}: {e}")
241
+ raise HTTPException(status_code=500, detail="Failed to cancel appointment.")
242
 
243
 
244
  async def fetch_appointments_from_db(
 
283
  if status:
284
  query = query.where(appointment_table.c.status.in_(status))
285
 
 
 
 
286
  appointments = await database.fetch_all(query)
 
287
 
288
  appointments_dicts = [dict(appointment) for appointment in appointments]
289
 
 
 
290
  return appointments_dicts, total_count
291
 
292
  except Exception as e:
app/repositories/cache.py CHANGED
@@ -1,7 +1,7 @@
1
- import json
2
  import logging
3
  from typing import Any
4
  from app.utils.cache import get_redis_client
 
5
 
6
  logger = logging.getLogger(__name__)
7
 
@@ -35,13 +35,13 @@ async def get_or_set_cache(key: str, fetch_func, expiry: int = CACHE_EXPIRY_SECO
35
  cached_data = await redis_client.get(key)
36
  if cached_data:
37
  logger.info(f"Cache hit for key: {key}")
38
- return json.loads(cached_data)
39
 
40
  logger.info(f"Cache miss for key: {key}. Fetching fresh data...")
41
  data = await fetch_func()
42
 
43
  if data is not None:
44
- await redis_client.set(key, json.dumps(data, default=_json_serializer), ex=expiry)
45
  logger.info(f"Data cached for key: {key} with expiry: {expiry} seconds")
46
 
47
  return data
@@ -49,4 +49,92 @@ async def get_or_set_cache(key: str, fetch_func, expiry: int = CACHE_EXPIRY_SECO
49
  except Exception as e:
50
  logger.error(f"❌ Redis error for key {key}: {e}")
51
  logger.info("Falling back to fetching data without cache.")
52
- return await fetch_func()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import logging
2
  from typing import Any
3
  from app.utils.cache import get_redis_client
4
+ from app.utils.json_utils import async_fast_dumps, async_fast_loads
5
 
6
  logger = logging.getLogger(__name__)
7
 
 
35
  cached_data = await redis_client.get(key)
36
  if cached_data:
37
  logger.info(f"Cache hit for key: {key}")
38
+ return await async_fast_loads(cached_data)
39
 
40
  logger.info(f"Cache miss for key: {key}. Fetching fresh data...")
41
  data = await fetch_func()
42
 
43
  if data is not None:
44
+ await redis_client.set(key, await async_fast_dumps(data), ex=expiry)
45
  logger.info(f"Data cached for key: {key} with expiry: {expiry} seconds")
46
 
47
  return data
 
49
  except Exception as e:
50
  logger.error(f"❌ Redis error for key {key}: {e}")
51
  logger.info("Falling back to fetching data without cache.")
52
+ return await fetch_func()
53
+
54
+
55
+ async def get_from_cache(key: str) -> Any:
56
+ """
57
+ Retrieve data from Redis cache.
58
+ """
59
+ try:
60
+ cached_data = await redis_client.get(key)
61
+ if cached_data:
62
+ return cached_data.decode('utf-8') if isinstance(cached_data, bytes) else cached_data
63
+ return None
64
+ except Exception as e:
65
+ logger.error(f"❌ Redis get error for key {key}: {e}")
66
+ return None
67
+
68
+
69
+ async def save_to_cache(key: str, data: str, ttl: int = None) -> bool:
70
+ """
71
+ Save data to Redis cache with optional TTL.
72
+ """
73
+ try:
74
+ if ttl:
75
+ await redis_client.setex(key, ttl, data)
76
+ else:
77
+ await redis_client.set(key, data)
78
+ return True
79
+ except Exception as e:
80
+ logger.error(f"❌ Redis save error for key {key}: {e}")
81
+ return False
82
+
83
+
84
+ async def get_or_set_appointment_cache(key: str, fetch_func, status: str = None, expiry: int = None) -> Any:
85
+ """
86
+ Enhanced caching for appointment queries with different TTL based on appointment status.
87
+ Past appointments get longer cache times since they don't change frequently.
88
+ """
89
+ try:
90
+ # Determine cache expiry based on appointment status
91
+ if expiry is None:
92
+ if status == "past":
93
+ expiry = 3600 # 1 hour for past appointments
94
+ elif status == "active":
95
+ expiry = 300 # 5 minutes for active appointments
96
+ else:
97
+ expiry = CACHE_EXPIRY_SECONDS # Default 5 minutes
98
+
99
+ logger.info(f"Getting or setting appointment cache for key: {key} with expiry: {expiry}s")
100
+
101
+ cached_data = await redis_client.get(key)
102
+ if cached_data:
103
+ logger.info(f"Appointment cache hit for key: {key}")
104
+ return await async_fast_loads(cached_data)
105
+
106
+ logger.info(f"Appointment cache miss for key: {key}. Fetching fresh data...")
107
+ data = await fetch_func()
108
+
109
+ if data is not None:
110
+ await redis_client.set(key, await async_fast_dumps(data), ex=expiry)
111
+ logger.info(f"Appointment data cached for key: {key} with expiry: {expiry} seconds")
112
+
113
+ return data
114
+
115
+ except Exception as e:
116
+ logger.error(f"❌ Redis error for appointment cache key {key}: {e}")
117
+ logger.info("Falling back to fetching appointment data without cache.")
118
+ return await fetch_func()
119
+
120
+
121
+ async def invalidate_appointment_cache(customer_id: str, merchant_id: str = None):
122
+ """
123
+ Invalidate appointment-related cache entries when appointments are modified.
124
+ """
125
+ try:
126
+ patterns = [
127
+ f"appointments:{customer_id}:*",
128
+ ]
129
+
130
+ if merchant_id:
131
+ patterns.append(f"appointments:merchant:{merchant_id}:*")
132
+
133
+ for pattern in patterns:
134
+ keys = await redis_client.keys(pattern)
135
+ if keys:
136
+ await redis_client.delete(*keys)
137
+ logger.info(f"Invalidated {len(keys)} cache entries for pattern: {pattern}")
138
+
139
+ except Exception as e:
140
+ logger.error(f"❌ Error invalidating appointment cache: {e}")
app/repositories/cart.py CHANGED
@@ -1,8 +1,8 @@
1
- import json
2
  import redis.asyncio as redis
3
  from datetime import timedelta
4
- from app.utils.cache import get_redis_client
5
  import logging
 
 
6
 
7
  # Configure logging
8
  logging.basicConfig(level=logging.INFO)
@@ -24,7 +24,7 @@ async def save_cart_draft(cart_key: str, cart_data: dict, ttl: timedelta = None)
24
  Raises:
25
  ValueError: If ttl is provided and not a positive duration.
26
  """
27
- serialized_data = json.dumps(cart_data)
28
 
29
  try:
30
  if ttl is None:
@@ -48,7 +48,7 @@ async def get_cart_draft(cart_key: str):
48
  """
49
  serialized_data = await redis_client.get(cart_key)
50
  if serialized_data:
51
- return json.loads(serialized_data)
52
  return None
53
 
54
  async def delete_cart_draft(cart_key: str):
@@ -72,7 +72,7 @@ async def save_to_cache(key: str, value: dict, ttl: int = 1800):
72
  """
73
  try:
74
  redis = await get_redis_client()
75
- await redis.setex(key, ttl, json.dumps(value))
76
  logger.info(f"✅ Data cached in Redis: {key} (Expires in {ttl}s)")
77
  except Exception as e:
78
  logger.error(f"❌ Failed to store in Redis: {e}")
@@ -90,7 +90,7 @@ async def get_from_cache(key: str):
90
  try:
91
  redis = await get_redis_client()
92
  data = await redis.get(key)
93
- return json.loads(data) if data else None
94
  except Exception as e:
95
  logger.error(f"❌ Failed to retrieve from Redis: {e}")
96
  return None
 
 
1
  import redis.asyncio as redis
2
  from datetime import timedelta
 
3
  import logging
4
+ from app.utils.cache import get_redis_client
5
+ from app.utils.json_utils import async_fast_dumps, async_fast_loads
6
 
7
  # Configure logging
8
  logging.basicConfig(level=logging.INFO)
 
24
  Raises:
25
  ValueError: If ttl is provided and not a positive duration.
26
  """
27
+ serialized_data = await async_fast_dumps(cart_data)
28
 
29
  try:
30
  if ttl is None:
 
48
  """
49
  serialized_data = await redis_client.get(cart_key)
50
  if serialized_data:
51
+ return await async_fast_loads(serialized_data)
52
  return None
53
 
54
  async def delete_cart_draft(cart_key: str):
 
72
  """
73
  try:
74
  redis = await get_redis_client()
75
+ await redis.setex(key, ttl, await async_fast_dumps(value))
76
  logger.info(f"✅ Data cached in Redis: {key} (Expires in {ttl}s)")
77
  except Exception as e:
78
  logger.error(f"❌ Failed to store in Redis: {e}")
 
90
  try:
91
  redis = await get_redis_client()
92
  data = await redis.get(key)
93
+ return await async_fast_loads(data) if data else None
94
  except Exception as e:
95
  logger.error(f"❌ Failed to retrieve from Redis: {e}")
96
  return None
app/repositories/payment.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.core.sql_config import database
2
+ from fastapi import HTTPException
3
+ import logging
4
+
5
+ # Configure logging
6
+ logging.basicConfig(level=logging.INFO)
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ async def save_order_to_db(order_id: str, customer_id: str, amount: float, currency: str, status: str):
11
+ """
12
+ Save a Razorpay order to the database.
13
+
14
+ Args:
15
+ order_id (str): The Razorpay order ID
16
+ customer_id (str): The customer ID
17
+ amount (float): The order amount
18
+ currency (str): The currency code
19
+ status (str): The order status
20
+ """
21
+ try:
22
+ query = """
23
+ INSERT INTO razorpay_orders (order_id, customer_id, amount, currency, status)
24
+ VALUES (:order_id, :customer_id, :amount, :currency, :status)
25
+ ON CONFLICT (order_id) DO NOTHING;
26
+ """
27
+ logger.info(f"Saving order to database: order_id={order_id}, customer_id={customer_id}")
28
+
29
+ async with monitor_db_operation("INSERT", "razorpay_orders"):
30
+ await database.execute(query, values={
31
+ "order_id": order_id,
32
+ "customer_id": customer_id,
33
+ "amount": amount,
34
+ "currency": currency,
35
+ "status": status
36
+ })
37
+
38
+ logger.info(f"Order saved successfully: {order_id}")
39
+ except Exception as e:
40
+ logger.error(f"Failed to save order {order_id}: {e}")
41
+ raise HTTPException(status_code=500, detail="Failed to save order to database")
42
+
43
+
44
+ async def get_order_by_id(order_id: str):
45
+ """
46
+ Retrieve a Razorpay order by its ID.
47
+
48
+ Args:
49
+ order_id (str): The Razorpay order ID
50
+
51
+ Returns:
52
+ dict: The order details or None if not found
53
+ """
54
+ try:
55
+ query = "SELECT * FROM razorpay_orders WHERE order_id = :order_id"
56
+ result = await database.fetch_one(query, values={"order_id": order_id})
57
+
58
+ if result:
59
+ logger.info(f"Order retrieved successfully: {order_id}")
60
+ else:
61
+ logger.warning(f"Order not found: {order_id}")
62
+
63
+ return result
64
+ except Exception as e:
65
+ logger.error(f"Failed to retrieve order {order_id}: {e}")
66
+ raise HTTPException(status_code=500, detail="Failed to retrieve order from database")
67
+
68
+
69
+ async def get_order_by_payment_id(payment_id: str):
70
+ """
71
+ Retrieve a Razorpay order by its payment ID.
72
+
73
+ Args:
74
+ payment_id (str): The Razorpay payment ID
75
+
76
+ Returns:
77
+ dict: The order details or None if not found
78
+ """
79
+ try:
80
+ query = "SELECT * FROM razorpay_orders WHERE payment_id = :payment_id"
81
+ result = await database.fetch_one(query, values={"payment_id": payment_id})
82
+
83
+ if result:
84
+ logger.info(f"Order retrieved by payment ID successfully: {payment_id}")
85
+ else:
86
+ logger.warning(f"Order not found for payment ID: {payment_id}")
87
+
88
+ return result
89
+ except Exception as e:
90
+ logger.error(f"Failed to retrieve order by payment ID {payment_id}: {e}")
91
+ raise HTTPException(status_code=500, detail="Failed to retrieve order from database")
92
+
93
+
94
+ async def update_order_status(payment_id: str, status: str):
95
+ """
96
+ Update the status of a Razorpay order.
97
+
98
+ Args:
99
+ payment_id (str): The Razorpay payment ID
100
+ status (str): The new status
101
+ """
102
+ try:
103
+ query = "UPDATE razorpay_orders SET status = :status WHERE payment_id = :payment_id"
104
+ result = await database.execute(query, values={"payment_id": payment_id, "status": status})
105
+
106
+ if result.rowcount == 0:
107
+ logger.warning(f"No order found to update for payment ID: {payment_id}")
108
+ raise HTTPException(status_code=404, detail="Order not found")
109
+
110
+ logger.info(f"Order status updated successfully: payment_id={payment_id}, status={status}")
111
+ except HTTPException:
112
+ raise
113
+ except Exception as e:
114
+ logger.error(f"Failed to update order status for payment ID {payment_id}: {e}")
115
+ raise HTTPException(status_code=500, detail="Failed to update order status")
app/routers/__init__.py ADDED
File without changes
app/routers/appointment.py CHANGED
@@ -1,5 +1,191 @@
1
- from fastapi import APIRouter
2
- from app.controllers.appointment import router as appointment
3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  router = APIRouter()
5
- router.include_router(appointment, prefix="", tags=["Appointments"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Depends, Query
 
2
 
3
+ from app.services.appointment import (
4
+ create_new_appointment,
5
+ reschedule_appointment,
6
+ cancel_appointment_service,
7
+ get_appointments_by_customer_id
8
+ )
9
+ from app.models.appointment import Appointment, AppointmentListResponse
10
+ from app.services.order import OrderController
11
+ from app.auth import get_current_user
12
+
13
+ import logging
14
+
15
+ from typing import Optional
16
+
17
+ from app.repositories.cache import get_or_set_cache, get_or_set_appointment_cache
18
+ from app.core.config import settings
19
+
20
+ # Initialize router and logger
21
  router = APIRouter()
22
+ logger = logging.getLogger(__name__)
23
+
24
+ @router.post("/appointment")
25
+ async def create_appointment(appointment: Appointment, current_user: dict = Depends(get_current_user)):
26
+ """
27
+ API endpoint to create a new appointment and generate a Razorpay order.
28
+
29
+ Args:
30
+ appointment (Appointment): The details of the appointment to create.
31
+ razorpay_service (RazorpayService): Dependency injection for Razorpay service.
32
+
33
+ Returns:
34
+ dict: Confirmation message with payment details.
35
+ """
36
+ try:
37
+ # Extract customer_id from current_user token
38
+ customer_id = current_user.get("sub")
39
+ if not customer_id:
40
+ raise HTTPException(status_code=401, detail="Invalid token: missing customer ID")
41
+
42
+ # Set the customer_id in the appointment object
43
+ appointment.customer_id = customer_id
44
+
45
+ logger.info(f"Creating a new appointment for customer: {customer_id}")
46
+ return await create_new_appointment(appointment)
47
+ except HTTPException as e:
48
+ logger.error(f"Failed to create appointment: {e.detail}")
49
+ raise e
50
+ except Exception as e:
51
+ logger.error(f"Unexpected error while creating appointment: {e}")
52
+ raise HTTPException(status_code=500, detail="Failed to create appointment")
53
+
54
+
55
+ @router.put("/reschedule/{appointment_id}")
56
+ async def reschedule(appointment_id: str, new_date: str, new_time: str, current_user: dict = Depends(get_current_user)):
57
+ """
58
+ API endpoint to reschedule an existing appointment.
59
+
60
+ Args:
61
+ appointment_id (str): The ID of the appointment to reschedule.
62
+ new_date (str): The new date for the appointment (YYYY-MM-DD).
63
+ new_time (str): The new time for the appointment (HH:MM:SS).
64
+
65
+ Returns:
66
+ dict: Confirmation message.
67
+ """
68
+ try:
69
+ # Extract customer_id from current_user token for authorization
70
+ customer_id = current_user.get("sub")
71
+ if not customer_id:
72
+ raise HTTPException(status_code=401, detail="Invalid token: missing customer ID")
73
+
74
+ logger.info(f"Rescheduling appointment {appointment_id} for customer: {customer_id}")
75
+
76
+ return await reschedule_appointment(appointment_id, new_date, new_time, customer_id)
77
+ except HTTPException as e:
78
+ logger.error(f"Failed to reschedule appointment {appointment_id}: {e.detail}")
79
+ raise e
80
+ except ValueError as ve:
81
+ logger.error(f"Invalid date or time format: {ve}")
82
+ raise HTTPException(status_code=400, detail=f"Invalid date or time format: {ve}")
83
+ except Exception as e:
84
+ logger.error(f"Unexpected error while rescheduling appointment {appointment_id}: {e}")
85
+ raise HTTPException(status_code=500, detail="Failed to reschedule appointment")
86
+
87
+
88
+ @router.put("/cancel/{appointment_id}")
89
+ async def cancel_appointment(appointment_id: str, cancle_reason: str, current_user: dict = Depends(get_current_user)):
90
+ """
91
+ API endpoint to cancel an appointment.
92
+
93
+ Args:
94
+ appointment_id (str): The ID of the appointment to cancel.
95
+ cancle_reason (str): The reason for cancellation.
96
+
97
+ Returns:
98
+ dict: Confirmation message. need validate the user role
99
+ """
100
+ try:
101
+ # Extract customer_id from current_user token for authorization
102
+ customer_id = current_user.get("sub")
103
+ if not customer_id:
104
+ raise HTTPException(status_code=401, detail="Invalid token: missing customer ID")
105
+
106
+ logger.info(f"Cancelling appointment {appointment_id} for customer: {customer_id}")
107
+
108
+ return await cancel_appointment_service(
109
+ appointment_id=appointment_id,
110
+ cancel_reason=cancle_reason,
111
+ customer_id=customer_id
112
+ )
113
+ except HTTPException as e:
114
+ logger.error(f"Failed to cancel appointment {appointment_id}: {e.detail}")
115
+ raise e
116
+ except Exception as e:
117
+ logger.error(f"Unexpected error while cancelling appointment {appointment_id}: {e}")
118
+ raise HTTPException(status_code=500, detail="Failed to cancel appointment")
119
+
120
+ @router.post("/order")
121
+ async def create_order(amount: float, currency: str = "INR", order_controller: OrderController = Depends(), current_user: dict = Depends(get_current_user)):
122
+ """
123
+ Creates a Razorpay order before booking an appointment, with caching in Redis.
124
+
125
+ Args:
126
+ amount (float): Total amount for payment.
127
+ currency (str): Currency for the payment (default: INR).
128
+
129
+ Returns:
130
+ dict: Razorpay order response.
131
+ """
132
+ try:
133
+ # Extract customer_id from current_user token
134
+ customer_id = current_user.get("sub")
135
+ if not customer_id:
136
+ raise HTTPException(status_code=401, detail="Invalid token: missing customer ID")
137
+
138
+ return await order_controller.create_order(customer_id, amount, currency)
139
+ except HTTPException as e:
140
+ raise e
141
+ except Exception as e:
142
+ logger.error(f"❌ Failed to create Razorpay order: {e}")
143
+ raise HTTPException(status_code=500, detail="Failed to create Razorpay order")
144
+
145
+
146
+ @router.get(
147
+ "/",
148
+ response_model=dict,
149
+ summary="Get customer appointments",
150
+ description="Retrieve paginated list of appointments for the authenticated customer"
151
+ )
152
+ async def list_customer_appointments(
153
+ limit: int = Query(10, ge=1, le=50),
154
+ offset: int = Query(0, ge=0),
155
+ status: Optional[str] = Query(None, description="Either 'active' or 'past'"),
156
+ current_user: dict = Depends(get_current_user)
157
+ ):
158
+ # Extract customer_id from current_user token
159
+ customer_id = current_user.get("sub")
160
+ if not customer_id:
161
+ raise HTTPException(status_code=401, detail="Invalid token: missing customer ID")
162
+
163
+ # ✅ Validate status input
164
+ valid_statuses = {"active", "past"}
165
+ if status and status.lower() not in valid_statuses:
166
+ raise HTTPException(status_code=400, detail="Invalid status. Use 'active' or 'past'.")
167
+
168
+ cache_key = settings.get_cache_key("appointments", customer_id, status, limit, offset)
169
+
170
+ async def fetch_from_db():
171
+ return await get_appointments_by_customer_id(
172
+ customer_id=customer_id,
173
+ limit=limit,
174
+ offset=offset,
175
+ status=status.lower() if status else None
176
+ )
177
+
178
+ try:
179
+ response = await get_or_set_appointment_cache(
180
+ cache_key,
181
+ fetch_from_db,
182
+ status=status.lower() if status else None
183
+ )
184
+ return {"data": response}
185
+ except Exception as e:
186
+ logger.error(f"Error in list_customer_appointments: {e}")
187
+ raise HTTPException(
188
+ status_code=500,
189
+ detail={"message": "Failed to retrieve appointments", "error": str(e)}
190
+ )
191
+
app/routers/cart.py CHANGED
@@ -1,5 +1,52 @@
1
- from fastapi import APIRouter
2
- from app.controllers.cart import router as cart
 
 
 
 
 
 
3
 
4
  router = APIRouter()
5
- router.include_router(cart, prefix="", tags=["Cart Management"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Depends
2
+ from app.models.cart import AppointmentCart
3
+ from app.services.cart import (
4
+ add_appointment_to_cart,
5
+ retrieve_appointment_from_cart,
6
+ remove_appointment_from_cart,
7
+ )
8
+ from app.auth import get_current_user
9
 
10
  router = APIRouter()
11
+
12
+ @router.post("/appointment")
13
+ async def add_to_cart(appointment_cart: AppointmentCart, current_user: dict = Depends(get_current_user)):
14
+ try:
15
+ # Extract customer_id from current_user token
16
+ customer_id = current_user.get("sub")
17
+ if not customer_id:
18
+ raise HTTPException(status_code=401, detail="Invalid token: missing customer ID")
19
+
20
+ # Override the customer_id from the token to ensure security
21
+ appointment_cart.customer_id = customer_id
22
+
23
+ return await add_appointment_to_cart(
24
+ customer_id=customer_id,
25
+ appointment_data=appointment_cart.dict()
26
+ )
27
+ except Exception as e:
28
+ raise HTTPException(status_code=500, detail=f"Failed to add to cart: {e}")
29
+
30
+ @router.get("/appointment")
31
+ async def get_from_cart(current_user: dict = Depends(get_current_user)):
32
+ try:
33
+ # Extract customer_id from current_user token
34
+ customer_id = current_user.get("sub")
35
+ if not customer_id:
36
+ raise HTTPException(status_code=401, detail="Invalid token: missing customer ID")
37
+
38
+ return await retrieve_appointment_from_cart(customer_id)
39
+ except Exception as e:
40
+ raise HTTPException(status_code=500, detail=f"Failed to retrieve from cart: {e}")
41
+
42
+ @router.delete("/appointment")
43
+ async def delete_from_cart(current_user: dict = Depends(get_current_user)):
44
+ try:
45
+ # Extract customer_id from current_user token
46
+ customer_id = current_user.get("sub")
47
+ if not customer_id:
48
+ raise HTTPException(status_code=401, detail="Invalid token: missing customer ID")
49
+
50
+ return await remove_appointment_from_cart(customer_id)
51
+ except Exception as e:
52
+ raise HTTPException(status_code=500, detail=f"Failed to delete from cart: {e}")
app/services/appointment.py CHANGED
@@ -9,9 +9,10 @@ from app.repositories.appointment import (
9
  update_appointment,
10
  cancel_appointment,
11
  get_appointment_by_id,
12
- get_order_by_id,
13
  fetch_appointments_from_db
14
  )
 
 
15
  from app.models.appointment import Appointment, AppointmentStatus, PaymentMode, AppointmentListResponse, PaginationMeta, AppointmentResponse, PaymentStatus
16
  import string, random
17
 
@@ -132,6 +133,9 @@ async def create_new_appointment(appointment: Appointment):
132
  await create_appointment(appointment)
133
  logger.info(f"✅ Appointment stored in DB: {appointment.appointment_id}")
134
 
 
 
 
135
  return {
136
  "appointment_id": appointment.appointment_id,
137
  "appointment_details": appointment.dict(exclude_none=True),
@@ -144,7 +148,7 @@ async def create_new_appointment(appointment: Appointment):
144
  logger.error(f"❌ Unexpected error while creating appointment: {str(e)}")
145
  raise HTTPException(status_code=500, detail="Failed to create appointment")
146
 
147
- async def reschedule_appointment(appointment_id: str, new_date: str, new_time: str):
148
  """
149
  Reschedules an existing appointment.
150
 
@@ -152,6 +156,7 @@ async def reschedule_appointment(appointment_id: str, new_date: str, new_time: s
152
  appointment_id (str): The ID of the appointment to reschedule.
153
  new_date (str): The new date for the appointment in string format (YYYY-MM-DD).
154
  new_time (str): The new time for the appointment in string format (HH:MM:SS).
 
155
 
156
  Returns:
157
  dict: Confirmation message.
@@ -167,6 +172,11 @@ async def reschedule_appointment(appointment_id: str, new_date: str, new_time: s
167
  if not existing_appointment:
168
  logger.warning(f"Appointment not found: {appointment_id}")
169
  raise HTTPException(status_code=404, detail="Appointment not found")
 
 
 
 
 
170
 
171
  # Check if the appointment is canceled
172
 
@@ -213,6 +223,9 @@ async def reschedule_appointment(appointment_id: str, new_date: str, new_time: s
213
  await update_appointment(appointment_id, update_data)
214
  logger.info(f"Appointment rescheduled successfully: {appointment_id}")
215
 
 
 
 
216
  return {"message": "Appointment rescheduled successfully"}
217
  except ValueError as ve:
218
  logger.error(f"Invalid date or time format for appointment {appointment_id}: {ve}")
@@ -225,13 +238,14 @@ async def reschedule_appointment(appointment_id: str, new_date: str, new_time: s
225
  raise HTTPException(status_code=500, detail=f"Failed to reschedule appointment: {e}")
226
 
227
 
228
- async def cancel_appointment_service(appointment_id: str, cancel_reason: str):
229
  """
230
  Cancel an appointment with a valid reason.
231
 
232
  Args:
233
  appointment_id (str): The ID of the appointment.
234
  cancel_reason (str): The reason for cancellation.
 
235
 
236
  Returns:
237
  dict: Confirmation message.
@@ -243,6 +257,11 @@ async def cancel_appointment_service(appointment_id: str, cancel_reason: str):
243
  if not existing_appointment:
244
  raise HTTPException(status_code=404, detail="Appointment not found")
245
 
 
 
 
 
 
246
 
247
  # Ensure the appointment is not already canceled
248
  if existing_appointment["status"] == AppointmentStatus.CANCELED.value:
@@ -271,6 +290,9 @@ async def cancel_appointment_service(appointment_id: str, cancel_reason: str):
271
  await cancel_appointment(appointment_id, update_data)
272
  logger.info(f"Appointment canceled successfully: {appointment_id}")
273
 
 
 
 
274
  # Implement wallet refund logic here
275
  #await post_wallet_refund_message(appointment_id)
276
 
 
9
  update_appointment,
10
  cancel_appointment,
11
  get_appointment_by_id,
 
12
  fetch_appointments_from_db
13
  )
14
+ from app.repositories.payment import get_order_by_id
15
+ from app.repositories.cache import invalidate_appointment_cache
16
  from app.models.appointment import Appointment, AppointmentStatus, PaymentMode, AppointmentListResponse, PaginationMeta, AppointmentResponse, PaymentStatus
17
  import string, random
18
 
 
133
  await create_appointment(appointment)
134
  logger.info(f"✅ Appointment stored in DB: {appointment.appointment_id}")
135
 
136
+ # Invalidate appointment cache for the customer and merchant
137
+ await invalidate_appointment_cache(appointment.customer_id, appointment.merchant_id)
138
+
139
  return {
140
  "appointment_id": appointment.appointment_id,
141
  "appointment_details": appointment.dict(exclude_none=True),
 
148
  logger.error(f"❌ Unexpected error while creating appointment: {str(e)}")
149
  raise HTTPException(status_code=500, detail="Failed to create appointment")
150
 
151
+ async def reschedule_appointment(appointment_id: str, new_date: str, new_time: str, customer_id: str):
152
  """
153
  Reschedules an existing appointment.
154
 
 
156
  appointment_id (str): The ID of the appointment to reschedule.
157
  new_date (str): The new date for the appointment in string format (YYYY-MM-DD).
158
  new_time (str): The new time for the appointment in string format (HH:MM:SS).
159
+ customer_id (str): The ID of the customer requesting the reschedule.
160
 
161
  Returns:
162
  dict: Confirmation message.
 
172
  if not existing_appointment:
173
  logger.warning(f"Appointment not found: {appointment_id}")
174
  raise HTTPException(status_code=404, detail="Appointment not found")
175
+
176
+ # Authorization check: Ensure the appointment belongs to the customer
177
+ if existing_appointment["customer_id"] != customer_id:
178
+ logger.warning(f"Unauthorized reschedule attempt: Customer {customer_id} tried to reschedule appointment {appointment_id} belonging to {existing_appointment['customer_id']}")
179
+ raise HTTPException(status_code=403, detail="You are not authorized to reschedule this appointment")
180
 
181
  # Check if the appointment is canceled
182
 
 
223
  await update_appointment(appointment_id, update_data)
224
  logger.info(f"Appointment rescheduled successfully: {appointment_id}")
225
 
226
+ # Invalidate appointment cache for the customer and merchant
227
+ await invalidate_appointment_cache(existing_appointment["customer_id"], existing_appointment["merchant_id"])
228
+
229
  return {"message": "Appointment rescheduled successfully"}
230
  except ValueError as ve:
231
  logger.error(f"Invalid date or time format for appointment {appointment_id}: {ve}")
 
238
  raise HTTPException(status_code=500, detail=f"Failed to reschedule appointment: {e}")
239
 
240
 
241
+ async def cancel_appointment_service(appointment_id: str, cancel_reason: str, customer_id: str):
242
  """
243
  Cancel an appointment with a valid reason.
244
 
245
  Args:
246
  appointment_id (str): The ID of the appointment.
247
  cancel_reason (str): The reason for cancellation.
248
+ customer_id (str): The ID of the customer requesting the cancellation.
249
 
250
  Returns:
251
  dict: Confirmation message.
 
257
  if not existing_appointment:
258
  raise HTTPException(status_code=404, detail="Appointment not found")
259
 
260
+ # Authorization check: Ensure the appointment belongs to the customer
261
+ if existing_appointment["customer_id"] != customer_id:
262
+ logger.warning(f"Unauthorized cancel attempt: Customer {customer_id} tried to cancel appointment {appointment_id} belonging to {existing_appointment['customer_id']}")
263
+ raise HTTPException(status_code=403, detail="You are not authorized to cancel this appointment")
264
+
265
 
266
  # Ensure the appointment is not already canceled
267
  if existing_appointment["status"] == AppointmentStatus.CANCELED.value:
 
290
  await cancel_appointment(appointment_id, update_data)
291
  logger.info(f"Appointment canceled successfully: {appointment_id}")
292
 
293
+ # Invalidate appointment cache for the customer and merchant
294
+ await invalidate_appointment_cache(existing_appointment["customer_id"], existing_appointment["merchant_id"])
295
+
296
  # Implement wallet refund logic here
297
  #await post_wallet_refund_message(appointment_id)
298
 
app/services/cart.py CHANGED
@@ -1,9 +1,11 @@
1
  from datetime import timedelta
2
- import json
3
  import logging
4
  from fastapi import HTTPException
5
  from app.models.cart import AppointmentCart
6
  from app.repositories.cart import save_cart_draft, get_cart_draft, delete_cart_draft
 
 
 
7
 
8
  # Configure logging
9
  logger = logging.getLogger(__name__)
@@ -20,7 +22,7 @@ async def add_appointment_to_cart(customer_id: str, appointment_data: dict):
20
  Returns:
21
  dict: Confirmation message with cart ID.
22
  """
23
- cart_key = f"cart:{customer_id}"
24
  ttl = appointment_data.get("ttl", "forever")
25
 
26
  try:
@@ -34,8 +36,8 @@ async def add_appointment_to_cart(customer_id: str, appointment_data: dict):
34
 
35
  logger.info(f"Adding appointment to cart for user: {customer_id} with TTL: {ttl}")
36
 
37
- # Serialize the data to JSON
38
- serialized_data = json.dumps(appointment_data)
39
 
40
  if ttl == "forever":
41
  # No expiration for the key
@@ -67,13 +69,13 @@ async def retrieve_appointment_from_cart(customer_id: str):
67
  Returns:
68
  dict: The appointment draft or a message if not found.
69
  """
70
- cart_key = f"cart:{customer_id}"
71
  try:
72
  logger.info(f"Retrieving appointment draft for user: {customer_id}")
73
  cart_data = await get_cart_draft(cart_key)
74
  if cart_data:
75
  logger.info(f"Appointment draft found for user: {customer_id}")
76
- return {"customer_id": customer_id, "appointment": json.loads(cart_data)}
77
  logger.info(f"No appointment draft found for user: {customer_id}")
78
  return {"message": "No appointment draft found in the cart."}
79
  except Exception as e:
@@ -90,7 +92,7 @@ async def remove_appointment_from_cart(customer_id: str):
90
  Returns:
91
  dict: Confirmation message or an error message if not found.
92
  """
93
- cart_key = f"cart:{customer_id}"
94
  try:
95
  logger.info(f"Removing appointment draft for user: {customer_id}")
96
  result = await delete_cart_draft(cart_key)
@@ -114,12 +116,12 @@ async def get_order_from_cache(customer_id: str):
114
  Returns:
115
  dict: Razorpay order if found, None otherwise.
116
  """
117
- order_key = f"order:{customer_id}"
118
  try:
119
  cached_order = await get_from_cache(order_key)
120
  if cached_order:
121
  logger.info(f"✅ Retrieved cached Razorpay order for user {customer_id}")
122
- return json.loads(cached_order)
123
  return None
124
  except Exception as e:
125
  logger.error(f"❌ Failed to retrieve order from cache: {e}")
@@ -138,9 +140,9 @@ async def save_order_to_cache(customer_id: str, order_data: dict, ttl: int = 180
138
  Returns:
139
  None
140
  """
141
- order_key = f"order:{customer_id}"
142
  try:
143
- await save_to_cache(order_key, json.dumps(order_data), ttl)
144
  logger.info(f"✅ Razorpay order cached for user {customer_id} (expires in {ttl}s)")
145
  except Exception as e:
146
  logger.error(f"❌ Failed to store order in cache: {e}")
 
1
  from datetime import timedelta
 
2
  import logging
3
  from fastapi import HTTPException
4
  from app.models.cart import AppointmentCart
5
  from app.repositories.cart import save_cart_draft, get_cart_draft, delete_cart_draft
6
+ from app.repositories.cache import get_from_cache, save_to_cache
7
+ from app.core.config import settings
8
+ from app.utils.json_utils import async_fast_dumps, async_fast_loads
9
 
10
  # Configure logging
11
  logger = logging.getLogger(__name__)
 
22
  Returns:
23
  dict: Confirmation message with cart ID.
24
  """
25
+ cart_key = settings.get_cache_key("cart", customer_id)
26
  ttl = appointment_data.get("ttl", "forever")
27
 
28
  try:
 
36
 
37
  logger.info(f"Adding appointment to cart for user: {customer_id} with TTL: {ttl}")
38
 
39
+ # Serialize the data to JSON using optimized JSON utils
40
+ serialized_data = await async_fast_dumps(appointment_data)
41
 
42
  if ttl == "forever":
43
  # No expiration for the key
 
69
  Returns:
70
  dict: The appointment draft or a message if not found.
71
  """
72
+ cart_key = settings.get_cache_key("cart", customer_id)
73
  try:
74
  logger.info(f"Retrieving appointment draft for user: {customer_id}")
75
  cart_data = await get_cart_draft(cart_key)
76
  if cart_data:
77
  logger.info(f"Appointment draft found for user: {customer_id}")
78
+ return {"customer_id": customer_id, "appointment": await async_fast_loads(cart_data)}
79
  logger.info(f"No appointment draft found for user: {customer_id}")
80
  return {"message": "No appointment draft found in the cart."}
81
  except Exception as e:
 
92
  Returns:
93
  dict: Confirmation message or an error message if not found.
94
  """
95
+ cart_key = settings.get_cache_key("cart", customer_id)
96
  try:
97
  logger.info(f"Removing appointment draft for user: {customer_id}")
98
  result = await delete_cart_draft(cart_key)
 
116
  Returns:
117
  dict: Razorpay order if found, None otherwise.
118
  """
119
+ order_key = settings.get_cache_key("order", customer_id)
120
  try:
121
  cached_order = await get_from_cache(order_key)
122
  if cached_order:
123
  logger.info(f"✅ Retrieved cached Razorpay order for user {customer_id}")
124
+ return await async_fast_loads(cached_order)
125
  return None
126
  except Exception as e:
127
  logger.error(f"❌ Failed to retrieve order from cache: {e}")
 
140
  Returns:
141
  None
142
  """
143
+ order_key = settings.get_cache_key("order", customer_id)
144
  try:
145
+ await save_to_cache(order_key, await async_fast_dumps(order_data), ttl)
146
  logger.info(f"✅ Razorpay order cached for user {customer_id} (expires in {ttl}s)")
147
  except Exception as e:
148
  logger.error(f"❌ Failed to store order in cache: {e}")
app/services/order.py CHANGED
@@ -2,9 +2,9 @@ import asyncio
2
  import logging
3
 
4
  from fastapi import Depends, HTTPException
5
- from app.services.razorpay_service import RazorpayService
6
  from app.repositories.cart import save_to_cache, get_from_cache
7
- from app.repositories.appointment import save_order_to_db
8
 
9
  logger = logging.getLogger(__name__) # Use module-level logger
10
 
 
2
  import logging
3
 
4
  from fastapi import Depends, HTTPException
5
+ from app.services.razorpay import RazorpayService
6
  from app.repositories.cart import save_to_cache, get_from_cache
7
+ from app.repositories.payment import save_order_to_db
8
 
9
  logger = logging.getLogger(__name__) # Use module-level logger
10
 
app/services/{razorpay_service.py → razorpay.py} RENAMED
File without changes
app/utils/{sql.py → database.py} RENAMED
@@ -65,13 +65,25 @@ def serialize_appointment(appointment):
65
  return {
66
  "appointment_id": appointment["appointment_id"],
67
  "merchant_id": appointment["merchant_id"],
 
 
 
 
68
  "customer_id": appointment["customer_id"],
69
- "appointment_date": appointment["appointment_date"].isoformat(),
70
- "appointment_time": appointment["appointment_time"].isoformat(),
 
71
  "status": appointment["status"],
72
  "services": appointment["services"],
73
- #"created_at": appointment["created_at"].isoformat(),
74
- #"modified_at": appointment["modified_at"].isoformat(),
 
 
 
 
 
 
 
75
  }
76
 
77
  def validate_existing_appointment(appointment):
 
65
  return {
66
  "appointment_id": appointment["appointment_id"],
67
  "merchant_id": appointment["merchant_id"],
68
+ "merchant_name": appointment["merchant_name"],
69
+ "city": appointment["city"],
70
+ "merchant_address": appointment["merchant_address"],
71
+ "location_id": appointment["location_id"],
72
  "customer_id": appointment["customer_id"],
73
+ "appointment_date": appointment["appointment_date"].isoformat() if appointment["appointment_date"] else None,
74
+ "appointment_time": appointment["appointment_time"],
75
+ "associates": appointment["associates"],
76
  "status": appointment["status"],
77
  "services": appointment["services"],
78
+ "notes": appointment.get("notes"),
79
+ "total_amount": float(appointment["total_amount"]) if appointment["total_amount"] else 0.0,
80
+ "discount": float(appointment["discount"]) if appointment["discount"] else 0.0,
81
+ "payment_mode": appointment["payment_mode"],
82
+ "payment_status": appointment["payment_status"],
83
+ "payment_id": appointment.get("payment_id"),
84
+ "cleared_amount": float(appointment["cleared_amount"]) if appointment["cleared_amount"] else 0.0,
85
+ "order_id": appointment.get("order_id"),
86
+ "cancel_reason": appointment.get("cancel_reason"),
87
  }
88
 
89
  def validate_existing_appointment(appointment):
app/utils/json_utils.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Optimized JSON utilities using orjson for performance-critical operations.
3
+ Falls back to standard json if orjson is not available.
4
+ """
5
+ import logging
6
+ from typing import Any, Union
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ try:
11
+ import orjson
12
+ HAS_ORJSON = True
13
+ logger.info("Using orjson for optimized JSON operations")
14
+ except ImportError:
15
+ import json
16
+ HAS_ORJSON = False
17
+ logger.info("orjson not available, falling back to standard json")
18
+
19
+
20
+ def fast_dumps(obj: Any) -> str:
21
+ """
22
+ Fast JSON serialization using orjson if available, otherwise standard json.
23
+
24
+ Args:
25
+ obj: Object to serialize
26
+
27
+ Returns:
28
+ str: JSON string
29
+ """
30
+ if HAS_ORJSON:
31
+ # orjson returns bytes, so we decode to string
32
+ return orjson.dumps(obj).decode('utf-8')
33
+ else:
34
+ return json.dumps(obj, default=_json_serializer)
35
+
36
+
37
+ def fast_loads(json_str: Union[str, bytes]) -> Any:
38
+ """
39
+ Fast JSON deserialization using orjson if available, otherwise standard json.
40
+
41
+ Args:
42
+ json_str: JSON string or bytes to deserialize
43
+
44
+ Returns:
45
+ Any: Deserialized object
46
+ """
47
+ if HAS_ORJSON:
48
+ if isinstance(json_str, str):
49
+ json_str = json_str.encode('utf-8')
50
+ return orjson.loads(json_str)
51
+ else:
52
+ if isinstance(json_str, bytes):
53
+ json_str = json_str.decode('utf-8')
54
+ return json.loads(json_str)
55
+
56
+
57
+ def fast_dumps_bytes(obj: Any) -> bytes:
58
+ """
59
+ Fast JSON serialization returning bytes using orjson if available.
60
+
61
+ Args:
62
+ obj: Object to serialize
63
+
64
+ Returns:
65
+ bytes: JSON bytes
66
+ """
67
+ if HAS_ORJSON:
68
+ return orjson.dumps(obj)
69
+ else:
70
+ return json.dumps(obj, default=_json_serializer).encode('utf-8')
71
+
72
+
73
+ def _json_serializer(obj):
74
+ """Fallback serializer for standard json module."""
75
+ from enum import Enum
76
+ from datetime import datetime, date
77
+ from pydantic import BaseModel
78
+
79
+ if isinstance(obj, (datetime, date)):
80
+ return obj.isoformat()
81
+ elif isinstance(obj, Enum):
82
+ return obj.value
83
+ elif isinstance(obj, BaseModel):
84
+ return obj.dict()
85
+ raise TypeError(f"Type {type(obj)} not serializable")
86
+
87
+
88
+ # Async wrapper functions for use in async contexts
89
+ async def async_fast_dumps(obj: Any) -> str:
90
+ """Async wrapper for fast_dumps."""
91
+ return fast_dumps(obj)
92
+
93
+
94
+ async def async_fast_loads(json_str: Union[str, bytes]) -> Any:
95
+ """Async wrapper for fast_loads."""
96
+ return fast_loads(json_str)
97
+
98
+
99
+ async def async_fast_dumps_bytes(obj: Any) -> bytes:
100
+ """Async wrapper for fast_dumps_bytes."""
101
+ return fast_dumps_bytes(obj)
app/utils/performance_metrics.py ADDED
@@ -0,0 +1,405 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import time
3
+ from typing import Dict, List, Optional, Any
4
+ from datetime import datetime, timezone, timedelta
5
+ from dataclasses import dataclass, asdict
6
+ from collections import defaultdict, deque
7
+ import json
8
+ import logging
9
+ from enum import Enum
10
+ import statistics
11
+
12
+ # Configure metrics logger
13
+ metrics_logger = logging.getLogger("performance_metrics")
14
+
15
+
16
+ class MetricType(Enum):
17
+ """Types of performance metrics"""
18
+ QUERY_EXECUTION_TIME = "query_execution_time"
19
+ QUERY_COUNT = "query_count"
20
+ SLOW_QUERY_COUNT = "slow_query_count"
21
+ ERROR_COUNT = "error_count"
22
+ CONNECTION_COUNT = "connection_count"
23
+ TRANSACTION_TIME = "transaction_time"
24
+
25
+
26
+ @dataclass
27
+ class PerformanceMetric:
28
+ """Individual performance metric data point"""
29
+ metric_type: MetricType
30
+ value: float
31
+ timestamp: datetime
32
+ labels: Optional[Dict[str, str]] = None
33
+
34
+ def to_dict(self) -> Dict[str, Any]:
35
+ """Convert to dictionary for serialization"""
36
+ data = asdict(self)
37
+ data['metric_type'] = self.metric_type.value
38
+ data['timestamp'] = self.timestamp.isoformat()
39
+ return data
40
+
41
+
42
+ @dataclass
43
+ class MetricSummary:
44
+ """Summary statistics for a metric"""
45
+ metric_type: MetricType
46
+ count: int
47
+ min_value: float
48
+ max_value: float
49
+ avg_value: float
50
+ median_value: float
51
+ p95_value: float
52
+ p99_value: float
53
+ total_value: float
54
+ time_window: str
55
+
56
+ def to_dict(self) -> Dict[str, Any]:
57
+ """Convert to dictionary for serialization"""
58
+ data = asdict(self)
59
+ data['metric_type'] = self.metric_type.value
60
+ return data
61
+
62
+
63
+ class PerformanceMetricsCollector:
64
+ """Collects and analyzes database performance metrics"""
65
+
66
+ def __init__(self,
67
+ max_metrics_per_type: int = 1000,
68
+ cleanup_interval: int = 300, # 5 minutes
69
+ retention_hours: int = 24):
70
+ """
71
+ Initialize metrics collector
72
+
73
+ Args:
74
+ max_metrics_per_type: Maximum metrics to keep per type
75
+ cleanup_interval: Cleanup interval in seconds
76
+ retention_hours: How long to retain metrics
77
+ """
78
+ self.max_metrics_per_type = max_metrics_per_type
79
+ self.cleanup_interval = cleanup_interval
80
+ self.retention_hours = retention_hours
81
+
82
+ # Store metrics in deques for efficient operations
83
+ self.metrics: Dict[MetricType, deque] = defaultdict(
84
+ lambda: deque(maxlen=max_metrics_per_type)
85
+ )
86
+
87
+ # Aggregated counters for quick access
88
+ self.counters: Dict[str, int] = defaultdict(int)
89
+ self.gauges: Dict[str, float] = defaultdict(float)
90
+
91
+ # Last cleanup time
92
+ self.last_cleanup = time.time()
93
+
94
+ # Start background cleanup task
95
+ self._cleanup_task = None
96
+ self._start_cleanup_task()
97
+
98
+ def _start_cleanup_task(self):
99
+ """Start background cleanup task"""
100
+ try:
101
+ if self._cleanup_task is None or self._cleanup_task.done():
102
+ self._cleanup_task = asyncio.create_task(self._periodic_cleanup())
103
+ except RuntimeError:
104
+ # No event loop running, cleanup task will be started later
105
+ pass
106
+
107
+ async def _periodic_cleanup(self):
108
+ """Periodic cleanup of old metrics"""
109
+ while True:
110
+ try:
111
+ await asyncio.sleep(self.cleanup_interval)
112
+ self._cleanup_old_metrics()
113
+ except asyncio.CancelledError:
114
+ break
115
+ except Exception as e:
116
+ metrics_logger.error(f"Error in periodic cleanup: {e}")
117
+
118
+ def _cleanup_old_metrics(self):
119
+ """Remove metrics older than retention period"""
120
+ cutoff_time = datetime.now(timezone.utc) - timedelta(hours=self.retention_hours)
121
+
122
+ for metric_type, metric_deque in self.metrics.items():
123
+ # Remove old metrics from the left side of deque
124
+ while metric_deque and metric_deque[0].timestamp < cutoff_time:
125
+ metric_deque.popleft()
126
+
127
+ self.last_cleanup = time.time()
128
+ metrics_logger.debug(f"Cleaned up metrics older than {cutoff_time}")
129
+
130
+ def record_metric(self,
131
+ metric_type: MetricType,
132
+ value: float,
133
+ labels: Optional[Dict[str, str]] = None):
134
+ """
135
+ Record a performance metric
136
+
137
+ Args:
138
+ metric_type: Type of metric
139
+ value: Metric value
140
+ labels: Optional labels for the metric
141
+ """
142
+ metric = PerformanceMetric(
143
+ metric_type=metric_type,
144
+ value=value,
145
+ timestamp=datetime.now(timezone.utc),
146
+ labels=labels or {}
147
+ )
148
+
149
+ self.metrics[metric_type].append(metric)
150
+
151
+ # Update counters and gauges
152
+ counter_key = f"{metric_type.value}_count"
153
+ self.counters[counter_key] += 1
154
+
155
+ if metric_type in [MetricType.QUERY_EXECUTION_TIME, MetricType.TRANSACTION_TIME]:
156
+ gauge_key = f"{metric_type.value}_latest"
157
+ self.gauges[gauge_key] = value
158
+
159
+ def record_query_execution(self, execution_time: float, query_type: str, is_slow: bool = False):
160
+ """Record query execution metrics"""
161
+ labels = {"query_type": query_type}
162
+
163
+ self.record_metric(MetricType.QUERY_EXECUTION_TIME, execution_time, labels)
164
+ self.record_metric(MetricType.QUERY_COUNT, 1, labels)
165
+
166
+ if is_slow:
167
+ self.record_metric(MetricType.SLOW_QUERY_COUNT, 1, labels)
168
+
169
+ def record_query_error(self, query_type: str, error_type: str):
170
+ """Record query error metrics"""
171
+ labels = {"query_type": query_type, "error_type": error_type}
172
+ self.record_metric(MetricType.ERROR_COUNT, 1, labels)
173
+
174
+ def record_transaction_time(self, transaction_time: float, transaction_type: str = "default"):
175
+ """Record transaction execution time"""
176
+ labels = {"transaction_type": transaction_type}
177
+ self.record_metric(MetricType.TRANSACTION_TIME, transaction_time, labels)
178
+
179
+ def get_metric_summary(self,
180
+ metric_type: MetricType,
181
+ time_window_minutes: Optional[int] = None) -> Optional[MetricSummary]:
182
+ """
183
+ Get summary statistics for a metric type
184
+
185
+ Args:
186
+ metric_type: Type of metric to summarize
187
+ time_window_minutes: Time window in minutes (None for all data)
188
+
189
+ Returns:
190
+ MetricSummary or None if no data
191
+ """
192
+ if metric_type not in self.metrics:
193
+ return None
194
+
195
+ metrics_data = list(self.metrics[metric_type])
196
+
197
+ if not metrics_data:
198
+ return None
199
+
200
+ # Filter by time window if specified
201
+ if time_window_minutes:
202
+ cutoff_time = datetime.now(timezone.utc) - timedelta(minutes=time_window_minutes)
203
+ metrics_data = [m for m in metrics_data if m.timestamp >= cutoff_time]
204
+
205
+ if not metrics_data:
206
+ return None
207
+
208
+ values = [m.value for m in metrics_data]
209
+
210
+ return MetricSummary(
211
+ metric_type=metric_type,
212
+ count=len(values),
213
+ min_value=min(values),
214
+ max_value=max(values),
215
+ avg_value=statistics.mean(values),
216
+ median_value=statistics.median(values),
217
+ p95_value=self._percentile(values, 95),
218
+ p99_value=self._percentile(values, 99),
219
+ total_value=sum(values),
220
+ time_window=f"{time_window_minutes}min" if time_window_minutes else "all"
221
+ )
222
+
223
+ def _percentile(self, values: List[float], percentile: int) -> float:
224
+ """Calculate percentile value"""
225
+ if not values:
226
+ return 0.0
227
+
228
+ sorted_values = sorted(values)
229
+ k = (len(sorted_values) - 1) * percentile / 100
230
+ f = int(k)
231
+ c = k - f
232
+
233
+ if f == len(sorted_values) - 1:
234
+ return sorted_values[f]
235
+
236
+ return sorted_values[f] * (1 - c) + sorted_values[f + 1] * c
237
+
238
+ def get_all_summaries(self, time_window_minutes: Optional[int] = None) -> Dict[str, MetricSummary]:
239
+ """Get summaries for all metric types"""
240
+ summaries = {}
241
+
242
+ for metric_type in MetricType:
243
+ summary = self.get_metric_summary(metric_type, time_window_minutes)
244
+ if summary:
245
+ summaries[metric_type.value] = summary
246
+
247
+ return summaries
248
+
249
+ def get_counters(self) -> Dict[str, int]:
250
+ """Get current counter values"""
251
+ return dict(self.counters)
252
+
253
+ def get_gauges(self) -> Dict[str, float]:
254
+ """Get current gauge values"""
255
+ return dict(self.gauges)
256
+
257
+ def get_health_metrics(self) -> Dict[str, Any]:
258
+ """Get health-related metrics"""
259
+ now = datetime.now(timezone.utc)
260
+ last_5_min = now - timedelta(minutes=5)
261
+ last_hour = now - timedelta(hours=1)
262
+
263
+ # Get recent query metrics
264
+ recent_queries = []
265
+ recent_errors = []
266
+
267
+ for metric in self.metrics[MetricType.QUERY_EXECUTION_TIME]:
268
+ if metric.timestamp >= last_5_min:
269
+ recent_queries.append(metric.value)
270
+
271
+ for metric in self.metrics[MetricType.ERROR_COUNT]:
272
+ if metric.timestamp >= last_hour:
273
+ recent_errors.append(metric.value)
274
+
275
+ return {
276
+ "queries_last_5min": len(recent_queries),
277
+ "avg_query_time_last_5min": statistics.mean(recent_queries) if recent_queries else 0,
278
+ "errors_last_hour": len(recent_errors),
279
+ "slow_queries_last_hour": len([
280
+ m for m in self.metrics[MetricType.SLOW_QUERY_COUNT]
281
+ if m.timestamp >= last_hour
282
+ ]),
283
+ "total_metrics_stored": sum(len(deque) for deque in self.metrics.values()),
284
+ "last_cleanup": self.last_cleanup
285
+ }
286
+
287
+ def export_metrics(self, format_type: str = "json") -> str:
288
+ """
289
+ Export metrics in specified format
290
+
291
+ Args:
292
+ format_type: Export format ("json" or "prometheus")
293
+
294
+ Returns:
295
+ Formatted metrics string
296
+ """
297
+ if format_type.lower() == "json":
298
+ return self._export_json()
299
+ elif format_type.lower() == "prometheus":
300
+ return self._export_prometheus()
301
+ else:
302
+ raise ValueError(f"Unsupported format: {format_type}")
303
+
304
+ def _export_json(self) -> str:
305
+ """Export metrics as JSON"""
306
+ export_data = {
307
+ "timestamp": datetime.now(timezone.utc).isoformat(),
308
+ "summaries": {k: v.to_dict() for k, v in self.get_all_summaries(60).items()},
309
+ "counters": self.get_counters(),
310
+ "gauges": self.get_gauges(),
311
+ "health": self.get_health_metrics()
312
+ }
313
+
314
+ return json.dumps(export_data, indent=2)
315
+
316
+ def _export_prometheus(self) -> str:
317
+ """Export metrics in Prometheus format"""
318
+ lines = []
319
+ timestamp = int(time.time() * 1000)
320
+
321
+ # Export counters
322
+ for name, value in self.get_counters().items():
323
+ lines.append(f"db_{name} {value} {timestamp}")
324
+
325
+ # Export gauges
326
+ for name, value in self.get_gauges().items():
327
+ lines.append(f"db_{name} {value} {timestamp}")
328
+
329
+ # Export summaries
330
+ for metric_type, summary in self.get_all_summaries(60).items():
331
+ prefix = f"db_{metric_type}_summary"
332
+ lines.extend([
333
+ f"{prefix}_count {summary.count} {timestamp}",
334
+ f"{prefix}_avg {summary.avg_value} {timestamp}",
335
+ f"{prefix}_p95 {summary.p95_value} {timestamp}",
336
+ f"{prefix}_p99 {summary.p99_value} {timestamp}"
337
+ ])
338
+
339
+ return "\n".join(lines)
340
+
341
+ def log_performance_report(self, time_window_minutes: int = 60):
342
+ """Log a performance report"""
343
+ summaries = self.get_all_summaries(time_window_minutes)
344
+ health = self.get_health_metrics()
345
+
346
+ report = {
347
+ "time_window_minutes": time_window_minutes,
348
+ "summaries": {k: v.to_dict() for k, v in summaries.items()},
349
+ "health_metrics": health
350
+ }
351
+
352
+ metrics_logger.info(f"Performance Report: {json.dumps(report, indent=2)}")
353
+
354
+ def cleanup(self):
355
+ """Cleanup resources"""
356
+ if self._cleanup_task and not self._cleanup_task.done():
357
+ self._cleanup_task.cancel()
358
+
359
+
360
+ # Global metrics collector instance
361
+ metrics_collector = PerformanceMetricsCollector()
362
+
363
+
364
+ # Convenience functions
365
+ def record_query_execution(execution_time: float, query_type: str, is_slow: bool = False):
366
+ """Record query execution metrics"""
367
+ metrics_collector.record_query_execution(execution_time, query_type, is_slow)
368
+
369
+
370
+ def record_query_error(query_type: str, error_type: str):
371
+ """Record query error metrics"""
372
+ metrics_collector.record_query_error(query_type, error_type)
373
+
374
+
375
+ def record_transaction_time(transaction_time: float, transaction_type: str = "default"):
376
+ """Record transaction time metrics"""
377
+ metrics_collector.record_transaction_time(transaction_time, transaction_type)
378
+
379
+
380
+ def get_performance_summary(time_window_minutes: int = 60) -> Dict[str, Any]:
381
+ """Get performance summary"""
382
+ return {
383
+ "summaries": {k: v.to_dict() for k, v in metrics_collector.get_all_summaries(time_window_minutes).items()},
384
+ "health": metrics_collector.get_health_metrics()
385
+ }
386
+
387
+
388
+ def log_performance_report(time_window_minutes: int = 60):
389
+ """Log performance report"""
390
+ metrics_collector.log_performance_report(time_window_minutes)
391
+
392
+
393
+ # Export main components
394
+ __all__ = [
395
+ 'PerformanceMetricsCollector',
396
+ 'PerformanceMetric',
397
+ 'MetricSummary',
398
+ 'MetricType',
399
+ 'metrics_collector',
400
+ 'record_query_execution',
401
+ 'record_query_error',
402
+ 'record_transaction_time',
403
+ 'get_performance_summary',
404
+ 'log_performance_report'
405
+ ]
database-scripts/create_appointments.sql DELETED
@@ -1,38 +0,0 @@
1
- -- Table: public.appointments
2
-
3
- -- DROP TABLE IF EXISTS public.appointments;
4
-
5
- CREATE TABLE IF NOT EXISTS public.appointments
6
- (
7
- appointment_id uuid NOT NULL DEFAULT gen_random_uuid()
8
- merchant_id character varying(255) COLLATE pg_catalog."default" NOT NULL,
9
- location_id character varying(255) COLLATE pg_catalog."default" NOT NULL,
10
- customer_id character varying(255) COLLATE pg_catalog."default" NOT NULL,
11
- appointment_date date NOT NULL,
12
- appointment_time time without time zone NOT NULL,
13
- associates jsonb NOT NULL,
14
- status character varying(50) COLLATE pg_catalog."default" NOT NULL,
15
- services jsonb NOT NULL,
16
- notes text COLLATE pg_catalog."default",
17
- total_amount numeric(10,2) NOT NULL,
18
- discount numeric(10,2) DEFAULT 0,
19
- payment_mode character varying(50) COLLATE pg_catalog."default" NOT NULL,
20
- payment_status character varying(50) COLLATE pg_catalog."default" DEFAULT 'pending'::character varying,
21
- payment_id character varying(255) COLLATE pg_catalog."default",
22
- cleared_amount numeric(10,2) DEFAULT 0,
23
- created_at timestamp with time zone DEFAULT now(),
24
- modified_at timestamp with time zone DEFAULT now(),
25
- order_id character varying(255) COLLATE pg_catalog."default",
26
- merchant_name character varying COLLATE pg_catalog."default",
27
- merchant_address character varying COLLATE pg_catalog."default",
28
- CONSTRAINT appointments_pkey PRIMARY KEY (appointment_id),
29
- CONSTRAINT appointments_cleared_amount_check CHECK (cleared_amount >= 0::numeric),
30
- CONSTRAINT appointments_discount_check CHECK (discount >= 0::numeric),
31
- CONSTRAINT appointments_payment_mode_check CHECK (lower(payment_mode::text) = ANY (ARRAY['online'::text, 'offline'::text])),
32
- CONSTRAINT appointments_total_amount_check CHECK (total_amount > 0::numeric)
33
- )
34
-
35
- TABLESPACE pg_default;
36
-
37
- ALTER TABLE IF EXISTS public.appointments
38
- OWNER to trans_owner;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
database-scripts/create_orders.sql DELETED
@@ -1,22 +0,0 @@
1
- -- Table: public.razorpay_orders
2
-
3
- -- DROP TABLE IF EXISTS public.razorpay_orders;
4
-
5
- CREATE TABLE IF NOT EXISTS public.razorpay_orders
6
- (
7
- id integer NOT NULL DEFAULT nextval('razorpay_orders_id_seq'::regclass),
8
- order_id character varying(255) COLLATE pg_catalog."default" NOT NULL,
9
- customer_id character varying(255) COLLATE pg_catalog."default" NOT NULL,
10
- amount numeric(10,2) NOT NULL,
11
- currency character varying(10) COLLATE pg_catalog."default" NOT NULL,
12
- status character varying(20) COLLATE pg_catalog."default" DEFAULT 'pending'::character varying,
13
- created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
14
- CONSTRAINT razorpay_orders_pkey PRIMARY KEY (id),
15
- CONSTRAINT razorpay_orders_order_id_key UNIQUE (order_id),
16
- CONSTRAINT razorpay_orders_status_check CHECK (status::text = ANY (ARRAY['pending'::character varying, 'confirmed'::character varying, 'failed'::character varying]::text[]))
17
- )
18
-
19
- TABLESPACE pg_default;
20
-
21
- ALTER TABLE IF EXISTS public.razorpay_orders
22
- OWNER to trans_owner;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -1,28 +1,12 @@
1
- # Core dependencies
2
- fastapi==0.100.0
3
- uvicorn==0.23.2
4
- databases[postgresql]==0.6.1 # Async database interactions for PostgreSQL
5
- redis==4.6.0 # Redis support for cart management
6
- python-dotenv==1.0.0 # Environment variable management
7
- #pydantic[email]==1.10.9
8
- # Pydantic with email validation
9
-
10
- pydantic[email]
11
- email-validator==1.3.1 # Email validation support
12
-
13
- # Security and authentication
14
- python-jose==3.3.0 # JWT support
15
- bcrypt==4.0.1 # Password hashing
16
-
17
- # HTTP requests (optional but useful for external integrations)
18
- requests==2.31.0
19
-
20
- # Testing dependencies
21
- pytest==7.4.0
22
- pytest-asyncio==0.21.0
23
-
24
- razorpay
25
-
26
- # Optional for development
27
- # httpx==0.24.1
28
- # aiohttp==4.7.1
 
1
+ fastapi==0.104.1
2
+ uvicorn==0.24.0
3
+ pydantic==2.5.0
4
+ databases==0.8.0
5
+ asyncpg==0.29.0
6
+ sqlalchemy==2.0.23
7
+ redis==5.0.1
8
+ python-multipart==0.0.6
9
+ python-jose[cryptography]==3.3.0
10
+ passlib[bcrypt]==1.7.4
11
+ razorpay==1.4.2
12
+ orjson==3.11.3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/unit/test_appointment.py CHANGED
@@ -16,7 +16,6 @@ from app.repositories.appointment import (
16
  update_appointment,
17
  cancel_appointment,
18
  get_appointment_by_id,
19
- get_order_by_id,
20
  fetch_appointments_from_db,
21
  )
22
 
@@ -31,8 +30,16 @@ TEST_APPOINTMENT = Appointment(
31
  appointment_id=MOCK_APPOINTMENT_ID,
32
  customer_id=MOCK_CUSTOMER_ID,
33
  merchant_id=MOCK_MERCHANT_ID,
 
 
 
 
34
  appointment_date="2023-12-25",
35
  appointment_time="10:00",
 
 
 
 
36
  payment_mode=PaymentMode.ONLINE,
37
  order_id=MOCK_ORDER_ID,
38
  )
@@ -45,7 +52,7 @@ def mock_create_appointment():
45
 
46
  @pytest.fixture
47
  def mock_get_order_by_id():
48
- with patch("app.repositories.appointment.get_order_by_id", new_callable=AsyncMock) as mock:
49
  mock.return_value = {"status": "pending"}
50
  yield mock
51
 
@@ -93,7 +100,7 @@ async def test_reschedule_appointment_success(mock_get_appointment_by_id, mock_u
93
  "appointment_date": "2023-12-25",
94
  "appointment_time": "10:00:00",
95
  }
96
- result = await reschedule_appointment(MOCK_APPOINTMENT_ID, "2023-12-26", "11:00:00")
97
  assert result["message"] == "Appointment rescheduled successfully"
98
  mock_update_appointment.assert_called_once()
99
 
@@ -106,7 +113,7 @@ async def test_reschedule_appointment_past_date(mock_get_appointment_by_id):
106
  "appointment_time": "10:00:00",
107
  }
108
  with pytest.raises(HTTPException) as exc_info:
109
- await reschedule_appointment(MOCK_APPOINTMENT_ID, "2023-12-24", "11:00:00")
110
  assert exc_info.value.status_code == 400
111
  assert "Cannot reschedule to a past time" in str(exc_info.value.detail)
112
 
@@ -118,7 +125,7 @@ async def test_cancel_appointment_service_success(mock_get_appointment_by_id, mo
118
  "appointment_date": "2023-12-25",
119
  "appointment_time": "10:00:00",
120
  }
121
- result = await cancel_appointment_service(MOCK_APPOINTMENT_ID, "change_of_plans")
122
  assert result["message"] == "Appointment canceled successfully"
123
  mock_cancel_appointment.assert_called_once()
124
 
@@ -131,7 +138,7 @@ async def test_cancel_appointment_service_already_canceled(mock_get_appointment_
131
  "appointment_time": "10:00:00",
132
  }
133
  with pytest.raises(HTTPException) as exc_info:
134
- await cancel_appointment_service(MOCK_APPOINTMENT_ID, "change_of_plans")
135
  assert exc_info.value.status_code == 400
136
  assert "Appointment is already canceled" in str(exc_info.value.detail)
137
 
 
16
  update_appointment,
17
  cancel_appointment,
18
  get_appointment_by_id,
 
19
  fetch_appointments_from_db,
20
  )
21
 
 
30
  appointment_id=MOCK_APPOINTMENT_ID,
31
  customer_id=MOCK_CUSTOMER_ID,
32
  merchant_id=MOCK_MERCHANT_ID,
33
+ merchant_name="Test Merchant",
34
+ city="Test City",
35
+ merchant_address={"street": "123 Test St", "city": "Test City"},
36
+ location_id="LOC001",
37
  appointment_date="2023-12-25",
38
  appointment_time="10:00",
39
+ associates=[{"associate_id": "STAFF1", "name": "John Doe"}],
40
+ status=AppointmentStatus.PENDING,
41
+ services=[{"service_id": "SERV1", "name": "Test Service", "price": 100.0, "duration": "30 minutes", "quantity": 1}],
42
+ total_amount=100.0,
43
  payment_mode=PaymentMode.ONLINE,
44
  order_id=MOCK_ORDER_ID,
45
  )
 
52
 
53
  @pytest.fixture
54
  def mock_get_order_by_id():
55
+ with patch("app.repositories.payment.get_order_by_id", new_callable=AsyncMock) as mock:
56
  mock.return_value = {"status": "pending"}
57
  yield mock
58
 
 
100
  "appointment_date": "2023-12-25",
101
  "appointment_time": "10:00:00",
102
  }
103
+ result = await reschedule_appointment(MOCK_APPOINTMENT_ID, "2023-12-26", "11:00:00", MOCK_CUSTOMER_ID)
104
  assert result["message"] == "Appointment rescheduled successfully"
105
  mock_update_appointment.assert_called_once()
106
 
 
113
  "appointment_time": "10:00:00",
114
  }
115
  with pytest.raises(HTTPException) as exc_info:
116
+ await reschedule_appointment(MOCK_APPOINTMENT_ID, "2023-12-24", "11:00:00", MOCK_CUSTOMER_ID)
117
  assert exc_info.value.status_code == 400
118
  assert "Cannot reschedule to a past time" in str(exc_info.value.detail)
119
 
 
125
  "appointment_date": "2023-12-25",
126
  "appointment_time": "10:00:00",
127
  }
128
+ result = await cancel_appointment_service(MOCK_APPOINTMENT_ID, "change_of_plans", MOCK_CUSTOMER_ID)
129
  assert result["message"] == "Appointment canceled successfully"
130
  mock_cancel_appointment.assert_called_once()
131
 
 
138
  "appointment_time": "10:00:00",
139
  }
140
  with pytest.raises(HTTPException) as exc_info:
141
+ await cancel_appointment_service(MOCK_APPOINTMENT_ID, "change_of_plans", MOCK_CUSTOMER_ID)
142
  assert exc_info.value.status_code == 400
143
  assert "Appointment is already canceled" in str(exc_info.value.detail)
144
 
tests/unit/test_cart.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from unittest.mock import Mock, patch
3
+ from app.services.cart import add_appointment_to_cart, retrieve_appointment_from_cart, remove_appointment_from_cart
4
+ from app.models.cart import AppointmentCart
5
+
6
+
7
+ class TestCartService:
8
+ """Test cases for Cart Service Functions"""
9
+
10
+ @pytest.fixture
11
+ def customer_id(self):
12
+ """Fixture to provide customer ID"""
13
+ return "test_customer_123"
14
+
15
+ def test_cart_functions_exist(self, customer_id):
16
+ """Test cart functions are importable"""
17
+ # This is a placeholder test - implement based on actual cart functionality
18
+ assert add_appointment_to_cart is not None
19
+ assert retrieve_appointment_from_cart is not None
20
+ assert remove_appointment_from_cart is not None
21
+
22
+ def test_cart_operations(self, customer_id):
23
+ """Test basic cart operations"""
24
+ # This is a placeholder test - implement based on actual cart functionality
25
+ pass
26
+
27
+
28
+ class TestCartModel:
29
+ """Test cases for Cart model"""
30
+
31
+ def test_cart_model_creation(self):
32
+ """Test cart model instantiation"""
33
+ # This is a placeholder test - implement based on actual cart model
34
+ pass
tests/unit/test_order.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from unittest.mock import Mock, patch
3
+ from app.services.order import OrderController
4
+
5
+
6
+ class TestOrderService:
7
+ """Test cases for OrderController"""
8
+
9
+ @pytest.fixture
10
+ def order_service(self):
11
+ """Fixture to create OrderController instance"""
12
+ with patch('app.services.order.RazorpayService'):
13
+ return OrderController()
14
+
15
+ def test_order_service_creation(self, order_service):
16
+ """Test order service instantiation"""
17
+ assert order_service is not None
18
+
19
+ @patch('app.services.order.save_order_to_db')
20
+ @patch('app.services.order.get_from_cache')
21
+ def test_order_processing(self, mock_get_cache, mock_save_order, order_service):
22
+ """Test order processing functionality"""
23
+ # Mock cache data
24
+ mock_get_cache.return_value = {
25
+ 'items': [{'id': 1, 'quantity': 2}],
26
+ 'total': 1000
27
+ }
28
+
29
+ # Mock database save
30
+ mock_save_order.return_value = True
31
+
32
+ # This is a placeholder test - implement based on actual functionality
33
+ assert True # Replace with actual test logic
34
+
35
+ def test_order_validation(self, order_service):
36
+ """Test order validation logic"""
37
+ # This is a placeholder test - implement based on actual functionality
38
+ pass
tests/unit/test_payment.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from unittest.mock import Mock, patch
3
+ from app.repositories.payment import save_order_to_db
4
+ from app.services.razorpay import RazorpayService
5
+
6
+
7
+ class TestPaymentRepository:
8
+ """Test cases for Payment repository"""
9
+
10
+ @patch('app.repositories.payment.database')
11
+ def test_save_order_to_db(self, mock_database):
12
+ """Test saving order to database"""
13
+ # Mock database response
14
+ mock_database.execute.return_value = Mock()
15
+
16
+ # Test data
17
+ order_data = {
18
+ "order_id": "test_order_123",
19
+ "amount": 1000,
20
+ "currency": "INR"
21
+ }
22
+
23
+ # This is a placeholder test - implement based on actual functionality
24
+ assert True # Replace with actual test logic
25
+
26
+
27
+ class TestRazorpayService:
28
+ """Test cases for Razorpay service"""
29
+
30
+ @pytest.fixture
31
+ def razorpay_service(self):
32
+ """Fixture to create RazorpayService instance"""
33
+ with patch.dict('os.environ', {
34
+ 'RAZORPAY_KEY_ID': 'test_key_id',
35
+ 'RAZORPAY_KEY_SECRET': 'test_key_secret'
36
+ }):
37
+ return RazorpayService()
38
+
39
+ @patch('razorpay.Client')
40
+ def test_create_order(self, mock_client, razorpay_service):
41
+ """Test order creation"""
42
+ # Mock Razorpay client
43
+ mock_order = {
44
+ 'id': 'order_test123',
45
+ 'amount': 100000,
46
+ 'currency': 'INR'
47
+ }
48
+ mock_client.return_value.order.create.return_value = mock_order
49
+
50
+ # Test order creation
51
+ result = razorpay_service.create_order(1000, "receipt_123", "INR")
52
+
53
+ assert result['id'] == 'order_test123'
54
+ assert result['amount'] == 100000