diff --git a/.emergent/markers/.restore-complete b/.emergent/markers/.restore-complete new file mode 100644 index 0000000..e69de29 diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..f898dad --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,57 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import HTTPException, Security, Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +import os + +# JWT Configuration +SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "your-secret-key-change-in-production-epic-travel-2025") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24 hours + +# Password hashing +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# Security +security = HTTPBearer() + +def hash_password(password: str) -> str: + """Hash a password""" + return pwd_context.hash(password) + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against a hash""" + return pwd_context.verify(plain_password, hashed_password) + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """Create a JWT access token""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +def decode_access_token(token: str) -> dict: + """Decode and verify a JWT token""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + raise HTTPException(status_code=401, detail="Could not validate credentials") + +async def get_current_admin(credentials: HTTPAuthorizationCredentials = Security(security)) -> dict: + """Dependency to get current admin from JWT token""" + token = credentials.credentials + payload = decode_access_token(token) + + email: str = payload.get("sub") + if email is None: + raise HTTPException(status_code=401, detail="Invalid authentication credentials") + + return {"email": email} diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..e2313c5 --- /dev/null +++ b/backend/models/__init__.py @@ -0,0 +1 @@ +# Models module diff --git a/backend/models/schemas.py b/backend/models/schemas.py new file mode 100644 index 0000000..b1ab11c --- /dev/null +++ b/backend/models/schemas.py @@ -0,0 +1,85 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime +import uuid + +class Destination(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + name: str + location: str + description: str + image: str + category: str # City, Beach, Adventure + rating: float + price: float + currency: str = "USD" + created_at: datetime = Field(default_factory=datetime.utcnow) + +class DestinationCreate(BaseModel): + name: str + location: str + description: str + image: str + category: str + rating: float + price: float + currency: str = "USD" + +class DestinationUpdate(BaseModel): + name: Optional[str] = None + location: Optional[str] = None + description: Optional[str] = None + image: Optional[str] = None + category: Optional[str] = None + rating: Optional[float] = None + price: Optional[float] = None + currency: Optional[str] = None + +class Special(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + destination_id: str + discount: float + end_date: str # ISO format date + highlights: List[str] + created_at: datetime = Field(default_factory=datetime.utcnow) + +class SpecialCreate(BaseModel): + destination_id: str + discount: float + end_date: str + highlights: List[str] + +class SpecialUpdate(BaseModel): + discount: Optional[float] = None + end_date: Optional[str] = None + highlights: Optional[List[str]] = None + +class AdminUser(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + email: str + password_hash: str + created_at: datetime = Field(default_factory=datetime.utcnow) + +class AdminLogin(BaseModel): + email: str + password: str + +class Contact(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + name: str + email: str + message: str + created_at: datetime = Field(default_factory=datetime.utcnow) + +class ContactCreate(BaseModel): + name: str + email: str + message: str + +class NewsletterSubscriber(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + email: str + subscribed_at: datetime = Field(default_factory=datetime.utcnow) + +class NewsletterSubscribe(BaseModel): + email: str diff --git a/backend/requirements.txt b/backend/requirements.txt index 42a9b32..283185d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,27 +1,123 @@ -fastapi==0.110.1 -uvicorn==0.25.0 -boto3>=1.34.129 -requests-oauthlib>=2.0.0 -cryptography>=42.0.8 -python-dotenv>=1.0.1 -pymongo==4.5.0 -pydantic>=2.6.4 -email-validator>=2.2.0 -pyjwt>=2.10.1 +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.1 +attrs==25.4.0 bcrypt==4.1.3 -passlib>=1.7.4 -tzdata>=2024.2 -motor==3.3.1 -pytest>=8.0.0 -black>=24.1.1 -isort>=5.13.2 -flake8>=7.0.0 -mypy>=1.8.0 -python-jose>=3.3.0 -requests>=2.31.0 -pandas>=2.2.0 -numpy>=1.26.0 -python-multipart>=0.0.9 -jq>=1.6.0 -typer>=0.9.0 +black==26.1.0 +boto3==1.42.58 +botocore==1.42.58 +certifi==2026.2.25 +cffi==2.0.0 +charset-normalizer==3.4.4 +click==8.3.1 +cryptography==46.0.5 +distro==1.9.0 +dnspython==2.8.0 +ecdsa==0.19.1 +email-validator==2.3.0 emergentintegrations==0.1.0 +fastapi==0.110.1 +fastuuid==0.14.0 +filelock==3.25.0 +flake8==7.3.0 +frozenlist==1.8.0 +fsspec==2026.2.0 +google-ai-generativelanguage==0.6.15 +google-api-core==2.30.0 +google-api-python-client==2.191.0 +google-auth==2.49.0.dev0 +google-auth-httplib2==0.3.0 +google-genai==1.65.0 +google-generativeai==0.8.6 +googleapis-common-protos==1.72.0 +grpcio==1.78.0 +grpcio-status==1.71.2 +h11==0.16.0 +hf-xet==1.3.2 +httpcore==1.0.9 +httplib2==0.31.2 +httpx==0.28.1 +huggingface_hub==1.5.0 +idna==3.11 +importlib_metadata==8.7.1 +iniconfig==2.3.0 +isort==8.0.0 +Jinja2==3.1.6 +jiter==0.13.0 +jmespath==1.1.0 +jq==1.11.0 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +librt==0.8.1 +litellm==1.80.0 +markdown-it-py==4.0.0 +MarkupSafe==3.0.3 +mccabe==0.7.0 +mdurl==0.1.2 +motor==3.3.1 +multidict==6.7.1 +mypy==1.19.1 +mypy_extensions==1.1.0 +numpy==2.4.2 +oauthlib==3.3.1 +openai==1.99.9 +packaging==26.0 +pandas==3.0.1 +passlib==1.7.4 +pathspec==1.0.4 +pillow==12.1.1 +platformdirs==4.9.2 +pluggy==1.6.0 +propcache==0.4.1 +proto-plus==1.27.1 +protobuf==5.29.6 +pyasn1==0.6.2 +pyasn1_modules==0.4.2 +pycodestyle==2.14.0 +pycparser==3.0 +pydantic==2.12.5 +pydantic_core==2.41.5 +pyflakes==3.4.0 +Pygments==2.19.2 +PyJWT==2.11.0 +pymongo==4.5.0 +pyparsing==3.3.2 +pytest==9.0.2 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +python-jose==3.5.0 +python-multipart==0.0.22 +pytokens==0.4.1 +PyYAML==6.0.3 +referencing==0.37.0 +regex==2026.2.28 +requests==2.32.5 +requests-oauthlib==2.0.0 +rich==14.3.3 +rpds-py==0.30.0 +rsa==4.9.1 +s3transfer==0.16.0 +s5cmd==0.2.0 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +starlette==0.37.2 +stripe==14.4.0 +tenacity==9.1.4 +tiktoken==0.12.0 +tokenizers==0.22.2 +tqdm==4.67.3 +typer==0.24.1 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +tzdata==2025.3 +uritemplate==4.2.0 +urllib3==2.6.3 +uvicorn==0.25.0 +watchfiles==1.1.1 +websockets==16.0 +yarl==1.23.0 +zipp==3.23.0 diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 0000000..1102393 --- /dev/null +++ b/backend/routes/__init__.py @@ -0,0 +1 @@ +# Routes module diff --git a/backend/routes/auth_routes.py b/backend/routes/auth_routes.py new file mode 100644 index 0000000..349a928 --- /dev/null +++ b/backend/routes/auth_routes.py @@ -0,0 +1,61 @@ +from fastapi import APIRouter, HTTPException, Depends +from models.schemas import AdminLogin +from auth import hash_password, verify_password, create_access_token +from motor.motor_asyncio import AsyncIOMotorClient +import os + +router = APIRouter(prefix="/api/auth", tags=["Authentication"]) + +# MongoDB connection will be injected +db = None + +def set_db(database): + global db + db = database + +@router.post("/login") +async def login(credentials: AdminLogin): + """Admin login endpoint""" + # Find admin user + admin = await db.admin_users.find_one({"email": credentials.email}) + + if not admin: + raise HTTPException(status_code=401, detail="Invalid email or password") + + # Verify password + if not verify_password(credentials.password, admin["password_hash"]): + raise HTTPException(status_code=401, detail="Invalid email or password") + + # Create access token + access_token = create_access_token(data={"sub": admin["email"]}) + + return { + "access_token": access_token, + "token_type": "bearer", + "email": admin["email"] + } + +@router.post("/verify") +async def verify_token(admin: dict = Depends(lambda: __import__('auth').get_current_admin)): + """Verify JWT token""" + return {"valid": True, "email": admin["email"]} + +@router.post("/initialize-admin") +async def initialize_admin(): + """Initialize default admin user (for development/setup only)""" + # Check if admin already exists + existing_admin = await db.admin_users.find_one({"email": "admin@epictravel.com"}) + + if existing_admin: + return {"message": "Admin user already exists"} + + # Create default admin + admin_data = { + "email": "admin@epictravel.com", + "password_hash": hash_password("admin123"), + "created_at": __import__('datetime').datetime.utcnow() + } + + await db.admin_users.insert_one(admin_data) + + return {"message": "Admin user created successfully", "email": "admin@epictravel.com"} diff --git a/backend/routes/destination_routes.py b/backend/routes/destination_routes.py new file mode 100644 index 0000000..6dfd979 --- /dev/null +++ b/backend/routes/destination_routes.py @@ -0,0 +1,113 @@ +from fastapi import APIRouter, HTTPException, Depends +from typing import List, Optional +from models.schemas import Destination, DestinationCreate, DestinationUpdate +from auth import get_current_admin +import uuid +from datetime import datetime + +router = APIRouter(prefix="/api/destinations", tags=["Destinations"]) + +# MongoDB connection will be injected +db = None + +def set_db(database): + global db + db = database + +@router.get("", response_model=List[Destination]) +async def get_destinations(category: Optional[str] = None, search: Optional[str] = None): + """Get all destinations with optional filtering""" + query = {} + + if category and category != "All": + query["category"] = category + + if search: + query["$or"] = [ + {"name": {"$regex": search, "$options": "i"}}, + {"location": {"$regex": search, "$options": "i"}} + ] + + destinations = await db.destinations.find(query).to_list(1000) + + # Convert MongoDB _id to id for response + for dest in destinations: + if "_id" in dest: + del dest["_id"] + + return destinations + +@router.get("/{destination_id}", response_model=Destination) +async def get_destination(destination_id: str): + """Get a single destination by ID""" + destination = await db.destinations.find_one({"id": destination_id}) + + if not destination: + raise HTTPException(status_code=404, detail="Destination not found") + + if "_id" in destination: + del destination["_id"] + + return destination + +@router.post("", response_model=Destination) +async def create_destination( + destination: DestinationCreate, + admin: dict = Depends(get_current_admin) +): + """Create a new destination (admin only)""" + destination_data = destination.dict() + destination_data["id"] = str(uuid.uuid4()) + destination_data["created_at"] = datetime.utcnow() + + await db.destinations.insert_one(destination_data) + + if "_id" in destination_data: + del destination_data["_id"] + + return destination_data + +@router.put("/{destination_id}", response_model=Destination) +async def update_destination( + destination_id: str, + destination_update: DestinationUpdate, + admin: dict = Depends(get_current_admin) +): + """Update a destination (admin only)""" + # Check if destination exists + existing = await db.destinations.find_one({"id": destination_id}) + if not existing: + raise HTTPException(status_code=404, detail="Destination not found") + + # Update only provided fields + update_data = {k: v for k, v in destination_update.dict().items() if v is not None} + + if update_data: + await db.destinations.update_one( + {"id": destination_id}, + {"$set": update_data} + ) + + # Fetch updated destination + updated = await db.destinations.find_one({"id": destination_id}) + + if "_id" in updated: + del updated["_id"] + + return updated + +@router.delete("/{destination_id}") +async def delete_destination( + destination_id: str, + admin: dict = Depends(get_current_admin) +): + """Delete a destination (admin only)""" + result = await db.destinations.delete_one({"id": destination_id}) + + if result.deleted_count == 0: + raise HTTPException(status_code=404, detail="Destination not found") + + # Also delete any specials for this destination + await db.specials.delete_many({"destination_id": destination_id}) + + return {"message": "Destination deleted successfully"} diff --git a/backend/routes/other_routes.py b/backend/routes/other_routes.py new file mode 100644 index 0000000..838e255 --- /dev/null +++ b/backend/routes/other_routes.py @@ -0,0 +1,77 @@ +from fastapi import APIRouter, HTTPException, File, UploadFile +from fastapi.responses import FileResponse +from typing import List +from models.schemas import ContactCreate, NewsletterSubscribe +from datetime import datetime +import uuid +import os +import shutil + +router = APIRouter(prefix="/api", tags=["Other"]) + +# MongoDB connection will be injected +db = None + +def set_db(database): + global db + db = database + +@router.post("/contact") +async def submit_contact(contact: ContactCreate): + """Submit a contact form""" + contact_data = contact.dict() + contact_data["id"] = str(uuid.uuid4()) + contact_data["created_at"] = datetime.utcnow() + + await db.contacts.insert_one(contact_data) + + return {"message": "Contact form submitted successfully"} + +@router.post("/newsletter/subscribe") +async def subscribe_newsletter(subscriber: NewsletterSubscribe): + """Subscribe to newsletter""" + # Check if already subscribed + existing = await db.newsletter_subscribers.find_one({"email": subscriber.email}) + if existing: + return {"message": "Email already subscribed"} + + subscriber_data = subscriber.dict() + subscriber_data["id"] = str(uuid.uuid4()) + subscriber_data["subscribed_at"] = datetime.utcnow() + + await db.newsletter_subscribers.insert_one(subscriber_data) + + return {"message": "Successfully subscribed to newsletter"} + +@router.post("/upload/image") +async def upload_image(file: UploadFile = File(...)): + """Upload an image file""" + # Validate file type + allowed_extensions = [".jpg", ".jpeg", ".png", ".webp"] + file_ext = os.path.splitext(file.filename)[1].lower() + + if file_ext not in allowed_extensions: + raise HTTPException(status_code=400, detail="Invalid file type. Allowed: jpg, jpeg, png, webp") + + # Generate unique filename + unique_filename = f"{uuid.uuid4()}{file_ext}" + file_path = f"/app/backend/uploads/{unique_filename}" + + # Save file + with open(file_path, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + # Return URL + file_url = f"/api/uploads/{unique_filename}" + + return {"url": file_url, "filename": unique_filename} + +@router.get("/uploads/{filename}") +async def get_uploaded_image(filename: str): + """Serve uploaded images""" + file_path = f"/app/backend/uploads/{filename}" + + if not os.path.exists(file_path): + raise HTTPException(status_code=404, detail="Image not found") + + return FileResponse(file_path) diff --git a/backend/routes/special_routes.py b/backend/routes/special_routes.py new file mode 100644 index 0000000..9fc328f --- /dev/null +++ b/backend/routes/special_routes.py @@ -0,0 +1,109 @@ +from fastapi import APIRouter, HTTPException, Depends +from typing import List +from models.schemas import Special, SpecialCreate, SpecialUpdate +from auth import get_current_admin +import uuid +from datetime import datetime + +router = APIRouter(prefix="/api/specials", tags=["Specials"]) + +# MongoDB connection will be injected +db = None + +def set_db(database): + global db + db = database + +@router.get("", response_model=List[Special]) +async def get_specials(): + """Get all weekly specials""" + specials = await db.specials.find().to_list(1000) + + # Convert MongoDB _id to id for response + for special in specials: + if "_id" in special: + del special["_id"] + + return specials + +@router.get("/{special_id}", response_model=Special) +async def get_special(special_id: str): + """Get a single special by ID""" + special = await db.specials.find_one({"id": special_id}) + + if not special: + raise HTTPException(status_code=404, detail="Special not found") + + if "_id" in special: + del special["_id"] + + return special + +@router.post("", response_model=Special) +async def create_special( + special: SpecialCreate, + admin: dict = Depends(get_current_admin) +): + """Add a destination to specials (admin only)""" + # Check if destination exists + destination = await db.destinations.find_one({"id": special.destination_id}) + if not destination: + raise HTTPException(status_code=404, detail="Destination not found") + + # Check if special already exists for this destination + existing = await db.specials.find_one({"destination_id": special.destination_id}) + if existing: + raise HTTPException(status_code=400, detail="Special already exists for this destination") + + special_data = special.dict() + special_data["id"] = str(uuid.uuid4()) + special_data["created_at"] = datetime.utcnow() + + await db.specials.insert_one(special_data) + + if "_id" in special_data: + del special_data["_id"] + + return special_data + +@router.put("/{special_id}", response_model=Special) +async def update_special( + special_id: str, + special_update: SpecialUpdate, + admin: dict = Depends(get_current_admin) +): + """Update a special (admin only)""" + # Check if special exists + existing = await db.specials.find_one({"id": special_id}) + if not existing: + raise HTTPException(status_code=404, detail="Special not found") + + # Update only provided fields + update_data = {k: v for k, v in special_update.dict().items() if v is not None} + + if update_data: + await db.specials.update_one( + {"id": special_id}, + {"$set": update_data} + ) + + # Fetch updated special + updated = await db.specials.find_one({"id": special_id}) + + if "_id" in updated: + del updated["_id"] + + return updated + +@router.delete("/destination/{destination_id}") +async def delete_special_by_destination( + destination_id: str, + admin: dict = Depends(get_current_admin) +): + """Remove a destination from specials (admin only)""" + result = await db.specials.delete_one({"destination_id": destination_id}) + + if result.deleted_count == 0: + raise HTTPException(status_code=404, detail="Special not found for this destination") + + return {"message": "Special removed successfully"} diff --git a/backend/server.py b/backend/server.py index 9ed97af..08e3a5e 100644 --- a/backend/server.py +++ b/backend/server.py @@ -1,15 +1,15 @@ -from fastapi import FastAPI, APIRouter +from fastapi import FastAPI from dotenv import load_dotenv from starlette.middleware.cors import CORSMiddleware from motor.motor_asyncio import AsyncIOMotorClient import os import logging from pathlib import Path -from pydantic import BaseModel, Field, ConfigDict -from typing import List -import uuid -from datetime import datetime, timezone +from auth import hash_password +from datetime import datetime +# Import route modules +from routes import auth_routes, destination_routes, special_routes, other_routes ROOT_DIR = Path(__file__).parent load_dotenv(ROOT_DIR / '.env') @@ -19,60 +19,30 @@ mongo_url = os.environ['MONGO_URL'] client = AsyncIOMotorClient(mongo_url) db = client[os.environ['DB_NAME']] -# Create the main app without a prefix -app = FastAPI() +# Inject database into route modules +auth_routes.set_db(db) +destination_routes.set_db(db) +special_routes.set_db(db) +other_routes.set_db(db) -# Create a router with the /api prefix -api_router = APIRouter(prefix="/api") +# Create the main app +app = FastAPI(title="Epic Travel & Destinations API") +# Include routers +app.include_router(auth_routes.router) +app.include_router(destination_routes.router) +app.include_router(special_routes.router) +app.include_router(other_routes.router) -# Define Models -class StatusCheck(BaseModel): - model_config = ConfigDict(extra="ignore") # Ignore MongoDB's _id field - - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - client_name: str - timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - -class StatusCheckCreate(BaseModel): - client_name: str - -# Add your routes to the router instead of directly to app -@api_router.get("/") +# Health check endpoint +@app.get("/api") async def root(): - return {"message": "Hello World"} - -@api_router.post("/status", response_model=StatusCheck) -async def create_status_check(input: StatusCheckCreate): - status_dict = input.model_dump() - status_obj = StatusCheck(**status_dict) - - # Convert to dict and serialize datetime to ISO string for MongoDB - doc = status_obj.model_dump() - doc['timestamp'] = doc['timestamp'].isoformat() - - _ = await db.status_checks.insert_one(doc) - return status_obj - -@api_router.get("/status", response_model=List[StatusCheck]) -async def get_status_checks(): - # Exclude MongoDB's _id field from the query results - status_checks = await db.status_checks.find({}, {"_id": 0}).to_list(1000) - - # Convert ISO string timestamps back to datetime objects - for check in status_checks: - if isinstance(check['timestamp'], str): - check['timestamp'] = datetime.fromisoformat(check['timestamp']) - - return status_checks - -# Include the router in the main app -app.include_router(api_router) + return {"message": "Epic Travel API is running", "status": "healthy"} app.add_middleware( CORSMiddleware, allow_credentials=True, - allow_origins=os.environ.get('CORS_ORIGINS', '*').split(','), + allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) @@ -84,6 +54,208 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) +@app.on_event("startup") +async def startup_db_client(): + """Initialize database with seed data if empty""" + try: + # Check if admin user exists, if not create one + admin_exists = await db.admin_users.find_one({"email": "admin@epictravel.com"}) + if not admin_exists: + admin_data = { + "id": "admin-1", + "email": "admin@epictravel.com", + "password_hash": hash_password("admin123"), + "created_at": datetime.utcnow() + } + await db.admin_users.insert_one(admin_data) + logger.info("Default admin user created") + + # Check if destinations exist, if not seed initial data + dest_count = await db.destinations.count_documents({}) + if dest_count == 0: + # Seed initial destinations + initial_destinations = [ + { + "id": "1", + "name": "Paris", + "location": "France", + "description": "Experience the romance and elegance of the City of Light. Visit iconic landmarks like the Eiffel Tower, Louvre Museum, and stroll along the Champs-ΓlysΓ©es.", + "image": "https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=800&q=80", + "category": "City", + "rating": 4.9, + "price": 1299, + "currency": "USD", + "created_at": datetime.utcnow() + }, + { + "id": "2", + "name": "Bali", + "location": "Indonesia", + "description": "Discover tropical paradise with stunning beaches, ancient temples, lush rice terraces, and vibrant culture in this Indonesian gem.", + "image": "https://images.unsplash.com/photo-1537996194471-e657df975ab4?w=800&q=80", + "category": "Beach", + "rating": 4.8, + "price": 899, + "currency": "USD", + "created_at": datetime.utcnow() + }, + { + "id": "3", + "name": "Tokyo", + "location": "Japan", + "description": "Immerse yourself in the perfect blend of ancient tradition and cutting-edge technology in Japan's bustling capital city.", + "image": "https://images.unsplash.com/photo-1540959733332-eab4deabeeaf?w=800&q=80", + "category": "City", + "rating": 4.9, + "price": 1499, + "currency": "USD", + "created_at": datetime.utcnow() + }, + { + "id": "4", + "name": "Santorini", + "location": "Greece", + "description": "Marvel at breathtaking sunsets, whitewashed buildings, and crystal-clear waters in this stunning Greek island paradise.", + "image": "https://images.unsplash.com/photo-1613395877344-13d4a8e0d49e?w=800&q=80", + "category": "Beach", + "rating": 4.9, + "price": 1199, + "currency": "USD", + "created_at": datetime.utcnow() + }, + { + "id": "5", + "name": "Iceland", + "location": "Iceland", + "description": "Witness the Northern Lights, explore glaciers, geysers, and volcanic landscapes in this land of fire and ice.", + "image": "https://images.unsplash.com/photo-1504829857797-ddff29c27927?w=800&q=80", + "category": "Adventure", + "rating": 4.8, + "price": 1699, + "currency": "USD", + "created_at": datetime.utcnow() + }, + { + "id": "6", + "name": "Dubai", + "location": "UAE", + "description": "Experience luxury and innovation in the desert with world-class shopping, stunning architecture, and endless entertainment.", + "image": "https://images.unsplash.com/photo-1512453979798-5ea266f8880c?w=800&q=80", + "category": "City", + "rating": 4.7, + "price": 1399, + "currency": "USD", + "created_at": datetime.utcnow() + }, + { + "id": "7", + "name": "Maldives", + "location": "Maldives", + "description": "Relax in overwater bungalows, dive in pristine coral reefs, and enjoy the ultimate tropical island getaway.", + "image": "https://images.unsplash.com/photo-1514282401047-d79a71a590e8?w=800&q=80", + "category": "Beach", + "rating": 5.0, + "price": 2199, + "currency": "USD", + "created_at": datetime.utcnow() + }, + { + "id": "8", + "name": "New York", + "location": "USA", + "description": "Explore the city that never sleeps with iconic landmarks, world-class museums, Broadway shows, and diverse neighborhoods.", + "image": "https://images.unsplash.com/photo-1496442226666-8d4d0e62e6e9?w=800&q=80", + "category": "City", + "rating": 4.8, + "price": 1099, + "currency": "USD", + "created_at": datetime.utcnow() + }, + { + "id": "9", + "name": "Machu Picchu", + "location": "Peru", + "description": "Trek to the ancient Incan citadel nestled high in the Andes Mountains, one of the New Seven Wonders of the World.", + "image": "https://images.unsplash.com/photo-1587595431973-160d0d94add1?w=800&q=80", + "category": "Adventure", + "rating": 4.9, + "price": 1299, + "currency": "USD", + "created_at": datetime.utcnow() + }, + { + "id": "10", + "name": "Swiss Alps", + "location": "Switzerland", + "description": "Ski pristine slopes, hike mountain trails, and enjoy charming alpine villages with breathtaking mountain vistas.", + "image": "https://images.unsplash.com/photo-1531366936337-7c912a4589a7?w=800&q=80", + "category": "Adventure", + "rating": 4.9, + "price": 1799, + "currency": "USD", + "created_at": datetime.utcnow() + }, + { + "id": "11", + "name": "Venice", + "location": "Italy", + "description": "Glide through romantic canals, admire Renaissance architecture, and savor authentic Italian cuisine in this unique floating city.", + "image": "https://images.unsplash.com/photo-1523906834658-6e24ef2386f9?w=800&q=80", + "category": "City", + "rating": 4.8, + "price": 1149, + "currency": "USD", + "created_at": datetime.utcnow() + }, + { + "id": "12", + "name": "Safari Kenya", + "location": "Kenya", + "description": "Witness the Great Migration, spot the Big Five, and experience the raw beauty of African wilderness.", + "image": "https://images.unsplash.com/photo-1516426122078-c23e76319801?w=800&q=80", + "category": "Adventure", + "rating": 4.9, + "price": 2499, + "currency": "USD", + "created_at": datetime.utcnow() + } + ] + await db.destinations.insert_many(initial_destinations) + logger.info(f"Seeded {len(initial_destinations)} initial destinations") + + # Seed initial specials + initial_specials = [ + { + "id": "special-1", + "destination_id": "2", + "discount": 25, + "end_date": "2025-02-28", + "highlights": ["Free spa treatment", "Complimentary airport transfer", "Sunset dinner cruise"], + "created_at": datetime.utcnow() + }, + { + "id": "special-2", + "destination_id": "4", + "discount": 30, + "end_date": "2025-03-15", + "highlights": ["Wine tasting tour", "Private yacht excursion", "Luxury accommodation upgrade"], + "created_at": datetime.utcnow() + }, + { + "id": "special-3", + "destination_id": "7", + "discount": 20, + "end_date": "2025-02-20", + "highlights": ["Snorkeling adventure", "Couples massage", "Romantic beach dinner"], + "created_at": datetime.utcnow() + } + ] + await db.specials.insert_many(initial_specials) + logger.info(f"Seeded {len(initial_specials)} initial specials") + + except Exception as e: + logger.error(f"Error during startup: {str(e)}") + @app.on_event("shutdown") async def shutdown_db_client(): - client.close() \ No newline at end of file + client.close() diff --git a/backend_test.py b/backend_test.py new file mode 100644 index 0000000..773675d --- /dev/null +++ b/backend_test.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +import requests +import sys +import json +from datetime import datetime + +class EpicTravelAPITester: + def __init__(self, base_url="https://world-explorer-139.preview.emergentagent.com"): + self.base_url = base_url + self.api_url = f"{base_url}/api" + self.token = None + self.tests_run = 0 + self.tests_passed = 0 + self.test_results = [] + + def log_test(self, name, success, message="", response_data=None): + """Log test results""" + self.tests_run += 1 + if success: + self.tests_passed += 1 + print(f"β {name}: PASSED - {message}") + else: + print(f"β {name}: FAILED - {message}") + + self.test_results.append({ + "test": name, + "status": "PASSED" if success else "FAILED", + "message": message, + "response_data": response_data + }) + return success + + def run_test(self, name, method, endpoint, expected_status, data=None, headers=None): + """Run a single API test""" + url = f"{self.api_url}/{endpoint}" + test_headers = {'Content-Type': 'application/json'} + + if self.token: + test_headers['Authorization'] = f'Bearer {self.token}' + + if headers: + test_headers.update(headers) + + try: + if method == 'GET': + response = requests.get(url, headers=test_headers) + elif method == 'POST': + response = requests.post(url, json=data, headers=test_headers) + elif method == 'PUT': + response = requests.put(url, json=data, headers=test_headers) + elif method == 'DELETE': + response = requests.delete(url, headers=test_headers) + + success = response.status_code == expected_status + + if success: + try: + response_data = response.json() if response.content else {} + return self.log_test(name, True, f"Status: {response.status_code}", response_data), response_data + except: + return self.log_test(name, True, f"Status: {response.status_code}", {}), {} + else: + try: + error_data = response.json() if response.content else {} + return self.log_test(name, False, f"Expected {expected_status}, got {response.status_code} - {error_data}"), {} + except: + return self.log_test(name, False, f"Expected {expected_status}, got {response.status_code}"), {} + + except Exception as e: + return self.log_test(name, False, f"Error: {str(e)}"), {} + + def test_health_check(self): + """Test API health check""" + success, data = self.run_test("API Health Check", "GET", "", 200) + return success + + def test_admin_login(self): + """Test admin login""" + login_data = { + "email": "admin@epictravel.com", + "password": "admin123" + } + success, response = self.run_test("Admin Login", "POST", "auth/login", 200, login_data) + + if success and 'access_token' in response: + self.token = response['access_token'] + self.log_test("Token Extraction", True, "JWT token successfully extracted") + return True + else: + self.log_test("Token Extraction", False, "Failed to extract JWT token") + return False + + def test_get_destinations(self): + """Test getting all destinations""" + success, data = self.run_test("Get All Destinations", "GET", "destinations", 200) + if success and isinstance(data, list) and len(data) > 0: + self.log_test("Destinations Data Validation", True, f"Found {len(data)} destinations") + return data + else: + self.log_test("Destinations Data Validation", False, "No destinations returned or invalid data") + return [] + + def test_get_specials(self): + """Test getting all specials""" + success, data = self.run_test("Get All Specials", "GET", "specials", 200) + if success and isinstance(data, list): + self.log_test("Specials Data Validation", True, f"Found {len(data)} specials") + return data + else: + self.log_test("Specials Data Validation", False, "Failed to get specials or invalid data") + return [] + + def test_search_destinations(self): + """Test destination search functionality""" + # Test search by name + success, data = self.run_test("Search Destinations by Name", "GET", "destinations?search=Paris", 200) + if success: + found_paris = any(dest.get('name', '').lower() == 'paris' for dest in data) + if found_paris: + self.log_test("Search Results Validation", True, "Paris found in search results") + else: + self.log_test("Search Results Validation", False, "Paris not found in search results") + + # Test filter by category + success, data = self.run_test("Filter Destinations by Category", "GET", "destinations?category=City", 200) + if success: + all_city = all(dest.get('category') == 'City' for dest in data) + if all_city: + self.log_test("Filter Results Validation", True, "All returned destinations are City type") + else: + self.log_test("Filter Results Validation", False, "Filter not working correctly") + + def test_contact_form(self): + """Test contact form submission""" + contact_data = { + "name": "Test User", + "email": "test@example.com", + "message": "This is a test contact message" + } + success, data = self.run_test("Contact Form Submission", "POST", "contact", 200, contact_data) + return success + + def test_newsletter_subscription(self): + """Test newsletter subscription""" + newsletter_data = { + "email": "newsletter@test.com" + } + success, data = self.run_test("Newsletter Subscription", "POST", "newsletter/subscribe", 200, newsletter_data) + return success + + def test_admin_crud_operations(self): + """Test full CRUD operations for destinations (requires authentication)""" + if not self.token: + self.log_test("CRUD Operations", False, "No authentication token available") + return False + + # Test creating a new destination + new_destination = { + "name": "Test Destination", + "location": "Test Country", + "description": "A test destination for API testing", + "image": "https://via.placeholder.com/800x600", + "category": "City", + "rating": 4.5, + "price": 999, + "currency": "USD" + } + + success, created_dest = self.run_test("Create New Destination", "POST", "destinations", 200, new_destination) + + if not success: + return False + + dest_id = created_dest.get('id') + if not dest_id: + self.log_test("Destination ID Extraction", False, "Failed to get destination ID from creation response") + return False + + # Test updating the destination + update_data = { + "name": "Updated Test Destination", + "price": 1299 + } + success, updated_dest = self.run_test("Update Destination", "PUT", f"destinations/{dest_id}", 200, update_data) + + if success and updated_dest.get('name') == "Updated Test Destination": + self.log_test("Update Validation", True, "Destination updated successfully") + else: + self.log_test("Update Validation", False, "Destination update failed or data not persisted") + + # Test getting single destination + success, single_dest = self.run_test("Get Single Destination", "GET", f"destinations/{dest_id}", 200) + + # Test adding destination to specials + special_data = { + "destination_id": dest_id, + "discount": 25, + "end_date": "2025-12-31", + "highlights": ["Test special", "Limited time", "API test"] + } + success, created_special = self.run_test("Add Destination to Specials", "POST", "specials", 200, special_data) + + special_id = created_special.get('id') if success else None + + if special_id: + # Test updating special + special_update = { + "discount": 30, + "end_date": "2025-11-30" + } + success, updated_special = self.run_test("Update Special", "PUT", f"specials/{special_id}", 200, special_update) + + # Test removing destination from specials + success, _ = self.run_test("Remove from Specials", "DELETE", f"specials/destination/{dest_id}", 200) + + # Test deleting the destination (cleanup) + success, _ = self.run_test("Delete Test Destination", "DELETE", f"destinations/{dest_id}", 200) + + return True + + def run_all_tests(self): + """Run all tests in sequence""" + print("π Starting Epic Travel API Testing...") + print("=" * 50) + + # Test 1: Health Check + print("\nπ Testing Basic API Access...") + if not self.test_health_check(): + print("β Health check failed, stopping tests") + return self.generate_summary() + + # Test 2: Authentication + print("\nπ Testing Authentication...") + if not self.test_admin_login(): + print("β Authentication failed, continuing with public endpoints only") + + # Test 3: Public Endpoints + print("\nπ Testing Public Endpoints...") + destinations = self.test_get_destinations() + specials = self.test_get_specials() + self.test_search_destinations() + + # Test 4: Forms + print("\nπ Testing Form Submissions...") + self.test_contact_form() + self.test_newsletter_subscription() + + # Test 5: Admin Operations + if self.token: + print("\nβοΈ Testing Admin CRUD Operations...") + self.test_admin_crud_operations() + else: + print("\nβ οΈ Skipping Admin CRUD tests - No authentication token") + + return self.generate_summary() + + def generate_summary(self): + """Generate test summary""" + print("\n" + "=" * 50) + print("π TEST SUMMARY") + print("=" * 50) + print(f"Tests Run: {self.tests_run}") + print(f"Tests Passed: {self.tests_passed}") + print(f"Tests Failed: {self.tests_run - self.tests_passed}") + print(f"Success Rate: {(self.tests_passed / self.tests_run * 100):.1f}%" if self.tests_run > 0 else "0%") + + failed_tests = [test for test in self.test_results if test['status'] == 'FAILED'] + if failed_tests: + print("\nβ FAILED TESTS:") + for test in failed_tests: + print(f" - {test['test']}: {test['message']}") + + print("\n" + "=" * 50) + + return { + "total_tests": self.tests_run, + "passed_tests": self.tests_passed, + "failed_tests": self.tests_run - self.tests_passed, + "success_rate": (self.tests_passed / self.tests_run * 100) if self.tests_run > 0 else 0, + "detailed_results": self.test_results + } + +def main(): + tester = EpicTravelAPITester() + results = tester.run_all_tests() + + # Return appropriate exit code + return 0 if results['failed_tests'] == 0 else 1 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/frontend/src/pages/AdminDashboard.jsx b/frontend/src/pages/AdminDashboard.jsx index c37edf7..968278e 100644 --- a/frontend/src/pages/AdminDashboard.jsx +++ b/frontend/src/pages/AdminDashboard.jsx @@ -13,12 +13,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/tabs' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '../components/ui/dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select'; import { toast } from 'sonner'; -import { destinations as initialDestinations, specials as initialSpecials } from '../mockData'; +import { destinationsAPI, specialsAPI, uploadAPI } from '../services/api'; const AdminDashboard = () => { const navigate = useNavigate(); - const [destinations, setDestinations] = useState(initialDestinations); - const [specials, setSpecials] = useState(initialSpecials); + const [destinations, setDestinations] = useState([]); + const [specials, setSpecials] = useState([]); + const [loading, setLoading] = useState(true); const [isEditMode, setIsEditMode] = useState(false); const [editingDestination, setEditingDestination] = useState(null); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); @@ -37,35 +38,59 @@ const AdminDashboard = () => { const isAuthenticated = localStorage.getItem('isAdminAuthenticated'); if (!isAuthenticated) { navigate('/admin'); + } else { + fetchData(); } }, [navigate]); + const fetchData = async () => { + try { + setLoading(true); + const [destinationsData, specialsData] = await Promise.all([ + destinationsAPI.getAll(), + specialsAPI.getAll() + ]); + setDestinations(destinationsData); + setSpecials(specialsData); + } catch (error) { + console.error('Error fetching data:', error); + toast.error('Failed to load data'); + } finally { + setLoading(false); + } + }; + const handleLogout = () => { localStorage.removeItem('isAdminAuthenticated'); + localStorage.removeItem('auth_token'); toast.success('Logged out successfully'); navigate('/admin'); }; - const handleAddDestination = () => { - const newDest = { - ...newDestination, - id: String(destinations.length + 1), - rating: parseFloat(newDestination.rating), - price: parseFloat(newDestination.price) - }; - setDestinations([...destinations, newDest]); - setIsAddDialogOpen(false); - setNewDestination({ - name: '', - location: '', - description: '', - image: '', - category: 'City', - rating: 4.5, - price: 999, - currency: 'USD' - }); - toast.success('Destination added successfully!'); + const handleAddDestination = async () => { + try { + const newDest = await destinationsAPI.create({ + ...newDestination, + rating: parseFloat(newDestination.rating), + price: parseFloat(newDestination.price) + }); + setDestinations([...destinations, newDest]); + setIsAddDialogOpen(false); + setNewDestination({ + name: '', + location: '', + description: '', + image: '', + category: 'City', + rating: 4.5, + price: 999, + currency: 'USD' + }); + toast.success('Destination added successfully!'); + } catch (error) { + console.error('Error adding destination:', error); + toast.error('Failed to add destination'); + } }; const handleEditDestination = (destination) => { @@ -73,45 +98,71 @@ const AdminDashboard = () => { setIsEditMode(true); }; - const handleSaveEdit = () => { - setDestinations(destinations.map(dest => - dest.id === editingDestination.id ? editingDestination : dest - )); - setIsEditMode(false); - setEditingDestination(null); - toast.success('Destination updated successfully!'); - }; - - const handleDeleteDestination = (id) => { - setDestinations(destinations.filter(dest => dest.id !== id)); - // Also remove from specials if exists - setSpecials(specials.filter(special => special.destinationId !== id)); - toast.success('Destination deleted successfully!'); - }; - - const handleToggleSpecial = (destinationId) => { - const existingSpecial = specials.find(s => s.destinationId === destinationId); - if (existingSpecial) { - setSpecials(specials.filter(s => s.destinationId !== destinationId)); - toast.success('Removed from specials'); - } else { - const newSpecial = { - destinationId, - discount: 20, - endDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], - highlights: ['Special offer', 'Limited time', 'Book now'] - }; - setSpecials([...specials, newSpecial]); - toast.success('Added to specials!'); + const handleSaveEdit = async () => { + try { + const updated = await destinationsAPI.update(editingDestination.id, editingDestination); + setDestinations(destinations.map(dest => + dest.id === updated.id ? updated : dest + )); + setIsEditMode(false); + setEditingDestination(null); + toast.success('Destination updated successfully!'); + } catch (error) { + console.error('Error updating destination:', error); + toast.error('Failed to update destination'); } }; - const handleUpdateSpecial = (destinationId, field, value) => { - setSpecials(specials.map(special => - special.destinationId === destinationId - ? { ...special, [field]: field === 'discount' ? parseFloat(value) : value } - : special - )); + const handleDeleteDestination = async (id) => { + try { + await destinationsAPI.delete(id); + setDestinations(destinations.filter(dest => dest.id !== id)); + setSpecials(specials.filter(special => special.destination_id !== id)); + toast.success('Destination deleted successfully!'); + } catch (error) { + console.error('Error deleting destination:', error); + toast.error('Failed to delete destination'); + } + }; + + const handleToggleSpecial = async (destinationId) => { + const existingSpecial = specials.find(s => s.destination_id === destinationId); + try { + if (existingSpecial) { + await specialsAPI.deleteByDestination(destinationId); + setSpecials(specials.filter(s => s.destination_id !== destinationId)); + toast.success('Removed from specials'); + } else { + const newSpecial = await specialsAPI.create({ + destination_id: destinationId, + discount: 20, + end_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + highlights: ['Special offer', 'Limited time', 'Book now'] + }); + setSpecials([...specials, newSpecial]); + toast.success('Added to specials!'); + } + } catch (error) { + console.error('Error toggling special:', error); + toast.error('Failed to update special'); + } + }; + + const handleUpdateSpecial = async (specialId, field, value) => { + try { + const updatedSpecial = specials.find(s => s.id === specialId); + if (!updatedSpecial) return; + + const updateData = { [field]: field === 'discount' ? parseFloat(value) : value }; + const updated = await specialsAPI.update(specialId, updateData); + + setSpecials(specials.map(special => + special.id === specialId ? updated : special + )); + } catch (error) { + console.error('Error updating special:', error); + toast.error('Failed to update special'); + } }; return ( @@ -247,8 +298,13 @@ const AdminDashboard = () => { -
Loading destinations...
+{destination.description}