Spaces:
Sleeping
Sleeping
Commit ·
82a6cc5
1
Parent(s): d846c9a
feat: add wallet, address, pet and guest management features
Browse files- Add new routers for wallet, address, pet and guest management
- Implement models and schemas for each feature
- Add CRUD operations for wallet transactions, addresses, pets and guests
- Include validation and error handling for all new features
- app/app.py +5 -1
- app/models/address_model.py +175 -0
- app/models/guest_model.py +237 -0
- app/models/pet_model.py +220 -0
- app/models/user_model.py +34 -1
- app/models/wallet_model.py +158 -0
- app/routers/address_router.py +337 -0
- app/routers/guest_router.py +273 -0
- app/routers/pet_router.py +272 -0
- app/routers/profile_router.py +148 -0
- app/routers/wallet_router.py +223 -0
- app/schemas/address_schema.py +91 -0
- app/schemas/guest_schema.py +155 -0
- app/schemas/pet_schema.py +108 -0
- app/schemas/profile_schema.py +91 -0
- app/schemas/wallet_schema.py +71 -0
- app/services/wallet_service.py +212 -0
app/app.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
from fastapi import FastAPI
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
-
from app.routers import user_router, profile_router, account_router
|
| 6 |
from app.middleware.rate_limiter import RateLimitMiddleware
|
| 7 |
from app.middleware.security_middleware import SecurityMiddleware
|
| 8 |
import logging
|
|
@@ -48,6 +48,10 @@ app.add_middleware(
|
|
| 48 |
app.include_router(user_router.router, prefix="/auth", tags=["user_auth"])
|
| 49 |
app.include_router(profile_router.router, prefix="/profile", tags=["profile"])
|
| 50 |
app.include_router(account_router.router, prefix="/account", tags=["account_management"])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
@app.get("/")
|
| 53 |
def root():
|
|
|
|
| 2 |
|
| 3 |
from fastapi import FastAPI
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
from app.routers import user_router, profile_router, account_router, wallet_router, address_router, pet_router, guest_router
|
| 6 |
from app.middleware.rate_limiter import RateLimitMiddleware
|
| 7 |
from app.middleware.security_middleware import SecurityMiddleware
|
| 8 |
import logging
|
|
|
|
| 48 |
app.include_router(user_router.router, prefix="/auth", tags=["user_auth"])
|
| 49 |
app.include_router(profile_router.router, prefix="/profile", tags=["profile"])
|
| 50 |
app.include_router(account_router.router, prefix="/account", tags=["account_management"])
|
| 51 |
+
app.include_router(wallet_router.router, prefix="/wallet", tags=["wallet_management"])
|
| 52 |
+
app.include_router(address_router.router, prefix="/addresses", tags=["address_management"])
|
| 53 |
+
app.include_router(pet_router.router, prefix="/api/v1/users", tags=["pet_management"])
|
| 54 |
+
app.include_router(guest_router.router, prefix="/api/v1/users", tags=["guest_management"])
|
| 55 |
|
| 56 |
@app.get("/")
|
| 57 |
def root():
|
app/models/address_model.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import Optional, List, Dict, Any
|
| 3 |
+
from bson import ObjectId
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
from app.core.nosql_client import db
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
class AddressModel:
|
| 11 |
+
"""Model for managing user delivery addresses"""
|
| 12 |
+
|
| 13 |
+
collection = db["user_addresses"]
|
| 14 |
+
|
| 15 |
+
@staticmethod
|
| 16 |
+
async def create_address(user_id: str, address_data: Dict[str, Any]) -> Optional[str]:
|
| 17 |
+
"""Create a new address for a user"""
|
| 18 |
+
try:
|
| 19 |
+
address_doc = {
|
| 20 |
+
"user_id": user_id,
|
| 21 |
+
"address_line_1": address_data.get("address_line_1"),
|
| 22 |
+
"address_line_2": address_data.get("address_line_2", ""),
|
| 23 |
+
"city": address_data.get("city"),
|
| 24 |
+
"state": address_data.get("state"),
|
| 25 |
+
"postal_code": address_data.get("postal_code"),
|
| 26 |
+
"country": address_data.get("country", "India"),
|
| 27 |
+
"address_type": address_data.get("address_type", "home"), # home, work, other
|
| 28 |
+
"is_default": address_data.get("is_default", False),
|
| 29 |
+
"landmark": address_data.get("landmark", ""),
|
| 30 |
+
"contact_name": address_data.get("contact_name", ""),
|
| 31 |
+
"contact_phone": address_data.get("contact_phone", ""),
|
| 32 |
+
"created_at": datetime.utcnow(),
|
| 33 |
+
"updated_at": datetime.utcnow()
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
# If this is set as default, unset other default addresses
|
| 37 |
+
if address_doc["is_default"]:
|
| 38 |
+
await AddressModel.collection.update_many(
|
| 39 |
+
{"user_id": user_id, "is_default": True},
|
| 40 |
+
{"$set": {"is_default": False, "updated_at": datetime.utcnow()}}
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
result = await AddressModel.collection.insert_one(address_doc)
|
| 44 |
+
logger.info(f"Created address for user {user_id}")
|
| 45 |
+
return str(result.inserted_id)
|
| 46 |
+
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.error(f"Error creating address for user {user_id}: {str(e)}")
|
| 49 |
+
return None
|
| 50 |
+
|
| 51 |
+
@staticmethod
|
| 52 |
+
async def get_user_addresses(user_id: str) -> List[Dict[str, Any]]:
|
| 53 |
+
"""Get all addresses for a user"""
|
| 54 |
+
try:
|
| 55 |
+
cursor = AddressModel.collection.find({"user_id": user_id}).sort("created_at", -1)
|
| 56 |
+
addresses = []
|
| 57 |
+
|
| 58 |
+
async for address in cursor:
|
| 59 |
+
address["_id"] = str(address["_id"])
|
| 60 |
+
addresses.append(address)
|
| 61 |
+
|
| 62 |
+
return addresses
|
| 63 |
+
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.error(f"Error getting addresses for user {user_id}: {str(e)}")
|
| 66 |
+
return []
|
| 67 |
+
|
| 68 |
+
@staticmethod
|
| 69 |
+
async def get_address_by_id(user_id: str, address_id: str) -> Optional[Dict[str, Any]]:
|
| 70 |
+
"""Get a specific address by ID for a user"""
|
| 71 |
+
try:
|
| 72 |
+
address = await AddressModel.collection.find_one({
|
| 73 |
+
"_id": ObjectId(address_id),
|
| 74 |
+
"user_id": user_id
|
| 75 |
+
})
|
| 76 |
+
|
| 77 |
+
if address:
|
| 78 |
+
address["_id"] = str(address["_id"])
|
| 79 |
+
|
| 80 |
+
return address
|
| 81 |
+
|
| 82 |
+
except Exception as e:
|
| 83 |
+
logger.error(f"Error getting address {address_id} for user {user_id}: {str(e)}")
|
| 84 |
+
return None
|
| 85 |
+
|
| 86 |
+
@staticmethod
|
| 87 |
+
async def update_address(user_id: str, address_id: str, update_data: Dict[str, Any]) -> bool:
|
| 88 |
+
"""Update an existing address"""
|
| 89 |
+
try:
|
| 90 |
+
# Prepare update data
|
| 91 |
+
update_fields = {}
|
| 92 |
+
allowed_fields = [
|
| 93 |
+
"address_line_1", "address_line_2", "city", "state", "postal_code",
|
| 94 |
+
"country", "address_type", "is_default", "landmark", "contact_name", "contact_phone"
|
| 95 |
+
]
|
| 96 |
+
|
| 97 |
+
for field in allowed_fields:
|
| 98 |
+
if field in update_data:
|
| 99 |
+
update_fields[field] = update_data[field]
|
| 100 |
+
|
| 101 |
+
update_fields["updated_at"] = datetime.utcnow()
|
| 102 |
+
|
| 103 |
+
# If setting as default, unset other default addresses
|
| 104 |
+
if update_fields.get("is_default"):
|
| 105 |
+
await AddressModel.collection.update_many(
|
| 106 |
+
{"user_id": user_id, "is_default": True, "_id": {"$ne": ObjectId(address_id)}},
|
| 107 |
+
{"$set": {"is_default": False, "updated_at": datetime.utcnow()}}
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
result = await AddressModel.collection.update_one(
|
| 111 |
+
{"_id": ObjectId(address_id), "user_id": user_id},
|
| 112 |
+
{"$set": update_fields}
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
return result.modified_count > 0
|
| 116 |
+
|
| 117 |
+
except Exception as e:
|
| 118 |
+
logger.error(f"Error updating address {address_id} for user {user_id}: {str(e)}")
|
| 119 |
+
return False
|
| 120 |
+
|
| 121 |
+
@staticmethod
|
| 122 |
+
async def delete_address(user_id: str, address_id: str) -> bool:
|
| 123 |
+
"""Delete an address"""
|
| 124 |
+
try:
|
| 125 |
+
result = await AddressModel.collection.delete_one({
|
| 126 |
+
"_id": ObjectId(address_id),
|
| 127 |
+
"user_id": user_id
|
| 128 |
+
})
|
| 129 |
+
|
| 130 |
+
logger.info(f"Deleted address {address_id} for user {user_id}")
|
| 131 |
+
return result.deleted_count > 0
|
| 132 |
+
|
| 133 |
+
except Exception as e:
|
| 134 |
+
logger.error(f"Error deleting address {address_id} for user {user_id}: {str(e)}")
|
| 135 |
+
return False
|
| 136 |
+
|
| 137 |
+
@staticmethod
|
| 138 |
+
async def get_default_address(user_id: str) -> Optional[Dict[str, Any]]:
|
| 139 |
+
"""Get the default address for a user"""
|
| 140 |
+
try:
|
| 141 |
+
address = await AddressModel.collection.find_one({
|
| 142 |
+
"user_id": user_id,
|
| 143 |
+
"is_default": True
|
| 144 |
+
})
|
| 145 |
+
|
| 146 |
+
if address:
|
| 147 |
+
address["_id"] = str(address["_id"])
|
| 148 |
+
|
| 149 |
+
return address
|
| 150 |
+
|
| 151 |
+
except Exception as e:
|
| 152 |
+
logger.error(f"Error getting default address for user {user_id}: {str(e)}")
|
| 153 |
+
return None
|
| 154 |
+
|
| 155 |
+
@staticmethod
|
| 156 |
+
async def set_default_address(user_id: str, address_id: str) -> bool:
|
| 157 |
+
"""Set an address as default"""
|
| 158 |
+
try:
|
| 159 |
+
# First, unset all default addresses for the user
|
| 160 |
+
await AddressModel.collection.update_many(
|
| 161 |
+
{"user_id": user_id, "is_default": True},
|
| 162 |
+
{"$set": {"is_default": False, "updated_at": datetime.utcnow()}}
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
# Then set the specified address as default
|
| 166 |
+
result = await AddressModel.collection.update_one(
|
| 167 |
+
{"_id": ObjectId(address_id), "user_id": user_id},
|
| 168 |
+
{"$set": {"is_default": True, "updated_at": datetime.utcnow()}}
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
return result.modified_count > 0
|
| 172 |
+
|
| 173 |
+
except Exception as e:
|
| 174 |
+
logger.error(f"Error setting default address {address_id} for user {user_id}: {str(e)}")
|
| 175 |
+
return False
|
app/models/guest_model.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.core.nosql_client import db
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import List, Optional, Dict, Any
|
| 4 |
+
import uuid
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
class GuestModel:
|
| 10 |
+
"""Model for managing guest profiles in the database"""
|
| 11 |
+
|
| 12 |
+
@staticmethod
|
| 13 |
+
async def create_guest(
|
| 14 |
+
user_id: str,
|
| 15 |
+
first_name: str,
|
| 16 |
+
last_name: Optional[str] = None,
|
| 17 |
+
email: Optional[str] = None,
|
| 18 |
+
phone_number: Optional[str] = None,
|
| 19 |
+
gender: Optional[str] = None,
|
| 20 |
+
date_of_birth: Optional[datetime] = None,
|
| 21 |
+
relationship: Optional[str] = None,
|
| 22 |
+
notes: Optional[str] = None
|
| 23 |
+
) -> Optional[str]:
|
| 24 |
+
"""
|
| 25 |
+
Create a new guest profile for a user.
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
user_id: ID of the user creating the guest profile
|
| 29 |
+
first_name: Guest's first name
|
| 30 |
+
last_name: Guest's last name
|
| 31 |
+
email: Guest's email address
|
| 32 |
+
phone_number: Guest's phone number
|
| 33 |
+
gender: Guest's gender
|
| 34 |
+
date_of_birth: Guest's date of birth
|
| 35 |
+
relationship: Relationship to the user
|
| 36 |
+
notes: Additional notes about the guest
|
| 37 |
+
|
| 38 |
+
Returns:
|
| 39 |
+
Guest ID if successful, None otherwise
|
| 40 |
+
"""
|
| 41 |
+
try:
|
| 42 |
+
guests_collection = db.guests
|
| 43 |
+
|
| 44 |
+
guest_id = str(uuid.uuid4())
|
| 45 |
+
current_time = datetime.utcnow()
|
| 46 |
+
|
| 47 |
+
guest_data = {
|
| 48 |
+
"guest_id": guest_id,
|
| 49 |
+
"user_id": user_id,
|
| 50 |
+
"first_name": first_name,
|
| 51 |
+
"last_name": last_name,
|
| 52 |
+
"email": email,
|
| 53 |
+
"phone_number": phone_number,
|
| 54 |
+
"gender": gender,
|
| 55 |
+
"date_of_birth": date_of_birth,
|
| 56 |
+
"relationship": relationship,
|
| 57 |
+
"notes": notes,
|
| 58 |
+
"created_at": current_time,
|
| 59 |
+
"updated_at": current_time
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
result = await guests_collection.insert_one(guest_data)
|
| 63 |
+
|
| 64 |
+
if result.inserted_id:
|
| 65 |
+
logger.info(f"Guest created successfully: {guest_id} for user: {user_id}")
|
| 66 |
+
return guest_id
|
| 67 |
+
else:
|
| 68 |
+
logger.error(f"Failed to create guest for user: {user_id}")
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
except Exception as e:
|
| 72 |
+
logger.error(f"Error creating guest for user {user_id}: {str(e)}")
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
@staticmethod
|
| 76 |
+
async def get_user_guests(user_id: str) -> List[Dict[str, Any]]:
|
| 77 |
+
"""
|
| 78 |
+
Get all guests for a specific user.
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
user_id: ID of the user
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
List of guest documents
|
| 85 |
+
"""
|
| 86 |
+
try:
|
| 87 |
+
guests_collection = db.guests
|
| 88 |
+
|
| 89 |
+
cursor = guests_collection.find({"user_id": user_id})
|
| 90 |
+
guests = await cursor.to_list(length=None)
|
| 91 |
+
|
| 92 |
+
# Remove MongoDB's _id field
|
| 93 |
+
for guest in guests:
|
| 94 |
+
guest.pop("_id", None)
|
| 95 |
+
|
| 96 |
+
logger.info(f"Retrieved {len(guests)} guests for user: {user_id}")
|
| 97 |
+
return guests
|
| 98 |
+
|
| 99 |
+
except Exception as e:
|
| 100 |
+
logger.error(f"Error getting guests for user {user_id}: {str(e)}")
|
| 101 |
+
return []
|
| 102 |
+
|
| 103 |
+
@staticmethod
|
| 104 |
+
async def get_guest_by_id(guest_id: str) -> Optional[Dict[str, Any]]:
|
| 105 |
+
"""
|
| 106 |
+
Get a specific guest by ID.
|
| 107 |
+
|
| 108 |
+
Args:
|
| 109 |
+
guest_id: ID of the guest
|
| 110 |
+
|
| 111 |
+
Returns:
|
| 112 |
+
Guest document if found, None otherwise
|
| 113 |
+
"""
|
| 114 |
+
try:
|
| 115 |
+
guests_collection = db.guests
|
| 116 |
+
|
| 117 |
+
guest = await guests_collection.find_one({"guest_id": guest_id})
|
| 118 |
+
|
| 119 |
+
if guest:
|
| 120 |
+
guest.pop("_id", None)
|
| 121 |
+
logger.info(f"Guest found: {guest_id}")
|
| 122 |
+
return guest
|
| 123 |
+
else:
|
| 124 |
+
logger.warning(f"Guest not found: {guest_id}")
|
| 125 |
+
return None
|
| 126 |
+
|
| 127 |
+
except Exception as e:
|
| 128 |
+
logger.error(f"Error getting guest {guest_id}: {str(e)}")
|
| 129 |
+
return None
|
| 130 |
+
|
| 131 |
+
@staticmethod
|
| 132 |
+
async def update_guest(guest_id: str, update_fields: Dict[str, Any]) -> bool:
|
| 133 |
+
"""
|
| 134 |
+
Update a guest's information.
|
| 135 |
+
|
| 136 |
+
Args:
|
| 137 |
+
guest_id: ID of the guest to update
|
| 138 |
+
update_fields: Dictionary of fields to update
|
| 139 |
+
|
| 140 |
+
Returns:
|
| 141 |
+
True if successful, False otherwise
|
| 142 |
+
"""
|
| 143 |
+
try:
|
| 144 |
+
guests_collection = db.guests
|
| 145 |
+
|
| 146 |
+
# Add updated timestamp
|
| 147 |
+
update_fields["updated_at"] = datetime.utcnow()
|
| 148 |
+
|
| 149 |
+
result = await guests_collection.update_one(
|
| 150 |
+
{"guest_id": guest_id},
|
| 151 |
+
{"$set": update_fields}
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
if result.modified_count > 0:
|
| 155 |
+
logger.info(f"Guest updated successfully: {guest_id}")
|
| 156 |
+
return True
|
| 157 |
+
else:
|
| 158 |
+
logger.warning(f"No changes made to guest: {guest_id}")
|
| 159 |
+
return False
|
| 160 |
+
|
| 161 |
+
except Exception as e:
|
| 162 |
+
logger.error(f"Error updating guest {guest_id}: {str(e)}")
|
| 163 |
+
return False
|
| 164 |
+
|
| 165 |
+
@staticmethod
|
| 166 |
+
async def delete_guest(guest_id: str) -> bool:
|
| 167 |
+
"""
|
| 168 |
+
Delete a guest profile.
|
| 169 |
+
|
| 170 |
+
Args:
|
| 171 |
+
guest_id: ID of the guest to delete
|
| 172 |
+
|
| 173 |
+
Returns:
|
| 174 |
+
True if successful, False otherwise
|
| 175 |
+
"""
|
| 176 |
+
try:
|
| 177 |
+
guests_collection = db.guests
|
| 178 |
+
|
| 179 |
+
result = await guests_collection.delete_one({"guest_id": guest_id})
|
| 180 |
+
|
| 181 |
+
if result.deleted_count > 0:
|
| 182 |
+
logger.info(f"Guest deleted successfully: {guest_id}")
|
| 183 |
+
return True
|
| 184 |
+
else:
|
| 185 |
+
logger.warning(f"Guest not found for deletion: {guest_id}")
|
| 186 |
+
return False
|
| 187 |
+
|
| 188 |
+
except Exception as e:
|
| 189 |
+
logger.error(f"Error deleting guest {guest_id}: {str(e)}")
|
| 190 |
+
return False
|
| 191 |
+
|
| 192 |
+
@staticmethod
|
| 193 |
+
async def get_guest_count_for_user(user_id: str) -> int:
|
| 194 |
+
"""
|
| 195 |
+
Get the total number of guests for a user.
|
| 196 |
+
|
| 197 |
+
Args:
|
| 198 |
+
user_id: ID of the user
|
| 199 |
+
|
| 200 |
+
Returns:
|
| 201 |
+
Number of guests
|
| 202 |
+
"""
|
| 203 |
+
try:
|
| 204 |
+
guests_collection = db.guests
|
| 205 |
+
|
| 206 |
+
count = await guests_collection.count_documents({"user_id": user_id})
|
| 207 |
+
return count
|
| 208 |
+
|
| 209 |
+
except Exception as e:
|
| 210 |
+
logger.error(f"Error counting guests for user {user_id}: {str(e)}")
|
| 211 |
+
return 0
|
| 212 |
+
|
| 213 |
+
@staticmethod
|
| 214 |
+
async def check_guest_ownership(guest_id: str, user_id: str) -> bool:
|
| 215 |
+
"""
|
| 216 |
+
Check if a guest belongs to a specific user.
|
| 217 |
+
|
| 218 |
+
Args:
|
| 219 |
+
guest_id: ID of the guest
|
| 220 |
+
user_id: ID of the user
|
| 221 |
+
|
| 222 |
+
Returns:
|
| 223 |
+
True if guest belongs to user, False otherwise
|
| 224 |
+
"""
|
| 225 |
+
try:
|
| 226 |
+
guests_collection = db.guests
|
| 227 |
+
|
| 228 |
+
guest = await guests_collection.find_one({
|
| 229 |
+
"guest_id": guest_id,
|
| 230 |
+
"user_id": user_id
|
| 231 |
+
})
|
| 232 |
+
|
| 233 |
+
return guest is not None
|
| 234 |
+
|
| 235 |
+
except Exception as e:
|
| 236 |
+
logger.error(f"Error checking guest ownership {guest_id} for user {user_id}: {str(e)}")
|
| 237 |
+
return False
|
app/models/pet_model.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.core.nosql_client import db
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import List, Optional, Dict, Any
|
| 4 |
+
import uuid
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
class PetModel:
|
| 10 |
+
"""Model for managing pet profiles in the database"""
|
| 11 |
+
|
| 12 |
+
@staticmethod
|
| 13 |
+
async def create_pet(
|
| 14 |
+
user_id: str,
|
| 15 |
+
pet_name: str,
|
| 16 |
+
species: str,
|
| 17 |
+
breed: Optional[str] = None,
|
| 18 |
+
date_of_birth: Optional[datetime] = None,
|
| 19 |
+
age: Optional[int] = None,
|
| 20 |
+
weight: Optional[float] = None,
|
| 21 |
+
gender: Optional[str] = None,
|
| 22 |
+
temperament: Optional[str] = None,
|
| 23 |
+
health_notes: Optional[str] = None,
|
| 24 |
+
is_vaccinated: bool = False,
|
| 25 |
+
pet_photo_url: Optional[str] = None
|
| 26 |
+
) -> Optional[str]:
|
| 27 |
+
"""
|
| 28 |
+
Create a new pet profile for a user.
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
user_id: ID of the pet owner
|
| 32 |
+
pet_name: Name of the pet
|
| 33 |
+
species: Species (Dog, Cat, Other)
|
| 34 |
+
breed: Breed of the pet
|
| 35 |
+
date_of_birth: Pet's date of birth
|
| 36 |
+
age: Pet's age (if DOB not provided)
|
| 37 |
+
weight: Pet's weight
|
| 38 |
+
gender: Pet's gender
|
| 39 |
+
temperament: Pet's temperament
|
| 40 |
+
health_notes: Health notes including allergies/medications
|
| 41 |
+
is_vaccinated: Vaccination status
|
| 42 |
+
pet_photo_url: URL to pet's photo
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
Pet ID if successful, None otherwise
|
| 46 |
+
"""
|
| 47 |
+
try:
|
| 48 |
+
pets_collection = db.pets
|
| 49 |
+
|
| 50 |
+
pet_id = str(uuid.uuid4())
|
| 51 |
+
current_time = datetime.utcnow()
|
| 52 |
+
|
| 53 |
+
pet_data = {
|
| 54 |
+
"pet_id": pet_id,
|
| 55 |
+
"user_id": user_id,
|
| 56 |
+
"pet_name": pet_name,
|
| 57 |
+
"species": species,
|
| 58 |
+
"breed": breed,
|
| 59 |
+
"date_of_birth": date_of_birth,
|
| 60 |
+
"age": age,
|
| 61 |
+
"weight": weight,
|
| 62 |
+
"gender": gender,
|
| 63 |
+
"temperament": temperament,
|
| 64 |
+
"health_notes": health_notes,
|
| 65 |
+
"is_vaccinated": is_vaccinated,
|
| 66 |
+
"pet_photo_url": pet_photo_url,
|
| 67 |
+
"created_at": current_time,
|
| 68 |
+
"updated_at": current_time
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
result = await pets_collection.insert_one(pet_data)
|
| 72 |
+
|
| 73 |
+
if result.inserted_id:
|
| 74 |
+
logger.info(f"Pet created successfully: {pet_id} for user: {user_id}")
|
| 75 |
+
return pet_id
|
| 76 |
+
else:
|
| 77 |
+
logger.error(f"Failed to create pet for user: {user_id}")
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger.error(f"Error creating pet for user {user_id}: {str(e)}")
|
| 82 |
+
return None
|
| 83 |
+
|
| 84 |
+
@staticmethod
|
| 85 |
+
async def get_user_pets(user_id: str) -> List[Dict[str, Any]]:
|
| 86 |
+
"""
|
| 87 |
+
Get all pets for a specific user.
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
user_id: ID of the pet owner
|
| 91 |
+
|
| 92 |
+
Returns:
|
| 93 |
+
List of pet documents
|
| 94 |
+
"""
|
| 95 |
+
try:
|
| 96 |
+
pets_collection = db.pets
|
| 97 |
+
|
| 98 |
+
cursor = pets_collection.find({"user_id": user_id})
|
| 99 |
+
pets = await cursor.to_list(length=None)
|
| 100 |
+
|
| 101 |
+
# Remove MongoDB's _id field
|
| 102 |
+
for pet in pets:
|
| 103 |
+
pet.pop("_id", None)
|
| 104 |
+
|
| 105 |
+
logger.info(f"Retrieved {len(pets)} pets for user: {user_id}")
|
| 106 |
+
return pets
|
| 107 |
+
|
| 108 |
+
except Exception as e:
|
| 109 |
+
logger.error(f"Error getting pets for user {user_id}: {str(e)}")
|
| 110 |
+
return []
|
| 111 |
+
|
| 112 |
+
@staticmethod
|
| 113 |
+
async def get_pet_by_id(pet_id: str) -> Optional[Dict[str, Any]]:
|
| 114 |
+
"""
|
| 115 |
+
Get a specific pet by ID.
|
| 116 |
+
|
| 117 |
+
Args:
|
| 118 |
+
pet_id: ID of the pet
|
| 119 |
+
|
| 120 |
+
Returns:
|
| 121 |
+
Pet document if found, None otherwise
|
| 122 |
+
"""
|
| 123 |
+
try:
|
| 124 |
+
pets_collection = db.pets
|
| 125 |
+
|
| 126 |
+
pet = await pets_collection.find_one({"pet_id": pet_id})
|
| 127 |
+
|
| 128 |
+
if pet:
|
| 129 |
+
pet.pop("_id", None)
|
| 130 |
+
logger.info(f"Pet found: {pet_id}")
|
| 131 |
+
return pet
|
| 132 |
+
else:
|
| 133 |
+
logger.warning(f"Pet not found: {pet_id}")
|
| 134 |
+
return None
|
| 135 |
+
|
| 136 |
+
except Exception as e:
|
| 137 |
+
logger.error(f"Error getting pet {pet_id}: {str(e)}")
|
| 138 |
+
return None
|
| 139 |
+
|
| 140 |
+
@staticmethod
|
| 141 |
+
async def update_pet(pet_id: str, update_fields: Dict[str, Any]) -> bool:
|
| 142 |
+
"""
|
| 143 |
+
Update a pet's information.
|
| 144 |
+
|
| 145 |
+
Args:
|
| 146 |
+
pet_id: ID of the pet to update
|
| 147 |
+
update_fields: Dictionary of fields to update
|
| 148 |
+
|
| 149 |
+
Returns:
|
| 150 |
+
True if successful, False otherwise
|
| 151 |
+
"""
|
| 152 |
+
try:
|
| 153 |
+
pets_collection = db.pets
|
| 154 |
+
|
| 155 |
+
# Add updated timestamp
|
| 156 |
+
update_fields["updated_at"] = datetime.utcnow()
|
| 157 |
+
|
| 158 |
+
result = await pets_collection.update_one(
|
| 159 |
+
{"pet_id": pet_id},
|
| 160 |
+
{"$set": update_fields}
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
if result.modified_count > 0:
|
| 164 |
+
logger.info(f"Pet updated successfully: {pet_id}")
|
| 165 |
+
return True
|
| 166 |
+
else:
|
| 167 |
+
logger.warning(f"No changes made to pet: {pet_id}")
|
| 168 |
+
return False
|
| 169 |
+
|
| 170 |
+
except Exception as e:
|
| 171 |
+
logger.error(f"Error updating pet {pet_id}: {str(e)}")
|
| 172 |
+
return False
|
| 173 |
+
|
| 174 |
+
@staticmethod
|
| 175 |
+
async def delete_pet(pet_id: str) -> bool:
|
| 176 |
+
"""
|
| 177 |
+
Delete a pet profile.
|
| 178 |
+
|
| 179 |
+
Args:
|
| 180 |
+
pet_id: ID of the pet to delete
|
| 181 |
+
|
| 182 |
+
Returns:
|
| 183 |
+
True if successful, False otherwise
|
| 184 |
+
"""
|
| 185 |
+
try:
|
| 186 |
+
pets_collection = db.pets
|
| 187 |
+
|
| 188 |
+
result = await pets_collection.delete_one({"pet_id": pet_id})
|
| 189 |
+
|
| 190 |
+
if result.deleted_count > 0:
|
| 191 |
+
logger.info(f"Pet deleted successfully: {pet_id}")
|
| 192 |
+
return True
|
| 193 |
+
else:
|
| 194 |
+
logger.warning(f"Pet not found for deletion: {pet_id}")
|
| 195 |
+
return False
|
| 196 |
+
|
| 197 |
+
except Exception as e:
|
| 198 |
+
logger.error(f"Error deleting pet {pet_id}: {str(e)}")
|
| 199 |
+
return False
|
| 200 |
+
|
| 201 |
+
@staticmethod
|
| 202 |
+
async def get_pet_count_for_user(user_id: str) -> int:
|
| 203 |
+
"""
|
| 204 |
+
Get the total number of pets for a user.
|
| 205 |
+
|
| 206 |
+
Args:
|
| 207 |
+
user_id: ID of the pet owner
|
| 208 |
+
|
| 209 |
+
Returns:
|
| 210 |
+
Number of pets
|
| 211 |
+
"""
|
| 212 |
+
try:
|
| 213 |
+
pets_collection = db.pets
|
| 214 |
+
|
| 215 |
+
count = await pets_collection.count_documents({"user_id": user_id})
|
| 216 |
+
return count
|
| 217 |
+
|
| 218 |
+
except Exception as e:
|
| 219 |
+
logger.error(f"Error counting pets for user {user_id}: {str(e)}")
|
| 220 |
+
return 0
|
app/models/user_model.py
CHANGED
|
@@ -123,4 +123,37 @@ class BookMyServiceUserModel:
|
|
| 123 |
|
| 124 |
except ValueError as ve:
|
| 125 |
logger.error(f"Validation error for identifier {identifier}: {str(ve)}")
|
| 126 |
-
raise HTTPException(status_code=400, detail=str(ve))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
except ValueError as ve:
|
| 125 |
logger.error(f"Validation error for identifier {identifier}: {str(ve)}")
|
| 126 |
+
raise HTTPException(status_code=400, detail=str(ve))
|
| 127 |
+
|
| 128 |
+
@staticmethod
|
| 129 |
+
async def update_profile(user_id: str, update_fields: dict):
|
| 130 |
+
"""Update user profile by user_id"""
|
| 131 |
+
try:
|
| 132 |
+
from datetime import datetime
|
| 133 |
+
|
| 134 |
+
# Add updated_at timestamp
|
| 135 |
+
update_fields["updated_at"] = datetime.utcnow()
|
| 136 |
+
|
| 137 |
+
result = await BookMyServiceUserModel.collection.update_one(
|
| 138 |
+
{"user_id": user_id},
|
| 139 |
+
{"$set": update_fields}
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
if result.matched_count == 0:
|
| 143 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 144 |
+
|
| 145 |
+
return result.modified_count > 0
|
| 146 |
+
|
| 147 |
+
except Exception as e:
|
| 148 |
+
logger.error(f"Error updating profile for user {user_id}: {str(e)}")
|
| 149 |
+
raise HTTPException(status_code=500, detail="Failed to update profile")
|
| 150 |
+
|
| 151 |
+
@staticmethod
|
| 152 |
+
async def find_by_id(user_id: str):
|
| 153 |
+
"""Find user by user_id"""
|
| 154 |
+
try:
|
| 155 |
+
user = await BookMyServiceUserModel.collection.find_one({"user_id": user_id})
|
| 156 |
+
return user
|
| 157 |
+
except Exception as e:
|
| 158 |
+
logger.error(f"Error finding user by ID {user_id}: {str(e)}")
|
| 159 |
+
return None
|
app/models/wallet_model.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import Optional, List, Dict, Any
|
| 3 |
+
from bson import ObjectId
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
from app.core.nosql_client import db
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
class WalletModel:
|
| 11 |
+
"""Model for managing user wallet operations"""
|
| 12 |
+
|
| 13 |
+
wallet_collection = db["user_wallets"]
|
| 14 |
+
transaction_collection = db["wallet_transactions"]
|
| 15 |
+
|
| 16 |
+
@staticmethod
|
| 17 |
+
async def get_wallet_balance(user_id: str) -> float:
|
| 18 |
+
"""Get current wallet balance for a user"""
|
| 19 |
+
try:
|
| 20 |
+
wallet = await WalletModel.wallet_collection.find_one({"user_id": user_id})
|
| 21 |
+
if wallet:
|
| 22 |
+
return wallet.get("balance", 0.0)
|
| 23 |
+
else:
|
| 24 |
+
# Create wallet if doesn't exist
|
| 25 |
+
await WalletModel.create_wallet(user_id)
|
| 26 |
+
return 0.0
|
| 27 |
+
except Exception as e:
|
| 28 |
+
logger.error(f"Error getting wallet balance for user {user_id}: {str(e)}")
|
| 29 |
+
return 0.0
|
| 30 |
+
|
| 31 |
+
@staticmethod
|
| 32 |
+
async def create_wallet(user_id: str, initial_balance: float = 0.0) -> bool:
|
| 33 |
+
"""Create a new wallet for a user"""
|
| 34 |
+
try:
|
| 35 |
+
wallet_doc = {
|
| 36 |
+
"user_id": user_id,
|
| 37 |
+
"balance": initial_balance,
|
| 38 |
+
"created_at": datetime.utcnow(),
|
| 39 |
+
"updated_at": datetime.utcnow()
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
result = await WalletModel.wallet_collection.insert_one(wallet_doc)
|
| 43 |
+
logger.info(f"Created wallet for user {user_id} with balance {initial_balance}")
|
| 44 |
+
return result.inserted_id is not None
|
| 45 |
+
except Exception as e:
|
| 46 |
+
logger.error(f"Error creating wallet for user {user_id}: {str(e)}")
|
| 47 |
+
return False
|
| 48 |
+
|
| 49 |
+
@staticmethod
|
| 50 |
+
async def update_balance(user_id: str, amount: float, transaction_type: str,
|
| 51 |
+
description: str = "", reference_id: str = None) -> bool:
|
| 52 |
+
"""Update wallet balance and create transaction record"""
|
| 53 |
+
try:
|
| 54 |
+
# Get current balance
|
| 55 |
+
current_balance = await WalletModel.get_wallet_balance(user_id)
|
| 56 |
+
|
| 57 |
+
# Calculate new balance
|
| 58 |
+
if transaction_type in ["credit", "refund", "cashback"]:
|
| 59 |
+
new_balance = current_balance + amount
|
| 60 |
+
elif transaction_type in ["debit", "payment", "withdrawal"]:
|
| 61 |
+
if current_balance < amount:
|
| 62 |
+
logger.warning(f"Insufficient balance for user {user_id}. Current: {current_balance}, Required: {amount}")
|
| 63 |
+
return False
|
| 64 |
+
new_balance = current_balance - amount
|
| 65 |
+
else:
|
| 66 |
+
logger.error(f"Invalid transaction type: {transaction_type}")
|
| 67 |
+
return False
|
| 68 |
+
|
| 69 |
+
# Update wallet balance
|
| 70 |
+
update_result = await WalletModel.wallet_collection.update_one(
|
| 71 |
+
{"user_id": user_id},
|
| 72 |
+
{
|
| 73 |
+
"$set": {
|
| 74 |
+
"balance": new_balance,
|
| 75 |
+
"updated_at": datetime.utcnow()
|
| 76 |
+
}
|
| 77 |
+
},
|
| 78 |
+
upsert=True
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
# Create transaction record
|
| 82 |
+
transaction_doc = {
|
| 83 |
+
"user_id": user_id,
|
| 84 |
+
"amount": amount,
|
| 85 |
+
"transaction_type": transaction_type,
|
| 86 |
+
"description": description,
|
| 87 |
+
"reference_id": reference_id,
|
| 88 |
+
"balance_before": current_balance,
|
| 89 |
+
"balance_after": new_balance,
|
| 90 |
+
"timestamp": datetime.utcnow(),
|
| 91 |
+
"status": "completed"
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
await WalletModel.transaction_collection.insert_one(transaction_doc)
|
| 95 |
+
|
| 96 |
+
logger.info(f"Updated wallet for user {user_id}: {transaction_type} of {amount}, new balance: {new_balance}")
|
| 97 |
+
return True
|
| 98 |
+
|
| 99 |
+
except Exception as e:
|
| 100 |
+
logger.error(f"Error updating wallet balance for user {user_id}: {str(e)}")
|
| 101 |
+
return False
|
| 102 |
+
|
| 103 |
+
@staticmethod
|
| 104 |
+
async def get_transaction_history(user_id: str, page: int = 1, per_page: int = 20) -> Dict[str, Any]:
|
| 105 |
+
"""Get paginated transaction history for a user"""
|
| 106 |
+
try:
|
| 107 |
+
skip = (page - 1) * per_page
|
| 108 |
+
|
| 109 |
+
# Get transactions with pagination
|
| 110 |
+
cursor = WalletModel.transaction_collection.find(
|
| 111 |
+
{"user_id": user_id}
|
| 112 |
+
).sort("timestamp", -1).skip(skip).limit(per_page)
|
| 113 |
+
|
| 114 |
+
transactions = []
|
| 115 |
+
async for transaction in cursor:
|
| 116 |
+
# Convert ObjectId to string for JSON serialization
|
| 117 |
+
transaction["_id"] = str(transaction["_id"])
|
| 118 |
+
transactions.append(transaction)
|
| 119 |
+
|
| 120 |
+
# Get total count
|
| 121 |
+
total_count = await WalletModel.transaction_collection.count_documents({"user_id": user_id})
|
| 122 |
+
|
| 123 |
+
return {
|
| 124 |
+
"transactions": transactions,
|
| 125 |
+
"total_count": total_count,
|
| 126 |
+
"page": page,
|
| 127 |
+
"per_page": per_page,
|
| 128 |
+
"total_pages": (total_count + per_page - 1) // per_page
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger.error(f"Error getting transaction history for user {user_id}: {str(e)}")
|
| 133 |
+
return {
|
| 134 |
+
"transactions": [],
|
| 135 |
+
"total_count": 0,
|
| 136 |
+
"page": page,
|
| 137 |
+
"per_page": per_page,
|
| 138 |
+
"total_pages": 0
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
@staticmethod
|
| 142 |
+
async def get_wallet_summary(user_id: str) -> Dict[str, Any]:
|
| 143 |
+
"""Get wallet summary including balance and recent transactions"""
|
| 144 |
+
try:
|
| 145 |
+
balance = await WalletModel.get_wallet_balance(user_id)
|
| 146 |
+
recent_transactions = await WalletModel.get_transaction_history(user_id, page=1, per_page=5)
|
| 147 |
+
|
| 148 |
+
return {
|
| 149 |
+
"balance": balance,
|
| 150 |
+
"recent_transactions": recent_transactions["transactions"]
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
except Exception as e:
|
| 154 |
+
logger.error(f"Error getting wallet summary for user {user_id}: {str(e)}")
|
| 155 |
+
return {
|
| 156 |
+
"balance": 0.0,
|
| 157 |
+
"recent_transactions": []
|
| 158 |
+
}
|
app/routers/address_router.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from typing import List
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
from app.utils.jwt import get_current_user_id
|
| 6 |
+
from app.models.address_model import AddressModel
|
| 7 |
+
from app.schemas.address_schema import (
|
| 8 |
+
AddressCreateRequest, AddressUpdateRequest, AddressResponse,
|
| 9 |
+
AddressListResponse, SetDefaultAddressRequest, AddressOperationResponse
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
|
| 16 |
+
@router.get("/", response_model=AddressListResponse)
|
| 17 |
+
async def get_user_addresses(current_user_id: str = Depends(get_current_user_id)):
|
| 18 |
+
"""
|
| 19 |
+
Get all delivery addresses for the current user.
|
| 20 |
+
|
| 21 |
+
This endpoint is JWT protected and requires a valid Bearer token.
|
| 22 |
+
"""
|
| 23 |
+
try:
|
| 24 |
+
logger.info(f"Get addresses request for user: {current_user_id}")
|
| 25 |
+
|
| 26 |
+
addresses = await AddressModel.get_user_addresses(current_user_id)
|
| 27 |
+
|
| 28 |
+
address_responses = []
|
| 29 |
+
for addr in addresses:
|
| 30 |
+
address_responses.append(AddressResponse(
|
| 31 |
+
address_id=addr["address_id"],
|
| 32 |
+
user_id=addr["user_id"],
|
| 33 |
+
address_type=addr["address_type"],
|
| 34 |
+
full_name=addr["full_name"],
|
| 35 |
+
phone=addr["phone"],
|
| 36 |
+
address_line_1=addr["address_line_1"],
|
| 37 |
+
address_line_2=addr.get("address_line_2"),
|
| 38 |
+
city=addr["city"],
|
| 39 |
+
state=addr["state"],
|
| 40 |
+
postal_code=addr["postal_code"],
|
| 41 |
+
country=addr["country"],
|
| 42 |
+
is_default=addr.get("is_default", False),
|
| 43 |
+
created_at=addr.get("created_at"),
|
| 44 |
+
updated_at=addr.get("updated_at")
|
| 45 |
+
))
|
| 46 |
+
|
| 47 |
+
return AddressListResponse(
|
| 48 |
+
success=True,
|
| 49 |
+
message="Addresses retrieved successfully",
|
| 50 |
+
addresses=address_responses,
|
| 51 |
+
total_count=len(address_responses)
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
except Exception as e:
|
| 55 |
+
logger.error(f"Error getting addresses for user {current_user_id}: {str(e)}")
|
| 56 |
+
raise HTTPException(
|
| 57 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 58 |
+
detail="Failed to retrieve addresses"
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
@router.post("/", response_model=AddressOperationResponse)
|
| 62 |
+
async def create_address(
|
| 63 |
+
address_data: AddressCreateRequest,
|
| 64 |
+
current_user_id: str = Depends(get_current_user_id)
|
| 65 |
+
):
|
| 66 |
+
"""
|
| 67 |
+
Create a new delivery address for the current user.
|
| 68 |
+
"""
|
| 69 |
+
try:
|
| 70 |
+
logger.info(f"Create address request for user: {current_user_id}")
|
| 71 |
+
|
| 72 |
+
# Check if user already has 5 addresses (limit)
|
| 73 |
+
existing_addresses = await AddressModel.get_user_addresses(current_user_id)
|
| 74 |
+
if len(existing_addresses) >= 5:
|
| 75 |
+
raise HTTPException(
|
| 76 |
+
status_code=400,
|
| 77 |
+
detail="Maximum of 5 addresses allowed per user"
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# If this is the first address, make it default
|
| 81 |
+
is_default = len(existing_addresses) == 0 or address_data.is_default
|
| 82 |
+
|
| 83 |
+
address_id = await AddressModel.create_address(
|
| 84 |
+
user_id=current_user_id,
|
| 85 |
+
address_type=address_data.address_type,
|
| 86 |
+
full_name=address_data.full_name,
|
| 87 |
+
phone=address_data.phone,
|
| 88 |
+
address_line_1=address_data.address_line_1,
|
| 89 |
+
address_line_2=address_data.address_line_2,
|
| 90 |
+
city=address_data.city,
|
| 91 |
+
state=address_data.state,
|
| 92 |
+
postal_code=address_data.postal_code,
|
| 93 |
+
country=address_data.country,
|
| 94 |
+
is_default=is_default
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
if address_id:
|
| 98 |
+
# Get the created address
|
| 99 |
+
created_address = await AddressModel.get_address_by_id(address_id)
|
| 100 |
+
|
| 101 |
+
address_response = AddressResponse(
|
| 102 |
+
address_id=created_address["address_id"],
|
| 103 |
+
user_id=created_address["user_id"],
|
| 104 |
+
address_type=created_address["address_type"],
|
| 105 |
+
full_name=created_address["full_name"],
|
| 106 |
+
phone=created_address["phone"],
|
| 107 |
+
address_line_1=created_address["address_line_1"],
|
| 108 |
+
address_line_2=created_address.get("address_line_2"),
|
| 109 |
+
city=created_address["city"],
|
| 110 |
+
state=created_address["state"],
|
| 111 |
+
postal_code=created_address["postal_code"],
|
| 112 |
+
country=created_address["country"],
|
| 113 |
+
is_default=created_address.get("is_default", False),
|
| 114 |
+
created_at=created_address.get("created_at"),
|
| 115 |
+
updated_at=created_address.get("updated_at")
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
return AddressOperationResponse(
|
| 119 |
+
success=True,
|
| 120 |
+
message="Address created successfully",
|
| 121 |
+
address=address_response
|
| 122 |
+
)
|
| 123 |
+
else:
|
| 124 |
+
return AddressOperationResponse(
|
| 125 |
+
success=False,
|
| 126 |
+
message="Failed to create address"
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
except HTTPException:
|
| 130 |
+
raise
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger.error(f"Error creating address for user {current_user_id}: {str(e)}")
|
| 133 |
+
raise HTTPException(
|
| 134 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 135 |
+
detail="Failed to create address"
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
@router.put("/{address_id}", response_model=AddressOperationResponse)
|
| 139 |
+
async def update_address(
|
| 140 |
+
address_id: str,
|
| 141 |
+
address_data: AddressUpdateRequest,
|
| 142 |
+
current_user_id: str = Depends(get_current_user_id)
|
| 143 |
+
):
|
| 144 |
+
"""
|
| 145 |
+
Update an existing delivery address.
|
| 146 |
+
"""
|
| 147 |
+
try:
|
| 148 |
+
logger.info(f"Update address request for user: {current_user_id}, address: {address_id}")
|
| 149 |
+
|
| 150 |
+
# Check if address exists and belongs to user
|
| 151 |
+
existing_address = await AddressModel.get_address_by_id(address_id)
|
| 152 |
+
if not existing_address:
|
| 153 |
+
raise HTTPException(status_code=404, detail="Address not found")
|
| 154 |
+
|
| 155 |
+
if existing_address["user_id"] != current_user_id:
|
| 156 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 157 |
+
|
| 158 |
+
# Prepare update fields
|
| 159 |
+
update_fields = {}
|
| 160 |
+
|
| 161 |
+
if address_data.address_type is not None:
|
| 162 |
+
update_fields["address_type"] = address_data.address_type
|
| 163 |
+
if address_data.full_name is not None:
|
| 164 |
+
update_fields["full_name"] = address_data.full_name
|
| 165 |
+
if address_data.phone is not None:
|
| 166 |
+
update_fields["phone"] = address_data.phone
|
| 167 |
+
if address_data.address_line_1 is not None:
|
| 168 |
+
update_fields["address_line_1"] = address_data.address_line_1
|
| 169 |
+
if address_data.address_line_2 is not None:
|
| 170 |
+
update_fields["address_line_2"] = address_data.address_line_2
|
| 171 |
+
if address_data.city is not None:
|
| 172 |
+
update_fields["city"] = address_data.city
|
| 173 |
+
if address_data.state is not None:
|
| 174 |
+
update_fields["state"] = address_data.state
|
| 175 |
+
if address_data.postal_code is not None:
|
| 176 |
+
update_fields["postal_code"] = address_data.postal_code
|
| 177 |
+
if address_data.country is not None:
|
| 178 |
+
update_fields["country"] = address_data.country
|
| 179 |
+
|
| 180 |
+
if not update_fields:
|
| 181 |
+
raise HTTPException(status_code=400, detail="No fields to update")
|
| 182 |
+
|
| 183 |
+
success = await AddressModel.update_address(address_id, update_fields)
|
| 184 |
+
|
| 185 |
+
if success:
|
| 186 |
+
# Get updated address
|
| 187 |
+
updated_address = await AddressModel.get_address_by_id(address_id)
|
| 188 |
+
|
| 189 |
+
address_response = AddressResponse(
|
| 190 |
+
address_id=updated_address["address_id"],
|
| 191 |
+
user_id=updated_address["user_id"],
|
| 192 |
+
address_type=updated_address["address_type"],
|
| 193 |
+
full_name=updated_address["full_name"],
|
| 194 |
+
phone=updated_address["phone"],
|
| 195 |
+
address_line_1=updated_address["address_line_1"],
|
| 196 |
+
address_line_2=updated_address.get("address_line_2"),
|
| 197 |
+
city=updated_address["city"],
|
| 198 |
+
state=updated_address["state"],
|
| 199 |
+
postal_code=updated_address["postal_code"],
|
| 200 |
+
country=updated_address["country"],
|
| 201 |
+
is_default=updated_address.get("is_default", False),
|
| 202 |
+
created_at=updated_address.get("created_at"),
|
| 203 |
+
updated_at=updated_address.get("updated_at")
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
return AddressOperationResponse(
|
| 207 |
+
success=True,
|
| 208 |
+
message="Address updated successfully",
|
| 209 |
+
address=address_response
|
| 210 |
+
)
|
| 211 |
+
else:
|
| 212 |
+
return AddressOperationResponse(
|
| 213 |
+
success=False,
|
| 214 |
+
message="Failed to update address"
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
except HTTPException:
|
| 218 |
+
raise
|
| 219 |
+
except Exception as e:
|
| 220 |
+
logger.error(f"Error updating address {address_id} for user {current_user_id}: {str(e)}")
|
| 221 |
+
raise HTTPException(
|
| 222 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 223 |
+
detail="Failed to update address"
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
@router.delete("/{address_id}", response_model=AddressOperationResponse)
|
| 227 |
+
async def delete_address(
|
| 228 |
+
address_id: str,
|
| 229 |
+
current_user_id: str = Depends(get_current_user_id)
|
| 230 |
+
):
|
| 231 |
+
"""
|
| 232 |
+
Delete a delivery address.
|
| 233 |
+
"""
|
| 234 |
+
try:
|
| 235 |
+
logger.info(f"Delete address request for user: {current_user_id}, address: {address_id}")
|
| 236 |
+
|
| 237 |
+
# Check if address exists and belongs to user
|
| 238 |
+
existing_address = await AddressModel.get_address_by_id(address_id)
|
| 239 |
+
if not existing_address:
|
| 240 |
+
raise HTTPException(status_code=404, detail="Address not found")
|
| 241 |
+
|
| 242 |
+
if existing_address["user_id"] != current_user_id:
|
| 243 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 244 |
+
|
| 245 |
+
# Check if this is the default address
|
| 246 |
+
if existing_address.get("is_default", False):
|
| 247 |
+
# Get other addresses to potentially set a new default
|
| 248 |
+
user_addresses = await AddressModel.get_user_addresses(current_user_id)
|
| 249 |
+
other_addresses = [addr for addr in user_addresses if addr["address_id"] != address_id]
|
| 250 |
+
|
| 251 |
+
if other_addresses:
|
| 252 |
+
# Set the first other address as default
|
| 253 |
+
await AddressModel.set_default_address(current_user_id, other_addresses[0]["address_id"])
|
| 254 |
+
|
| 255 |
+
success = await AddressModel.delete_address(address_id)
|
| 256 |
+
|
| 257 |
+
if success:
|
| 258 |
+
return AddressOperationResponse(
|
| 259 |
+
success=True,
|
| 260 |
+
message="Address deleted successfully"
|
| 261 |
+
)
|
| 262 |
+
else:
|
| 263 |
+
return AddressOperationResponse(
|
| 264 |
+
success=False,
|
| 265 |
+
message="Failed to delete address"
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
except HTTPException:
|
| 269 |
+
raise
|
| 270 |
+
except Exception as e:
|
| 271 |
+
logger.error(f"Error deleting address {address_id} for user {current_user_id}: {str(e)}")
|
| 272 |
+
raise HTTPException(
|
| 273 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 274 |
+
detail="Failed to delete address"
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
@router.post("/set-default", response_model=AddressOperationResponse)
|
| 278 |
+
async def set_default_address(
|
| 279 |
+
request: SetDefaultAddressRequest,
|
| 280 |
+
current_user_id: str = Depends(get_current_user_id)
|
| 281 |
+
):
|
| 282 |
+
"""
|
| 283 |
+
Set an address as the default delivery address.
|
| 284 |
+
"""
|
| 285 |
+
try:
|
| 286 |
+
logger.info(f"Set default address request for user: {current_user_id}, address: {request.address_id}")
|
| 287 |
+
|
| 288 |
+
# Check if address exists and belongs to user
|
| 289 |
+
existing_address = await AddressModel.get_address_by_id(request.address_id)
|
| 290 |
+
if not existing_address:
|
| 291 |
+
raise HTTPException(status_code=404, detail="Address not found")
|
| 292 |
+
|
| 293 |
+
if existing_address["user_id"] != current_user_id:
|
| 294 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 295 |
+
|
| 296 |
+
success = await AddressModel.set_default_address(current_user_id, request.address_id)
|
| 297 |
+
|
| 298 |
+
if success:
|
| 299 |
+
# Get updated address
|
| 300 |
+
updated_address = await AddressModel.get_address_by_id(request.address_id)
|
| 301 |
+
|
| 302 |
+
address_response = AddressResponse(
|
| 303 |
+
address_id=updated_address["address_id"],
|
| 304 |
+
user_id=updated_address["user_id"],
|
| 305 |
+
address_type=updated_address["address_type"],
|
| 306 |
+
full_name=updated_address["full_name"],
|
| 307 |
+
phone=updated_address["phone"],
|
| 308 |
+
address_line_1=updated_address["address_line_1"],
|
| 309 |
+
address_line_2=updated_address.get("address_line_2"),
|
| 310 |
+
city=updated_address["city"],
|
| 311 |
+
state=updated_address["state"],
|
| 312 |
+
postal_code=updated_address["postal_code"],
|
| 313 |
+
country=updated_address["country"],
|
| 314 |
+
is_default=updated_address.get("is_default", False),
|
| 315 |
+
created_at=updated_address.get("created_at"),
|
| 316 |
+
updated_at=updated_address.get("updated_at")
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
return AddressOperationResponse(
|
| 320 |
+
success=True,
|
| 321 |
+
message="Default address set successfully",
|
| 322 |
+
address=address_response
|
| 323 |
+
)
|
| 324 |
+
else:
|
| 325 |
+
return AddressOperationResponse(
|
| 326 |
+
success=False,
|
| 327 |
+
message="Failed to set default address"
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
except HTTPException:
|
| 331 |
+
raise
|
| 332 |
+
except Exception as e:
|
| 333 |
+
logger.error(f"Error setting default address for user {current_user_id}: {str(e)}")
|
| 334 |
+
raise HTTPException(
|
| 335 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 336 |
+
detail="Failed to set default address"
|
| 337 |
+
)
|
app/routers/guest_router.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends, status
|
| 2 |
+
from fastapi.security import HTTPBearer
|
| 3 |
+
from app.models.guest_model import GuestModel
|
| 4 |
+
from app.schemas.guest_schema import (
|
| 5 |
+
GuestCreateRequest,
|
| 6 |
+
GuestUpdateRequest,
|
| 7 |
+
GuestResponse,
|
| 8 |
+
GuestListResponse,
|
| 9 |
+
GuestDeleteResponse
|
| 10 |
+
)
|
| 11 |
+
from app.utils.jwt import verify_token
|
| 12 |
+
from typing import Dict, Any
|
| 13 |
+
import logging
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
security = HTTPBearer()
|
| 19 |
+
|
| 20 |
+
async def get_current_user(token: str = Depends(security)) -> Dict[str, Any]:
|
| 21 |
+
"""
|
| 22 |
+
Dependency to get current authenticated user from JWT token
|
| 23 |
+
"""
|
| 24 |
+
try:
|
| 25 |
+
payload = verify_token(token.credentials)
|
| 26 |
+
if not payload:
|
| 27 |
+
raise HTTPException(
|
| 28 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 29 |
+
detail="Invalid or expired token"
|
| 30 |
+
)
|
| 31 |
+
return payload
|
| 32 |
+
except Exception as e:
|
| 33 |
+
logger.error(f"Token verification failed: {str(e)}")
|
| 34 |
+
raise HTTPException(
|
| 35 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 36 |
+
detail="Invalid or expired token"
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
@router.get("/{user_id}/guests", response_model=GuestListResponse)
|
| 40 |
+
async def get_user_guests(
|
| 41 |
+
user_id: str,
|
| 42 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 43 |
+
):
|
| 44 |
+
"""
|
| 45 |
+
Get all guests for a specific user.
|
| 46 |
+
|
| 47 |
+
- **user_id**: ID of the user
|
| 48 |
+
- Returns list of guests with total count
|
| 49 |
+
"""
|
| 50 |
+
try:
|
| 51 |
+
# Verify user can only access their own guests
|
| 52 |
+
if current_user.get("user_id") != user_id:
|
| 53 |
+
raise HTTPException(
|
| 54 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 55 |
+
detail="Access denied. You can only view your own guests."
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
guests_data = await GuestModel.get_user_guests(user_id)
|
| 59 |
+
|
| 60 |
+
guests = [GuestResponse(**guest) for guest in guests_data]
|
| 61 |
+
|
| 62 |
+
return GuestListResponse(
|
| 63 |
+
guests=guests,
|
| 64 |
+
total_count=len(guests)
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
except HTTPException:
|
| 68 |
+
raise
|
| 69 |
+
except Exception as e:
|
| 70 |
+
logger.error(f"Error getting guests for user {user_id}: {str(e)}")
|
| 71 |
+
raise HTTPException(
|
| 72 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 73 |
+
detail="Failed to retrieve guests"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
@router.post("/{user_id}/guests", response_model=GuestResponse, status_code=status.HTTP_201_CREATED)
|
| 77 |
+
async def create_guest(
|
| 78 |
+
user_id: str,
|
| 79 |
+
guest_data: GuestCreateRequest,
|
| 80 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 81 |
+
):
|
| 82 |
+
"""
|
| 83 |
+
Create a new guest profile for a user.
|
| 84 |
+
|
| 85 |
+
- **user_id**: ID of the user creating the guest profile
|
| 86 |
+
- **guest_data**: Guest information including name, contact details, etc.
|
| 87 |
+
- Returns the created guest profile
|
| 88 |
+
"""
|
| 89 |
+
try:
|
| 90 |
+
# Verify user can only create guests for themselves
|
| 91 |
+
if current_user.get("user_id") != user_id:
|
| 92 |
+
raise HTTPException(
|
| 93 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 94 |
+
detail="Access denied. You can only create guests for yourself."
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# Create guest in database
|
| 98 |
+
guest_id = await GuestModel.create_guest(
|
| 99 |
+
user_id=user_id,
|
| 100 |
+
first_name=guest_data.first_name,
|
| 101 |
+
last_name=guest_data.last_name,
|
| 102 |
+
email=guest_data.email,
|
| 103 |
+
phone_number=guest_data.phone_number,
|
| 104 |
+
gender=guest_data.gender.value if guest_data.gender else None,
|
| 105 |
+
date_of_birth=guest_data.date_of_birth,
|
| 106 |
+
relationship=guest_data.relationship.value if guest_data.relationship else None,
|
| 107 |
+
notes=guest_data.notes
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
if not guest_id:
|
| 111 |
+
raise HTTPException(
|
| 112 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 113 |
+
detail="Failed to create guest profile"
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
# Retrieve and return the created guest
|
| 117 |
+
created_guest = await GuestModel.get_guest_by_id(guest_id)
|
| 118 |
+
if not created_guest:
|
| 119 |
+
raise HTTPException(
|
| 120 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 121 |
+
detail="Guest created but failed to retrieve"
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
return GuestResponse(**created_guest)
|
| 125 |
+
|
| 126 |
+
except HTTPException:
|
| 127 |
+
raise
|
| 128 |
+
except Exception as e:
|
| 129 |
+
logger.error(f"Error creating guest for user {user_id}: {str(e)}")
|
| 130 |
+
raise HTTPException(
|
| 131 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 132 |
+
detail="Failed to create guest profile"
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
@router.put("/{user_id}/guests/{guest_id}", response_model=GuestResponse)
|
| 136 |
+
async def update_guest(
|
| 137 |
+
user_id: str,
|
| 138 |
+
guest_id: str,
|
| 139 |
+
guest_data: GuestUpdateRequest,
|
| 140 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 141 |
+
):
|
| 142 |
+
"""
|
| 143 |
+
Update an existing guest profile.
|
| 144 |
+
|
| 145 |
+
- **user_id**: ID of the user who owns the guest profile
|
| 146 |
+
- **guest_id**: ID of the guest to update
|
| 147 |
+
- **guest_data**: Updated guest information
|
| 148 |
+
- Returns the updated guest profile
|
| 149 |
+
"""
|
| 150 |
+
try:
|
| 151 |
+
# Verify user can only update their own guests
|
| 152 |
+
if current_user.get("user_id") != user_id:
|
| 153 |
+
raise HTTPException(
|
| 154 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 155 |
+
detail="Access denied. You can only update your own guests."
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
# Check if guest exists and belongs to user
|
| 159 |
+
existing_guest = await GuestModel.get_guest_by_id(guest_id)
|
| 160 |
+
if not existing_guest:
|
| 161 |
+
raise HTTPException(
|
| 162 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 163 |
+
detail="Guest not found"
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
if existing_guest.get("user_id") != user_id:
|
| 167 |
+
raise HTTPException(
|
| 168 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 169 |
+
detail="Access denied. This guest doesn't belong to you."
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
# Prepare update fields (only include non-None values)
|
| 173 |
+
update_fields = {}
|
| 174 |
+
for field, value in guest_data.dict(exclude_unset=True).items():
|
| 175 |
+
if value is not None:
|
| 176 |
+
if hasattr(value, 'value'): # Handle enum values
|
| 177 |
+
update_fields[field] = value.value
|
| 178 |
+
else:
|
| 179 |
+
update_fields[field] = value
|
| 180 |
+
|
| 181 |
+
if not update_fields:
|
| 182 |
+
raise HTTPException(
|
| 183 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 184 |
+
detail="No valid fields provided for update"
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
# Update guest in database
|
| 188 |
+
success = await GuestModel.update_guest(guest_id, update_fields)
|
| 189 |
+
if not success:
|
| 190 |
+
raise HTTPException(
|
| 191 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 192 |
+
detail="Failed to update guest profile"
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
# Retrieve and return updated guest
|
| 196 |
+
updated_guest = await GuestModel.get_guest_by_id(guest_id)
|
| 197 |
+
if not updated_guest:
|
| 198 |
+
raise HTTPException(
|
| 199 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 200 |
+
detail="Guest updated but failed to retrieve"
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
return GuestResponse(**updated_guest)
|
| 204 |
+
|
| 205 |
+
except HTTPException:
|
| 206 |
+
raise
|
| 207 |
+
except Exception as e:
|
| 208 |
+
logger.error(f"Error updating guest {guest_id} for user {user_id}: {str(e)}")
|
| 209 |
+
raise HTTPException(
|
| 210 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 211 |
+
detail="Failed to update guest profile"
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
@router.delete("/{user_id}/guests/{guest_id}", response_model=GuestDeleteResponse)
|
| 215 |
+
async def delete_guest(
|
| 216 |
+
user_id: str,
|
| 217 |
+
guest_id: str,
|
| 218 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 219 |
+
):
|
| 220 |
+
"""
|
| 221 |
+
Delete a guest profile.
|
| 222 |
+
|
| 223 |
+
- **user_id**: ID of the user who owns the guest profile
|
| 224 |
+
- **guest_id**: ID of the guest to delete
|
| 225 |
+
- Returns confirmation of deletion
|
| 226 |
+
"""
|
| 227 |
+
try:
|
| 228 |
+
# Verify user can only delete their own guests
|
| 229 |
+
if current_user.get("user_id") != user_id:
|
| 230 |
+
raise HTTPException(
|
| 231 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 232 |
+
detail="Access denied. You can only delete your own guests."
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
# Check if guest exists and belongs to user
|
| 236 |
+
existing_guest = await GuestModel.get_guest_by_id(guest_id)
|
| 237 |
+
if not existing_guest:
|
| 238 |
+
raise HTTPException(
|
| 239 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 240 |
+
detail="Guest not found"
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
if existing_guest.get("user_id") != user_id:
|
| 244 |
+
raise HTTPException(
|
| 245 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 246 |
+
detail="Access denied. This guest doesn't belong to you."
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
# Delete guest from database
|
| 250 |
+
success = await GuestModel.delete_guest(guest_id)
|
| 251 |
+
if not success:
|
| 252 |
+
raise HTTPException(
|
| 253 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 254 |
+
detail="Failed to delete guest profile"
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
guest_name = existing_guest.get('first_name', 'Guest')
|
| 258 |
+
if existing_guest.get('last_name'):
|
| 259 |
+
guest_name += f" {existing_guest.get('last_name')}"
|
| 260 |
+
|
| 261 |
+
return GuestDeleteResponse(
|
| 262 |
+
message=f"Guest '{guest_name}' has been successfully deleted",
|
| 263 |
+
guest_id=guest_id
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
except HTTPException:
|
| 267 |
+
raise
|
| 268 |
+
except Exception as e:
|
| 269 |
+
logger.error(f"Error deleting guest {guest_id} for user {user_id}: {str(e)}")
|
| 270 |
+
raise HTTPException(
|
| 271 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 272 |
+
detail="Failed to delete guest profile"
|
| 273 |
+
)
|
app/routers/pet_router.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends, status
|
| 2 |
+
from fastapi.security import HTTPBearer
|
| 3 |
+
from app.models.pet_model import PetModel
|
| 4 |
+
from app.schemas.pet_schema import (
|
| 5 |
+
PetCreateRequest,
|
| 6 |
+
PetUpdateRequest,
|
| 7 |
+
PetResponse,
|
| 8 |
+
PetListResponse,
|
| 9 |
+
PetDeleteResponse
|
| 10 |
+
)
|
| 11 |
+
from app.utils.jwt import verify_token
|
| 12 |
+
from typing import Dict, Any
|
| 13 |
+
import logging
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
security = HTTPBearer()
|
| 19 |
+
|
| 20 |
+
async def get_current_user(token: str = Depends(security)) -> Dict[str, Any]:
|
| 21 |
+
"""
|
| 22 |
+
Dependency to get current authenticated user from JWT token
|
| 23 |
+
"""
|
| 24 |
+
try:
|
| 25 |
+
payload = verify_token(token.credentials)
|
| 26 |
+
if not payload:
|
| 27 |
+
raise HTTPException(
|
| 28 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 29 |
+
detail="Invalid or expired token"
|
| 30 |
+
)
|
| 31 |
+
return payload
|
| 32 |
+
except Exception as e:
|
| 33 |
+
logger.error(f"Token verification failed: {str(e)}")
|
| 34 |
+
raise HTTPException(
|
| 35 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 36 |
+
detail="Invalid or expired token"
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
@router.get("/{user_id}/pets", response_model=PetListResponse)
|
| 40 |
+
async def get_user_pets(
|
| 41 |
+
user_id: str,
|
| 42 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 43 |
+
):
|
| 44 |
+
"""
|
| 45 |
+
Get all pets for a specific user.
|
| 46 |
+
|
| 47 |
+
- **user_id**: ID of the pet owner
|
| 48 |
+
- Returns list of pets with total count
|
| 49 |
+
"""
|
| 50 |
+
try:
|
| 51 |
+
# Verify user can only access their own pets
|
| 52 |
+
if current_user.get("user_id") != user_id:
|
| 53 |
+
raise HTTPException(
|
| 54 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 55 |
+
detail="Access denied. You can only view your own pets."
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
pets_data = await PetModel.get_user_pets(user_id)
|
| 59 |
+
|
| 60 |
+
pets = [PetResponse(**pet) for pet in pets_data]
|
| 61 |
+
|
| 62 |
+
return PetListResponse(
|
| 63 |
+
pets=pets,
|
| 64 |
+
total_count=len(pets)
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
except HTTPException:
|
| 68 |
+
raise
|
| 69 |
+
except Exception as e:
|
| 70 |
+
logger.error(f"Error getting pets for user {user_id}: {str(e)}")
|
| 71 |
+
raise HTTPException(
|
| 72 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 73 |
+
detail="Failed to retrieve pets"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
@router.post("/{user_id}/pets", response_model=PetResponse, status_code=status.HTTP_201_CREATED)
|
| 77 |
+
async def create_pet(
|
| 78 |
+
user_id: str,
|
| 79 |
+
pet_data: PetCreateRequest,
|
| 80 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 81 |
+
):
|
| 82 |
+
"""
|
| 83 |
+
Create a new pet profile for a user.
|
| 84 |
+
|
| 85 |
+
- **user_id**: ID of the pet owner
|
| 86 |
+
- **pet_data**: Pet information including name, species, breed, etc.
|
| 87 |
+
- Returns the created pet profile
|
| 88 |
+
"""
|
| 89 |
+
try:
|
| 90 |
+
# Verify user can only create pets for themselves
|
| 91 |
+
if current_user.get("user_id") != user_id:
|
| 92 |
+
raise HTTPException(
|
| 93 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 94 |
+
detail="Access denied. You can only create pets for yourself."
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# Create pet in database
|
| 98 |
+
pet_id = await PetModel.create_pet(
|
| 99 |
+
user_id=user_id,
|
| 100 |
+
pet_name=pet_data.pet_name,
|
| 101 |
+
species=pet_data.species.value,
|
| 102 |
+
breed=pet_data.breed,
|
| 103 |
+
date_of_birth=pet_data.date_of_birth,
|
| 104 |
+
age=pet_data.age,
|
| 105 |
+
weight=pet_data.weight,
|
| 106 |
+
gender=pet_data.gender.value if pet_data.gender else None,
|
| 107 |
+
temperament=pet_data.temperament.value if pet_data.temperament else None,
|
| 108 |
+
health_notes=pet_data.health_notes,
|
| 109 |
+
is_vaccinated=pet_data.is_vaccinated,
|
| 110 |
+
pet_photo_url=pet_data.pet_photo_url
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
if not pet_id:
|
| 114 |
+
raise HTTPException(
|
| 115 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 116 |
+
detail="Failed to create pet profile"
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
# Retrieve and return the created pet
|
| 120 |
+
created_pet = await PetModel.get_pet_by_id(pet_id)
|
| 121 |
+
if not created_pet:
|
| 122 |
+
raise HTTPException(
|
| 123 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 124 |
+
detail="Pet created but failed to retrieve"
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
return PetResponse(**created_pet)
|
| 128 |
+
|
| 129 |
+
except HTTPException:
|
| 130 |
+
raise
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger.error(f"Error creating pet for user {user_id}: {str(e)}")
|
| 133 |
+
raise HTTPException(
|
| 134 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 135 |
+
detail="Failed to create pet profile"
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
@router.put("/{user_id}/pets/{pet_id}", response_model=PetResponse)
|
| 139 |
+
async def update_pet(
|
| 140 |
+
user_id: str,
|
| 141 |
+
pet_id: str,
|
| 142 |
+
pet_data: PetUpdateRequest,
|
| 143 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 144 |
+
):
|
| 145 |
+
"""
|
| 146 |
+
Update an existing pet profile.
|
| 147 |
+
|
| 148 |
+
- **user_id**: ID of the pet owner
|
| 149 |
+
- **pet_id**: ID of the pet to update
|
| 150 |
+
- **pet_data**: Updated pet information
|
| 151 |
+
- Returns the updated pet profile
|
| 152 |
+
"""
|
| 153 |
+
try:
|
| 154 |
+
# Verify user can only update their own pets
|
| 155 |
+
if current_user.get("user_id") != user_id:
|
| 156 |
+
raise HTTPException(
|
| 157 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 158 |
+
detail="Access denied. You can only update your own pets."
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# Check if pet exists and belongs to user
|
| 162 |
+
existing_pet = await PetModel.get_pet_by_id(pet_id)
|
| 163 |
+
if not existing_pet:
|
| 164 |
+
raise HTTPException(
|
| 165 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 166 |
+
detail="Pet not found"
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
if existing_pet.get("user_id") != user_id:
|
| 170 |
+
raise HTTPException(
|
| 171 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 172 |
+
detail="Access denied. This pet doesn't belong to you."
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
# Prepare update fields (only include non-None values)
|
| 176 |
+
update_fields = {}
|
| 177 |
+
for field, value in pet_data.dict(exclude_unset=True).items():
|
| 178 |
+
if value is not None:
|
| 179 |
+
if hasattr(value, 'value'): # Handle enum values
|
| 180 |
+
update_fields[field] = value.value
|
| 181 |
+
else:
|
| 182 |
+
update_fields[field] = value
|
| 183 |
+
|
| 184 |
+
if not update_fields:
|
| 185 |
+
raise HTTPException(
|
| 186 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 187 |
+
detail="No valid fields provided for update"
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
# Update pet in database
|
| 191 |
+
success = await PetModel.update_pet(pet_id, update_fields)
|
| 192 |
+
if not success:
|
| 193 |
+
raise HTTPException(
|
| 194 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 195 |
+
detail="Failed to update pet profile"
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
# Retrieve and return updated pet
|
| 199 |
+
updated_pet = await PetModel.get_pet_by_id(pet_id)
|
| 200 |
+
if not updated_pet:
|
| 201 |
+
raise HTTPException(
|
| 202 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 203 |
+
detail="Pet updated but failed to retrieve"
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
return PetResponse(**updated_pet)
|
| 207 |
+
|
| 208 |
+
except HTTPException:
|
| 209 |
+
raise
|
| 210 |
+
except Exception as e:
|
| 211 |
+
logger.error(f"Error updating pet {pet_id} for user {user_id}: {str(e)}")
|
| 212 |
+
raise HTTPException(
|
| 213 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 214 |
+
detail="Failed to update pet profile"
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
@router.delete("/{user_id}/pets/{pet_id}", response_model=PetDeleteResponse)
|
| 218 |
+
async def delete_pet(
|
| 219 |
+
user_id: str,
|
| 220 |
+
pet_id: str,
|
| 221 |
+
current_user: Dict[str, Any] = Depends(get_current_user)
|
| 222 |
+
):
|
| 223 |
+
"""
|
| 224 |
+
Delete a pet profile.
|
| 225 |
+
|
| 226 |
+
- **user_id**: ID of the pet owner
|
| 227 |
+
- **pet_id**: ID of the pet to delete
|
| 228 |
+
- Returns confirmation of deletion
|
| 229 |
+
"""
|
| 230 |
+
try:
|
| 231 |
+
# Verify user can only delete their own pets
|
| 232 |
+
if current_user.get("user_id") != user_id:
|
| 233 |
+
raise HTTPException(
|
| 234 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 235 |
+
detail="Access denied. You can only delete your own pets."
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
# Check if pet exists and belongs to user
|
| 239 |
+
existing_pet = await PetModel.get_pet_by_id(pet_id)
|
| 240 |
+
if not existing_pet:
|
| 241 |
+
raise HTTPException(
|
| 242 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 243 |
+
detail="Pet not found"
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
if existing_pet.get("user_id") != user_id:
|
| 247 |
+
raise HTTPException(
|
| 248 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 249 |
+
detail="Access denied. This pet doesn't belong to you."
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
# Delete pet from database
|
| 253 |
+
success = await PetModel.delete_pet(pet_id)
|
| 254 |
+
if not success:
|
| 255 |
+
raise HTTPException(
|
| 256 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 257 |
+
detail="Failed to delete pet profile"
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
return PetDeleteResponse(
|
| 261 |
+
message=f"Pet '{existing_pet.get('pet_name')}' has been successfully deleted",
|
| 262 |
+
pet_id=pet_id
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
except HTTPException:
|
| 266 |
+
raise
|
| 267 |
+
except Exception as e:
|
| 268 |
+
logger.error(f"Error deleting pet {pet_id} for user {user_id}: {str(e)}")
|
| 269 |
+
raise HTTPException(
|
| 270 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 271 |
+
detail="Failed to delete pet profile"
|
| 272 |
+
)
|
app/routers/profile_router.py
CHANGED
|
@@ -7,6 +7,13 @@ import logging
|
|
| 7 |
|
| 8 |
from app.utils.jwt import get_current_user_id
|
| 9 |
from app.services.profile_service import profile_service
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
logger = logging.getLogger(__name__)
|
| 12 |
|
|
@@ -50,3 +57,144 @@ async def get_profile(current_user_id: str = Depends(get_current_user_id)):
|
|
| 50 |
detail="Internal server error"
|
| 51 |
)
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
from app.utils.jwt import get_current_user_id
|
| 9 |
from app.services.profile_service import profile_service
|
| 10 |
+
from app.services.wallet_service import WalletService
|
| 11 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 12 |
+
from app.models.address_model import AddressModel
|
| 13 |
+
from app.schemas.profile_schema import (
|
| 14 |
+
ProfileUpdateRequest, ProfileResponse, ProfileOperationResponse,
|
| 15 |
+
PersonalDetailsResponse, WalletDisplayResponse, ProfileDashboardResponse
|
| 16 |
+
)
|
| 17 |
|
| 18 |
logger = logging.getLogger(__name__)
|
| 19 |
|
|
|
|
| 57 |
detail="Internal server error"
|
| 58 |
)
|
| 59 |
|
| 60 |
+
@router.get("/dashboard", response_model=ProfileDashboardResponse)
|
| 61 |
+
async def get_profile_dashboard(current_user_id: str = Depends(get_current_user_id)):
|
| 62 |
+
"""
|
| 63 |
+
Get complete profile dashboard with personal details, wallet, and address info.
|
| 64 |
+
|
| 65 |
+
This endpoint matches the screenshot requirements showing:
|
| 66 |
+
- Personal details (name, email, phone, DOB)
|
| 67 |
+
- Wallet balance
|
| 68 |
+
- Address management info
|
| 69 |
+
"""
|
| 70 |
+
try:
|
| 71 |
+
logger.info(f"Dashboard request for user: {current_user_id}")
|
| 72 |
+
|
| 73 |
+
# Get user profile
|
| 74 |
+
user = await BookMyServiceUserModel.find_by_id(current_user_id)
|
| 75 |
+
if not user:
|
| 76 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 77 |
+
|
| 78 |
+
# Parse name into first and last name
|
| 79 |
+
name_parts = user.get("name", "").split(" ", 1)
|
| 80 |
+
first_name = name_parts[0] if name_parts else ""
|
| 81 |
+
last_name = name_parts[1] if len(name_parts) > 1 else ""
|
| 82 |
+
|
| 83 |
+
# Get wallet balance
|
| 84 |
+
wallet_balance = await WalletService.get_wallet_balance(current_user_id)
|
| 85 |
+
|
| 86 |
+
# Get address count and default address status
|
| 87 |
+
addresses = await AddressModel.get_user_addresses(current_user_id)
|
| 88 |
+
address_count = len(addresses)
|
| 89 |
+
has_default_address = any(addr.get("is_default", False) for addr in addresses)
|
| 90 |
+
|
| 91 |
+
# Build response
|
| 92 |
+
personal_details = PersonalDetailsResponse(
|
| 93 |
+
first_name=first_name,
|
| 94 |
+
last_name=last_name,
|
| 95 |
+
email=user.get("email", ""),
|
| 96 |
+
phone=user.get("phone", ""),
|
| 97 |
+
date_of_birth=user.get("date_of_birth")
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
wallet_display = WalletDisplayResponse(
|
| 101 |
+
balance=wallet_balance.balance,
|
| 102 |
+
formatted_balance=wallet_balance.formatted_balance,
|
| 103 |
+
currency=wallet_balance.currency
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
return ProfileDashboardResponse(
|
| 107 |
+
personal_details=personal_details,
|
| 108 |
+
wallet=wallet_display,
|
| 109 |
+
address_count=address_count,
|
| 110 |
+
has_default_address=has_default_address
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
except HTTPException:
|
| 114 |
+
raise
|
| 115 |
+
except Exception as e:
|
| 116 |
+
logger.error(f"Error getting profile dashboard for user {current_user_id}: {str(e)}")
|
| 117 |
+
raise HTTPException(
|
| 118 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 119 |
+
detail="Internal server error"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
@router.put("/update", response_model=ProfileOperationResponse)
|
| 123 |
+
async def update_profile(
|
| 124 |
+
profile_data: ProfileUpdateRequest,
|
| 125 |
+
current_user_id: str = Depends(get_current_user_id)
|
| 126 |
+
):
|
| 127 |
+
"""
|
| 128 |
+
Update user profile information including personal details and DOB.
|
| 129 |
+
"""
|
| 130 |
+
try:
|
| 131 |
+
logger.info(f"Profile update request for user: {current_user_id}")
|
| 132 |
+
|
| 133 |
+
# Prepare update fields
|
| 134 |
+
update_fields = {}
|
| 135 |
+
|
| 136 |
+
if profile_data.name is not None:
|
| 137 |
+
update_fields["name"] = profile_data.name
|
| 138 |
+
|
| 139 |
+
if profile_data.email is not None:
|
| 140 |
+
# Check if email is already used by another user
|
| 141 |
+
existing_user = await BookMyServiceUserModel.find_by_email(str(profile_data.email))
|
| 142 |
+
if existing_user and existing_user.get("user_id") != current_user_id:
|
| 143 |
+
raise HTTPException(status_code=409, detail="Email already in use by another account")
|
| 144 |
+
update_fields["email"] = str(profile_data.email)
|
| 145 |
+
|
| 146 |
+
if profile_data.phone is not None:
|
| 147 |
+
# Check if phone is already used by another user
|
| 148 |
+
existing_user = await BookMyServiceUserModel.find_by_phone(profile_data.phone)
|
| 149 |
+
if existing_user and existing_user.get("user_id") != current_user_id:
|
| 150 |
+
raise HTTPException(status_code=409, detail="Phone number already in use by another account")
|
| 151 |
+
update_fields["phone"] = profile_data.phone
|
| 152 |
+
|
| 153 |
+
if profile_data.date_of_birth is not None:
|
| 154 |
+
update_fields["date_of_birth"] = profile_data.date_of_birth
|
| 155 |
+
|
| 156 |
+
if profile_data.profile_picture is not None:
|
| 157 |
+
update_fields["profile_picture"] = profile_data.profile_picture
|
| 158 |
+
|
| 159 |
+
if not update_fields:
|
| 160 |
+
raise HTTPException(status_code=400, detail="No fields to update")
|
| 161 |
+
|
| 162 |
+
# Update profile
|
| 163 |
+
success = await BookMyServiceUserModel.update_profile(current_user_id, update_fields)
|
| 164 |
+
|
| 165 |
+
if success:
|
| 166 |
+
# Get updated profile
|
| 167 |
+
updated_user = await BookMyServiceUserModel.find_by_id(current_user_id)
|
| 168 |
+
|
| 169 |
+
profile_response = ProfileResponse(
|
| 170 |
+
user_id=updated_user["user_id"],
|
| 171 |
+
name=updated_user["name"],
|
| 172 |
+
email=updated_user.get("email"),
|
| 173 |
+
phone=updated_user.get("phone"),
|
| 174 |
+
date_of_birth=updated_user.get("date_of_birth"),
|
| 175 |
+
profile_picture=updated_user.get("profile_picture"),
|
| 176 |
+
auth_method=updated_user.get("auth_mode", "unknown"),
|
| 177 |
+
created_at=updated_user.get("created_at"),
|
| 178 |
+
updated_at=updated_user.get("updated_at")
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
return ProfileOperationResponse(
|
| 182 |
+
success=True,
|
| 183 |
+
message="Profile updated successfully",
|
| 184 |
+
profile=profile_response
|
| 185 |
+
)
|
| 186 |
+
else:
|
| 187 |
+
return ProfileOperationResponse(
|
| 188 |
+
success=False,
|
| 189 |
+
message="Failed to update profile"
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
except HTTPException:
|
| 193 |
+
raise
|
| 194 |
+
except Exception as e:
|
| 195 |
+
logger.error(f"Error updating profile for user {current_user_id}: {str(e)}")
|
| 196 |
+
raise HTTPException(
|
| 197 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 198 |
+
detail="Internal server error"
|
| 199 |
+
)
|
| 200 |
+
|
app/routers/wallet_router.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
from app.utils.jwt import get_current_user_id
|
| 6 |
+
from app.services.wallet_service import WalletService
|
| 7 |
+
from app.schemas.wallet_schema import (
|
| 8 |
+
WalletBalanceResponse, TransactionHistoryResponse, WalletSummaryResponse,
|
| 9 |
+
AddMoneyRequest, WithdrawMoneyRequest, TransactionRequest, TransactionResponse
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
|
| 16 |
+
@router.get("/balance", response_model=WalletBalanceResponse)
|
| 17 |
+
async def get_wallet_balance(current_user_id: str = Depends(get_current_user_id)):
|
| 18 |
+
"""
|
| 19 |
+
Get current user's wallet balance.
|
| 20 |
+
|
| 21 |
+
This endpoint is JWT protected and requires a valid Bearer token.
|
| 22 |
+
"""
|
| 23 |
+
try:
|
| 24 |
+
logger.info(f"Wallet balance request for user: {current_user_id}")
|
| 25 |
+
|
| 26 |
+
balance_info = await WalletService.get_wallet_balance(current_user_id)
|
| 27 |
+
return balance_info
|
| 28 |
+
|
| 29 |
+
except Exception as e:
|
| 30 |
+
logger.error(f"Error getting wallet balance for user {current_user_id}: {str(e)}")
|
| 31 |
+
raise HTTPException(
|
| 32 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 33 |
+
detail="Failed to retrieve wallet balance"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
@router.get("/summary", response_model=WalletSummaryResponse)
|
| 37 |
+
async def get_wallet_summary(current_user_id: str = Depends(get_current_user_id)):
|
| 38 |
+
"""
|
| 39 |
+
Get wallet summary including balance and recent transaction stats.
|
| 40 |
+
"""
|
| 41 |
+
try:
|
| 42 |
+
logger.info(f"Wallet summary request for user: {current_user_id}")
|
| 43 |
+
|
| 44 |
+
summary = await WalletService.get_wallet_summary(current_user_id)
|
| 45 |
+
return summary
|
| 46 |
+
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.error(f"Error getting wallet summary for user {current_user_id}: {str(e)}")
|
| 49 |
+
raise HTTPException(
|
| 50 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 51 |
+
detail="Failed to retrieve wallet summary"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
@router.get("/transactions", response_model=TransactionHistoryResponse)
|
| 55 |
+
async def get_transaction_history(
|
| 56 |
+
current_user_id: str = Depends(get_current_user_id),
|
| 57 |
+
page: int = Query(1, ge=1, description="Page number"),
|
| 58 |
+
limit: int = Query(10, ge=1, le=100, description="Number of transactions per page"),
|
| 59 |
+
transaction_type: Optional[str] = Query(None, description="Filter by transaction type")
|
| 60 |
+
):
|
| 61 |
+
"""
|
| 62 |
+
Get paginated transaction history for the current user.
|
| 63 |
+
|
| 64 |
+
Query parameters:
|
| 65 |
+
- page: Page number (default: 1)
|
| 66 |
+
- limit: Number of transactions per page (default: 10, max: 100)
|
| 67 |
+
- transaction_type: Filter by type (credit, debit, refund, etc.)
|
| 68 |
+
"""
|
| 69 |
+
try:
|
| 70 |
+
logger.info(f"Transaction history request for user: {current_user_id}, page: {page}, limit: {limit}")
|
| 71 |
+
|
| 72 |
+
history = await WalletService.get_transaction_history(
|
| 73 |
+
current_user_id,
|
| 74 |
+
page=page,
|
| 75 |
+
limit=limit,
|
| 76 |
+
transaction_type=transaction_type
|
| 77 |
+
)
|
| 78 |
+
return history
|
| 79 |
+
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger.error(f"Error getting transaction history for user {current_user_id}: {str(e)}")
|
| 82 |
+
raise HTTPException(
|
| 83 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 84 |
+
detail="Failed to retrieve transaction history"
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
@router.post("/add-money", response_model=TransactionResponse)
|
| 88 |
+
async def add_money_to_wallet(
|
| 89 |
+
request: AddMoneyRequest,
|
| 90 |
+
current_user_id: str = Depends(get_current_user_id)
|
| 91 |
+
):
|
| 92 |
+
"""
|
| 93 |
+
Add money to user's wallet.
|
| 94 |
+
|
| 95 |
+
This would typically integrate with a payment gateway.
|
| 96 |
+
For now, it simulates adding money to the wallet.
|
| 97 |
+
"""
|
| 98 |
+
try:
|
| 99 |
+
logger.info(f"Add money request for user: {current_user_id}, amount: {request.amount}")
|
| 100 |
+
|
| 101 |
+
if request.amount <= 0:
|
| 102 |
+
raise HTTPException(status_code=400, detail="Amount must be greater than zero")
|
| 103 |
+
|
| 104 |
+
transaction = await WalletService.add_money(
|
| 105 |
+
current_user_id,
|
| 106 |
+
request.amount,
|
| 107 |
+
request.payment_method,
|
| 108 |
+
request.reference_id,
|
| 109 |
+
request.description
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
return TransactionResponse(
|
| 113 |
+
success=True,
|
| 114 |
+
message="Money added successfully",
|
| 115 |
+
transaction=transaction,
|
| 116 |
+
new_balance=transaction.balance_after
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
except HTTPException:
|
| 120 |
+
raise
|
| 121 |
+
except Exception as e:
|
| 122 |
+
logger.error(f"Error adding money for user {current_user_id}: {str(e)}")
|
| 123 |
+
raise HTTPException(
|
| 124 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 125 |
+
detail="Failed to add money to wallet"
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
@router.post("/withdraw", response_model=TransactionResponse)
|
| 129 |
+
async def withdraw_money(
|
| 130 |
+
request: WithdrawMoneyRequest,
|
| 131 |
+
current_user_id: str = Depends(get_current_user_id)
|
| 132 |
+
):
|
| 133 |
+
"""
|
| 134 |
+
Withdraw money from user's wallet.
|
| 135 |
+
|
| 136 |
+
This would typically integrate with a payment gateway for bank transfers.
|
| 137 |
+
"""
|
| 138 |
+
try:
|
| 139 |
+
logger.info(f"Withdraw request for user: {current_user_id}, amount: {request.amount}")
|
| 140 |
+
|
| 141 |
+
if request.amount <= 0:
|
| 142 |
+
raise HTTPException(status_code=400, detail="Amount must be greater than zero")
|
| 143 |
+
|
| 144 |
+
# Check if user has sufficient balance
|
| 145 |
+
balance_info = await WalletService.get_wallet_balance(current_user_id)
|
| 146 |
+
if balance_info.balance < request.amount:
|
| 147 |
+
raise HTTPException(status_code=400, detail="Insufficient wallet balance")
|
| 148 |
+
|
| 149 |
+
transaction = await WalletService.deduct_money(
|
| 150 |
+
current_user_id,
|
| 151 |
+
request.amount,
|
| 152 |
+
"withdrawal",
|
| 153 |
+
request.bank_account_id,
|
| 154 |
+
request.description or "Wallet withdrawal"
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
return TransactionResponse(
|
| 158 |
+
success=True,
|
| 159 |
+
message="Withdrawal processed successfully",
|
| 160 |
+
transaction=transaction,
|
| 161 |
+
new_balance=transaction.balance_after
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
except HTTPException:
|
| 165 |
+
raise
|
| 166 |
+
except Exception as e:
|
| 167 |
+
logger.error(f"Error processing withdrawal for user {current_user_id}: {str(e)}")
|
| 168 |
+
raise HTTPException(
|
| 169 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 170 |
+
detail="Failed to process withdrawal"
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
@router.post("/transaction", response_model=TransactionResponse)
|
| 174 |
+
async def create_transaction(
|
| 175 |
+
request: TransactionRequest,
|
| 176 |
+
current_user_id: str = Depends(get_current_user_id)
|
| 177 |
+
):
|
| 178 |
+
"""
|
| 179 |
+
Create a generic transaction (for internal use, service bookings, etc.).
|
| 180 |
+
"""
|
| 181 |
+
try:
|
| 182 |
+
logger.info(f"Transaction request for user: {current_user_id}, type: {request.transaction_type}, amount: {request.amount}")
|
| 183 |
+
|
| 184 |
+
if request.amount <= 0:
|
| 185 |
+
raise HTTPException(status_code=400, detail="Amount must be greater than zero")
|
| 186 |
+
|
| 187 |
+
if request.transaction_type == "debit":
|
| 188 |
+
# Check if user has sufficient balance for debit transactions
|
| 189 |
+
balance_info = await WalletService.get_wallet_balance(current_user_id)
|
| 190 |
+
if balance_info.balance < request.amount:
|
| 191 |
+
raise HTTPException(status_code=400, detail="Insufficient wallet balance")
|
| 192 |
+
|
| 193 |
+
transaction = await WalletService.deduct_money(
|
| 194 |
+
current_user_id,
|
| 195 |
+
request.amount,
|
| 196 |
+
request.category or "service",
|
| 197 |
+
request.reference_id,
|
| 198 |
+
request.description
|
| 199 |
+
)
|
| 200 |
+
else: # credit or refund
|
| 201 |
+
transaction = await WalletService.add_money(
|
| 202 |
+
current_user_id,
|
| 203 |
+
request.amount,
|
| 204 |
+
request.category or "refund",
|
| 205 |
+
request.reference_id,
|
| 206 |
+
request.description
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
return TransactionResponse(
|
| 210 |
+
success=True,
|
| 211 |
+
message=f"Transaction processed successfully",
|
| 212 |
+
transaction=transaction,
|
| 213 |
+
new_balance=transaction.balance_after
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
except HTTPException:
|
| 217 |
+
raise
|
| 218 |
+
except Exception as e:
|
| 219 |
+
logger.error(f"Error creating transaction for user {current_user_id}: {str(e)}")
|
| 220 |
+
raise HTTPException(
|
| 221 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 222 |
+
detail="Failed to process transaction"
|
| 223 |
+
)
|
app/schemas/address_schema.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field, validator
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import List, Optional, Literal
|
| 4 |
+
|
| 5 |
+
class AddressCreateRequest(BaseModel):
|
| 6 |
+
"""Request model for creating a new address"""
|
| 7 |
+
address_line_1: str = Field(..., min_length=5, max_length=200, description="Primary address line")
|
| 8 |
+
address_line_2: Optional[str] = Field("", max_length=200, description="Secondary address line")
|
| 9 |
+
city: str = Field(..., min_length=2, max_length=100, description="City name")
|
| 10 |
+
state: str = Field(..., min_length=2, max_length=100, description="State name")
|
| 11 |
+
postal_code: str = Field(..., min_length=5, max_length=10, description="Postal/ZIP code")
|
| 12 |
+
country: str = Field(default="India", max_length=100, description="Country name")
|
| 13 |
+
address_type: Literal["home", "work", "other"] = Field(default="home", description="Type of address")
|
| 14 |
+
is_default: bool = Field(default=False, description="Set as default address")
|
| 15 |
+
landmark: Optional[str] = Field("", max_length=200, description="Nearby landmark")
|
| 16 |
+
contact_name: Optional[str] = Field("", max_length=100, description="Contact person name")
|
| 17 |
+
contact_phone: Optional[str] = Field("", max_length=15, description="Contact phone number")
|
| 18 |
+
|
| 19 |
+
@validator('postal_code')
|
| 20 |
+
def validate_postal_code(cls, v):
|
| 21 |
+
if not v.isdigit():
|
| 22 |
+
raise ValueError('Postal code must contain only digits')
|
| 23 |
+
return v
|
| 24 |
+
|
| 25 |
+
@validator('contact_phone')
|
| 26 |
+
def validate_contact_phone(cls, v):
|
| 27 |
+
if v and not v.isdigit():
|
| 28 |
+
raise ValueError('Contact phone must contain only digits')
|
| 29 |
+
return v
|
| 30 |
+
|
| 31 |
+
class AddressUpdateRequest(BaseModel):
|
| 32 |
+
"""Request model for updating an existing address"""
|
| 33 |
+
address_line_1: Optional[str] = Field(None, min_length=5, max_length=200, description="Primary address line")
|
| 34 |
+
address_line_2: Optional[str] = Field(None, max_length=200, description="Secondary address line")
|
| 35 |
+
city: Optional[str] = Field(None, min_length=2, max_length=100, description="City name")
|
| 36 |
+
state: Optional[str] = Field(None, min_length=2, max_length=100, description="State name")
|
| 37 |
+
postal_code: Optional[str] = Field(None, min_length=5, max_length=10, description="Postal/ZIP code")
|
| 38 |
+
country: Optional[str] = Field(None, max_length=100, description="Country name")
|
| 39 |
+
address_type: Optional[Literal["home", "work", "other"]] = Field(None, description="Type of address")
|
| 40 |
+
is_default: Optional[bool] = Field(None, description="Set as default address")
|
| 41 |
+
landmark: Optional[str] = Field(None, max_length=200, description="Nearby landmark")
|
| 42 |
+
contact_name: Optional[str] = Field(None, max_length=100, description="Contact person name")
|
| 43 |
+
contact_phone: Optional[str] = Field(None, max_length=15, description="Contact phone number")
|
| 44 |
+
|
| 45 |
+
@validator('postal_code')
|
| 46 |
+
def validate_postal_code(cls, v):
|
| 47 |
+
if v and not v.isdigit():
|
| 48 |
+
raise ValueError('Postal code must contain only digits')
|
| 49 |
+
return v
|
| 50 |
+
|
| 51 |
+
@validator('contact_phone')
|
| 52 |
+
def validate_contact_phone(cls, v):
|
| 53 |
+
if v and not v.isdigit():
|
| 54 |
+
raise ValueError('Contact phone must contain only digits')
|
| 55 |
+
return v
|
| 56 |
+
|
| 57 |
+
class AddressResponse(BaseModel):
|
| 58 |
+
"""Response model for address data"""
|
| 59 |
+
address_id: str = Field(..., description="Unique address ID")
|
| 60 |
+
address_line_1: str = Field(..., description="Primary address line")
|
| 61 |
+
address_line_2: str = Field(..., description="Secondary address line")
|
| 62 |
+
city: str = Field(..., description="City name")
|
| 63 |
+
state: str = Field(..., description="State name")
|
| 64 |
+
postal_code: str = Field(..., description="Postal/ZIP code")
|
| 65 |
+
country: str = Field(..., description="Country name")
|
| 66 |
+
address_type: str = Field(..., description="Type of address")
|
| 67 |
+
is_default: bool = Field(..., description="Is default address")
|
| 68 |
+
landmark: str = Field(..., description="Nearby landmark")
|
| 69 |
+
contact_name: str = Field(..., description="Contact person name")
|
| 70 |
+
contact_phone: str = Field(..., description="Contact phone number")
|
| 71 |
+
created_at: datetime = Field(..., description="Address creation timestamp")
|
| 72 |
+
updated_at: datetime = Field(..., description="Address last update timestamp")
|
| 73 |
+
|
| 74 |
+
class Config:
|
| 75 |
+
from_attributes = True
|
| 76 |
+
|
| 77 |
+
class AddressListResponse(BaseModel):
|
| 78 |
+
"""Response model for list of addresses"""
|
| 79 |
+
addresses: List[AddressResponse] = Field(..., description="List of user addresses")
|
| 80 |
+
total_count: int = Field(..., description="Total number of addresses")
|
| 81 |
+
default_address_id: Optional[str] = Field(None, description="ID of default address")
|
| 82 |
+
|
| 83 |
+
class SetDefaultAddressRequest(BaseModel):
|
| 84 |
+
"""Request model for setting default address"""
|
| 85 |
+
address_id: str = Field(..., description="Address ID to set as default")
|
| 86 |
+
|
| 87 |
+
class AddressOperationResponse(BaseModel):
|
| 88 |
+
"""Response model for address operations"""
|
| 89 |
+
success: bool = Field(..., description="Operation success status")
|
| 90 |
+
message: str = Field(..., description="Response message")
|
| 91 |
+
address_id: Optional[str] = Field(None, description="Address ID if applicable")
|
app/schemas/guest_schema.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field, validator, EmailStr
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from enum import Enum
|
| 5 |
+
|
| 6 |
+
class GenderEnum(str, Enum):
|
| 7 |
+
MALE = "Male"
|
| 8 |
+
FEMALE = "Female"
|
| 9 |
+
OTHER = "Other"
|
| 10 |
+
|
| 11 |
+
class RelationshipEnum(str, Enum):
|
| 12 |
+
FAMILY = "Family"
|
| 13 |
+
FRIEND = "Friend"
|
| 14 |
+
COLLEAGUE = "Colleague"
|
| 15 |
+
OTHER = "Other"
|
| 16 |
+
|
| 17 |
+
class GuestCreateRequest(BaseModel):
|
| 18 |
+
"""Schema for creating a new guest profile"""
|
| 19 |
+
first_name: str = Field(..., min_length=1, max_length=100, description="Guest's first name")
|
| 20 |
+
last_name: Optional[str] = Field(None, max_length=100, description="Guest's last name")
|
| 21 |
+
email: Optional[EmailStr] = Field(None, description="Guest's email address")
|
| 22 |
+
phone_number: Optional[str] = Field(None, max_length=20, description="Guest's phone number")
|
| 23 |
+
gender: Optional[GenderEnum] = Field(None, description="Guest's gender")
|
| 24 |
+
date_of_birth: Optional[datetime] = Field(None, description="Guest's date of birth for age calculation")
|
| 25 |
+
relationship: Optional[RelationshipEnum] = Field(None, description="Relationship to the user")
|
| 26 |
+
notes: Optional[str] = Field(None, max_length=500, description="Additional notes about the guest")
|
| 27 |
+
|
| 28 |
+
@validator('first_name')
|
| 29 |
+
def validate_first_name(cls, v):
|
| 30 |
+
if not v or not v.strip():
|
| 31 |
+
raise ValueError('First name cannot be empty')
|
| 32 |
+
return v.strip()
|
| 33 |
+
|
| 34 |
+
@validator('last_name')
|
| 35 |
+
def validate_last_name(cls, v):
|
| 36 |
+
if v is not None and v.strip() == '':
|
| 37 |
+
return None
|
| 38 |
+
return v.strip() if v else v
|
| 39 |
+
|
| 40 |
+
@validator('phone_number')
|
| 41 |
+
def validate_phone_number(cls, v):
|
| 42 |
+
if v is not None:
|
| 43 |
+
# Remove spaces and special characters for validation
|
| 44 |
+
cleaned = ''.join(filter(str.isdigit, v))
|
| 45 |
+
if len(cleaned) < 10 or len(cleaned) > 15:
|
| 46 |
+
raise ValueError('Phone number must be between 10 and 15 digits')
|
| 47 |
+
return v
|
| 48 |
+
|
| 49 |
+
@validator('date_of_birth')
|
| 50 |
+
def validate_date_of_birth(cls, v):
|
| 51 |
+
if v is not None:
|
| 52 |
+
if v > datetime.now():
|
| 53 |
+
raise ValueError('Date of birth cannot be in the future')
|
| 54 |
+
# Check if age would be reasonable (not more than 120 years old)
|
| 55 |
+
age = (datetime.now() - v).days // 365
|
| 56 |
+
if age > 120:
|
| 57 |
+
raise ValueError('Date of birth indicates unrealistic age')
|
| 58 |
+
return v
|
| 59 |
+
|
| 60 |
+
class GuestUpdateRequest(BaseModel):
|
| 61 |
+
"""Schema for updating a guest profile"""
|
| 62 |
+
first_name: Optional[str] = Field(None, min_length=1, max_length=100, description="Guest's first name")
|
| 63 |
+
last_name: Optional[str] = Field(None, max_length=100, description="Guest's last name")
|
| 64 |
+
email: Optional[EmailStr] = Field(None, description="Guest's email address")
|
| 65 |
+
phone_number: Optional[str] = Field(None, max_length=20, description="Guest's phone number")
|
| 66 |
+
gender: Optional[GenderEnum] = Field(None, description="Guest's gender")
|
| 67 |
+
date_of_birth: Optional[datetime] = Field(None, description="Guest's date of birth for age calculation")
|
| 68 |
+
relationship: Optional[RelationshipEnum] = Field(None, description="Relationship to the user")
|
| 69 |
+
notes: Optional[str] = Field(None, max_length=500, description="Additional notes about the guest")
|
| 70 |
+
|
| 71 |
+
@validator('first_name')
|
| 72 |
+
def validate_first_name(cls, v):
|
| 73 |
+
if v is not None and (not v or not v.strip()):
|
| 74 |
+
raise ValueError('First name cannot be empty')
|
| 75 |
+
return v.strip() if v else v
|
| 76 |
+
|
| 77 |
+
@validator('last_name')
|
| 78 |
+
def validate_last_name(cls, v):
|
| 79 |
+
if v is not None and v.strip() == '':
|
| 80 |
+
return None
|
| 81 |
+
return v.strip() if v else v
|
| 82 |
+
|
| 83 |
+
@validator('phone_number')
|
| 84 |
+
def validate_phone_number(cls, v):
|
| 85 |
+
if v is not None:
|
| 86 |
+
# Remove spaces and special characters for validation
|
| 87 |
+
cleaned = ''.join(filter(str.isdigit, v))
|
| 88 |
+
if len(cleaned) < 10 or len(cleaned) > 15:
|
| 89 |
+
raise ValueError('Phone number must be between 10 and 15 digits')
|
| 90 |
+
return v
|
| 91 |
+
|
| 92 |
+
@validator('date_of_birth')
|
| 93 |
+
def validate_date_of_birth(cls, v):
|
| 94 |
+
if v is not None:
|
| 95 |
+
if v > datetime.now():
|
| 96 |
+
raise ValueError('Date of birth cannot be in the future')
|
| 97 |
+
# Check if age would be reasonable (not more than 120 years old)
|
| 98 |
+
age = (datetime.now() - v).days // 365
|
| 99 |
+
if age > 120:
|
| 100 |
+
raise ValueError('Date of birth indicates unrealistic age')
|
| 101 |
+
return v
|
| 102 |
+
|
| 103 |
+
class GuestResponse(BaseModel):
|
| 104 |
+
"""Schema for guest profile response"""
|
| 105 |
+
guest_id: str = Field(..., description="Unique guest identifier")
|
| 106 |
+
user_id: str = Field(..., description="User ID who created this guest profile")
|
| 107 |
+
first_name: str = Field(..., description="Guest's first name")
|
| 108 |
+
last_name: Optional[str] = Field(None, description="Guest's last name")
|
| 109 |
+
email: Optional[str] = Field(None, description="Guest's email address")
|
| 110 |
+
phone_number: Optional[str] = Field(None, description="Guest's phone number")
|
| 111 |
+
gender: Optional[str] = Field(None, description="Guest's gender")
|
| 112 |
+
date_of_birth: Optional[datetime] = Field(None, description="Guest's date of birth")
|
| 113 |
+
relationship: Optional[str] = Field(None, description="Relationship to the user")
|
| 114 |
+
notes: Optional[str] = Field(None, description="Additional notes about the guest")
|
| 115 |
+
created_at: datetime = Field(..., description="Guest profile creation timestamp")
|
| 116 |
+
updated_at: datetime = Field(..., description="Guest profile last update timestamp")
|
| 117 |
+
|
| 118 |
+
@property
|
| 119 |
+
def full_name(self) -> str:
|
| 120 |
+
"""Get the full name of the guest"""
|
| 121 |
+
if self.last_name:
|
| 122 |
+
return f"{self.first_name} {self.last_name}"
|
| 123 |
+
return self.first_name
|
| 124 |
+
|
| 125 |
+
@property
|
| 126 |
+
def age(self) -> Optional[int]:
|
| 127 |
+
"""Calculate age from date of birth"""
|
| 128 |
+
if self.date_of_birth:
|
| 129 |
+
today = datetime.now()
|
| 130 |
+
return today.year - self.date_of_birth.year - (
|
| 131 |
+
(today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day)
|
| 132 |
+
)
|
| 133 |
+
return None
|
| 134 |
+
|
| 135 |
+
class Config:
|
| 136 |
+
from_attributes = True
|
| 137 |
+
json_encoders = {
|
| 138 |
+
datetime: lambda v: v.isoformat()
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
class GuestListResponse(BaseModel):
|
| 142 |
+
"""Schema for list of guests response"""
|
| 143 |
+
guests: List[GuestResponse] = Field(..., description="List of user's guests")
|
| 144 |
+
total_count: int = Field(..., description="Total number of guests")
|
| 145 |
+
|
| 146 |
+
class Config:
|
| 147 |
+
from_attributes = True
|
| 148 |
+
|
| 149 |
+
class GuestDeleteResponse(BaseModel):
|
| 150 |
+
"""Schema for guest deletion response"""
|
| 151 |
+
message: str = Field(..., description="Deletion confirmation message")
|
| 152 |
+
guest_id: str = Field(..., description="ID of the deleted guest")
|
| 153 |
+
|
| 154 |
+
class Config:
|
| 155 |
+
from_attributes = True
|
app/schemas/pet_schema.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field, validator
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from enum import Enum
|
| 5 |
+
|
| 6 |
+
class SpeciesEnum(str, Enum):
|
| 7 |
+
DOG = "Dog"
|
| 8 |
+
CAT = "Cat"
|
| 9 |
+
OTHER = "Other"
|
| 10 |
+
|
| 11 |
+
class GenderEnum(str, Enum):
|
| 12 |
+
MALE = "Male"
|
| 13 |
+
FEMALE = "Female"
|
| 14 |
+
OTHER = "Other"
|
| 15 |
+
|
| 16 |
+
class TemperamentEnum(str, Enum):
|
| 17 |
+
CALM = "Calm"
|
| 18 |
+
NERVOUS = "Nervous"
|
| 19 |
+
AGGRESSIVE = "Aggressive"
|
| 20 |
+
SOCIAL = "Social"
|
| 21 |
+
|
| 22 |
+
class PetCreateRequest(BaseModel):
|
| 23 |
+
"""Schema for creating a new pet profile"""
|
| 24 |
+
pet_name: str = Field(..., min_length=1, max_length=100, description="Name of the pet")
|
| 25 |
+
species: SpeciesEnum = Field(..., description="Species of the pet")
|
| 26 |
+
breed: Optional[str] = Field(None, max_length=100, description="Breed of the pet")
|
| 27 |
+
date_of_birth: Optional[datetime] = Field(None, description="Pet's date of birth")
|
| 28 |
+
age: Optional[int] = Field(None, ge=0, le=50, description="Pet's age in years")
|
| 29 |
+
weight: Optional[float] = Field(None, ge=0, le=200, description="Pet's weight in kg")
|
| 30 |
+
gender: Optional[GenderEnum] = Field(None, description="Pet's gender")
|
| 31 |
+
temperament: Optional[TemperamentEnum] = Field(None, description="Pet's temperament")
|
| 32 |
+
health_notes: Optional[str] = Field(None, max_length=1000, description="Health notes, allergies, medications")
|
| 33 |
+
is_vaccinated: bool = Field(False, description="Vaccination status")
|
| 34 |
+
pet_photo_url: Optional[str] = Field(None, max_length=500, description="URL to pet's photo")
|
| 35 |
+
|
| 36 |
+
@validator('pet_name')
|
| 37 |
+
def validate_pet_name(cls, v):
|
| 38 |
+
if not v or not v.strip():
|
| 39 |
+
raise ValueError('Pet name cannot be empty')
|
| 40 |
+
return v.strip()
|
| 41 |
+
|
| 42 |
+
@validator('age', 'date_of_birth')
|
| 43 |
+
def validate_age_or_dob(cls, v, values):
|
| 44 |
+
# At least one of age or date_of_birth should be provided
|
| 45 |
+
if 'age' in values and 'date_of_birth' in values:
|
| 46 |
+
if not values.get('age') and not values.get('date_of_birth'):
|
| 47 |
+
raise ValueError('Either age or date of birth must be provided')
|
| 48 |
+
return v
|
| 49 |
+
|
| 50 |
+
class PetUpdateRequest(BaseModel):
|
| 51 |
+
"""Schema for updating a pet profile"""
|
| 52 |
+
pet_name: Optional[str] = Field(None, min_length=1, max_length=100, description="Name of the pet")
|
| 53 |
+
species: Optional[SpeciesEnum] = Field(None, description="Species of the pet")
|
| 54 |
+
breed: Optional[str] = Field(None, max_length=100, description="Breed of the pet")
|
| 55 |
+
date_of_birth: Optional[datetime] = Field(None, description="Pet's date of birth")
|
| 56 |
+
age: Optional[int] = Field(None, ge=0, le=50, description="Pet's age in years")
|
| 57 |
+
weight: Optional[float] = Field(None, ge=0, le=200, description="Pet's weight in kg")
|
| 58 |
+
gender: Optional[GenderEnum] = Field(None, description="Pet's gender")
|
| 59 |
+
temperament: Optional[TemperamentEnum] = Field(None, description="Pet's temperament")
|
| 60 |
+
health_notes: Optional[str] = Field(None, max_length=1000, description="Health notes, allergies, medications")
|
| 61 |
+
is_vaccinated: Optional[bool] = Field(None, description="Vaccination status")
|
| 62 |
+
pet_photo_url: Optional[str] = Field(None, max_length=500, description="URL to pet's photo")
|
| 63 |
+
|
| 64 |
+
@validator('pet_name')
|
| 65 |
+
def validate_pet_name(cls, v):
|
| 66 |
+
if v is not None and (not v or not v.strip()):
|
| 67 |
+
raise ValueError('Pet name cannot be empty')
|
| 68 |
+
return v.strip() if v else v
|
| 69 |
+
|
| 70 |
+
class PetResponse(BaseModel):
|
| 71 |
+
"""Schema for pet profile response"""
|
| 72 |
+
pet_id: str = Field(..., description="Unique pet identifier")
|
| 73 |
+
user_id: str = Field(..., description="Owner's user ID")
|
| 74 |
+
pet_name: str = Field(..., description="Name of the pet")
|
| 75 |
+
species: str = Field(..., description="Species of the pet")
|
| 76 |
+
breed: Optional[str] = Field(None, description="Breed of the pet")
|
| 77 |
+
date_of_birth: Optional[datetime] = Field(None, description="Pet's date of birth")
|
| 78 |
+
age: Optional[int] = Field(None, description="Pet's age in years")
|
| 79 |
+
weight: Optional[float] = Field(None, description="Pet's weight in kg")
|
| 80 |
+
gender: Optional[str] = Field(None, description="Pet's gender")
|
| 81 |
+
temperament: Optional[str] = Field(None, description="Pet's temperament")
|
| 82 |
+
health_notes: Optional[str] = Field(None, description="Health notes, allergies, medications")
|
| 83 |
+
is_vaccinated: bool = Field(..., description="Vaccination status")
|
| 84 |
+
pet_photo_url: Optional[str] = Field(None, description="URL to pet's photo")
|
| 85 |
+
created_at: datetime = Field(..., description="Pet profile creation timestamp")
|
| 86 |
+
updated_at: datetime = Field(..., description="Pet profile last update timestamp")
|
| 87 |
+
|
| 88 |
+
class Config:
|
| 89 |
+
from_attributes = True
|
| 90 |
+
json_encoders = {
|
| 91 |
+
datetime: lambda v: v.isoformat()
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
class PetListResponse(BaseModel):
|
| 95 |
+
"""Schema for list of pets response"""
|
| 96 |
+
pets: List[PetResponse] = Field(..., description="List of user's pets")
|
| 97 |
+
total_count: int = Field(..., description="Total number of pets")
|
| 98 |
+
|
| 99 |
+
class Config:
|
| 100 |
+
from_attributes = True
|
| 101 |
+
|
| 102 |
+
class PetDeleteResponse(BaseModel):
|
| 103 |
+
"""Schema for pet deletion response"""
|
| 104 |
+
message: str = Field(..., description="Deletion confirmation message")
|
| 105 |
+
pet_id: str = Field(..., description="ID of the deleted pet")
|
| 106 |
+
|
| 107 |
+
class Config:
|
| 108 |
+
from_attributes = True
|
app/schemas/profile_schema.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr, Field, validator
|
| 2 |
+
from datetime import datetime, date
|
| 3 |
+
from typing import Optional, Dict, Any
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
class ProfileUpdateRequest(BaseModel):
|
| 7 |
+
"""Request model for updating user profile"""
|
| 8 |
+
name: Optional[str] = Field(None, min_length=2, max_length=100, description="User's full name")
|
| 9 |
+
email: Optional[EmailStr] = Field(None, description="User's email address")
|
| 10 |
+
phone: Optional[str] = Field(None, description="User's phone number")
|
| 11 |
+
date_of_birth: Optional[str] = Field(None, description="Date of birth in DD/MM/YYYY format")
|
| 12 |
+
profile_picture: Optional[str] = Field(None, description="Profile picture URL")
|
| 13 |
+
|
| 14 |
+
@validator('phone')
|
| 15 |
+
def validate_phone(cls, v):
|
| 16 |
+
if v is not None:
|
| 17 |
+
# Remove any non-digit characters
|
| 18 |
+
phone_digits = re.sub(r'\D', '', v)
|
| 19 |
+
if len(phone_digits) != 10:
|
| 20 |
+
raise ValueError('Phone number must be exactly 10 digits')
|
| 21 |
+
return phone_digits
|
| 22 |
+
return v
|
| 23 |
+
|
| 24 |
+
@validator('date_of_birth')
|
| 25 |
+
def validate_date_of_birth(cls, v):
|
| 26 |
+
if v is not None:
|
| 27 |
+
try:
|
| 28 |
+
# Parse DD/MM/YYYY format
|
| 29 |
+
day, month, year = map(int, v.split('/'))
|
| 30 |
+
birth_date = date(year, month, day)
|
| 31 |
+
|
| 32 |
+
# Check if date is not in the future
|
| 33 |
+
if birth_date > date.today():
|
| 34 |
+
raise ValueError('Date of birth cannot be in the future')
|
| 35 |
+
|
| 36 |
+
# Check if age is reasonable (not more than 120 years)
|
| 37 |
+
age = (date.today() - birth_date).days // 365
|
| 38 |
+
if age > 120:
|
| 39 |
+
raise ValueError('Invalid date of birth')
|
| 40 |
+
|
| 41 |
+
return v
|
| 42 |
+
except ValueError as e:
|
| 43 |
+
if "Invalid date of birth" in str(e) or "Date of birth cannot be in the future" in str(e):
|
| 44 |
+
raise e
|
| 45 |
+
raise ValueError('Date of birth must be in DD/MM/YYYY format')
|
| 46 |
+
return v
|
| 47 |
+
|
| 48 |
+
class ProfileResponse(BaseModel):
|
| 49 |
+
"""Response model for user profile"""
|
| 50 |
+
user_id: str = Field(..., description="Unique user identifier")
|
| 51 |
+
name: str = Field(..., description="User's full name")
|
| 52 |
+
email: Optional[str] = Field(None, description="User's email address")
|
| 53 |
+
phone: Optional[str] = Field(None, description="User's phone number")
|
| 54 |
+
date_of_birth: Optional[str] = Field(None, description="Date of birth in DD/MM/YYYY format")
|
| 55 |
+
profile_picture: Optional[str] = Field(None, description="Profile picture URL")
|
| 56 |
+
auth_method: str = Field(..., description="Authentication method used")
|
| 57 |
+
created_at: datetime = Field(..., description="Account creation timestamp")
|
| 58 |
+
updated_at: Optional[datetime] = Field(None, description="Last profile update timestamp")
|
| 59 |
+
|
| 60 |
+
class Config:
|
| 61 |
+
from_attributes = True
|
| 62 |
+
|
| 63 |
+
class PersonalDetailsResponse(BaseModel):
|
| 64 |
+
"""Response model for personal details section"""
|
| 65 |
+
first_name: str = Field(..., description="User's first name")
|
| 66 |
+
last_name: str = Field(..., description="User's last name")
|
| 67 |
+
email: str = Field(..., description="User's email address")
|
| 68 |
+
phone: str = Field(..., description="User's phone number")
|
| 69 |
+
date_of_birth: Optional[str] = Field(None, description="Date of birth in DD/MM/YYYY format")
|
| 70 |
+
|
| 71 |
+
class Config:
|
| 72 |
+
from_attributes = True
|
| 73 |
+
|
| 74 |
+
class ProfileOperationResponse(BaseModel):
|
| 75 |
+
"""Response model for profile operations"""
|
| 76 |
+
success: bool = Field(..., description="Operation success status")
|
| 77 |
+
message: str = Field(..., description="Response message")
|
| 78 |
+
profile: Optional[ProfileResponse] = Field(None, description="Updated profile data")
|
| 79 |
+
|
| 80 |
+
class WalletDisplayResponse(BaseModel):
|
| 81 |
+
"""Response model for wallet display in profile"""
|
| 82 |
+
balance: float = Field(..., description="Current wallet balance")
|
| 83 |
+
formatted_balance: str = Field(..., description="Formatted balance with currency symbol")
|
| 84 |
+
currency: str = Field(default="INR", description="Currency code")
|
| 85 |
+
|
| 86 |
+
class ProfileDashboardResponse(BaseModel):
|
| 87 |
+
"""Complete response model for profile dashboard"""
|
| 88 |
+
personal_details: PersonalDetailsResponse = Field(..., description="Personal details")
|
| 89 |
+
wallet: WalletDisplayResponse = Field(..., description="Wallet information")
|
| 90 |
+
address_count: int = Field(..., description="Number of saved addresses")
|
| 91 |
+
has_default_address: bool = Field(..., description="Whether user has a default address set")
|
app/schemas/wallet_schema.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import List, Optional, Literal
|
| 4 |
+
from decimal import Decimal
|
| 5 |
+
|
| 6 |
+
class WalletBalanceResponse(BaseModel):
|
| 7 |
+
"""Response model for wallet balance"""
|
| 8 |
+
balance: float = Field(..., description="Current wallet balance")
|
| 9 |
+
currency: str = Field(default="INR", description="Currency code")
|
| 10 |
+
formatted_balance: str = Field(..., description="Formatted balance with currency symbol")
|
| 11 |
+
|
| 12 |
+
class TransactionEntry(BaseModel):
|
| 13 |
+
"""Model for individual transaction entry"""
|
| 14 |
+
transaction_id: str = Field(..., description="Unique transaction ID")
|
| 15 |
+
amount: float = Field(..., description="Transaction amount")
|
| 16 |
+
transaction_type: Literal["credit", "debit", "refund", "cashback", "payment", "withdrawal"] = Field(..., description="Type of transaction")
|
| 17 |
+
description: str = Field(..., description="Transaction description")
|
| 18 |
+
reference_id: Optional[str] = Field(None, description="Reference ID for the transaction")
|
| 19 |
+
balance_before: float = Field(..., description="Balance before transaction")
|
| 20 |
+
balance_after: float = Field(..., description="Balance after transaction")
|
| 21 |
+
timestamp: datetime = Field(..., description="Transaction timestamp")
|
| 22 |
+
status: Literal["completed", "pending", "failed"] = Field(default="completed", description="Transaction status")
|
| 23 |
+
|
| 24 |
+
class TransactionHistoryResponse(BaseModel):
|
| 25 |
+
"""Response model for transaction history"""
|
| 26 |
+
transactions: List[TransactionEntry] = Field(..., description="List of transactions")
|
| 27 |
+
total_count: int = Field(..., description="Total number of transactions")
|
| 28 |
+
page: int = Field(..., description="Current page number")
|
| 29 |
+
per_page: int = Field(..., description="Number of items per page")
|
| 30 |
+
total_pages: int = Field(..., description="Total number of pages")
|
| 31 |
+
|
| 32 |
+
class WalletSummaryResponse(BaseModel):
|
| 33 |
+
"""Response model for wallet summary"""
|
| 34 |
+
balance: float = Field(..., description="Current wallet balance")
|
| 35 |
+
formatted_balance: str = Field(..., description="Formatted balance with currency symbol")
|
| 36 |
+
recent_transactions: List[TransactionEntry] = Field(..., description="Recent transactions")
|
| 37 |
+
|
| 38 |
+
class AddMoneyRequest(BaseModel):
|
| 39 |
+
"""Request model for adding money to wallet"""
|
| 40 |
+
amount: float = Field(..., gt=0, description="Amount to add (must be positive)")
|
| 41 |
+
payment_method: Literal["card", "upi", "netbanking"] = Field(..., description="Payment method")
|
| 42 |
+
reference_id: Optional[str] = Field(None, description="Payment reference ID")
|
| 43 |
+
description: Optional[str] = Field("Wallet top-up", description="Transaction description")
|
| 44 |
+
|
| 45 |
+
class WithdrawMoneyRequest(BaseModel):
|
| 46 |
+
"""Request model for withdrawing money from wallet"""
|
| 47 |
+
amount: float = Field(..., gt=0, description="Amount to withdraw (must be positive)")
|
| 48 |
+
bank_account_id: str = Field(..., description="Bank account ID for withdrawal")
|
| 49 |
+
description: Optional[str] = Field("Wallet withdrawal", description="Transaction description")
|
| 50 |
+
|
| 51 |
+
class TransactionRequest(BaseModel):
|
| 52 |
+
"""Generic transaction request model"""
|
| 53 |
+
amount: float = Field(..., gt=0, description="Transaction amount")
|
| 54 |
+
transaction_type: Literal["credit", "debit", "refund", "cashback", "payment"] = Field(..., description="Transaction type")
|
| 55 |
+
description: str = Field(..., description="Transaction description")
|
| 56 |
+
reference_id: Optional[str] = Field(None, description="Reference ID")
|
| 57 |
+
category: Optional[str] = Field(None, description="Transaction category")
|
| 58 |
+
|
| 59 |
+
class TransactionResponse(BaseModel):
|
| 60 |
+
"""Response model for transaction operations"""
|
| 61 |
+
success: bool = Field(..., description="Transaction success status")
|
| 62 |
+
message: str = Field(..., description="Response message")
|
| 63 |
+
transaction: Optional[TransactionEntry] = Field(None, description="Transaction details")
|
| 64 |
+
new_balance: Optional[float] = Field(None, description="New wallet balance after transaction")
|
| 65 |
+
|
| 66 |
+
class WalletTransactionResponse(BaseModel):
|
| 67 |
+
"""Response model for wallet transaction operations"""
|
| 68 |
+
success: bool = Field(..., description="Transaction success status")
|
| 69 |
+
message: str = Field(..., description="Response message")
|
| 70 |
+
transaction_id: Optional[str] = Field(None, description="Transaction ID if successful")
|
| 71 |
+
new_balance: Optional[float] = Field(None, description="New wallet balance after transaction")
|
app/services/wallet_service.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Any, Optional
|
| 2 |
+
import logging
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
from app.models.wallet_model import WalletModel
|
| 6 |
+
from app.schemas.wallet_schema import (
|
| 7 |
+
WalletBalanceResponse, WalletSummaryResponse, TransactionHistoryResponse,
|
| 8 |
+
TransactionEntry, WalletTransactionResponse
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
class WalletService:
|
| 14 |
+
"""Service for wallet operations"""
|
| 15 |
+
|
| 16 |
+
@staticmethod
|
| 17 |
+
async def get_wallet_balance(user_id: str) -> WalletBalanceResponse:
|
| 18 |
+
"""Get formatted wallet balance for user"""
|
| 19 |
+
try:
|
| 20 |
+
balance = await WalletModel.get_wallet_balance(user_id)
|
| 21 |
+
|
| 22 |
+
return WalletBalanceResponse(
|
| 23 |
+
balance=balance,
|
| 24 |
+
currency="INR",
|
| 25 |
+
formatted_balance=f"₹{balance:,.2f}"
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
except Exception as e:
|
| 29 |
+
logger.error(f"Error getting wallet balance for user {user_id}: {str(e)}")
|
| 30 |
+
return WalletBalanceResponse(
|
| 31 |
+
balance=0.0,
|
| 32 |
+
currency="INR",
|
| 33 |
+
formatted_balance="₹0.00"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
@staticmethod
|
| 37 |
+
async def get_wallet_summary(user_id: str) -> WalletSummaryResponse:
|
| 38 |
+
"""Get wallet summary with balance and recent transactions"""
|
| 39 |
+
try:
|
| 40 |
+
summary_data = await WalletModel.get_wallet_summary(user_id)
|
| 41 |
+
|
| 42 |
+
# Convert transactions to schema format
|
| 43 |
+
recent_transactions = []
|
| 44 |
+
for transaction in summary_data.get("recent_transactions", []):
|
| 45 |
+
recent_transactions.append(TransactionEntry(
|
| 46 |
+
transaction_id=transaction["_id"],
|
| 47 |
+
amount=transaction["amount"],
|
| 48 |
+
transaction_type=transaction["transaction_type"],
|
| 49 |
+
description=transaction["description"],
|
| 50 |
+
reference_id=transaction.get("reference_id"),
|
| 51 |
+
balance_before=transaction["balance_before"],
|
| 52 |
+
balance_after=transaction["balance_after"],
|
| 53 |
+
timestamp=transaction["timestamp"],
|
| 54 |
+
status=transaction["status"]
|
| 55 |
+
))
|
| 56 |
+
|
| 57 |
+
balance = summary_data.get("balance", 0.0)
|
| 58 |
+
|
| 59 |
+
return WalletSummaryResponse(
|
| 60 |
+
balance=balance,
|
| 61 |
+
formatted_balance=f"₹{balance:,.2f}",
|
| 62 |
+
recent_transactions=recent_transactions
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.error(f"Error getting wallet summary for user {user_id}: {str(e)}")
|
| 67 |
+
return WalletSummaryResponse(
|
| 68 |
+
balance=0.0,
|
| 69 |
+
formatted_balance="₹0.00",
|
| 70 |
+
recent_transactions=[]
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
@staticmethod
|
| 74 |
+
async def get_transaction_history(user_id: str, page: int = 1, per_page: int = 20) -> TransactionHistoryResponse:
|
| 75 |
+
"""Get paginated transaction history"""
|
| 76 |
+
try:
|
| 77 |
+
history_data = await WalletModel.get_transaction_history(user_id, page, per_page)
|
| 78 |
+
|
| 79 |
+
# Convert transactions to schema format
|
| 80 |
+
transactions = []
|
| 81 |
+
for transaction in history_data.get("transactions", []):
|
| 82 |
+
transactions.append(TransactionEntry(
|
| 83 |
+
transaction_id=transaction["_id"],
|
| 84 |
+
amount=transaction["amount"],
|
| 85 |
+
transaction_type=transaction["transaction_type"],
|
| 86 |
+
description=transaction["description"],
|
| 87 |
+
reference_id=transaction.get("reference_id"),
|
| 88 |
+
balance_before=transaction["balance_before"],
|
| 89 |
+
balance_after=transaction["balance_after"],
|
| 90 |
+
timestamp=transaction["timestamp"],
|
| 91 |
+
status=transaction["status"]
|
| 92 |
+
))
|
| 93 |
+
|
| 94 |
+
return TransactionHistoryResponse(
|
| 95 |
+
transactions=transactions,
|
| 96 |
+
total_count=history_data.get("total_count", 0),
|
| 97 |
+
page=page,
|
| 98 |
+
per_page=per_page,
|
| 99 |
+
total_pages=history_data.get("total_pages", 0)
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
except Exception as e:
|
| 103 |
+
logger.error(f"Error getting transaction history for user {user_id}: {str(e)}")
|
| 104 |
+
return TransactionHistoryResponse(
|
| 105 |
+
transactions=[],
|
| 106 |
+
total_count=0,
|
| 107 |
+
page=page,
|
| 108 |
+
per_page=per_page,
|
| 109 |
+
total_pages=0
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
@staticmethod
|
| 113 |
+
async def add_money(user_id: str, amount: float, payment_method: str,
|
| 114 |
+
description: str = "Wallet top-up", reference_id: str = None) -> WalletTransactionResponse:
|
| 115 |
+
"""Add money to wallet"""
|
| 116 |
+
try:
|
| 117 |
+
success = await WalletModel.update_balance(
|
| 118 |
+
user_id=user_id,
|
| 119 |
+
amount=amount,
|
| 120 |
+
transaction_type="credit",
|
| 121 |
+
description=f"{description} via {payment_method}",
|
| 122 |
+
reference_id=reference_id
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
if success:
|
| 126 |
+
new_balance = await WalletModel.get_wallet_balance(user_id)
|
| 127 |
+
return WalletTransactionResponse(
|
| 128 |
+
success=True,
|
| 129 |
+
message=f"Successfully added ₹{amount:,.2f} to wallet",
|
| 130 |
+
transaction_id=reference_id,
|
| 131 |
+
new_balance=new_balance
|
| 132 |
+
)
|
| 133 |
+
else:
|
| 134 |
+
return WalletTransactionResponse(
|
| 135 |
+
success=False,
|
| 136 |
+
message="Failed to add money to wallet"
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
except Exception as e:
|
| 140 |
+
logger.error(f"Error adding money to wallet for user {user_id}: {str(e)}")
|
| 141 |
+
return WalletTransactionResponse(
|
| 142 |
+
success=False,
|
| 143 |
+
message="Internal error occurred while adding money"
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
@staticmethod
|
| 147 |
+
async def deduct_money(user_id: str, amount: float, description: str,
|
| 148 |
+
reference_id: str = None) -> WalletTransactionResponse:
|
| 149 |
+
"""Deduct money from wallet (for payments)"""
|
| 150 |
+
try:
|
| 151 |
+
success = await WalletModel.update_balance(
|
| 152 |
+
user_id=user_id,
|
| 153 |
+
amount=amount,
|
| 154 |
+
transaction_type="debit",
|
| 155 |
+
description=description,
|
| 156 |
+
reference_id=reference_id
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
if success:
|
| 160 |
+
new_balance = await WalletModel.get_wallet_balance(user_id)
|
| 161 |
+
return WalletTransactionResponse(
|
| 162 |
+
success=True,
|
| 163 |
+
message=f"Successfully deducted ₹{amount:,.2f} from wallet",
|
| 164 |
+
transaction_id=reference_id,
|
| 165 |
+
new_balance=new_balance
|
| 166 |
+
)
|
| 167 |
+
else:
|
| 168 |
+
return WalletTransactionResponse(
|
| 169 |
+
success=False,
|
| 170 |
+
message="Insufficient balance or transaction failed"
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
except Exception as e:
|
| 174 |
+
logger.error(f"Error deducting money from wallet for user {user_id}: {str(e)}")
|
| 175 |
+
return WalletTransactionResponse(
|
| 176 |
+
success=False,
|
| 177 |
+
message="Internal error occurred while processing payment"
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
@staticmethod
|
| 181 |
+
async def process_refund(user_id: str, amount: float, description: str,
|
| 182 |
+
reference_id: str = None) -> WalletTransactionResponse:
|
| 183 |
+
"""Process refund to wallet"""
|
| 184 |
+
try:
|
| 185 |
+
success = await WalletModel.update_balance(
|
| 186 |
+
user_id=user_id,
|
| 187 |
+
amount=amount,
|
| 188 |
+
transaction_type="refund",
|
| 189 |
+
description=description,
|
| 190 |
+
reference_id=reference_id
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
if success:
|
| 194 |
+
new_balance = await WalletModel.get_wallet_balance(user_id)
|
| 195 |
+
return WalletTransactionResponse(
|
| 196 |
+
success=True,
|
| 197 |
+
message=f"Refund of ₹{amount:,.2f} processed successfully",
|
| 198 |
+
transaction_id=reference_id,
|
| 199 |
+
new_balance=new_balance
|
| 200 |
+
)
|
| 201 |
+
else:
|
| 202 |
+
return WalletTransactionResponse(
|
| 203 |
+
success=False,
|
| 204 |
+
message="Failed to process refund"
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
except Exception as e:
|
| 208 |
+
logger.error(f"Error processing refund for user {user_id}: {str(e)}")
|
| 209 |
+
return WalletTransactionResponse(
|
| 210 |
+
success=False,
|
| 211 |
+
message="Internal error occurred while processing refund"
|
| 212 |
+
)
|