diff --git a/backend/app/auth/schemas.py b/backend/app/auth/schemas.py index 3c76dc3e..c6ea1d5b 100644 --- a/backend/app/auth/schemas.py +++ b/backend/app/auth/schemas.py @@ -42,7 +42,7 @@ class UserResponse(BaseModel): id: str = Field(alias="_id") email: str name: str - avatar: Optional[str] = None + imageUrl: Optional[str] = None currency: str = "USD" created_at: datetime diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index fcd46993..465317ca 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -115,7 +115,7 @@ async def create_user_with_email( "email": email, "hashed_password": get_password_hash(password), "name": name, - "avatar": None, + "imageUrl": None, "currency": "USD", "created_at": datetime.now(timezone.utc), "auth_provider": "email", @@ -202,8 +202,8 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: update_data = {} if user.get("firebase_uid") != firebase_uid: update_data["firebase_uid"] = firebase_uid - if user.get("avatar") != picture and picture: - update_data["avatar"] = picture + if user.get("imageUrl") != picture and picture: + update_data["imageUrl"] = picture if update_data: await db.users.update_one( @@ -215,7 +215,7 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: user_doc = { "email": email, "name": name, - "avatar": picture, + "imageUrl": picture, "currency": "USD", "created_at": datetime.now(timezone.utc), "auth_provider": "google", diff --git a/backend/app/groups/service.py b/backend/app/groups/service.py index 6d40a7bc..0b6637d2 100644 --- a/backend/app/groups/service.py +++ b/backend/app/groups/service.py @@ -54,17 +54,13 @@ async def _enrich_members_with_user_details( if user else f"{member_user_id}@example.com" ), - "avatar": ( - user.get("imageUrl") or user.get("avatar") - if user - else None - ), + "imageUrl": (user.get("imageUrl") if user else None), } if user else { "name": f"User {member_user_id[-4:]}", "email": f"{member_user_id}@example.com", - "avatar": None, + "imageUrl": None, } ), } @@ -79,7 +75,7 @@ async def _enrich_members_with_user_details( "user": { "name": f"User {member_user_id[-4:]}", "email": f"{member_user_id}@example.com", - "avatar": None, + "imageUrl": None, }, } ) diff --git a/backend/app/user/service.py b/backend/app/user/service.py index 589a682a..17a71dce 100644 --- a/backend/app/user/service.py +++ b/backend/app/user/service.py @@ -41,7 +41,7 @@ def iso(dt): "id": user_id, "name": user.get("name"), "email": user.get("email"), - "imageUrl": user.get("imageUrl") or user.get("avatar"), + "imageUrl": user.get("imageUrl"), "currency": user.get("currency", "USD"), "createdAt": iso(user.get("created_at")), "updatedAt": iso(user.get("updated_at") or user.get("created_at")), diff --git a/backend/scripts/backup_db.py b/backend/scripts/backup_db.py new file mode 100644 index 00000000..dd135512 --- /dev/null +++ b/backend/scripts/backup_db.py @@ -0,0 +1,81 @@ +""" +Database backup script for Splitwiser. +Creates a backup of all collections before performing migrations. +""" + +import json +import os +from datetime import datetime + +from dotenv import load_dotenv +from pymongo import MongoClient + +# Get the script's directory and backend directory +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +BACKEND_DIR = os.path.dirname(SCRIPT_DIR) + +# Load environment variables from .env file in backend directory +load_dotenv(os.path.join(BACKEND_DIR, ".env")) + +# Get MongoDB connection details from environment +MONGODB_URL = os.getenv("MONGODB_URL") +DATABASE_NAME = os.getenv("DATABASE_NAME") + + +def create_backup(): + """Create a backup of all collections.""" + try: + # Create backup directory if it doesn't exist + backup_dir = "backups" + backup_time = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = os.path.join(backup_dir, f"backup_{backup_time}") + os.makedirs(backup_path, exist_ok=True) + + # Connect to MongoDB + client = MongoClient(MONGODB_URL) + db = client[DATABASE_NAME] + + # Get all collections + collections = db.list_collection_names() + backup_stats = {} + + for collection_name in collections: + collection = db[collection_name] + documents = list(collection.find({})) + + # Convert ObjectId to string for JSON serialization + for doc in documents: + doc["_id"] = str(doc["_id"]) + + # Save to file + backup_file = os.path.join(backup_path, f"{collection_name}.json") + with open(backup_file, "w") as f: + json.dump(documents, f, indent=2, default=str) + + backup_stats[collection_name] = len(documents) + + # Save backup metadata + metadata = { + "timestamp": datetime.now().isoformat(), + "database": DATABASE_NAME, + "collections": backup_stats, + "total_documents": sum(backup_stats.values()), + } + + with open(os.path.join(backup_path, "backup_metadata.json"), "w") as f: + json.dump(metadata, f, indent=2) + + return backup_path, metadata + + except Exception as e: + print(f"Backup failed: {str(e)}") + raise + + +if __name__ == "__main__": + backup_path, metadata = create_backup() + print(f"Backup created successfully at: {backup_path}") + print("\nBackup statistics:") + print(f"Total documents: {metadata['total_documents']}") + for coll, count in metadata["collections"].items(): + print(f"{coll}: {count} documents") diff --git a/backend/scripts/migrate_avatar_to_imageurl.py b/backend/scripts/migrate_avatar_to_imageurl.py new file mode 100644 index 00000000..7dce76ad --- /dev/null +++ b/backend/scripts/migrate_avatar_to_imageurl.py @@ -0,0 +1,167 @@ +""" +Migration script to standardize user avatar fields to imageUrl. +This script: +1. Identifies users with avatar field but no imageUrl field +2. Copies avatar values to imageUrl field +3. Removes the deprecated avatar field +4. Logs migration statistics +""" + +import json +import logging +import os +import sys +from datetime import datetime + +from backup_db import create_backup +from bson import ObjectId +from dotenv import load_dotenv +from pymongo import MongoClient, UpdateOne + +# Add the script's directory to Python path +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(SCRIPT_DIR) + + +# Load environment variables from the backend directory +BACKEND_DIR = os.path.dirname(SCRIPT_DIR) +load_dotenv(os.path.join(BACKEND_DIR, ".env")) + +# Get MongoDB connection details from environment +MONGODB_URL = os.getenv("MONGODB_URL") +DATABASE_NAME = os.getenv("DATABASE_NAME") + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Set up file logging +log_dir = "logs" +os.makedirs(log_dir, exist_ok=True) +log_file = os.path.join( + log_dir, f"migration_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" +) +file_handler = logging.FileHandler(log_file) +file_handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") +) +logger.addHandler(file_handler) + +# Validate required environment variables +if not MONGODB_URL: + logger.error("MONGODB_URL environment variable is required") + sys.exit(1) +if not DATABASE_NAME: + logger.error("DATABASE_NAME environment variable is required") + sys.exit(1) + + +def migrate_avatar_to_imageurl(): + """ + Migrate avatar field to imageUrl in users collection. + Returns statistics about the migration. + """ + try: + # First create a backup + logger.info("Creating database backup...") + backup_path, backup_metadata = create_backup() + logger.info(f"Backup created at: {backup_path}") + + # Connect to MongoDB + client = MongoClient(MONGODB_URL) + db = client[DATABASE_NAME] + users = db.users + + # Find users with avatar field + users_with_avatar = users.find({"avatar": {"$exists": True}}) + users_to_update = [] + stats = { + "total_users": users.count_documents({}), + "users_with_avatar": 0, + "users_with_both_fields": 0, + "users_updated": 0, + "conflicts": 0, + } + + for user in users_with_avatar: + stats["users_with_avatar"] += 1 + + # Check for conflicts (users with both fields) + if "imageUrl" in user and user["imageUrl"] is not None: + if user["imageUrl"] != user["avatar"]: + logger.warning( + f"Conflict found for user {user['_id']}: " + f"avatar='{user['avatar']}', imageUrl='{user['imageUrl']}'" + ) + stats["conflicts"] += 1 + continue + stats["users_with_both_fields"] += 1 + + # Prepare update + users_to_update.append( + UpdateOne( + {"_id": user["_id"]}, + {"$set": {"imageUrl": user["avatar"]}, "$unset": { + "avatar": ""}}, + ) + ) + + # Perform bulk update if there are users to update + if users_to_update: + result = users.bulk_write(users_to_update) + stats["users_updated"] = result.modified_count + logger.info(f"Successfully updated {result.modified_count} users") + + return stats + + except Exception as e: + logger.error(f"Migration failed: {str(e)}") + raise + + +def rollback_migration(backup_path): + """ + Rollback the migration using a specified backup. + """ + try: + client = MongoClient(MONGODB_URL) + db = client[DATABASE_NAME] + + backup_file_path = os.path.join(backup_path, "users.json") + if not os.path.exists(backup_file_path): + raise FileNotFoundError( + f"Backup file not found: {backup_file_path}") + + # Read users collection backup + with open(backup_file_path, "r") as f: + users_backup = json.load(f) + + # Convert string IDs back to ObjectId + for user in users_backup: + user["_id"] = ObjectId(user["_id"]) + + # Replace current users collection with backup + db.users.drop() + if users_backup: + db.users.insert_many(users_backup) + + logger.info(f"Successfully rolled back to backup: {backup_path}") + return True + + except Exception as e: + logger.error(f"Rollback failed: {str(e)}") + raise + + +if __name__ == "__main__": + logger.info("Starting avatar to imageUrl migration...") + stats = migrate_avatar_to_imageurl() + + logger.info("\nMigration completed. Statistics:") + logger.info(f"Total users: {stats['total_users']}") + logger.info(f"Users with avatar field: {stats['users_with_avatar']}") + logger.info(f"Users with both fields: {stats['users_with_both_fields']}") + logger.info(f"Users updated: {stats['users_updated']}") + logger.info(f"Conflicts found: {stats['conflicts']}") + + print("\nMigration completed. Check the log file for details:", log_file) diff --git a/backend/tests/auth/test_auth_routes.py b/backend/tests/auth/test_auth_routes.py index 0610a0fe..aa764f81 100644 --- a/backend/tests/auth/test_auth_routes.py +++ b/backend/tests/auth/test_auth_routes.py @@ -168,7 +168,7 @@ async def test_login_with_email_success(mock_db): "email": user_email, "hashed_password": hashed_password, "name": "Login User", - "avatar": None, + "imageUrl": None, "currency": "USD", # Ensure datetime is used "created_at": datetime.now(timezone.utc),