update to python fastpi
This commit is contained in:
35
Backend/.env.example
Normal file
35
Backend/.env.example
Normal file
@@ -0,0 +1,35 @@
|
||||
# Environment
|
||||
NODE_ENV=development
|
||||
|
||||
# Server
|
||||
PORT=3000
|
||||
HOST=localhost
|
||||
|
||||
# Database
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASS=
|
||||
DB_NAME=hotel_booking_dev
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your_super_secret_jwt_key_change_this_in_production
|
||||
JWT_EXPIRES_IN=1h
|
||||
JWT_REFRESH_SECRET=your_super_secret_refresh_key_change_this_in_production
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
# Client URL
|
||||
CLIENT_URL=http://localhost:5173
|
||||
|
||||
# Upload
|
||||
MAX_FILE_SIZE=5242880
|
||||
ALLOWED_FILE_TYPES=image/jpeg,image/png,image/jpg,image/webp
|
||||
|
||||
# Pagination
|
||||
DEFAULT_PAGE_SIZE=10
|
||||
MAX_PAGE_SIZE=100
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
|
||||
42
Backend/.gitignore
vendored
Normal file
42
Backend/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Uploads
|
||||
uploads/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Build
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
8
Backend/.sequelizerc
Normal file
8
Backend/.sequelizerc
Normal file
@@ -0,0 +1,8 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
'config': path.resolve('src', 'config', 'database.js'),
|
||||
'models-path': path.resolve('src/databases', 'models'),
|
||||
'seeders-path': path.resolve('src/databases', 'seeders'),
|
||||
'migrations-path': path.resolve('src/databases', 'migrations')
|
||||
};
|
||||
115
Backend/alembic.ini
Normal file
115
Backend/alembic.ini
Normal file
@@ -0,0 +1,115 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python-dateutil library that can be
|
||||
# installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
|
||||
73
Backend/alembic/env.py
Normal file
73
Backend/alembic/env.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
from alembic import context
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
# Import models and Base
|
||||
from src.config.database import Base
|
||||
from src.models import * # Import all models
|
||||
|
||||
# this is the Alembic Config object
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Get database URL from environment
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
db_user = os.getenv("DB_USER", "root")
|
||||
db_pass = os.getenv("DB_PASS", "")
|
||||
db_name = os.getenv("DB_NAME", "hotel_db")
|
||||
db_host = os.getenv("DB_HOST", "localhost")
|
||||
db_port = os.getenv("DB_PORT", "3306")
|
||||
database_url = f"mysql+pymysql://{db_user}:{db_pass}@{db_host}:{db_port}/{db_name}"
|
||||
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
# add your model's MetaData object here
|
||||
target_metadata = Base.metadata
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
|
||||
25
Backend/alembic/script.py.mako
Normal file
25
Backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,25 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
|
||||
19
Backend/requirements.txt
Normal file
19
Backend/requirements.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
python-dotenv==1.0.0
|
||||
sqlalchemy==2.0.23
|
||||
pymysql==1.1.0
|
||||
cryptography==41.0.7
|
||||
python-jose[cryptography]==3.3.0
|
||||
bcrypt==4.1.2
|
||||
python-multipart==0.0.6
|
||||
aiofiles==23.2.1
|
||||
email-validator==2.1.0
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
slowapi==0.1.9
|
||||
pillow==10.1.0
|
||||
aiosmtplib==3.0.1
|
||||
jinja2==3.1.2
|
||||
alembic==1.12.1
|
||||
|
||||
23
Backend/run.py
Normal file
23
Backend/run.py
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Main entry point for the FastAPI server
|
||||
"""
|
||||
import uvicorn
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.getenv("PORT", 8000))
|
||||
host = os.getenv("HOST", "0.0.0.0")
|
||||
reload = os.getenv("NODE_ENV") == "development"
|
||||
|
||||
uvicorn.run(
|
||||
"src.main:app",
|
||||
host=host,
|
||||
port=port,
|
||||
reload=reload,
|
||||
log_level="info"
|
||||
)
|
||||
|
||||
2
Backend/src/__init__.py
Normal file
2
Backend/src/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Hotel Booking Server Package
|
||||
|
||||
BIN
Backend/src/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/__pycache__/main.cpython-312.pyc
Normal file
BIN
Backend/src/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/config/__pycache__/database.cpython-312.pyc
Normal file
BIN
Backend/src/config/__pycache__/database.cpython-312.pyc
Normal file
Binary file not shown.
38
Backend/src/config/database.py
Normal file
38
Backend/src/config/database.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Database configuration
|
||||
DB_USER = os.getenv("DB_USER", "root")
|
||||
DB_PASS = os.getenv("DB_PASS", "")
|
||||
DB_NAME = os.getenv("DB_NAME", "hotel_db")
|
||||
DB_HOST = os.getenv("DB_HOST", "localhost")
|
||||
DB_PORT = os.getenv("DB_PORT", "3306")
|
||||
|
||||
DATABASE_URL = f"mysql+pymysql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
||||
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=300,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
echo=os.getenv("NODE_ENV") == "development"
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
# Dependency to get DB session
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
121
Backend/src/main.py
Normal file
121
Backend/src/main.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from jose.exceptions import JWTError
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.util import get_remote_address
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from .config.database import engine, Base
|
||||
from .middleware.error_handler import (
|
||||
validation_exception_handler,
|
||||
integrity_error_handler,
|
||||
jwt_error_handler,
|
||||
http_exception_handler,
|
||||
general_exception_handler
|
||||
)
|
||||
# Create database tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
from .routes import auth_routes
|
||||
|
||||
# Initialize FastAPI app
|
||||
app = FastAPI(
|
||||
title="Hotel Booking API",
|
||||
description="Hotel booking backend API",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# Rate limiting
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
# CORS configuration
|
||||
# Allow multiple origins for development
|
||||
client_url = os.getenv("CLIENT_URL", "http://localhost:5173")
|
||||
allowed_origins = [
|
||||
client_url,
|
||||
"http://localhost:5173", # Vite default
|
||||
"http://localhost:3000", # Alternative port
|
||||
"http://localhost:5174", # Vite alternative
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:5174",
|
||||
]
|
||||
|
||||
# In development, allow all localhost origins using regex
|
||||
if os.getenv("ENVIRONMENT", "development") == "development":
|
||||
# For development, use regex to allow any localhost port
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origin_regex=r"http://(localhost|127\.0\.0\.1)(:\d+)?",
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
else:
|
||||
# Production: use specific origins
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=allowed_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Serve static files (uploads)
|
||||
uploads_dir = Path(__file__).parent.parent / "uploads"
|
||||
uploads_dir.mkdir(exist_ok=True)
|
||||
app.mount("/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads")
|
||||
|
||||
# Exception handlers
|
||||
app.add_exception_handler(HTTPException, http_exception_handler)
|
||||
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
||||
app.add_exception_handler(IntegrityError, integrity_error_handler)
|
||||
app.add_exception_handler(JWTError, jwt_error_handler)
|
||||
app.add_exception_handler(Exception, general_exception_handler)
|
||||
|
||||
# Health check
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Server is running",
|
||||
"timestamp": __import__("datetime").datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
# API Routes
|
||||
app.include_router(auth_routes.router, prefix="/api")
|
||||
|
||||
# Import and include other routes
|
||||
from .routes import (
|
||||
room_routes, booking_routes, payment_routes, banner_routes,
|
||||
favorite_routes, service_routes, promotion_routes, report_routes,
|
||||
review_routes, user_routes
|
||||
)
|
||||
|
||||
app.include_router(room_routes.router, prefix="/api")
|
||||
app.include_router(booking_routes.router, prefix="/api")
|
||||
app.include_router(payment_routes.router, prefix="/api")
|
||||
app.include_router(banner_routes.router, prefix="/api")
|
||||
app.include_router(favorite_routes.router, prefix="/api")
|
||||
app.include_router(service_routes.router, prefix="/api")
|
||||
app.include_router(promotion_routes.router, prefix="/api")
|
||||
app.include_router(report_routes.router, prefix="/api")
|
||||
app.include_router(review_routes.router, prefix="/api")
|
||||
app.include_router(user_routes.router, prefix="/api")
|
||||
|
||||
# Note: FastAPI automatically handles 404s for unmatched routes
|
||||
# This handler is kept for custom 404 responses but may not be needed
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
port = int(os.getenv("PORT", 3000))
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=True)
|
||||
|
||||
BIN
Backend/src/middleware/__pycache__/auth.cpython-312.pyc
Normal file
BIN
Backend/src/middleware/__pycache__/auth.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/middleware/__pycache__/error_handler.cpython-312.pyc
Normal file
BIN
Backend/src/middleware/__pycache__/error_handler.cpython-312.pyc
Normal file
Binary file not shown.
61
Backend/src/middleware/auth.py
Normal file
61
Backend/src/middleware/auth.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
import os
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..models.user import User
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
"""
|
||||
Verify JWT token and return current user
|
||||
"""
|
||||
token = credentials.credentials
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"])
|
||||
user_id: int = payload.get("userId")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def authorize_roles(*allowed_roles: str):
|
||||
"""
|
||||
Check if user has required role
|
||||
"""
|
||||
def role_checker(current_user: User = Depends(get_current_user)) -> User:
|
||||
# Map role IDs to role names
|
||||
role_map = {1: "admin", 2: "staff", 3: "customer"}
|
||||
user_role_name = role_map.get(current_user.role_id)
|
||||
|
||||
if user_role_name not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You do not have permission to access this resource"
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
return role_checker
|
||||
|
||||
127
Backend/src/middleware/error_handler.py
Normal file
127
Backend/src/middleware/error_handler.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from fastapi import Request, status, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from jose.exceptions import JWTError
|
||||
import os
|
||||
import traceback
|
||||
|
||||
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""
|
||||
Handle validation errors
|
||||
"""
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
field = ".".join(str(loc) for loc in error["loc"] if loc != "body")
|
||||
errors.append({
|
||||
"field": field,
|
||||
"message": error["msg"]
|
||||
})
|
||||
|
||||
# Get the first error message for the main message
|
||||
first_error = errors[0]["message"] if errors else "Validation error"
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": first_error,
|
||||
"errors": errors
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def integrity_error_handler(request: Request, exc: IntegrityError):
|
||||
"""
|
||||
Handle database integrity errors (unique constraints, etc.)
|
||||
"""
|
||||
error_msg = str(exc.orig) if hasattr(exc, 'orig') else str(exc)
|
||||
|
||||
# Check for duplicate entry
|
||||
if "Duplicate entry" in error_msg or "UNIQUE constraint" in error_msg:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "Duplicate entry",
|
||||
"errors": [{"message": "This record already exists"}]
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "Database integrity error"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def jwt_error_handler(request: Request, exc: JWTError):
|
||||
"""
|
||||
Handle JWT errors
|
||||
"""
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "Invalid token"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
"""
|
||||
Handle HTTPException errors
|
||||
"""
|
||||
# If detail is already a dict with status/message, return it directly
|
||||
if isinstance(exc.detail, dict):
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=exc.detail
|
||||
)
|
||||
|
||||
# Otherwise format as standard error response
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": str(exc.detail) if exc.detail else "An error occurred"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
"""
|
||||
Handle all other exceptions
|
||||
"""
|
||||
# Log error
|
||||
print(f"Error: {exc}")
|
||||
if os.getenv("NODE_ENV") == "development":
|
||||
traceback.print_exc()
|
||||
|
||||
# Handle HTTPException with dict detail
|
||||
if isinstance(exc, Exception) and hasattr(exc, "status_code"):
|
||||
status_code = exc.status_code
|
||||
if hasattr(exc, "detail"):
|
||||
detail = exc.detail
|
||||
if isinstance(detail, dict):
|
||||
# If detail is already a dict with status/message, return it directly
|
||||
return JSONResponse(status_code=status_code, content=detail)
|
||||
message = str(detail) if detail else "An error occurred"
|
||||
else:
|
||||
message = str(exc) if str(exc) else "Internal server error"
|
||||
else:
|
||||
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
message = str(exc) if str(exc) else "Internal server error"
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": message,
|
||||
**({"stack": traceback.format_exc()} if os.getenv("NODE_ENV") == "development" else {})
|
||||
}
|
||||
)
|
||||
|
||||
34
Backend/src/models/__init__.py
Normal file
34
Backend/src/models/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from .role import Role
|
||||
from .user import User
|
||||
from .refresh_token import RefreshToken
|
||||
from .password_reset_token import PasswordResetToken
|
||||
from .room_type import RoomType
|
||||
from .room import Room
|
||||
from .booking import Booking
|
||||
from .payment import Payment
|
||||
from .service import Service
|
||||
from .service_usage import ServiceUsage
|
||||
from .promotion import Promotion
|
||||
from .checkin_checkout import CheckInCheckOut
|
||||
from .banner import Banner
|
||||
from .review import Review
|
||||
from .favorite import Favorite
|
||||
|
||||
__all__ = [
|
||||
"Role",
|
||||
"User",
|
||||
"RefreshToken",
|
||||
"PasswordResetToken",
|
||||
"RoomType",
|
||||
"Room",
|
||||
"Booking",
|
||||
"Payment",
|
||||
"Service",
|
||||
"ServiceUsage",
|
||||
"Promotion",
|
||||
"CheckInCheckOut",
|
||||
"Banner",
|
||||
"Review",
|
||||
"Favorite",
|
||||
]
|
||||
|
||||
BIN
Backend/src/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/banner.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/banner.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/booking.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/booking.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/checkin_checkout.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/checkin_checkout.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/favorite.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/favorite.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Backend/src/models/__pycache__/payment.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/payment.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/promotion.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/promotion.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/refresh_token.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/refresh_token.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/review.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/review.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/role.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/role.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/room.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/room.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/room_type.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/room_type.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/service.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/service_usage.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/service_usage.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/user.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/user.cpython-312.pyc
Normal file
Binary file not shown.
31
Backend/src/models/banner.py
Normal file
31
Backend/src/models/banner.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ..config.database import Base
|
||||
|
||||
|
||||
class Banner(Base):
|
||||
__tablename__ = "banners"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
title = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
image_url = Column(String(255), nullable=False)
|
||||
link_url = Column(String(255), nullable=True)
|
||||
position = Column(String(50), nullable=False, default="home")
|
||||
display_order = Column(Integer, nullable=False, default=0)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
start_date = Column(DateTime, nullable=True)
|
||||
end_date = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
def is_active_now(self):
|
||||
from datetime import datetime
|
||||
now = datetime.utcnow()
|
||||
if not self.is_active:
|
||||
return False
|
||||
if not self.start_date or not self.end_date:
|
||||
return self.is_active
|
||||
return self.start_date <= now <= self.end_date
|
||||
|
||||
40
Backend/src/models/booking.py
Normal file
40
Backend/src/models/booking.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Numeric, Boolean, Text, Enum, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
|
||||
class BookingStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
confirmed = "confirmed"
|
||||
checked_in = "checked_in"
|
||||
checked_out = "checked_out"
|
||||
cancelled = "cancelled"
|
||||
|
||||
|
||||
class Booking(Base):
|
||||
__tablename__ = "bookings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
booking_number = Column(String(50), unique=True, nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
room_id = Column(Integer, ForeignKey("rooms.id"), nullable=False)
|
||||
check_in_date = Column(DateTime, nullable=False)
|
||||
check_out_date = Column(DateTime, nullable=False)
|
||||
num_guests = Column(Integer, nullable=False, default=1)
|
||||
total_price = Column(Numeric(10, 2), nullable=False)
|
||||
status = Column(Enum(BookingStatus), nullable=False, default=BookingStatus.pending)
|
||||
deposit_paid = Column(Boolean, nullable=False, default=False)
|
||||
requires_deposit = Column(Boolean, nullable=False, default=False)
|
||||
special_requests = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="bookings")
|
||||
room = relationship("Room", back_populates="bookings")
|
||||
payments = relationship("Payment", back_populates="booking", cascade="all, delete-orphan")
|
||||
service_usages = relationship("ServiceUsage", back_populates="booking", cascade="all, delete-orphan")
|
||||
checkin_checkout = relationship("CheckInCheckOut", back_populates="booking", uselist=False)
|
||||
|
||||
27
Backend/src/models/checkin_checkout.py
Normal file
27
Backend/src/models/checkin_checkout.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from sqlalchemy import Column, Integer, DateTime, Numeric, Text, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ..config.database import Base
|
||||
|
||||
|
||||
class CheckInCheckOut(Base):
|
||||
__tablename__ = "checkin_checkout"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
booking_id = Column(Integer, ForeignKey("bookings.id"), nullable=False, unique=True)
|
||||
checkin_time = Column(DateTime, nullable=True)
|
||||
checkout_time = Column(DateTime, nullable=True)
|
||||
checkin_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
checkout_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
room_condition_checkin = Column(Text, nullable=True)
|
||||
room_condition_checkout = Column(Text, nullable=True)
|
||||
additional_charges = Column(Numeric(10, 2), nullable=False, default=0.0)
|
||||
notes = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
booking = relationship("Booking", back_populates="checkin_checkout")
|
||||
checked_in_by = relationship("User", foreign_keys=[checkin_by], back_populates="checkins_processed")
|
||||
checked_out_by = relationship("User", foreign_keys=[checkout_by], back_populates="checkouts_processed")
|
||||
|
||||
19
Backend/src/models/favorite.py
Normal file
19
Backend/src/models/favorite.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import Column, Integer, ForeignKey, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ..config.database import Base
|
||||
|
||||
|
||||
class Favorite(Base):
|
||||
__tablename__ = "favorites"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
room_id = Column(Integer, ForeignKey("rooms.id"), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="favorites")
|
||||
room = relationship("Room", back_populates="favorites")
|
||||
|
||||
20
Backend/src/models/password_reset_token.py
Normal file
20
Backend/src/models/password_reset_token.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ..config.database import Base
|
||||
|
||||
|
||||
class PasswordResetToken(Base):
|
||||
__tablename__ = "password_reset_tokens"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
token = Column(String(255), unique=True, nullable=False, index=True)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
used = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User")
|
||||
|
||||
49
Backend/src/models/payment.py
Normal file
49
Backend/src/models/payment.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Numeric, Text, Enum, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
|
||||
class PaymentMethod(str, enum.Enum):
|
||||
cash = "cash"
|
||||
credit_card = "credit_card"
|
||||
debit_card = "debit_card"
|
||||
bank_transfer = "bank_transfer"
|
||||
e_wallet = "e_wallet"
|
||||
|
||||
|
||||
class PaymentType(str, enum.Enum):
|
||||
full = "full"
|
||||
deposit = "deposit"
|
||||
remaining = "remaining"
|
||||
|
||||
|
||||
class PaymentStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
refunded = "refunded"
|
||||
|
||||
|
||||
class Payment(Base):
|
||||
__tablename__ = "payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
booking_id = Column(Integer, ForeignKey("bookings.id"), nullable=False)
|
||||
amount = Column(Numeric(10, 2), nullable=False)
|
||||
payment_method = Column(Enum(PaymentMethod), nullable=False)
|
||||
payment_type = Column(Enum(PaymentType), nullable=False, default=PaymentType.full)
|
||||
deposit_percentage = Column(Integer, nullable=True)
|
||||
related_payment_id = Column(Integer, ForeignKey("payments.id"), nullable=True)
|
||||
payment_status = Column(Enum(PaymentStatus), nullable=False, default=PaymentStatus.pending)
|
||||
transaction_id = Column(String(100), nullable=True)
|
||||
payment_date = Column(DateTime, nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
booking = relationship("Booking", back_populates="payments")
|
||||
related_payment = relationship("Payment", remote_side=[id], backref="related_payments")
|
||||
|
||||
60
Backend/src/models/promotion.py
Normal file
60
Backend/src/models/promotion.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Numeric, Boolean, Text, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
|
||||
class DiscountType(str, enum.Enum):
|
||||
percentage = "percentage"
|
||||
fixed_amount = "fixed_amount"
|
||||
|
||||
|
||||
class Promotion(Base):
|
||||
__tablename__ = "promotions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
code = Column(String(50), unique=True, nullable=False, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
discount_type = Column(Enum(DiscountType), nullable=False)
|
||||
discount_value = Column(Numeric(10, 2), nullable=False)
|
||||
min_booking_amount = Column(Numeric(10, 2), nullable=True)
|
||||
max_discount_amount = Column(Numeric(10, 2), nullable=True)
|
||||
start_date = Column(DateTime, nullable=False)
|
||||
end_date = Column(DateTime, nullable=False)
|
||||
usage_limit = Column(Integer, nullable=True)
|
||||
used_count = Column(Integer, nullable=False, default=0)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
def is_valid(self):
|
||||
from datetime import datetime
|
||||
now = datetime.utcnow()
|
||||
if not self.is_active:
|
||||
return False
|
||||
if now < self.start_date or now > self.end_date:
|
||||
return False
|
||||
if self.usage_limit is not None and self.used_count >= self.usage_limit:
|
||||
return False
|
||||
return True
|
||||
|
||||
def calculate_discount(self, booking_amount):
|
||||
if not self.is_valid():
|
||||
return 0.0
|
||||
|
||||
if self.min_booking_amount and booking_amount < float(self.min_booking_amount):
|
||||
return 0.0
|
||||
|
||||
discount = 0.0
|
||||
if self.discount_type == DiscountType.percentage:
|
||||
discount = float(booking_amount) * float(self.discount_value) / 100.0
|
||||
elif self.discount_type == DiscountType.fixed_amount:
|
||||
discount = float(self.discount_value)
|
||||
|
||||
if self.max_discount_amount and discount > float(self.max_discount_amount):
|
||||
discount = float(self.max_discount_amount)
|
||||
|
||||
return discount
|
||||
|
||||
18
Backend/src/models/refresh_token.py
Normal file
18
Backend/src/models/refresh_token.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ..config.database import Base
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
token = Column(String(500), unique=True, nullable=False, index=True)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="refresh_tokens")
|
||||
|
||||
29
Backend/src/models/review.py
Normal file
29
Backend/src/models/review.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from sqlalchemy import Column, Integer, Text, Enum, ForeignKey, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
|
||||
class ReviewStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
approved = "approved"
|
||||
rejected = "rejected"
|
||||
|
||||
|
||||
class Review(Base):
|
||||
__tablename__ = "reviews"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
room_id = Column(Integer, ForeignKey("rooms.id"), nullable=False)
|
||||
rating = Column(Integer, nullable=False)
|
||||
comment = Column(Text, nullable=False)
|
||||
status = Column(Enum(ReviewStatus), nullable=False, default=ReviewStatus.pending)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="reviews")
|
||||
room = relationship("Room", back_populates="reviews")
|
||||
|
||||
18
Backend/src/models/role.py
Normal file
18
Backend/src/models/role.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ..config.database import Base
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(50), unique=True, nullable=False, index=True)
|
||||
description = Column(String(255), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
users = relationship("User", back_populates="role")
|
||||
|
||||
36
Backend/src/models/room.py
Normal file
36
Backend/src/models/room.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, JSON, Enum, ForeignKey, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
|
||||
class RoomStatus(str, enum.Enum):
|
||||
available = "available"
|
||||
occupied = "occupied"
|
||||
maintenance = "maintenance"
|
||||
cleaning = "cleaning"
|
||||
|
||||
|
||||
class Room(Base):
|
||||
__tablename__ = "rooms"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
room_type_id = Column(Integer, ForeignKey("room_types.id"), nullable=False)
|
||||
room_number = Column(String(20), unique=True, nullable=False, index=True)
|
||||
floor = Column(Integer, nullable=False)
|
||||
status = Column(Enum(RoomStatus), nullable=False, default=RoomStatus.available)
|
||||
price = Column(Numeric(10, 2), nullable=False)
|
||||
featured = Column(Boolean, nullable=False, default=False)
|
||||
images = Column(JSON, nullable=True)
|
||||
amenities = Column(JSON, nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
room_type = relationship("RoomType", back_populates="rooms")
|
||||
bookings = relationship("Booking", back_populates="room")
|
||||
reviews = relationship("Review", back_populates="room")
|
||||
favorites = relationship("Favorite", back_populates="room", cascade="all, delete-orphan")
|
||||
|
||||
21
Backend/src/models/room_type.py
Normal file
21
Backend/src/models/room_type.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from sqlalchemy import Column, Integer, String, Numeric, Text, JSON, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ..config.database import Base
|
||||
|
||||
|
||||
class RoomType(Base):
|
||||
__tablename__ = "room_types"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(100), unique=True, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
base_price = Column(Numeric(10, 2), nullable=False)
|
||||
capacity = Column(Integer, nullable=False)
|
||||
amenities = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
rooms = relationship("Room", back_populates="room_type")
|
||||
|
||||
21
Backend/src/models/service.py
Normal file
21
Backend/src/models/service.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ..config.database import Base
|
||||
|
||||
|
||||
class Service(Base):
|
||||
__tablename__ = "services"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
price = Column(Numeric(10, 2), nullable=False)
|
||||
category = Column(String(50), nullable=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
service_usages = relationship("ServiceUsage", back_populates="service")
|
||||
|
||||
24
Backend/src/models/service_usage.py
Normal file
24
Backend/src/models/service_usage.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy import Column, Integer, DateTime, Numeric, Text, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ..config.database import Base
|
||||
|
||||
|
||||
class ServiceUsage(Base):
|
||||
__tablename__ = "service_usages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
booking_id = Column(Integer, ForeignKey("bookings.id"), nullable=False)
|
||||
service_id = Column(Integer, ForeignKey("services.id"), nullable=False)
|
||||
quantity = Column(Integer, nullable=False, default=1)
|
||||
unit_price = Column(Numeric(10, 2), nullable=False)
|
||||
total_price = Column(Numeric(10, 2), nullable=False)
|
||||
usage_date = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
notes = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
booking = relationship("Booking", back_populates="service_usages")
|
||||
service = relationship("Service", back_populates="service_usages")
|
||||
|
||||
30
Backend/src/models/user.py
Normal file
30
Backend/src/models/user.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Text, ForeignKey, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ..config.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
|
||||
email = Column(String(100), unique=True, nullable=False, index=True)
|
||||
password = Column(String(255), nullable=False)
|
||||
full_name = Column(String(100), nullable=False)
|
||||
phone = Column(String(20), nullable=True)
|
||||
address = Column(Text, nullable=True)
|
||||
avatar = Column(String(255), nullable=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
role = relationship("Role", back_populates="users")
|
||||
bookings = relationship("Booking", back_populates="user")
|
||||
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
|
||||
checkins_processed = relationship("CheckInCheckOut", foreign_keys="CheckInCheckOut.checkin_by", back_populates="checked_in_by")
|
||||
checkouts_processed = relationship("CheckInCheckOut", foreign_keys="CheckInCheckOut.checkout_by", back_populates="checked_out_by")
|
||||
reviews = relationship("Review", back_populates="user")
|
||||
favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
2
Backend/src/routes/__init__.py
Normal file
2
Backend/src/routes/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Routes package
|
||||
|
||||
BIN
Backend/src/routes/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/routes/__pycache__/auth_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/auth_routes.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/routes/__pycache__/banner_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/banner_routes.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/routes/__pycache__/favorite_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/favorite_routes.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/routes/__pycache__/promotion_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/promotion_routes.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/routes/__pycache__/report_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/report_routes.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/routes/__pycache__/review_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/review_routes.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/routes/__pycache__/room_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/room_routes.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/routes/__pycache__/service_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/service_routes.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/routes/__pycache__/user_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/user_routes.cpython-312.pyc
Normal file
Binary file not shown.
216
Backend/src/routes/auth_routes.py
Normal file
216
Backend/src/routes/auth_routes.py
Normal file
@@ -0,0 +1,216 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Cookie, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..services.auth_service import auth_service
|
||||
from ..schemas.auth import (
|
||||
RegisterRequest,
|
||||
LoginRequest,
|
||||
RefreshTokenRequest,
|
||||
ForgotPasswordRequest,
|
||||
ResetPasswordRequest,
|
||||
AuthResponse,
|
||||
TokenResponse,
|
||||
MessageResponse
|
||||
)
|
||||
from ..middleware.auth import get_current_user
|
||||
from ..models.user import User
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
request: RegisterRequest,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Register new user"""
|
||||
try:
|
||||
result = await auth_service.register(
|
||||
db=db,
|
||||
name=request.name,
|
||||
email=request.email,
|
||||
password=request.password,
|
||||
phone=request.phone
|
||||
)
|
||||
|
||||
# Set refresh token as HttpOnly cookie
|
||||
response.set_cookie(
|
||||
key="refreshToken",
|
||||
value=result["refreshToken"],
|
||||
httponly=True,
|
||||
secure=False, # Set to True in production with HTTPS
|
||||
samesite="strict",
|
||||
max_age=7 * 24 * 60 * 60, # 7 days
|
||||
path="/"
|
||||
)
|
||||
|
||||
# Format response to match frontend expectations
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Registration successful",
|
||||
"data": {
|
||||
"token": result["token"],
|
||||
"user": result["user"]
|
||||
}
|
||||
}
|
||||
except ValueError as e:
|
||||
error_message = str(e)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": error_message
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
request: LoginRequest,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Login user"""
|
||||
try:
|
||||
result = await auth_service.login(
|
||||
db=db,
|
||||
email=request.email,
|
||||
password=request.password,
|
||||
remember_me=request.rememberMe or False
|
||||
)
|
||||
|
||||
# Set refresh token as HttpOnly cookie
|
||||
max_age = 7 * 24 * 60 * 60 if request.rememberMe else 1 * 24 * 60 * 60
|
||||
response.set_cookie(
|
||||
key="refreshToken",
|
||||
value=result["refreshToken"],
|
||||
httponly=True,
|
||||
secure=False, # Set to True in production with HTTPS
|
||||
samesite="strict",
|
||||
max_age=max_age,
|
||||
path="/"
|
||||
)
|
||||
|
||||
# Format response to match frontend expectations
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"token": result["token"],
|
||||
"user": result["user"]
|
||||
}
|
||||
}
|
||||
except ValueError as e:
|
||||
error_message = str(e)
|
||||
status_code = status.HTTP_401_UNAUTHORIZED if "Invalid email or password" in error_message else status.HTTP_400_BAD_REQUEST
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": error_message
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh-token", response_model=TokenResponse)
|
||||
async def refresh_token(
|
||||
refreshToken: str = Cookie(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Refresh access token"""
|
||||
if not refreshToken:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Refresh token not found"
|
||||
)
|
||||
|
||||
try:
|
||||
result = await auth_service.refresh_access_token(db, refreshToken)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", response_model=MessageResponse)
|
||||
async def logout(
|
||||
response: Response,
|
||||
refreshToken: str = Cookie(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Logout user"""
|
||||
if refreshToken:
|
||||
await auth_service.logout(db, refreshToken)
|
||||
|
||||
# Clear refresh token cookie
|
||||
response.delete_cookie(key="refreshToken", path="/")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Logout successful"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/profile")
|
||||
async def get_profile(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user profile"""
|
||||
try:
|
||||
user = await auth_service.get_profile(db, current_user.id)
|
||||
return user
|
||||
except ValueError as e:
|
||||
if "User not found" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/forgot-password", response_model=MessageResponse)
|
||||
async def forgot_password(
|
||||
request: ForgotPasswordRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Send password reset link"""
|
||||
result = await auth_service.forgot_password(db, request.email)
|
||||
return {
|
||||
"status": "success",
|
||||
"message": result["message"]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/reset-password", response_model=MessageResponse)
|
||||
async def reset_password(
|
||||
request: ResetPasswordRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Reset password with token"""
|
||||
try:
|
||||
result = await auth_service.reset_password(
|
||||
db=db,
|
||||
token=request.token,
|
||||
password=request.password
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"message": result["message"]
|
||||
}
|
||||
except ValueError as e:
|
||||
status_code = status.HTTP_400_BAD_REQUEST
|
||||
if "User not found" in str(e):
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
raise HTTPException(
|
||||
status_code=status_code,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
229
Backend/src/routes/banner_routes.py
Normal file
229
Backend/src/routes/banner_routes.py
Normal file
@@ -0,0 +1,229 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import os
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.banner import Banner
|
||||
|
||||
router = APIRouter(prefix="/banners", tags=["banners"])
|
||||
|
||||
|
||||
def normalize_image_url(image_url: str, base_url: str) -> str:
|
||||
"""Normalize image URL to absolute URL"""
|
||||
if not image_url:
|
||||
return image_url
|
||||
if image_url.startswith('http://') or image_url.startswith('https://'):
|
||||
return image_url
|
||||
if image_url.startswith('/'):
|
||||
return f"{base_url}{image_url}"
|
||||
return f"{base_url}/{image_url}"
|
||||
|
||||
|
||||
def get_base_url(request: Request) -> str:
|
||||
"""Get base URL for image normalization"""
|
||||
return os.getenv("SERVER_URL") or f"http://{request.headers.get('host', 'localhost:3000')}"
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_banners(
|
||||
request: Request,
|
||||
position: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all active banners"""
|
||||
try:
|
||||
query = db.query(Banner).filter(Banner.is_active == True)
|
||||
|
||||
# Filter by position
|
||||
if position:
|
||||
query = query.filter(Banner.position == position)
|
||||
|
||||
# Filter by date range
|
||||
now = datetime.utcnow()
|
||||
query = query.filter(
|
||||
or_(
|
||||
Banner.start_date == None,
|
||||
Banner.start_date <= now
|
||||
)
|
||||
).filter(
|
||||
or_(
|
||||
Banner.end_date == None,
|
||||
Banner.end_date >= now
|
||||
)
|
||||
)
|
||||
|
||||
banners = query.order_by(Banner.display_order.asc(), Banner.created_at.desc()).all()
|
||||
|
||||
base_url = get_base_url(request)
|
||||
result = []
|
||||
for banner in banners:
|
||||
banner_dict = {
|
||||
"id": banner.id,
|
||||
"title": banner.title,
|
||||
"description": banner.description,
|
||||
"image_url": normalize_image_url(banner.image_url, base_url),
|
||||
"link_url": banner.link_url,
|
||||
"position": banner.position,
|
||||
"display_order": banner.display_order,
|
||||
"is_active": banner.is_active,
|
||||
"start_date": banner.start_date.isoformat() if banner.start_date else None,
|
||||
"end_date": banner.end_date.isoformat() if banner.end_date else None,
|
||||
"created_at": banner.created_at.isoformat() if banner.created_at else None,
|
||||
}
|
||||
result.append(banner_dict)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {"banners": result}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{id}")
|
||||
async def get_banner_by_id(
|
||||
id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get banner by ID"""
|
||||
try:
|
||||
banner = db.query(Banner).filter(Banner.id == id).first()
|
||||
if not banner:
|
||||
raise HTTPException(status_code=404, detail="Banner not found")
|
||||
|
||||
base_url = get_base_url(request)
|
||||
banner_dict = {
|
||||
"id": banner.id,
|
||||
"title": banner.title,
|
||||
"description": banner.description,
|
||||
"image_url": normalize_image_url(banner.image_url, base_url),
|
||||
"link_url": banner.link_url,
|
||||
"position": banner.position,
|
||||
"display_order": banner.display_order,
|
||||
"is_active": banner.is_active,
|
||||
"start_date": banner.start_date.isoformat() if banner.start_date else None,
|
||||
"end_date": banner.end_date.isoformat() if banner.end_date else None,
|
||||
"created_at": banner.created_at.isoformat() if banner.created_at else None,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {"banner": banner_dict}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def create_banner(
|
||||
banner_data: dict,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create new banner (Admin only)"""
|
||||
try:
|
||||
banner = Banner(
|
||||
title=banner_data.get("title"),
|
||||
description=banner_data.get("description"),
|
||||
image_url=banner_data.get("image_url"),
|
||||
link_url=banner_data.get("link"),
|
||||
position=banner_data.get("position", "home"),
|
||||
display_order=banner_data.get("display_order", 0),
|
||||
is_active=True,
|
||||
start_date=datetime.fromisoformat(banner_data["start_date"].replace('Z', '+00:00')) if banner_data.get("start_date") else None,
|
||||
end_date=datetime.fromisoformat(banner_data["end_date"].replace('Z', '+00:00')) if banner_data.get("end_date") else None,
|
||||
)
|
||||
|
||||
db.add(banner)
|
||||
db.commit()
|
||||
db.refresh(banner)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Banner created successfully",
|
||||
"data": {"banner": banner}
|
||||
}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{id}", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def update_banner(
|
||||
id: int,
|
||||
banner_data: dict,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update banner (Admin only)"""
|
||||
try:
|
||||
banner = db.query(Banner).filter(Banner.id == id).first()
|
||||
if not banner:
|
||||
raise HTTPException(status_code=404, detail="Banner not found")
|
||||
|
||||
if "title" in banner_data:
|
||||
banner.title = banner_data["title"]
|
||||
if "description" in banner_data:
|
||||
banner.description = banner_data["description"]
|
||||
if "image_url" in banner_data:
|
||||
banner.image_url = banner_data["image_url"]
|
||||
if "link" in banner_data:
|
||||
banner.link_url = banner_data["link"]
|
||||
if "position" in banner_data:
|
||||
banner.position = banner_data["position"]
|
||||
if "display_order" in banner_data:
|
||||
banner.display_order = banner_data["display_order"]
|
||||
if "is_active" in banner_data:
|
||||
banner.is_active = banner_data["is_active"]
|
||||
if "start_date" in banner_data:
|
||||
banner.start_date = datetime.fromisoformat(banner_data["start_date"].replace('Z', '+00:00')) if banner_data["start_date"] else None
|
||||
if "end_date" in banner_data:
|
||||
banner.end_date = datetime.fromisoformat(banner_data["end_date"].replace('Z', '+00:00')) if banner_data["end_date"] else None
|
||||
|
||||
db.commit()
|
||||
db.refresh(banner)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Banner updated successfully",
|
||||
"data": {"banner": banner}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{id}", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def delete_banner(
|
||||
id: int,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete banner (Admin only)"""
|
||||
try:
|
||||
banner = db.query(Banner).filter(Banner.id == id).first()
|
||||
if not banner:
|
||||
raise HTTPException(status_code=404, detail="Banner not found")
|
||||
|
||||
db.delete(banner)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Banner deleted successfully"
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
437
Backend/src/routes/booking_routes.py
Normal file
437
Backend/src/routes/booking_routes.py
Normal file
@@ -0,0 +1,437 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import random
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.booking import Booking, BookingStatus
|
||||
from ..models.room import Room
|
||||
from ..models.room_type import RoomType
|
||||
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
|
||||
|
||||
router = APIRouter(prefix="/bookings", tags=["bookings"])
|
||||
|
||||
|
||||
def generate_booking_number() -> str:
|
||||
"""Generate unique booking number"""
|
||||
prefix = "BK"
|
||||
ts = int(datetime.utcnow().timestamp() * 1000)
|
||||
rand = random.randint(1000, 9999)
|
||||
return f"{prefix}-{ts}-{rand}"
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_all_bookings(
|
||||
search: Optional[str] = Query(None),
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
startDate: Optional[str] = Query(None),
|
||||
endDate: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
current_user: User = Depends(authorize_roles("admin", "staff")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all bookings (Admin/Staff only)"""
|
||||
try:
|
||||
query = db.query(Booking)
|
||||
|
||||
# Filter by search (booking_number)
|
||||
if search:
|
||||
query = query.filter(Booking.booking_number.like(f"%{search}%"))
|
||||
|
||||
# Filter by status
|
||||
if status_filter:
|
||||
try:
|
||||
query = query.filter(Booking.status == BookingStatus(status_filter))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Filter by date range
|
||||
if startDate:
|
||||
start = datetime.fromisoformat(startDate.replace('Z', '+00:00'))
|
||||
query = query.filter(Booking.check_in_date >= start)
|
||||
|
||||
if endDate:
|
||||
end = datetime.fromisoformat(endDate.replace('Z', '+00:00'))
|
||||
query = query.filter(Booking.check_in_date <= end)
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Apply pagination
|
||||
offset = (page - 1) * limit
|
||||
bookings = query.order_by(Booking.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
# Include related data
|
||||
result = []
|
||||
for booking in bookings:
|
||||
booking_dict = {
|
||||
"id": booking.id,
|
||||
"booking_number": booking.booking_number,
|
||||
"user_id": booking.user_id,
|
||||
"room_id": booking.room_id,
|
||||
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
|
||||
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None,
|
||||
"num_guests": booking.num_guests,
|
||||
"total_price": float(booking.total_price) if booking.total_price else 0.0,
|
||||
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
||||
"deposit_paid": booking.deposit_paid,
|
||||
"requires_deposit": booking.requires_deposit,
|
||||
"special_requests": booking.special_requests,
|
||||
"created_at": booking.created_at.isoformat() if booking.created_at else None,
|
||||
}
|
||||
|
||||
# Add user info
|
||||
if booking.user:
|
||||
booking_dict["user"] = {
|
||||
"id": booking.user.id,
|
||||
"full_name": booking.user.full_name,
|
||||
"email": booking.user.email,
|
||||
"phone": booking.user.phone,
|
||||
}
|
||||
|
||||
# Add room info
|
||||
if booking.room:
|
||||
booking_dict["room"] = {
|
||||
"id": booking.room.id,
|
||||
"room_number": booking.room.room_number,
|
||||
"floor": booking.room.floor,
|
||||
}
|
||||
|
||||
result.append(booking_dict)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"bookings": result,
|
||||
"pagination": {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"totalPages": (total + limit - 1) // limit,
|
||||
},
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_my_bookings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's bookings"""
|
||||
try:
|
||||
bookings = db.query(Booking).filter(
|
||||
Booking.user_id == current_user.id
|
||||
).order_by(Booking.created_at.desc()).all()
|
||||
|
||||
result = []
|
||||
for booking in bookings:
|
||||
booking_dict = {
|
||||
"id": booking.id,
|
||||
"booking_number": booking.booking_number,
|
||||
"room_id": booking.room_id,
|
||||
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
|
||||
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None,
|
||||
"num_guests": booking.num_guests,
|
||||
"total_price": float(booking.total_price) if booking.total_price else 0.0,
|
||||
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
||||
"deposit_paid": booking.deposit_paid,
|
||||
"requires_deposit": booking.requires_deposit,
|
||||
"special_requests": booking.special_requests,
|
||||
"created_at": booking.created_at.isoformat() if booking.created_at else None,
|
||||
}
|
||||
|
||||
# Add room info
|
||||
if booking.room and booking.room.room_type:
|
||||
booking_dict["room"] = {
|
||||
"id": booking.room.id,
|
||||
"room_number": booking.room.room_number,
|
||||
"room_type": {
|
||||
"name": booking.room.room_type.name,
|
||||
}
|
||||
}
|
||||
|
||||
result.append(booking_dict)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"bookings": result}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_booking(
|
||||
booking_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create new booking"""
|
||||
try:
|
||||
room_id = booking_data.get("room_id")
|
||||
check_in_date = booking_data.get("check_in_date")
|
||||
check_out_date = booking_data.get("check_out_date")
|
||||
total_price = booking_data.get("total_price")
|
||||
guest_count = booking_data.get("guest_count", 1)
|
||||
notes = booking_data.get("notes")
|
||||
payment_method = booking_data.get("payment_method", "cash")
|
||||
|
||||
if not all([room_id, check_in_date, check_out_date, total_price]):
|
||||
raise HTTPException(status_code=400, detail="Missing required booking fields")
|
||||
|
||||
# Check if room exists
|
||||
room = db.query(Room).filter(Room.id == room_id).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
check_in = datetime.fromisoformat(check_in_date.replace('Z', '+00:00'))
|
||||
check_out = datetime.fromisoformat(check_out_date.replace('Z', '+00:00'))
|
||||
|
||||
# Check for overlapping bookings
|
||||
overlapping = db.query(Booking).filter(
|
||||
and_(
|
||||
Booking.room_id == room_id,
|
||||
Booking.status != BookingStatus.cancelled,
|
||||
Booking.check_in_date < check_out,
|
||||
Booking.check_out_date > check_in
|
||||
)
|
||||
).first()
|
||||
|
||||
if overlapping:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Room already booked for the selected dates"
|
||||
)
|
||||
|
||||
booking_number = generate_booking_number()
|
||||
|
||||
# Determine if deposit is required
|
||||
requires_deposit = payment_method == "cash"
|
||||
deposit_percentage = 20 if requires_deposit else 0
|
||||
deposit_amount = (float(total_price) * deposit_percentage) / 100 if requires_deposit else 0
|
||||
|
||||
# Create booking
|
||||
booking = Booking(
|
||||
booking_number=booking_number,
|
||||
user_id=current_user.id,
|
||||
room_id=room_id,
|
||||
check_in_date=check_in,
|
||||
check_out_date=check_out,
|
||||
num_guests=guest_count,
|
||||
total_price=total_price,
|
||||
special_requests=notes,
|
||||
status=BookingStatus.pending,
|
||||
requires_deposit=requires_deposit,
|
||||
deposit_paid=False,
|
||||
)
|
||||
|
||||
db.add(booking)
|
||||
db.flush()
|
||||
|
||||
# Create deposit payment if required
|
||||
if requires_deposit:
|
||||
payment = Payment(
|
||||
booking_id=booking.id,
|
||||
amount=deposit_amount,
|
||||
payment_method=PaymentMethod.bank_transfer,
|
||||
payment_type=PaymentType.deposit,
|
||||
deposit_percentage=deposit_percentage,
|
||||
payment_status=PaymentStatus.pending,
|
||||
notes=f"Deposit payment ({deposit_percentage}%) for booking {booking_number}",
|
||||
)
|
||||
db.add(payment)
|
||||
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
# Fetch with relations
|
||||
booking = db.query(Booking).filter(Booking.id == booking.id).first()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"booking": booking},
|
||||
"message": f"Booking created. Please pay {deposit_percentage}% deposit to confirm." if requires_deposit else "Booking created successfully"
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{id}")
|
||||
async def get_booking_by_id(
|
||||
id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get booking by ID"""
|
||||
try:
|
||||
booking = db.query(Booking).filter(Booking.id == id).first()
|
||||
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
|
||||
# Check access
|
||||
if current_user.role_id != 1 and booking.user_id != current_user.id: # Not admin
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
booking_dict = {
|
||||
"id": booking.id,
|
||||
"booking_number": booking.booking_number,
|
||||
"user_id": booking.user_id,
|
||||
"room_id": booking.room_id,
|
||||
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
|
||||
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None,
|
||||
"num_guests": booking.num_guests,
|
||||
"total_price": float(booking.total_price) if booking.total_price else 0.0,
|
||||
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
||||
"deposit_paid": booking.deposit_paid,
|
||||
"requires_deposit": booking.requires_deposit,
|
||||
"special_requests": booking.special_requests,
|
||||
"created_at": booking.created_at.isoformat() if booking.created_at else None,
|
||||
}
|
||||
|
||||
# Add relations
|
||||
if booking.room:
|
||||
booking_dict["room"] = {
|
||||
"id": booking.room.id,
|
||||
"room_number": booking.room.room_number,
|
||||
}
|
||||
if booking.room.room_type:
|
||||
booking_dict["room"]["room_type"] = {
|
||||
"name": booking.room.room_type.name,
|
||||
}
|
||||
|
||||
if booking.payments:
|
||||
booking_dict["payments"] = [
|
||||
{
|
||||
"id": p.id,
|
||||
"amount": float(p.amount) if p.amount else 0.0,
|
||||
"payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else p.payment_method,
|
||||
"payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status,
|
||||
}
|
||||
for p in booking.payments
|
||||
]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"booking": booking_dict}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/{id}/cancel")
|
||||
async def cancel_booking(
|
||||
id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Cancel a booking"""
|
||||
try:
|
||||
booking = db.query(Booking).filter(Booking.id == id).first()
|
||||
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
|
||||
if booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
if booking.status == BookingStatus.cancelled:
|
||||
raise HTTPException(status_code=400, detail="Booking already cancelled")
|
||||
|
||||
booking.status = BookingStatus.cancelled
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"booking": booking}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{id}", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def update_booking(
|
||||
id: int,
|
||||
booking_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update booking status (Admin only)"""
|
||||
try:
|
||||
booking = db.query(Booking).filter(Booking.id == id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
|
||||
status_value = booking_data.get("status")
|
||||
if status_value:
|
||||
try:
|
||||
booking.status = BookingStatus(status_value)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid status")
|
||||
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Booking updated successfully",
|
||||
"data": {"booking": booking}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/check/{booking_number}")
|
||||
async def check_booking_by_number(
|
||||
booking_number: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Check booking by booking number"""
|
||||
try:
|
||||
booking = db.query(Booking).filter(Booking.booking_number == booking_number).first()
|
||||
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
|
||||
booking_dict = {
|
||||
"id": booking.id,
|
||||
"booking_number": booking.booking_number,
|
||||
"room_id": booking.room_id,
|
||||
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
|
||||
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None,
|
||||
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
||||
}
|
||||
|
||||
if booking.room:
|
||||
booking_dict["room"] = {
|
||||
"id": booking.room.id,
|
||||
"room_number": booking.room.room_number,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {"booking": booking_dict}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
187
Backend/src/routes/favorite_routes.py
Normal file
187
Backend/src/routes/favorite_routes.py
Normal file
@@ -0,0 +1,187 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user
|
||||
from ..models.user import User
|
||||
from ..models.favorite import Favorite
|
||||
from ..models.room import Room
|
||||
from ..models.room_type import RoomType
|
||||
from ..models.review import Review, ReviewStatus
|
||||
|
||||
router = APIRouter(prefix="/favorites", tags=["favorites"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_favorites(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get user's favorite rooms"""
|
||||
try:
|
||||
favorites = db.query(Favorite).filter(
|
||||
Favorite.user_id == current_user.id
|
||||
).order_by(Favorite.created_at.desc()).all()
|
||||
|
||||
result = []
|
||||
for favorite in favorites:
|
||||
if not favorite.room:
|
||||
continue
|
||||
|
||||
room = favorite.room
|
||||
|
||||
# Get review stats
|
||||
review_stats = db.query(
|
||||
func.avg(Review.rating).label('average_rating'),
|
||||
func.count(Review.id).label('total_reviews')
|
||||
).filter(
|
||||
Review.room_id == room.id,
|
||||
Review.status == ReviewStatus.approved
|
||||
).first()
|
||||
|
||||
room_dict = {
|
||||
"id": room.id,
|
||||
"room_type_id": room.room_type_id,
|
||||
"room_number": room.room_number,
|
||||
"floor": room.floor,
|
||||
"status": room.status.value if hasattr(room.status, 'value') else room.status,
|
||||
"price": float(room.price) if room.price else 0.0,
|
||||
"featured": room.featured,
|
||||
"description": room.description,
|
||||
"amenities": room.amenities,
|
||||
"images": room.images or [],
|
||||
"average_rating": round(float(review_stats.average_rating or 0), 1) if review_stats and review_stats.average_rating else None,
|
||||
"total_reviews": review_stats.total_reviews or 0 if review_stats else 0,
|
||||
}
|
||||
|
||||
if room.room_type:
|
||||
room_dict["room_type"] = {
|
||||
"id": room.room_type.id,
|
||||
"name": room.room_type.name,
|
||||
"description": room.room_type.description,
|
||||
"base_price": float(room.room_type.base_price) if room.room_type.base_price else 0.0,
|
||||
"capacity": room.room_type.capacity,
|
||||
"amenities": room.room_type.amenities,
|
||||
}
|
||||
|
||||
favorite_dict = {
|
||||
"id": favorite.id,
|
||||
"user_id": favorite.user_id,
|
||||
"room_id": favorite.room_id,
|
||||
"room": room_dict,
|
||||
"created_at": favorite.created_at.isoformat() if favorite.created_at else None,
|
||||
}
|
||||
|
||||
result.append(favorite_dict)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"favorites": result,
|
||||
"total": len(result),
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{room_id}")
|
||||
async def add_favorite(
|
||||
room_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Add room to favorites"""
|
||||
try:
|
||||
# Check if room exists
|
||||
room = db.query(Room).filter(Room.id == room_id).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
# Check if already favorited
|
||||
existing = db.query(Favorite).filter(
|
||||
Favorite.user_id == current_user.id,
|
||||
Favorite.room_id == room_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Room already in favorites list"
|
||||
)
|
||||
|
||||
# Create favorite
|
||||
favorite = Favorite(
|
||||
user_id=current_user.id,
|
||||
room_id=room_id
|
||||
)
|
||||
|
||||
db.add(favorite)
|
||||
db.commit()
|
||||
db.refresh(favorite)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Added to favorites list",
|
||||
"data": {"favorite": favorite}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{room_id}")
|
||||
async def remove_favorite(
|
||||
room_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Remove room from favorites"""
|
||||
try:
|
||||
favorite = db.query(Favorite).filter(
|
||||
Favorite.user_id == current_user.id,
|
||||
Favorite.room_id == room_id
|
||||
).first()
|
||||
|
||||
if not favorite:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Room not found in favorites list"
|
||||
)
|
||||
|
||||
db.delete(favorite)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Removed from favorites list"
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/check/{room_id}")
|
||||
async def check_favorite(
|
||||
room_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Check if room is favorited by user"""
|
||||
try:
|
||||
favorite = db.query(Favorite).filter(
|
||||
Favorite.user_id == current_user.id,
|
||||
Favorite.room_id == room_id
|
||||
).first()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {"isFavorited": favorite is not None}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
228
Backend/src/routes/payment_routes.py
Normal file
228
Backend/src/routes/payment_routes.py
Normal file
@@ -0,0 +1,228 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
|
||||
from ..models.booking import Booking
|
||||
|
||||
router = APIRouter(prefix="/payments", tags=["payments"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_payments(
|
||||
booking_id: Optional[int] = Query(None),
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all payments"""
|
||||
try:
|
||||
query = db.query(Payment)
|
||||
|
||||
# Filter by booking_id
|
||||
if booking_id:
|
||||
query = query.filter(Payment.booking_id == booking_id)
|
||||
|
||||
# Filter by status
|
||||
if status_filter:
|
||||
try:
|
||||
query = query.filter(Payment.payment_status == PaymentStatus(status_filter))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Users can only see their own payments unless admin
|
||||
if current_user.role_id != 1: # Not admin
|
||||
query = query.join(Booking).filter(Booking.user_id == current_user.id)
|
||||
|
||||
total = query.count()
|
||||
offset = (page - 1) * limit
|
||||
payments = query.order_by(Payment.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for payment in payments:
|
||||
payment_dict = {
|
||||
"id": payment.id,
|
||||
"booking_id": payment.booking_id,
|
||||
"amount": float(payment.amount) if payment.amount else 0.0,
|
||||
"payment_method": payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else payment.payment_method,
|
||||
"payment_type": payment.payment_type.value if isinstance(payment.payment_type, PaymentType) else payment.payment_type,
|
||||
"deposit_percentage": payment.deposit_percentage,
|
||||
"related_payment_id": payment.related_payment_id,
|
||||
"payment_status": payment.payment_status.value if isinstance(payment.payment_status, PaymentStatus) else payment.payment_status,
|
||||
"transaction_id": payment.transaction_id,
|
||||
"payment_date": payment.payment_date.isoformat() if payment.payment_date else None,
|
||||
"notes": payment.notes,
|
||||
"created_at": payment.created_at.isoformat() if payment.created_at else None,
|
||||
}
|
||||
|
||||
if payment.booking:
|
||||
payment_dict["booking"] = {
|
||||
"id": payment.booking.id,
|
||||
"booking_number": payment.booking.booking_number,
|
||||
}
|
||||
|
||||
result.append(payment_dict)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"payments": result,
|
||||
"pagination": {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"totalPages": (total + limit - 1) // limit,
|
||||
},
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{id}")
|
||||
async def get_payment_by_id(
|
||||
id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get payment by ID"""
|
||||
try:
|
||||
payment = db.query(Payment).filter(Payment.id == id).first()
|
||||
if not payment:
|
||||
raise HTTPException(status_code=404, detail="Payment not found")
|
||||
|
||||
# Check access
|
||||
if current_user.role_id != 1: # Not admin
|
||||
if payment.booking and payment.booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
payment_dict = {
|
||||
"id": payment.id,
|
||||
"booking_id": payment.booking_id,
|
||||
"amount": float(payment.amount) if payment.amount else 0.0,
|
||||
"payment_method": payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else payment.payment_method,
|
||||
"payment_type": payment.payment_type.value if isinstance(payment.payment_type, PaymentType) else payment.payment_type,
|
||||
"deposit_percentage": payment.deposit_percentage,
|
||||
"related_payment_id": payment.related_payment_id,
|
||||
"payment_status": payment.payment_status.value if isinstance(payment.payment_status, PaymentStatus) else payment.payment_status,
|
||||
"transaction_id": payment.transaction_id,
|
||||
"payment_date": payment.payment_date.isoformat() if payment.payment_date else None,
|
||||
"notes": payment.notes,
|
||||
"created_at": payment.created_at.isoformat() if payment.created_at else None,
|
||||
}
|
||||
|
||||
if payment.booking:
|
||||
payment_dict["booking"] = {
|
||||
"id": payment.booking.id,
|
||||
"booking_number": payment.booking.booking_number,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {"payment": payment_dict}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_payment(
|
||||
payment_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create new payment"""
|
||||
try:
|
||||
booking_id = payment_data.get("booking_id")
|
||||
amount = float(payment_data.get("amount", 0))
|
||||
payment_method = payment_data.get("payment_method", "cash")
|
||||
payment_type = payment_data.get("payment_type", "full")
|
||||
|
||||
# Check if booking exists
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
|
||||
# Check access
|
||||
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
# Create payment
|
||||
payment = Payment(
|
||||
booking_id=booking_id,
|
||||
amount=amount,
|
||||
payment_method=PaymentMethod(payment_method),
|
||||
payment_type=PaymentType(payment_type),
|
||||
payment_status=PaymentStatus.pending,
|
||||
payment_date=datetime.utcnow() if payment_data.get("mark_as_paid") else None,
|
||||
notes=payment_data.get("notes"),
|
||||
)
|
||||
|
||||
# If marked as paid, update status
|
||||
if payment_data.get("mark_as_paid"):
|
||||
payment.payment_status = PaymentStatus.completed
|
||||
|
||||
db.add(payment)
|
||||
db.commit()
|
||||
db.refresh(payment)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Payment created successfully",
|
||||
"data": {"payment": payment}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{id}/status", dependencies=[Depends(authorize_roles("admin", "staff"))])
|
||||
async def update_payment_status(
|
||||
id: int,
|
||||
status_data: dict,
|
||||
current_user: User = Depends(authorize_roles("admin", "staff")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update payment status (Admin/Staff only)"""
|
||||
try:
|
||||
payment = db.query(Payment).filter(Payment.id == id).first()
|
||||
if not payment:
|
||||
raise HTTPException(status_code=404, detail="Payment not found")
|
||||
|
||||
status_value = status_data.get("status")
|
||||
if status_value:
|
||||
try:
|
||||
payment.payment_status = PaymentStatus(status_value)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid payment status")
|
||||
|
||||
if status_data.get("transaction_id"):
|
||||
payment.transaction_id = status_data["transaction_id"]
|
||||
|
||||
if status_data.get("mark_as_paid"):
|
||||
payment.payment_status = PaymentStatus.completed
|
||||
payment.payment_date = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(payment)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Payment status updated successfully",
|
||||
"data": {"payment": payment}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
334
Backend/src/routes/promotion_routes.py
Normal file
334
Backend/src/routes/promotion_routes.py
Normal file
@@ -0,0 +1,334 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.promotion import Promotion, DiscountType
|
||||
|
||||
router = APIRouter(prefix="/promotions", tags=["promotions"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_promotions(
|
||||
search: Optional[str] = Query(None),
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
type: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all promotions with filters"""
|
||||
try:
|
||||
query = db.query(Promotion)
|
||||
|
||||
# Filter by search (code or name)
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Promotion.code.like(f"%{search}%"),
|
||||
Promotion.name.like(f"%{search}%")
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by status (is_active)
|
||||
if status_filter:
|
||||
is_active = status_filter == "active"
|
||||
query = query.filter(Promotion.is_active == is_active)
|
||||
|
||||
# Filter by discount type
|
||||
if type:
|
||||
try:
|
||||
query = query.filter(Promotion.discount_type == DiscountType(type))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
total = query.count()
|
||||
offset = (page - 1) * limit
|
||||
promotions = query.order_by(Promotion.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for promo in promotions:
|
||||
promo_dict = {
|
||||
"id": promo.id,
|
||||
"code": promo.code,
|
||||
"name": promo.name,
|
||||
"description": promo.description,
|
||||
"discount_type": promo.discount_type.value if isinstance(promo.discount_type, DiscountType) else promo.discount_type,
|
||||
"discount_value": float(promo.discount_value) if promo.discount_value else 0.0,
|
||||
"min_booking_amount": float(promo.min_booking_amount) if promo.min_booking_amount else None,
|
||||
"max_discount_amount": float(promo.max_discount_amount) if promo.max_discount_amount else None,
|
||||
"start_date": promo.start_date.isoformat() if promo.start_date else None,
|
||||
"end_date": promo.end_date.isoformat() if promo.end_date else None,
|
||||
"usage_limit": promo.usage_limit,
|
||||
"used_count": promo.used_count,
|
||||
"is_active": promo.is_active,
|
||||
"created_at": promo.created_at.isoformat() if promo.created_at else None,
|
||||
}
|
||||
result.append(promo_dict)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"promotions": result,
|
||||
"pagination": {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"totalPages": (total + limit - 1) // limit,
|
||||
},
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{code}")
|
||||
async def get_promotion_by_code(code: str, db: Session = Depends(get_db)):
|
||||
"""Get promotion by code"""
|
||||
try:
|
||||
promotion = db.query(Promotion).filter(Promotion.code == code).first()
|
||||
if not promotion:
|
||||
raise HTTPException(status_code=404, detail="Promotion not found")
|
||||
|
||||
promo_dict = {
|
||||
"id": promotion.id,
|
||||
"code": promotion.code,
|
||||
"name": promotion.name,
|
||||
"description": promotion.description,
|
||||
"discount_type": promotion.discount_type.value if isinstance(promotion.discount_type, DiscountType) else promotion.discount_type,
|
||||
"discount_value": float(promotion.discount_value) if promotion.discount_value else 0.0,
|
||||
"min_booking_amount": float(promotion.min_booking_amount) if promotion.min_booking_amount else None,
|
||||
"max_discount_amount": float(promotion.max_discount_amount) if promotion.max_discount_amount else None,
|
||||
"start_date": promotion.start_date.isoformat() if promotion.start_date else None,
|
||||
"end_date": promotion.end_date.isoformat() if promotion.end_date else None,
|
||||
"usage_limit": promotion.usage_limit,
|
||||
"used_count": promotion.used_count,
|
||||
"is_active": promotion.is_active,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {"promotion": promo_dict}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/validate")
|
||||
async def validate_promotion(
|
||||
validation_data: dict,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Validate and apply promotion"""
|
||||
try:
|
||||
code = validation_data.get("code")
|
||||
booking_amount = float(validation_data.get("booking_amount", 0))
|
||||
|
||||
promotion = db.query(Promotion).filter(Promotion.code == code).first()
|
||||
if not promotion:
|
||||
raise HTTPException(status_code=404, detail="Promotion code not found")
|
||||
|
||||
# Check if promotion is active
|
||||
if not promotion.is_active:
|
||||
raise HTTPException(status_code=400, detail="Promotion is not active")
|
||||
|
||||
# Check date validity
|
||||
now = datetime.utcnow()
|
||||
if promotion.start_date and now < promotion.start_date:
|
||||
raise HTTPException(status_code=400, detail="Promotion is not valid at this time")
|
||||
if promotion.end_date and now > promotion.end_date:
|
||||
raise HTTPException(status_code=400, detail="Promotion is not valid at this time")
|
||||
|
||||
# Check usage limit
|
||||
if promotion.usage_limit and promotion.used_count >= promotion.usage_limit:
|
||||
raise HTTPException(status_code=400, detail="Promotion usage limit reached")
|
||||
|
||||
# Check minimum booking amount
|
||||
if promotion.min_booking_amount and booking_amount < float(promotion.min_booking_amount):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Minimum booking amount is {promotion.min_booking_amount}"
|
||||
)
|
||||
|
||||
# Calculate discount
|
||||
discount_amount = promotion.calculate_discount(booking_amount)
|
||||
final_amount = booking_amount - discount_amount
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"promotion": {
|
||||
"id": promotion.id,
|
||||
"code": promotion.code,
|
||||
"name": promotion.name,
|
||||
},
|
||||
"original_amount": booking_amount,
|
||||
"discount_amount": discount_amount,
|
||||
"final_amount": final_amount,
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def create_promotion(
|
||||
promotion_data: dict,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create new promotion (Admin only)"""
|
||||
try:
|
||||
code = promotion_data.get("code")
|
||||
|
||||
# Check if code exists
|
||||
existing = db.query(Promotion).filter(Promotion.code == code).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Promotion code already exists")
|
||||
|
||||
discount_type = promotion_data.get("discount_type")
|
||||
discount_value = float(promotion_data.get("discount_value", 0))
|
||||
|
||||
# Validate discount value
|
||||
if discount_type == "percentage" and discount_value > 100:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Percentage discount cannot exceed 100%"
|
||||
)
|
||||
|
||||
promotion = Promotion(
|
||||
code=code,
|
||||
name=promotion_data.get("name"),
|
||||
description=promotion_data.get("description"),
|
||||
discount_type=DiscountType(discount_type),
|
||||
discount_value=discount_value,
|
||||
min_booking_amount=float(promotion_data["min_booking_amount"]) if promotion_data.get("min_booking_amount") else None,
|
||||
max_discount_amount=float(promotion_data["max_discount_amount"]) if promotion_data.get("max_discount_amount") else None,
|
||||
start_date=datetime.fromisoformat(promotion_data["start_date"].replace('Z', '+00:00')) if promotion_data.get("start_date") else None,
|
||||
end_date=datetime.fromisoformat(promotion_data["end_date"].replace('Z', '+00:00')) if promotion_data.get("end_date") else None,
|
||||
usage_limit=promotion_data.get("usage_limit"),
|
||||
used_count=0,
|
||||
is_active=promotion_data.get("status") == "active" if promotion_data.get("status") else True,
|
||||
)
|
||||
|
||||
db.add(promotion)
|
||||
db.commit()
|
||||
db.refresh(promotion)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Promotion created successfully",
|
||||
"data": {"promotion": promotion}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{id}", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def update_promotion(
|
||||
id: int,
|
||||
promotion_data: dict,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update promotion (Admin only)"""
|
||||
try:
|
||||
promotion = db.query(Promotion).filter(Promotion.id == id).first()
|
||||
if not promotion:
|
||||
raise HTTPException(status_code=404, detail="Promotion not found")
|
||||
|
||||
# Check if new code exists (excluding current)
|
||||
code = promotion_data.get("code")
|
||||
if code and code != promotion.code:
|
||||
existing = db.query(Promotion).filter(
|
||||
Promotion.code == code,
|
||||
Promotion.id != id
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Promotion code already exists")
|
||||
|
||||
# Validate discount value
|
||||
discount_type = promotion_data.get("discount_type", promotion.discount_type.value if isinstance(promotion.discount_type, DiscountType) else promotion.discount_type)
|
||||
discount_value = promotion_data.get("discount_value")
|
||||
if discount_value is not None:
|
||||
discount_value = float(discount_value)
|
||||
if discount_type == "percentage" and discount_value > 100:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Percentage discount cannot exceed 100%"
|
||||
)
|
||||
|
||||
# Update fields
|
||||
if "code" in promotion_data:
|
||||
promotion.code = promotion_data["code"]
|
||||
if "name" in promotion_data:
|
||||
promotion.name = promotion_data["name"]
|
||||
if "description" in promotion_data:
|
||||
promotion.description = promotion_data["description"]
|
||||
if "discount_type" in promotion_data:
|
||||
promotion.discount_type = DiscountType(promotion_data["discount_type"])
|
||||
if "discount_value" in promotion_data:
|
||||
promotion.discount_value = discount_value
|
||||
if "min_booking_amount" in promotion_data:
|
||||
promotion.min_booking_amount = float(promotion_data["min_booking_amount"]) if promotion_data["min_booking_amount"] else None
|
||||
if "max_discount_amount" in promotion_data:
|
||||
promotion.max_discount_amount = float(promotion_data["max_discount_amount"]) if promotion_data["max_discount_amount"] else None
|
||||
if "start_date" in promotion_data:
|
||||
promotion.start_date = datetime.fromisoformat(promotion_data["start_date"].replace('Z', '+00:00')) if promotion_data["start_date"] else None
|
||||
if "end_date" in promotion_data:
|
||||
promotion.end_date = datetime.fromisoformat(promotion_data["end_date"].replace('Z', '+00:00')) if promotion_data["end_date"] else None
|
||||
if "usage_limit" in promotion_data:
|
||||
promotion.usage_limit = promotion_data["usage_limit"]
|
||||
if "status" in promotion_data:
|
||||
promotion.is_active = promotion_data["status"] == "active"
|
||||
|
||||
db.commit()
|
||||
db.refresh(promotion)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Promotion updated successfully",
|
||||
"data": {"promotion": promotion}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{id}", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def delete_promotion(
|
||||
id: int,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete promotion (Admin only)"""
|
||||
try:
|
||||
promotion = db.query(Promotion).filter(Promotion.id == id).first()
|
||||
if not promotion:
|
||||
raise HTTPException(status_code=404, detail="Promotion not found")
|
||||
|
||||
db.delete(promotion)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Promotion deleted successfully"
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
288
Backend/src/routes/report_routes.py
Normal file
288
Backend/src/routes/report_routes.py
Normal file
@@ -0,0 +1,288 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.booking import Booking, BookingStatus
|
||||
from ..models.payment import Payment, PaymentStatus
|
||||
from ..models.room import Room
|
||||
|
||||
router = APIRouter(prefix="/reports", tags=["reports"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_reports(
|
||||
from_date: Optional[str] = Query(None, alias="from"),
|
||||
to_date: Optional[str] = Query(None, alias="to"),
|
||||
type: Optional[str] = Query(None),
|
||||
current_user: User = Depends(authorize_roles("admin", "staff")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get comprehensive reports (Admin/Staff only)"""
|
||||
try:
|
||||
# Parse dates if provided
|
||||
start_date = None
|
||||
end_date = None
|
||||
if from_date:
|
||||
try:
|
||||
start_date = datetime.strptime(from_date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
start_date = datetime.fromisoformat(from_date.replace('Z', '+00:00'))
|
||||
if to_date:
|
||||
try:
|
||||
end_date = datetime.strptime(to_date, "%Y-%m-%d")
|
||||
# Set to end of day
|
||||
end_date = end_date.replace(hour=23, minute=59, second=59)
|
||||
except ValueError:
|
||||
end_date = datetime.fromisoformat(to_date.replace('Z', '+00:00'))
|
||||
|
||||
# Base queries
|
||||
booking_query = db.query(Booking)
|
||||
payment_query = db.query(Payment).filter(Payment.payment_status == PaymentStatus.completed)
|
||||
|
||||
# Apply date filters
|
||||
if start_date:
|
||||
booking_query = booking_query.filter(Booking.created_at >= start_date)
|
||||
payment_query = payment_query.filter(Payment.payment_date >= start_date)
|
||||
if end_date:
|
||||
booking_query = booking_query.filter(Booking.created_at <= end_date)
|
||||
payment_query = payment_query.filter(Payment.payment_date <= end_date)
|
||||
|
||||
# Total bookings
|
||||
total_bookings = booking_query.count()
|
||||
|
||||
# Total revenue
|
||||
total_revenue = payment_query.with_entities(func.sum(Payment.amount)).scalar() or 0.0
|
||||
|
||||
# Total customers (unique users with bookings)
|
||||
total_customers = db.query(func.count(func.distinct(Booking.user_id))).scalar() or 0
|
||||
if start_date or end_date:
|
||||
customer_query = db.query(func.count(func.distinct(Booking.user_id)))
|
||||
if start_date:
|
||||
customer_query = customer_query.filter(Booking.created_at >= start_date)
|
||||
if end_date:
|
||||
customer_query = customer_query.filter(Booking.created_at <= end_date)
|
||||
total_customers = customer_query.scalar() or 0
|
||||
|
||||
# Available rooms
|
||||
available_rooms = db.query(Room).filter(Room.status == "available").count()
|
||||
|
||||
# Occupied rooms (rooms with active bookings)
|
||||
occupied_rooms = db.query(func.count(func.distinct(Booking.room_id))).filter(
|
||||
Booking.status.in_([BookingStatus.confirmed, BookingStatus.checked_in])
|
||||
).scalar() or 0
|
||||
|
||||
# Revenue by date (daily breakdown)
|
||||
revenue_by_date = []
|
||||
if start_date and end_date:
|
||||
daily_revenue_query = db.query(
|
||||
func.date(Payment.payment_date).label('date'),
|
||||
func.sum(Payment.amount).label('revenue'),
|
||||
func.count(func.distinct(Payment.booking_id)).label('bookings')
|
||||
).filter(Payment.payment_status == PaymentStatus.completed)
|
||||
|
||||
if start_date:
|
||||
daily_revenue_query = daily_revenue_query.filter(Payment.payment_date >= start_date)
|
||||
if end_date:
|
||||
daily_revenue_query = daily_revenue_query.filter(Payment.payment_date <= end_date)
|
||||
|
||||
daily_revenue_query = daily_revenue_query.group_by(
|
||||
func.date(Payment.payment_date)
|
||||
).order_by(func.date(Payment.payment_date))
|
||||
|
||||
daily_data = daily_revenue_query.all()
|
||||
revenue_by_date = [
|
||||
{
|
||||
"date": str(date),
|
||||
"revenue": float(revenue or 0),
|
||||
"bookings": int(bookings or 0)
|
||||
}
|
||||
for date, revenue, bookings in daily_data
|
||||
]
|
||||
|
||||
# Bookings by status
|
||||
bookings_by_status = {}
|
||||
for status in BookingStatus:
|
||||
count = booking_query.filter(Booking.status == status).count()
|
||||
status_name = status.value if hasattr(status, 'value') else str(status)
|
||||
bookings_by_status[status_name] = count
|
||||
|
||||
# Top rooms (by revenue)
|
||||
top_rooms_query = db.query(
|
||||
Room.id,
|
||||
Room.room_number,
|
||||
func.count(Booking.id).label('bookings'),
|
||||
func.sum(Payment.amount).label('revenue')
|
||||
).join(Booking, Room.id == Booking.room_id).join(
|
||||
Payment, Booking.id == Payment.booking_id
|
||||
).filter(Payment.payment_status == PaymentStatus.completed)
|
||||
|
||||
if start_date:
|
||||
top_rooms_query = top_rooms_query.filter(Booking.created_at >= start_date)
|
||||
if end_date:
|
||||
top_rooms_query = top_rooms_query.filter(Booking.created_at <= end_date)
|
||||
|
||||
top_rooms_data = top_rooms_query.group_by(Room.id, Room.room_number).order_by(
|
||||
func.sum(Payment.amount).desc()
|
||||
).limit(10).all()
|
||||
|
||||
top_rooms = [
|
||||
{
|
||||
"room_id": room_id,
|
||||
"room_number": room_number,
|
||||
"bookings": int(bookings or 0),
|
||||
"revenue": float(revenue or 0)
|
||||
}
|
||||
for room_id, room_number, bookings, revenue in top_rooms_data
|
||||
]
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"success": True,
|
||||
"data": {
|
||||
"total_bookings": total_bookings,
|
||||
"total_revenue": float(total_revenue),
|
||||
"total_customers": int(total_customers),
|
||||
"available_rooms": available_rooms,
|
||||
"occupied_rooms": occupied_rooms,
|
||||
"revenue_by_date": revenue_by_date if revenue_by_date else None,
|
||||
"bookings_by_status": bookings_by_status,
|
||||
"top_rooms": top_rooms if top_rooms else None,
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def get_dashboard_stats(
|
||||
current_user: User = Depends(authorize_roles("admin", "staff")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get dashboard statistics (Admin/Staff only)"""
|
||||
try:
|
||||
# Total bookings
|
||||
total_bookings = db.query(Booking).count()
|
||||
|
||||
# Active bookings
|
||||
active_bookings = db.query(Booking).filter(
|
||||
Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])
|
||||
).count()
|
||||
|
||||
# Total revenue (from completed payments)
|
||||
total_revenue = db.query(func.sum(Payment.amount)).filter(
|
||||
Payment.payment_status == PaymentStatus.completed
|
||||
).scalar() or 0.0
|
||||
|
||||
# Today's revenue
|
||||
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_revenue = db.query(func.sum(Payment.amount)).filter(
|
||||
and_(
|
||||
Payment.payment_status == PaymentStatus.completed,
|
||||
Payment.payment_date >= today_start
|
||||
)
|
||||
).scalar() or 0.0
|
||||
|
||||
# Total rooms
|
||||
total_rooms = db.query(Room).count()
|
||||
|
||||
# Available rooms
|
||||
available_rooms = db.query(Room).filter(Room.status == "available").count()
|
||||
|
||||
# Recent bookings (last 7 days)
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
recent_bookings = db.query(Booking).filter(
|
||||
Booking.created_at >= week_ago
|
||||
).count()
|
||||
|
||||
# Pending payments
|
||||
pending_payments = db.query(Payment).filter(
|
||||
Payment.payment_status == PaymentStatus.pending
|
||||
).count()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"total_bookings": total_bookings,
|
||||
"active_bookings": active_bookings,
|
||||
"total_revenue": float(total_revenue),
|
||||
"today_revenue": float(today_revenue),
|
||||
"total_rooms": total_rooms,
|
||||
"available_rooms": available_rooms,
|
||||
"recent_bookings": recent_bookings,
|
||||
"pending_payments": pending_payments,
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/revenue")
|
||||
async def get_revenue_report(
|
||||
start_date: Optional[str] = Query(None),
|
||||
end_date: Optional[str] = Query(None),
|
||||
current_user: User = Depends(authorize_roles("admin", "staff")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get revenue report (Admin/Staff only)"""
|
||||
try:
|
||||
query = db.query(Payment).filter(
|
||||
Payment.payment_status == PaymentStatus.completed
|
||||
)
|
||||
|
||||
if start_date:
|
||||
start = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
||||
query = query.filter(Payment.payment_date >= start)
|
||||
|
||||
if end_date:
|
||||
end = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||||
query = query.filter(Payment.payment_date <= end)
|
||||
|
||||
# Total revenue
|
||||
total_revenue = db.query(func.sum(Payment.amount)).filter(
|
||||
Payment.payment_status == PaymentStatus.completed
|
||||
).scalar() or 0.0
|
||||
|
||||
# Revenue by payment method
|
||||
revenue_by_method = db.query(
|
||||
Payment.payment_method,
|
||||
func.sum(Payment.amount).label('total')
|
||||
).filter(
|
||||
Payment.payment_status == PaymentStatus.completed
|
||||
).group_by(Payment.payment_method).all()
|
||||
|
||||
method_breakdown = {}
|
||||
for method, total in revenue_by_method:
|
||||
method_name = method.value if hasattr(method, 'value') else str(method)
|
||||
method_breakdown[method_name] = float(total or 0)
|
||||
|
||||
# Revenue by date (daily breakdown)
|
||||
daily_revenue = db.query(
|
||||
func.date(Payment.payment_date).label('date'),
|
||||
func.sum(Payment.amount).label('total')
|
||||
).filter(
|
||||
Payment.payment_status == PaymentStatus.completed
|
||||
).group_by(func.date(Payment.payment_date)).order_by(func.date(Payment.payment_date).desc()).limit(30).all()
|
||||
|
||||
daily_breakdown = [
|
||||
{
|
||||
"date": date.isoformat() if isinstance(date, datetime) else str(date),
|
||||
"revenue": float(total or 0)
|
||||
}
|
||||
for date, total in daily_revenue
|
||||
]
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"total_revenue": float(total_revenue),
|
||||
"revenue_by_method": method_breakdown,
|
||||
"daily_breakdown": daily_breakdown,
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
251
Backend/src/routes/review_routes.py
Normal file
251
Backend/src/routes/review_routes.py
Normal file
@@ -0,0 +1,251 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.review import Review, ReviewStatus
|
||||
from ..models.room import Room
|
||||
|
||||
router = APIRouter(prefix="/reviews", tags=["reviews"])
|
||||
|
||||
|
||||
@router.get("/room/{room_id}")
|
||||
async def get_room_reviews(room_id: int, db: Session = Depends(get_db)):
|
||||
"""Get reviews for a room"""
|
||||
try:
|
||||
reviews = db.query(Review).filter(
|
||||
Review.room_id == room_id,
|
||||
Review.status == ReviewStatus.approved
|
||||
).order_by(Review.created_at.desc()).all()
|
||||
|
||||
result = []
|
||||
for review in reviews:
|
||||
review_dict = {
|
||||
"id": review.id,
|
||||
"user_id": review.user_id,
|
||||
"room_id": review.room_id,
|
||||
"rating": review.rating,
|
||||
"comment": review.comment,
|
||||
"status": review.status.value if isinstance(review.status, ReviewStatus) else review.status,
|
||||
"created_at": review.created_at.isoformat() if review.created_at else None,
|
||||
}
|
||||
|
||||
if review.user:
|
||||
review_dict["user"] = {
|
||||
"id": review.user.id,
|
||||
"full_name": review.user.full_name,
|
||||
"email": review.user.email,
|
||||
}
|
||||
|
||||
result.append(review_dict)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {"reviews": result}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def get_all_reviews(
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all reviews (Admin only)"""
|
||||
try:
|
||||
query = db.query(Review)
|
||||
|
||||
if status_filter:
|
||||
try:
|
||||
query = query.filter(Review.status == ReviewStatus(status_filter))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
total = query.count()
|
||||
offset = (page - 1) * limit
|
||||
reviews = query.order_by(Review.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for review in reviews:
|
||||
review_dict = {
|
||||
"id": review.id,
|
||||
"user_id": review.user_id,
|
||||
"room_id": review.room_id,
|
||||
"rating": review.rating,
|
||||
"comment": review.comment,
|
||||
"status": review.status.value if isinstance(review.status, ReviewStatus) else review.status,
|
||||
"created_at": review.created_at.isoformat() if review.created_at else None,
|
||||
}
|
||||
|
||||
if review.user:
|
||||
review_dict["user"] = {
|
||||
"id": review.user.id,
|
||||
"full_name": review.user.full_name,
|
||||
"email": review.user.email,
|
||||
"phone": review.user.phone,
|
||||
}
|
||||
|
||||
if review.room:
|
||||
review_dict["room"] = {
|
||||
"id": review.room.id,
|
||||
"room_number": review.room.room_number,
|
||||
}
|
||||
|
||||
result.append(review_dict)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"reviews": result,
|
||||
"pagination": {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"totalPages": (total + limit - 1) // limit,
|
||||
},
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_review(
|
||||
review_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create new review"""
|
||||
try:
|
||||
room_id = review_data.get("room_id")
|
||||
rating = review_data.get("rating")
|
||||
comment = review_data.get("comment")
|
||||
|
||||
# Check if room exists
|
||||
room = db.query(Room).filter(Room.id == room_id).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
# Check if user already reviewed this room
|
||||
existing = db.query(Review).filter(
|
||||
Review.user_id == current_user.id,
|
||||
Review.room_id == room_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="You have already reviewed this room"
|
||||
)
|
||||
|
||||
# Create review
|
||||
review = Review(
|
||||
user_id=current_user.id,
|
||||
room_id=room_id,
|
||||
rating=rating,
|
||||
comment=comment,
|
||||
status=ReviewStatus.pending,
|
||||
)
|
||||
|
||||
db.add(review)
|
||||
db.commit()
|
||||
db.refresh(review)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Review submitted successfully and is pending approval",
|
||||
"data": {"review": review}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{id}/approve", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def approve_review(
|
||||
id: int,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Approve review (Admin only)"""
|
||||
try:
|
||||
review = db.query(Review).filter(Review.id == id).first()
|
||||
if not review:
|
||||
raise HTTPException(status_code=404, detail="Review not found")
|
||||
|
||||
review.status = ReviewStatus.approved
|
||||
db.commit()
|
||||
db.refresh(review)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Review approved successfully",
|
||||
"data": {"review": review}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{id}/reject", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def reject_review(
|
||||
id: int,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Reject review (Admin only)"""
|
||||
try:
|
||||
review = db.query(Review).filter(Review.id == id).first()
|
||||
if not review:
|
||||
raise HTTPException(status_code=404, detail="Review not found")
|
||||
|
||||
review.status = ReviewStatus.rejected
|
||||
db.commit()
|
||||
db.refresh(review)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Review rejected successfully",
|
||||
"data": {"review": review}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{id}", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def delete_review(
|
||||
id: int,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete review (Admin only)"""
|
||||
try:
|
||||
review = db.query(Review).filter(Review.id == id).first()
|
||||
if not review:
|
||||
raise HTTPException(status_code=404, detail="Review not found")
|
||||
|
||||
db.delete(review)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Review deleted successfully"
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
517
Backend/src/routes/room_routes.py
Normal file
517
Backend/src/routes/room_routes.py
Normal file
@@ -0,0 +1,517 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Request, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_, func
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.room import Room, RoomStatus
|
||||
from ..models.room_type import RoomType
|
||||
from ..models.review import Review, ReviewStatus
|
||||
from ..models.booking import Booking, BookingStatus
|
||||
from ..services.room_service import get_rooms_with_ratings, get_amenities_list, normalize_images, get_base_url
|
||||
import os
|
||||
import aiofiles
|
||||
from pathlib import Path
|
||||
|
||||
router = APIRouter(prefix="/rooms", tags=["rooms"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_rooms(
|
||||
request: Request,
|
||||
type: Optional[str] = Query(None),
|
||||
minPrice: Optional[float] = Query(None),
|
||||
maxPrice: Optional[float] = Query(None),
|
||||
capacity: Optional[int] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
sort: Optional[str] = Query(None),
|
||||
featured: Optional[bool] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all rooms with filters"""
|
||||
try:
|
||||
# Build where clause for rooms
|
||||
where_clause = {}
|
||||
room_type_where = {}
|
||||
|
||||
if featured is not None:
|
||||
where_clause["featured"] = featured
|
||||
|
||||
if type:
|
||||
room_type_where["name"] = f"%{type}%"
|
||||
|
||||
if capacity:
|
||||
room_type_where["capacity"] = capacity
|
||||
|
||||
if minPrice or maxPrice:
|
||||
if minPrice:
|
||||
room_type_where["base_price_min"] = minPrice
|
||||
if maxPrice:
|
||||
room_type_where["base_price_max"] = maxPrice
|
||||
|
||||
# Build query
|
||||
query = db.query(Room).join(RoomType)
|
||||
|
||||
# Apply filters
|
||||
if where_clause.get("featured") is not None:
|
||||
query = query.filter(Room.featured == where_clause["featured"])
|
||||
|
||||
if room_type_where.get("name"):
|
||||
query = query.filter(RoomType.name.like(room_type_where["name"]))
|
||||
|
||||
if room_type_where.get("capacity"):
|
||||
query = query.filter(RoomType.capacity >= room_type_where["capacity"])
|
||||
|
||||
if room_type_where.get("base_price_min"):
|
||||
query = query.filter(RoomType.base_price >= room_type_where["base_price_min"])
|
||||
|
||||
if room_type_where.get("base_price_max"):
|
||||
query = query.filter(RoomType.base_price <= room_type_where["base_price_max"])
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Apply sorting
|
||||
if sort == "newest" or sort == "created_at":
|
||||
query = query.order_by(Room.created_at.desc())
|
||||
else:
|
||||
query = query.order_by(Room.featured.desc(), Room.created_at.desc())
|
||||
|
||||
# Apply pagination
|
||||
offset = (page - 1) * limit
|
||||
rooms = query.offset(offset).limit(limit).all()
|
||||
|
||||
# Get base URL
|
||||
base_url = get_base_url(request)
|
||||
|
||||
# Get rooms with ratings
|
||||
rooms_with_ratings = await get_rooms_with_ratings(db, rooms, base_url)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"rooms": rooms_with_ratings,
|
||||
"pagination": {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"totalPages": (total + limit - 1) // limit,
|
||||
},
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/amenities")
|
||||
async def get_amenities(db: Session = Depends(get_db)):
|
||||
"""Get all available amenities"""
|
||||
try:
|
||||
amenities = await get_amenities_list(db)
|
||||
return {"status": "success", "data": {"amenities": amenities}}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/available")
|
||||
async def search_available_rooms(
|
||||
request: Request,
|
||||
from_date: str = Query(..., alias="from"),
|
||||
to_date: str = Query(..., alias="to"),
|
||||
type: Optional[str] = Query(None),
|
||||
capacity: Optional[int] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(12, ge=1, le=100),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Search for available rooms"""
|
||||
try:
|
||||
check_in = datetime.fromisoformat(from_date.replace('Z', '+00:00'))
|
||||
check_out = datetime.fromisoformat(to_date.replace('Z', '+00:00'))
|
||||
|
||||
if check_in >= check_out:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Check-out date must be after check-in date"
|
||||
)
|
||||
|
||||
# Build room type filter
|
||||
query = db.query(Room).join(RoomType).filter(Room.status == RoomStatus.available)
|
||||
|
||||
if type:
|
||||
query = query.filter(RoomType.name.like(f"%{type}%"))
|
||||
|
||||
if capacity:
|
||||
query = query.filter(RoomType.capacity >= capacity)
|
||||
|
||||
# Exclude rooms with overlapping bookings
|
||||
overlapping_rooms = db.query(Booking.room_id).filter(
|
||||
and_(
|
||||
Booking.status != BookingStatus.cancelled,
|
||||
Booking.check_in_date < check_out,
|
||||
Booking.check_out_date > check_in
|
||||
)
|
||||
).subquery()
|
||||
|
||||
query = query.filter(~Room.id.in_(db.query(overlapping_rooms.c.room_id)))
|
||||
|
||||
# Get total
|
||||
total = query.count()
|
||||
|
||||
# Apply sorting and pagination
|
||||
query = query.order_by(Room.featured.desc(), Room.created_at.desc())
|
||||
offset = (page - 1) * limit
|
||||
rooms = query.offset(offset).limit(limit).all()
|
||||
|
||||
# Get base URL
|
||||
base_url = get_base_url(request)
|
||||
|
||||
# Get rooms with ratings
|
||||
rooms_with_ratings = await get_rooms_with_ratings(db, rooms, base_url)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"rooms": rooms_with_ratings,
|
||||
"search": {
|
||||
"from": from_date,
|
||||
"to": to_date,
|
||||
"type": type,
|
||||
"capacity": capacity,
|
||||
},
|
||||
"pagination": {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"totalPages": (total + limit - 1) // limit,
|
||||
},
|
||||
},
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{id}")
|
||||
async def get_room_by_id(id: int, request: Request, db: Session = Depends(get_db)):
|
||||
"""Get room by ID"""
|
||||
try:
|
||||
room = db.query(Room).filter(Room.id == id).first()
|
||||
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
# Get review stats
|
||||
review_stats = db.query(
|
||||
func.avg(Review.rating).label('average_rating'),
|
||||
func.count(Review.id).label('total_reviews')
|
||||
).filter(
|
||||
and_(
|
||||
Review.room_id == room.id,
|
||||
Review.status == ReviewStatus.approved
|
||||
)
|
||||
).first()
|
||||
|
||||
base_url = get_base_url(request)
|
||||
|
||||
room_dict = {
|
||||
"id": room.id,
|
||||
"room_type_id": room.room_type_id,
|
||||
"room_number": room.room_number,
|
||||
"floor": room.floor,
|
||||
"status": room.status.value if isinstance(room.status, RoomStatus) else room.status,
|
||||
"price": float(room.price) if room.price else 0.0,
|
||||
"featured": room.featured,
|
||||
"description": room.description,
|
||||
"amenities": room.amenities,
|
||||
"created_at": room.created_at.isoformat() if room.created_at else None,
|
||||
"updated_at": room.updated_at.isoformat() if room.updated_at else None,
|
||||
"average_rating": round(float(review_stats.average_rating or 0), 1) if review_stats and review_stats.average_rating else None,
|
||||
"total_reviews": review_stats.total_reviews or 0 if review_stats else 0,
|
||||
}
|
||||
|
||||
# Normalize images
|
||||
try:
|
||||
room_dict["images"] = normalize_images(room.images, base_url)
|
||||
except:
|
||||
room_dict["images"] = []
|
||||
|
||||
# Add room type
|
||||
if room.room_type:
|
||||
room_dict["room_type"] = {
|
||||
"id": room.room_type.id,
|
||||
"name": room.room_type.name,
|
||||
"description": room.room_type.description,
|
||||
"base_price": float(room.room_type.base_price) if room.room_type.base_price else 0.0,
|
||||
"capacity": room.room_type.capacity,
|
||||
"amenities": room.room_type.amenities,
|
||||
"images": [] # RoomType doesn't have images column in DB
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {"room": room_dict}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def create_room(
|
||||
room_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create new room (Admin only)"""
|
||||
try:
|
||||
# Check if room type exists
|
||||
room_type = db.query(RoomType).filter(RoomType.id == room_data.get("room_type_id")).first()
|
||||
if not room_type:
|
||||
raise HTTPException(status_code=404, detail="Room type not found")
|
||||
|
||||
# Check if room number exists
|
||||
existing = db.query(Room).filter(Room.room_number == room_data.get("room_number")).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Room number already exists")
|
||||
|
||||
room = Room(
|
||||
room_type_id=room_data.get("room_type_id"),
|
||||
room_number=room_data.get("room_number"),
|
||||
floor=room_data.get("floor"),
|
||||
status=RoomStatus(room_data.get("status", "available")),
|
||||
featured=room_data.get("featured", False),
|
||||
price=room_data.get("price", room_type.base_price),
|
||||
)
|
||||
|
||||
db.add(room)
|
||||
db.commit()
|
||||
db.refresh(room)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Room created successfully",
|
||||
"data": {"room": room}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{id}", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def update_room(
|
||||
id: int,
|
||||
room_data: dict,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update room (Admin only)"""
|
||||
try:
|
||||
room = db.query(Room).filter(Room.id == id).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
if room_data.get("room_type_id"):
|
||||
room_type = db.query(RoomType).filter(RoomType.id == room_data["room_type_id"]).first()
|
||||
if not room_type:
|
||||
raise HTTPException(status_code=404, detail="Room type not found")
|
||||
|
||||
# Update fields
|
||||
if "room_type_id" in room_data:
|
||||
room.room_type_id = room_data["room_type_id"]
|
||||
if "room_number" in room_data:
|
||||
room.room_number = room_data["room_number"]
|
||||
if "floor" in room_data:
|
||||
room.floor = room_data["floor"]
|
||||
if "status" in room_data:
|
||||
room.status = RoomStatus(room_data["status"])
|
||||
if "featured" in room_data:
|
||||
room.featured = room_data["featured"]
|
||||
if "price" in room_data:
|
||||
room.price = room_data["price"]
|
||||
|
||||
db.commit()
|
||||
db.refresh(room)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Room updated successfully",
|
||||
"data": {"room": room}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{id}", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def delete_room(
|
||||
id: int,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete room (Admin only)"""
|
||||
try:
|
||||
room = db.query(Room).filter(Room.id == id).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
db.delete(room)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Room deleted successfully"
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{id}/images", dependencies=[Depends(authorize_roles("admin", "staff"))])
|
||||
async def upload_room_images(
|
||||
id: int,
|
||||
images: List[UploadFile] = File(...),
|
||||
current_user: User = Depends(authorize_roles("admin", "staff")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Upload room images (Admin/Staff only)"""
|
||||
try:
|
||||
room = db.query(Room).filter(Room.id == id).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
# Create uploads directory
|
||||
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "rooms"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
image_urls = []
|
||||
for image in images:
|
||||
# Validate file type
|
||||
if not image.content_type.startswith('image/'):
|
||||
continue
|
||||
|
||||
# Generate filename
|
||||
import uuid
|
||||
ext = Path(image.filename).suffix
|
||||
filename = f"room-{uuid.uuid4()}{ext}"
|
||||
file_path = upload_dir / filename
|
||||
|
||||
# Save file
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
content = await image.read()
|
||||
await f.write(content)
|
||||
|
||||
image_urls.append(f"/uploads/rooms/{filename}")
|
||||
|
||||
# Update room images (images are stored on Room, not RoomType)
|
||||
existing_images = room.images or []
|
||||
updated_images = existing_images + image_urls
|
||||
room.images = updated_images
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Images uploaded successfully",
|
||||
"data": {"images": updated_images}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{id}/images", dependencies=[Depends(authorize_roles("admin", "staff"))])
|
||||
async def delete_room_images(
|
||||
id: int,
|
||||
image_url: str,
|
||||
current_user: User = Depends(authorize_roles("admin", "staff")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete room images (Admin/Staff only)"""
|
||||
try:
|
||||
room = db.query(Room).filter(Room.id == id).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
# Update room images (images are stored on Room, not RoomType)
|
||||
existing_images = room.images or []
|
||||
updated_images = [img for img in existing_images if img != image_url]
|
||||
|
||||
# Delete file from disk
|
||||
filename = Path(image_url).name
|
||||
file_path = Path(__file__).parent.parent.parent / "uploads" / "rooms" / filename
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
|
||||
room.images = updated_images
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Image deleted successfully",
|
||||
"data": {"images": updated_images}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{id}/reviews")
|
||||
async def get_room_reviews_route(
|
||||
id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get reviews for a specific room"""
|
||||
from ..models.review import Review, ReviewStatus
|
||||
try:
|
||||
room = db.query(Room).filter(Room.id == id).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
reviews = db.query(Review).filter(
|
||||
Review.room_id == id,
|
||||
Review.status == ReviewStatus.approved
|
||||
).order_by(Review.created_at.desc()).all()
|
||||
|
||||
result = []
|
||||
for review in reviews:
|
||||
review_dict = {
|
||||
"id": review.id,
|
||||
"user_id": review.user_id,
|
||||
"room_id": review.room_id,
|
||||
"rating": review.rating,
|
||||
"comment": review.comment,
|
||||
"status": review.status.value if isinstance(review.status, ReviewStatus) else review.status,
|
||||
"created_at": review.created_at.isoformat() if review.created_at else None,
|
||||
}
|
||||
if review.user:
|
||||
review_dict["user"] = {
|
||||
"id": review.user.id,
|
||||
"full_name": review.user.full_name,
|
||||
"email": review.user.email,
|
||||
}
|
||||
result.append(review_dict)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {"reviews": result}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
277
Backend/src/routes/service_routes.py
Normal file
277
Backend/src/routes/service_routes.py
Normal file
@@ -0,0 +1,277 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
from typing import Optional
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.service import Service
|
||||
from ..models.service_usage import ServiceUsage
|
||||
from ..models.booking import Booking, BookingStatus
|
||||
|
||||
router = APIRouter(prefix="/services", tags=["services"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_services(
|
||||
search: Optional[str] = Query(None),
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all services with filters"""
|
||||
try:
|
||||
query = db.query(Service)
|
||||
|
||||
# Filter by search (name or description)
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Service.name.like(f"%{search}%"),
|
||||
Service.description.like(f"%{search}%")
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by status (is_active)
|
||||
if status_filter:
|
||||
is_active = status_filter == "active"
|
||||
query = query.filter(Service.is_active == is_active)
|
||||
|
||||
total = query.count()
|
||||
offset = (page - 1) * limit
|
||||
services = query.order_by(Service.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for service in services:
|
||||
service_dict = {
|
||||
"id": service.id,
|
||||
"name": service.name,
|
||||
"description": service.description,
|
||||
"price": float(service.price) if service.price else 0.0,
|
||||
"category": service.category,
|
||||
"is_active": service.is_active,
|
||||
"created_at": service.created_at.isoformat() if service.created_at else None,
|
||||
}
|
||||
result.append(service_dict)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"services": result,
|
||||
"pagination": {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"totalPages": (total + limit - 1) // limit,
|
||||
},
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{id}")
|
||||
async def get_service_by_id(id: int, db: Session = Depends(get_db)):
|
||||
"""Get service by ID"""
|
||||
try:
|
||||
service = db.query(Service).filter(Service.id == id).first()
|
||||
if not service:
|
||||
raise HTTPException(status_code=404, detail="Service not found")
|
||||
|
||||
service_dict = {
|
||||
"id": service.id,
|
||||
"name": service.name,
|
||||
"description": service.description,
|
||||
"price": float(service.price) if service.price else 0.0,
|
||||
"category": service.category,
|
||||
"is_active": service.is_active,
|
||||
"created_at": service.created_at.isoformat() if service.created_at else None,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {"service": service_dict}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def create_service(
|
||||
service_data: dict,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create new service (Admin only)"""
|
||||
try:
|
||||
name = service_data.get("name")
|
||||
|
||||
# Check if name exists
|
||||
existing = db.query(Service).filter(Service.name == name).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Service name already exists")
|
||||
|
||||
service = Service(
|
||||
name=name,
|
||||
description=service_data.get("description"),
|
||||
price=float(service_data.get("price", 0)),
|
||||
category=service_data.get("category"),
|
||||
is_active=service_data.get("status") == "active" if service_data.get("status") else True,
|
||||
)
|
||||
|
||||
db.add(service)
|
||||
db.commit()
|
||||
db.refresh(service)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Service created successfully",
|
||||
"data": {"service": service}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{id}", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def update_service(
|
||||
id: int,
|
||||
service_data: dict,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update service (Admin only)"""
|
||||
try:
|
||||
service = db.query(Service).filter(Service.id == id).first()
|
||||
if not service:
|
||||
raise HTTPException(status_code=404, detail="Service not found")
|
||||
|
||||
# Check if new name exists (excluding current)
|
||||
name = service_data.get("name")
|
||||
if name and name != service.name:
|
||||
existing = db.query(Service).filter(
|
||||
Service.name == name,
|
||||
Service.id != id
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Service name already exists")
|
||||
|
||||
# Update fields
|
||||
if "name" in service_data:
|
||||
service.name = service_data["name"]
|
||||
if "description" in service_data:
|
||||
service.description = service_data["description"]
|
||||
if "price" in service_data:
|
||||
service.price = float(service_data["price"])
|
||||
if "category" in service_data:
|
||||
service.category = service_data["category"]
|
||||
if "status" in service_data:
|
||||
service.is_active = service_data["status"] == "active"
|
||||
|
||||
db.commit()
|
||||
db.refresh(service)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Service updated successfully",
|
||||
"data": {"service": service}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{id}", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def delete_service(
|
||||
id: int,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete service (Admin only)"""
|
||||
try:
|
||||
service = db.query(Service).filter(Service.id == id).first()
|
||||
if not service:
|
||||
raise HTTPException(status_code=404, detail="Service not found")
|
||||
|
||||
# Check if service is used in active bookings
|
||||
active_usage = db.query(ServiceUsage).join(Booking).filter(
|
||||
ServiceUsage.service_id == id,
|
||||
Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])
|
||||
).count()
|
||||
|
||||
if active_usage > 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete service that is used in active bookings"
|
||||
)
|
||||
|
||||
db.delete(service)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Service deleted successfully"
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/use")
|
||||
async def use_service(
|
||||
usage_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Add service to booking"""
|
||||
try:
|
||||
booking_id = usage_data.get("booking_id")
|
||||
service_id = usage_data.get("service_id")
|
||||
quantity = usage_data.get("quantity", 1)
|
||||
|
||||
# Check if booking exists
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
|
||||
# Check if service exists and is active
|
||||
service = db.query(Service).filter(Service.id == service_id).first()
|
||||
if not service or not service.is_active:
|
||||
raise HTTPException(status_code=404, detail="Service not found or inactive")
|
||||
|
||||
# Calculate total price
|
||||
total_price = float(service.price) * quantity
|
||||
|
||||
# Create service usage
|
||||
service_usage = ServiceUsage(
|
||||
booking_id=booking_id,
|
||||
service_id=service_id,
|
||||
quantity=quantity,
|
||||
unit_price=service.price,
|
||||
total_price=total_price,
|
||||
)
|
||||
|
||||
db.add(service_usage)
|
||||
db.commit()
|
||||
db.refresh(service_usage)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Service added to booking successfully",
|
||||
"data": {"bookingService": service_usage}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
317
Backend/src/routes/user_routes.py
Normal file
317
Backend/src/routes/user_routes.py
Normal file
@@ -0,0 +1,317 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
from typing import Optional
|
||||
import bcrypt
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.role import Role
|
||||
from ..models.booking import Booking, BookingStatus
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get("/", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def get_users(
|
||||
search: Optional[str] = Query(None),
|
||||
role: Optional[str] = Query(None),
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all users with filters and pagination (Admin only)"""
|
||||
try:
|
||||
query = db.query(User)
|
||||
|
||||
# Filter by search (full_name, email, phone)
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
User.full_name.like(f"%{search}%"),
|
||||
User.email.like(f"%{search}%"),
|
||||
User.phone.like(f"%{search}%")
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by role
|
||||
if role:
|
||||
role_map = {"admin": 1, "staff": 2, "customer": 3}
|
||||
if role in role_map:
|
||||
query = query.filter(User.role_id == role_map[role])
|
||||
|
||||
# Filter by status
|
||||
if status_filter:
|
||||
is_active = status_filter == "active"
|
||||
query = query.filter(User.is_active == is_active)
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Apply pagination
|
||||
offset = (page - 1) * limit
|
||||
users = query.order_by(User.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
# Transform users
|
||||
result = []
|
||||
for user in users:
|
||||
user_dict = {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"phone": user.phone,
|
||||
"phone_number": user.phone, # For frontend compatibility
|
||||
"address": user.address,
|
||||
"avatar": user.avatar,
|
||||
"is_active": user.is_active,
|
||||
"status": "active" if user.is_active else "inactive",
|
||||
"role_id": user.role_id,
|
||||
"role": user.role.name if user.role else "customer",
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
|
||||
}
|
||||
result.append(user_dict)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"users": result,
|
||||
"pagination": {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"totalPages": (total + limit - 1) // limit,
|
||||
},
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{id}", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def get_user_by_id(
|
||||
id: int,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get user by ID (Admin only)"""
|
||||
try:
|
||||
user = db.query(User).filter(User.id == id).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Get recent bookings
|
||||
bookings = db.query(Booking).filter(
|
||||
Booking.user_id == id
|
||||
).order_by(Booking.created_at.desc()).limit(5).all()
|
||||
|
||||
user_dict = {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"phone": user.phone,
|
||||
"phone_number": user.phone,
|
||||
"address": user.address,
|
||||
"avatar": user.avatar,
|
||||
"is_active": user.is_active,
|
||||
"status": "active" if user.is_active else "inactive",
|
||||
"role_id": user.role_id,
|
||||
"role": user.role.name if user.role else "customer",
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
|
||||
"bookings": [
|
||||
{
|
||||
"id": b.id,
|
||||
"booking_number": b.booking_number,
|
||||
"status": b.status.value if isinstance(b.status, BookingStatus) else b.status,
|
||||
"created_at": b.created_at.isoformat() if b.created_at else None,
|
||||
}
|
||||
for b in bookings
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {"user": user_dict}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def create_user(
|
||||
user_data: dict,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create new user (Admin only)"""
|
||||
try:
|
||||
email = user_data.get("email")
|
||||
password = user_data.get("password")
|
||||
full_name = user_data.get("full_name")
|
||||
phone_number = user_data.get("phone_number")
|
||||
role = user_data.get("role", "customer")
|
||||
status = user_data.get("status", "active")
|
||||
|
||||
# Map role string to role_id
|
||||
role_map = {"admin": 1, "staff": 2, "customer": 3}
|
||||
role_id = role_map.get(role, 3)
|
||||
|
||||
# Check if email exists
|
||||
existing = db.query(User).filter(User.email == email).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Email already exists")
|
||||
|
||||
# Hash password
|
||||
password_bytes = password.encode('utf-8')
|
||||
salt = bcrypt.gensalt()
|
||||
hashed_password = bcrypt.hashpw(password_bytes, salt).decode('utf-8')
|
||||
|
||||
# Create user
|
||||
user = User(
|
||||
email=email,
|
||||
password=hashed_password,
|
||||
full_name=full_name,
|
||||
phone=phone_number,
|
||||
role_id=role_id,
|
||||
is_active=status == "active",
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
# Remove password from response
|
||||
user_dict = {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"phone": user.phone,
|
||||
"phone_number": user.phone,
|
||||
"role_id": user.role_id,
|
||||
"is_active": user.is_active,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "User created successfully",
|
||||
"data": {"user": user_dict}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{id}")
|
||||
async def update_user(
|
||||
id: int,
|
||||
user_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update user"""
|
||||
try:
|
||||
# Users can only update themselves unless they're admin
|
||||
if current_user.role_id != 1 and current_user.id != id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
user = db.query(User).filter(User.id == id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Check if email is being changed and if it's taken
|
||||
email = user_data.get("email")
|
||||
if email and email != user.email:
|
||||
existing = db.query(User).filter(User.email == email).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Email already exists")
|
||||
|
||||
# Map role string to role_id (only admin can change role)
|
||||
role_map = {"admin": 1, "staff": 2, "customer": 3}
|
||||
|
||||
# Update fields
|
||||
if "full_name" in user_data:
|
||||
user.full_name = user_data["full_name"]
|
||||
if "email" in user_data and current_user.role_id == 1:
|
||||
user.email = user_data["email"]
|
||||
if "phone_number" in user_data:
|
||||
user.phone = user_data["phone_number"]
|
||||
if "role" in user_data and current_user.role_id == 1:
|
||||
user.role_id = role_map.get(user_data["role"], 3)
|
||||
if "status" in user_data and current_user.role_id == 1:
|
||||
user.is_active = user_data["status"] == "active"
|
||||
if "password" in user_data:
|
||||
password_bytes = user_data["password"].encode('utf-8')
|
||||
salt = bcrypt.gensalt()
|
||||
user.password = bcrypt.hashpw(password_bytes, salt).decode('utf-8')
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
# Remove password from response
|
||||
user_dict = {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"phone": user.phone,
|
||||
"phone_number": user.phone,
|
||||
"role_id": user.role_id,
|
||||
"is_active": user.is_active,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "User updated successfully",
|
||||
"data": {"user": user_dict}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{id}", dependencies=[Depends(authorize_roles("admin"))])
|
||||
async def delete_user(
|
||||
id: int,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete user (Admin only)"""
|
||||
try:
|
||||
user = db.query(User).filter(User.id == id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Check if user has active bookings
|
||||
active_bookings = db.query(Booking).filter(
|
||||
Booking.user_id == id,
|
||||
Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])
|
||||
).count()
|
||||
|
||||
if active_bookings > 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete user with active bookings"
|
||||
)
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "User deleted successfully"
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
BIN
Backend/src/schemas/__pycache__/auth.cpython-312.pyc
Normal file
BIN
Backend/src/schemas/__pycache__/auth.cpython-312.pyc
Normal file
Binary file not shown.
87
Backend/src/schemas/auth.py
Normal file
87
Backend/src/schemas/auth.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from pydantic import BaseModel, EmailStr, Field, validator
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
name: str = Field(..., min_length=2, max_length=50)
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8)
|
||||
phone: Optional[str] = None
|
||||
|
||||
@validator("password")
|
||||
def validate_password(cls, v):
|
||||
if len(v) < 8:
|
||||
raise ValueError("Password must be at least 8 characters")
|
||||
if not any(c.isupper() for c in v):
|
||||
raise ValueError("Password must contain at least one uppercase letter")
|
||||
if not any(c.islower() for c in v):
|
||||
raise ValueError("Password must contain at least one lowercase letter")
|
||||
if not any(c.isdigit() for c in v):
|
||||
raise ValueError("Password must contain at least one number")
|
||||
return v
|
||||
|
||||
@validator("phone")
|
||||
def validate_phone(cls, v):
|
||||
if v and not v.isdigit() or (v and len(v) not in [10, 11]):
|
||||
raise ValueError("Phone must be 10-11 digits")
|
||||
return v
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
rememberMe: Optional[bool] = False
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
refreshToken: Optional[str] = None
|
||||
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
token: str
|
||||
password: str = Field(..., min_length=8)
|
||||
|
||||
@validator("password")
|
||||
def validate_password(cls, v):
|
||||
if len(v) < 8:
|
||||
raise ValueError("Password must be at least 8 characters")
|
||||
if not any(c.isupper() for c in v):
|
||||
raise ValueError("Password must contain at least one uppercase letter")
|
||||
if not any(c.islower() for c in v):
|
||||
raise ValueError("Password must contain at least one lowercase letter")
|
||||
if not any(c.isdigit() for c in v):
|
||||
raise ValueError("Password must contain at least one number")
|
||||
return v
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
email: str
|
||||
phone: Optional[str]
|
||||
role: str
|
||||
createdAt: Optional[str]
|
||||
updatedAt: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
user: UserResponse
|
||||
token: str
|
||||
refreshToken: Optional[str] = None
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
status: str
|
||||
message: str
|
||||
|
||||
BIN
Backend/src/services/__pycache__/auth_service.cpython-312.pyc
Normal file
BIN
Backend/src/services/__pycache__/auth_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/services/__pycache__/room_service.cpython-312.pyc
Normal file
BIN
Backend/src/services/__pycache__/room_service.cpython-312.pyc
Normal file
Binary file not shown.
350
Backend/src/services/auth_service.py
Normal file
350
Backend/src/services/auth_service.py
Normal file
@@ -0,0 +1,350 @@
|
||||
from jose import jwt
|
||||
import bcrypt
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
import hashlib
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from ..models.user import User
|
||||
from ..models.refresh_token import RefreshToken
|
||||
from ..models.password_reset_token import PasswordResetToken
|
||||
from ..models.role import Role
|
||||
from ..utils.mailer import send_email
|
||||
import os
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self):
|
||||
self.jwt_secret = os.getenv("JWT_SECRET")
|
||||
self.jwt_refresh_secret = os.getenv("JWT_REFRESH_SECRET")
|
||||
self.jwt_expires_in = os.getenv("JWT_EXPIRES_IN", "1h")
|
||||
self.jwt_refresh_expires_in = os.getenv("JWT_REFRESH_EXPIRES_IN", "7d")
|
||||
|
||||
def generate_tokens(self, user_id: int) -> dict:
|
||||
"""Generate JWT tokens"""
|
||||
access_token = jwt.encode(
|
||||
{"userId": user_id},
|
||||
self.jwt_secret,
|
||||
algorithm="HS256"
|
||||
)
|
||||
|
||||
refresh_token = jwt.encode(
|
||||
{"userId": user_id},
|
||||
self.jwt_refresh_secret,
|
||||
algorithm="HS256"
|
||||
)
|
||||
|
||||
return {"accessToken": access_token, "refreshToken": refresh_token}
|
||||
|
||||
def verify_access_token(self, token: str) -> dict:
|
||||
"""Verify JWT access token"""
|
||||
return jwt.decode(token, self.jwt_secret, algorithms=["HS256"])
|
||||
|
||||
def verify_refresh_token(self, token: str) -> dict:
|
||||
"""Verify JWT refresh token"""
|
||||
return jwt.decode(token, self.jwt_refresh_secret, algorithms=["HS256"])
|
||||
|
||||
def hash_password(self, password: str) -> str:
|
||||
"""Hash password using bcrypt"""
|
||||
# bcrypt has 72 byte limit, but it handles truncation automatically
|
||||
password_bytes = password.encode('utf-8')
|
||||
# Generate salt and hash password
|
||||
salt = bcrypt.gensalt()
|
||||
hashed = bcrypt.hashpw(password_bytes, salt)
|
||||
return hashed.decode('utf-8')
|
||||
|
||||
def verify_password(self, plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify password using bcrypt"""
|
||||
try:
|
||||
password_bytes = plain_password.encode('utf-8')
|
||||
hashed_bytes = hashed_password.encode('utf-8')
|
||||
return bcrypt.checkpw(password_bytes, hashed_bytes)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def format_user_response(self, user: User) -> dict:
|
||||
"""Format user response"""
|
||||
return {
|
||||
"id": user.id,
|
||||
"name": user.full_name,
|
||||
"email": user.email,
|
||||
"phone": user.phone,
|
||||
"role": user.role.name if user.role else "customer",
|
||||
"createdAt": user.created_at.isoformat() if user.created_at else None,
|
||||
"updatedAt": user.updated_at.isoformat() if user.updated_at else None,
|
||||
}
|
||||
|
||||
async def register(self, db: Session, name: str, email: str, password: str, phone: Optional[str] = None) -> dict:
|
||||
"""Register new user"""
|
||||
# Check if email exists
|
||||
existing_user = db.query(User).filter(User.email == email).first()
|
||||
if existing_user:
|
||||
raise ValueError("Email already registered")
|
||||
|
||||
# Hash password
|
||||
hashed_password = self.hash_password(password)
|
||||
|
||||
# Create user (default role_id = 3 for customer)
|
||||
user = User(
|
||||
full_name=name,
|
||||
email=email,
|
||||
password=hashed_password,
|
||||
phone=phone,
|
||||
role_id=3 # Customer role
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
# Load role
|
||||
user.role = db.query(Role).filter(Role.id == user.role_id).first()
|
||||
|
||||
# Generate tokens
|
||||
tokens = self.generate_tokens(user.id)
|
||||
|
||||
# Save refresh token (expires in 7 days)
|
||||
expires_at = datetime.utcnow() + timedelta(days=7)
|
||||
refresh_token = RefreshToken(
|
||||
user_id=user.id,
|
||||
token=tokens["refreshToken"],
|
||||
expires_at=expires_at
|
||||
)
|
||||
db.add(refresh_token)
|
||||
db.commit()
|
||||
|
||||
# Send welcome email (non-blocking)
|
||||
try:
|
||||
client_url = os.getenv("CLIENT_URL", "http://localhost:5173")
|
||||
await send_email(
|
||||
to=user.email,
|
||||
subject="Welcome to Hotel Booking",
|
||||
html=f"""
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #4F46E5;">Welcome {user.full_name}!</h2>
|
||||
<p>Thank you for registering an account at <strong>Hotel Booking</strong>.</p>
|
||||
<p>Your account has been successfully created with email: <strong>{user.email}</strong></p>
|
||||
<div style="background-color: #F3F4F6; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 0;"><strong>You can:</strong></p>
|
||||
<ul style="margin-top: 10px;">
|
||||
<li>Search and book hotel rooms</li>
|
||||
<li>Manage your bookings</li>
|
||||
<li>Update your personal information</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>
|
||||
<a href="{client_url}/login" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
|
||||
Login Now
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to send welcome email: {e}")
|
||||
|
||||
return {
|
||||
"user": self.format_user_response(user),
|
||||
"token": tokens["accessToken"],
|
||||
"refreshToken": tokens["refreshToken"]
|
||||
}
|
||||
|
||||
async def login(self, db: Session, email: str, password: str, remember_me: bool = False) -> dict:
|
||||
"""Login user"""
|
||||
# Find user with role and password
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user:
|
||||
raise ValueError("Invalid email or password")
|
||||
|
||||
# Load role
|
||||
user.role = db.query(Role).filter(Role.id == user.role_id).first()
|
||||
|
||||
# Check password
|
||||
if not self.verify_password(password, user.password):
|
||||
raise ValueError("Invalid email or password")
|
||||
|
||||
# Generate tokens
|
||||
tokens = self.generate_tokens(user.id)
|
||||
|
||||
# Calculate expiry based on remember_me
|
||||
expiry_days = 7 if remember_me else 1
|
||||
expires_at = datetime.utcnow() + timedelta(days=expiry_days)
|
||||
|
||||
# Save refresh token
|
||||
refresh_token = RefreshToken(
|
||||
user_id=user.id,
|
||||
token=tokens["refreshToken"],
|
||||
expires_at=expires_at
|
||||
)
|
||||
db.add(refresh_token)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"user": self.format_user_response(user),
|
||||
"token": tokens["accessToken"],
|
||||
"refreshToken": tokens["refreshToken"]
|
||||
}
|
||||
|
||||
async def refresh_access_token(self, db: Session, refresh_token_str: str) -> dict:
|
||||
"""Refresh access token"""
|
||||
if not refresh_token_str:
|
||||
raise ValueError("Refresh token is required")
|
||||
|
||||
# Verify refresh token
|
||||
decoded = self.verify_refresh_token(refresh_token_str)
|
||||
|
||||
# Check if refresh token exists in database
|
||||
stored_token = db.query(RefreshToken).filter(
|
||||
RefreshToken.token == refresh_token_str,
|
||||
RefreshToken.user_id == decoded["userId"]
|
||||
).first()
|
||||
|
||||
if not stored_token:
|
||||
raise ValueError("Invalid refresh token")
|
||||
|
||||
# Check if token is expired
|
||||
if datetime.utcnow() > stored_token.expires_at:
|
||||
db.delete(stored_token)
|
||||
db.commit()
|
||||
raise ValueError("Refresh token expired")
|
||||
|
||||
# Generate new access token
|
||||
access_token = jwt.encode(
|
||||
{"userId": decoded["userId"]},
|
||||
self.jwt_secret,
|
||||
algorithm="HS256"
|
||||
)
|
||||
|
||||
return {"token": access_token}
|
||||
|
||||
async def logout(self, db: Session, refresh_token_str: str) -> bool:
|
||||
"""Logout user"""
|
||||
if refresh_token_str:
|
||||
db.query(RefreshToken).filter(RefreshToken.token == refresh_token_str).delete()
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
async def get_profile(self, db: Session, user_id: int) -> dict:
|
||||
"""Get user profile"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# Load role
|
||||
user.role = db.query(Role).filter(Role.id == user.role_id).first()
|
||||
|
||||
return self.format_user_response(user)
|
||||
|
||||
def generate_reset_token(self) -> tuple:
|
||||
"""Generate reset token"""
|
||||
reset_token = secrets.token_hex(32)
|
||||
hashed_token = hashlib.sha256(reset_token.encode()).hexdigest()
|
||||
return reset_token, hashed_token
|
||||
|
||||
async def forgot_password(self, db: Session, email: str) -> dict:
|
||||
"""Forgot Password - Send reset link"""
|
||||
# Find user by email
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
|
||||
# Always return success to prevent email enumeration
|
||||
if not user:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "If email exists, reset link has been sent"
|
||||
}
|
||||
|
||||
# Generate reset token
|
||||
reset_token, hashed_token = self.generate_reset_token()
|
||||
|
||||
# Delete old tokens
|
||||
db.query(PasswordResetToken).filter(PasswordResetToken.user_id == user.id).delete()
|
||||
|
||||
# Save token (expires in 1 hour)
|
||||
expires_at = datetime.utcnow() + timedelta(hours=1)
|
||||
reset_token_obj = PasswordResetToken(
|
||||
user_id=user.id,
|
||||
token=hashed_token,
|
||||
expires_at=expires_at
|
||||
)
|
||||
db.add(reset_token_obj)
|
||||
db.commit()
|
||||
|
||||
# Build reset URL
|
||||
client_url = os.getenv("CLIENT_URL", "http://localhost:5173")
|
||||
reset_url = f"{client_url}/reset-password/{reset_token}"
|
||||
|
||||
# Try to send email
|
||||
try:
|
||||
await send_email(
|
||||
to=user.email,
|
||||
subject="Reset password - Hotel Booking",
|
||||
html=f"""
|
||||
<p>You (or someone) has requested to reset your password.</p>
|
||||
<p>Click the link below to reset your password (expires in 1 hour):</p>
|
||||
<p><a href="{reset_url}">{reset_url}</a></p>
|
||||
"""
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to send reset email: {e}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Password reset link has been sent to your email"
|
||||
}
|
||||
|
||||
async def reset_password(self, db: Session, token: str, password: str) -> dict:
|
||||
"""Reset Password - Update password with token"""
|
||||
if not token or not password:
|
||||
raise ValueError("Token and password are required")
|
||||
|
||||
# Hash the token to compare
|
||||
hashed_token = hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
# Find valid token
|
||||
reset_token = db.query(PasswordResetToken).filter(
|
||||
PasswordResetToken.token == hashed_token,
|
||||
PasswordResetToken.expires_at > datetime.utcnow(),
|
||||
PasswordResetToken.used == False
|
||||
).first()
|
||||
|
||||
if not reset_token:
|
||||
raise ValueError("Invalid or expired reset token")
|
||||
|
||||
# Find user
|
||||
user = db.query(User).filter(User.id == reset_token.user_id).first()
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# Check if new password matches old password
|
||||
if self.verify_password(password, user.password):
|
||||
raise ValueError("New password must be different from the old password")
|
||||
|
||||
# Hash new password
|
||||
hashed_password = self.hash_password(password)
|
||||
|
||||
# Update password
|
||||
user.password = hashed_password
|
||||
db.commit()
|
||||
|
||||
# Mark token as used
|
||||
reset_token.used = True
|
||||
db.commit()
|
||||
|
||||
# Send confirmation email (non-blocking)
|
||||
try:
|
||||
await send_email(
|
||||
to=user.email,
|
||||
subject="Password Changed",
|
||||
html=f"<p>The password for account {user.email} has been changed successfully.</p>"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to send confirmation email: {e}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Password has been reset successfully"
|
||||
}
|
||||
|
||||
|
||||
auth_service = AuthService()
|
||||
|
||||
145
Backend/src/services/room_service.py
Normal file
145
Backend/src/services/room_service.py
Normal file
@@ -0,0 +1,145 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, or_
|
||||
from typing import Optional, List, Dict
|
||||
from datetime import datetime
|
||||
import os
|
||||
|
||||
from ..models.room import Room, RoomStatus
|
||||
from ..models.room_type import RoomType
|
||||
from ..models.review import Review, ReviewStatus
|
||||
|
||||
|
||||
def normalize_images(images, base_url: str) -> List[str]:
|
||||
"""Normalize image paths to absolute URLs"""
|
||||
if not images:
|
||||
return []
|
||||
|
||||
imgs = images
|
||||
if isinstance(images, str):
|
||||
try:
|
||||
import json
|
||||
imgs = json.loads(images)
|
||||
except:
|
||||
imgs = [s.strip() for s in images.split(',') if s.strip()]
|
||||
|
||||
if not isinstance(imgs, list):
|
||||
return []
|
||||
|
||||
result = []
|
||||
for img in imgs:
|
||||
if not img:
|
||||
continue
|
||||
if img.startswith('http://') or img.startswith('https://'):
|
||||
result.append(img)
|
||||
else:
|
||||
path_part = img if img.startswith('/') else f"/{img}"
|
||||
result.append(f"{base_url}{path_part}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_base_url(request) -> str:
|
||||
"""Get base URL for image normalization"""
|
||||
return os.getenv("SERVER_URL") or f"http://{request.headers.get('host', 'localhost:3000')}"
|
||||
|
||||
|
||||
async def get_rooms_with_ratings(
|
||||
db: Session,
|
||||
rooms: List[Room],
|
||||
base_url: str
|
||||
) -> List[Dict]:
|
||||
"""Get rooms with calculated ratings"""
|
||||
result = []
|
||||
|
||||
for room in rooms:
|
||||
# Get review stats
|
||||
review_stats = db.query(
|
||||
func.avg(Review.rating).label('average_rating'),
|
||||
func.count(Review.id).label('total_reviews')
|
||||
).filter(
|
||||
and_(
|
||||
Review.room_id == room.id,
|
||||
Review.status == ReviewStatus.approved
|
||||
)
|
||||
).first()
|
||||
|
||||
room_dict = {
|
||||
"id": room.id,
|
||||
"room_type_id": room.room_type_id,
|
||||
"room_number": room.room_number,
|
||||
"floor": room.floor,
|
||||
"status": room.status.value if isinstance(room.status, RoomStatus) else room.status,
|
||||
"price": float(room.price) if room.price else 0.0,
|
||||
"featured": room.featured,
|
||||
"description": room.description,
|
||||
"amenities": room.amenities,
|
||||
"created_at": room.created_at.isoformat() if room.created_at else None,
|
||||
"updated_at": room.updated_at.isoformat() if room.updated_at else None,
|
||||
"average_rating": round(float(review_stats.average_rating or 0), 1) if review_stats and review_stats.average_rating else None,
|
||||
"total_reviews": review_stats.total_reviews or 0 if review_stats else 0,
|
||||
}
|
||||
|
||||
# Normalize images
|
||||
try:
|
||||
room_dict["images"] = normalize_images(room.images, base_url)
|
||||
except:
|
||||
room_dict["images"] = []
|
||||
|
||||
# Add room type info
|
||||
if room.room_type:
|
||||
room_dict["room_type"] = {
|
||||
"id": room.room_type.id,
|
||||
"name": room.room_type.name,
|
||||
"description": room.room_type.description,
|
||||
"base_price": float(room.room_type.base_price) if room.room_type.base_price else 0.0,
|
||||
"capacity": room.room_type.capacity,
|
||||
"amenities": room.room_type.amenities,
|
||||
"images": [] # RoomType doesn't have images column in DB
|
||||
}
|
||||
|
||||
result.append(room_dict)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def get_amenities_list(db: Session) -> List[str]:
|
||||
"""Get all unique amenities from room types and rooms"""
|
||||
all_amenities = []
|
||||
|
||||
# Get from room types
|
||||
room_types = db.query(RoomType.amenities).all()
|
||||
for rt in room_types:
|
||||
if rt.amenities:
|
||||
if isinstance(rt.amenities, list):
|
||||
all_amenities.extend([str(a).strip() for a in rt.amenities])
|
||||
elif isinstance(rt.amenities, str):
|
||||
try:
|
||||
import json
|
||||
parsed = json.loads(rt.amenities)
|
||||
if isinstance(parsed, list):
|
||||
all_amenities.extend([str(a).strip() for a in parsed])
|
||||
else:
|
||||
all_amenities.extend([s.strip() for s in rt.amenities.split(',')])
|
||||
except:
|
||||
all_amenities.extend([s.strip() for s in rt.amenities.split(',')])
|
||||
|
||||
# Get from rooms
|
||||
rooms = db.query(Room.amenities).all()
|
||||
for r in rooms:
|
||||
if r.amenities:
|
||||
if isinstance(r.amenities, list):
|
||||
all_amenities.extend([str(a).strip() for a in r.amenities])
|
||||
elif isinstance(r.amenities, str):
|
||||
try:
|
||||
import json
|
||||
parsed = json.loads(r.amenities)
|
||||
if isinstance(parsed, list):
|
||||
all_amenities.extend([str(a).strip() for a in parsed])
|
||||
else:
|
||||
all_amenities.extend([s.strip() for s in r.amenities.split(',')])
|
||||
except:
|
||||
all_amenities.extend([s.strip() for s in r.amenities.split(',')])
|
||||
|
||||
# Return unique, non-empty values
|
||||
return sorted(list(set([a for a in all_amenities if a])))
|
||||
|
||||
BIN
Backend/src/utils/__pycache__/mailer.cpython-312.pyc
Normal file
BIN
Backend/src/utils/__pycache__/mailer.cpython-312.pyc
Normal file
Binary file not shown.
48
Backend/src/utils/mailer.py
Normal file
48
Backend/src/utils/mailer.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import aiosmtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import os
|
||||
|
||||
|
||||
async def send_email(to: str, subject: str, html: str = None, text: str = None):
|
||||
"""
|
||||
Send email using SMTP
|
||||
Requires MAIL_HOST, MAIL_USER and MAIL_PASS to be set in env.
|
||||
"""
|
||||
# Require SMTP credentials to be present
|
||||
mail_host = os.getenv("MAIL_HOST")
|
||||
mail_user = os.getenv("MAIL_USER")
|
||||
mail_pass = os.getenv("MAIL_PASS")
|
||||
|
||||
if not (mail_host and mail_user and mail_pass):
|
||||
raise ValueError(
|
||||
"SMTP mailer not configured. Set MAIL_HOST, MAIL_USER and MAIL_PASS in env."
|
||||
)
|
||||
|
||||
mail_port = int(os.getenv("MAIL_PORT", "587"))
|
||||
mail_secure = os.getenv("MAIL_SECURE", "false").lower() == "true"
|
||||
client_url = os.getenv("CLIENT_URL", "example.com")
|
||||
from_address = os.getenv("MAIL_FROM", f"no-reply@{client_url.replace('https://', '').replace('http://', '')}")
|
||||
|
||||
# Create message
|
||||
message = MIMEMultipart("alternative")
|
||||
message["From"] = from_address
|
||||
message["To"] = to
|
||||
message["Subject"] = subject
|
||||
|
||||
if text:
|
||||
message.attach(MIMEText(text, "plain"))
|
||||
if html:
|
||||
message.attach(MIMEText(html, "html"))
|
||||
|
||||
# Send email
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
hostname=mail_host,
|
||||
port=mail_port,
|
||||
use_tls=not mail_secure and mail_port == 587,
|
||||
start_tls=not mail_secure and mail_port == 587,
|
||||
username=mail_user,
|
||||
password=mail_pass,
|
||||
)
|
||||
|
||||
22
Backend/src/utils/vnpay_service.py
Normal file
22
Backend/src/utils/vnpay_service.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
VNPay integration removed
|
||||
This file is intentionally left as a stub to indicate the VNPay
|
||||
payment gateway has been removed from the project.
|
||||
"""
|
||||
|
||||
|
||||
def create_payment_url(*args, **kwargs):
|
||||
raise NotImplementedError("VNPay integration has been removed")
|
||||
|
||||
|
||||
def verify_return(*args, **kwargs):
|
||||
raise NotImplementedError("VNPay integration has been removed")
|
||||
|
||||
|
||||
def sort_object(obj):
|
||||
return {}
|
||||
|
||||
|
||||
def create_signature(*args, **kwargs):
|
||||
return ""
|
||||
|
||||
247
Backend/venv/bin/Activate.ps1
Normal file
247
Backend/venv/bin/Activate.ps1
Normal file
@@ -0,0 +1,247 @@
|
||||
<#
|
||||
.Synopsis
|
||||
Activate a Python virtual environment for the current PowerShell session.
|
||||
|
||||
.Description
|
||||
Pushes the python executable for a virtual environment to the front of the
|
||||
$Env:PATH environment variable and sets the prompt to signify that you are
|
||||
in a Python virtual environment. Makes use of the command line switches as
|
||||
well as the `pyvenv.cfg` file values present in the virtual environment.
|
||||
|
||||
.Parameter VenvDir
|
||||
Path to the directory that contains the virtual environment to activate. The
|
||||
default value for this is the parent of the directory that the Activate.ps1
|
||||
script is located within.
|
||||
|
||||
.Parameter Prompt
|
||||
The prompt prefix to display when this virtual environment is activated. By
|
||||
default, this prompt is the name of the virtual environment folder (VenvDir)
|
||||
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
|
||||
|
||||
.Example
|
||||
Activate.ps1
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Verbose
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and shows extra information about the activation as it executes.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
|
||||
Activates the Python virtual environment located in the specified location.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Prompt "MyPython"
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and prefixes the current prompt with the specified string (surrounded in
|
||||
parentheses) while the virtual environment is active.
|
||||
|
||||
.Notes
|
||||
On Windows, it may be required to enable this Activate.ps1 script by setting the
|
||||
execution policy for the user. You can do this by issuing the following PowerShell
|
||||
command:
|
||||
|
||||
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
|
||||
For more information on Execution Policies:
|
||||
https://go.microsoft.com/fwlink/?LinkID=135170
|
||||
|
||||
#>
|
||||
Param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$VenvDir,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$Prompt
|
||||
)
|
||||
|
||||
<# Function declarations --------------------------------------------------- #>
|
||||
|
||||
<#
|
||||
.Synopsis
|
||||
Remove all shell session elements added by the Activate script, including the
|
||||
addition of the virtual environment's Python executable from the beginning of
|
||||
the PATH variable.
|
||||
|
||||
.Parameter NonDestructive
|
||||
If present, do not remove this function from the global namespace for the
|
||||
session.
|
||||
|
||||
#>
|
||||
function global:deactivate ([switch]$NonDestructive) {
|
||||
# Revert to original values
|
||||
|
||||
# The prior prompt:
|
||||
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
|
||||
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
|
||||
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
|
||||
# The prior PYTHONHOME:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
}
|
||||
|
||||
# The prior PATH:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
|
||||
}
|
||||
|
||||
# Just remove the VIRTUAL_ENV altogether:
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV
|
||||
}
|
||||
|
||||
# Just remove VIRTUAL_ENV_PROMPT altogether.
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
|
||||
}
|
||||
|
||||
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
|
||||
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
|
||||
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
|
||||
}
|
||||
|
||||
# Leave deactivate function in the global namespace if requested:
|
||||
if (-not $NonDestructive) {
|
||||
Remove-Item -Path function:deactivate
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.Description
|
||||
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
|
||||
given folder, and returns them in a map.
|
||||
|
||||
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
|
||||
two strings separated by `=` (with any amount of whitespace surrounding the =)
|
||||
then it is considered a `key = value` line. The left hand string is the key,
|
||||
the right hand is the value.
|
||||
|
||||
If the value starts with a `'` or a `"` then the first and last character is
|
||||
stripped from the value before being captured.
|
||||
|
||||
.Parameter ConfigDir
|
||||
Path to the directory that contains the `pyvenv.cfg` file.
|
||||
#>
|
||||
function Get-PyVenvConfig(
|
||||
[String]
|
||||
$ConfigDir
|
||||
) {
|
||||
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
|
||||
|
||||
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
|
||||
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
|
||||
|
||||
# An empty map will be returned if no config file is found.
|
||||
$pyvenvConfig = @{ }
|
||||
|
||||
if ($pyvenvConfigPath) {
|
||||
|
||||
Write-Verbose "File exists, parse `key = value` lines"
|
||||
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
|
||||
|
||||
$pyvenvConfigContent | ForEach-Object {
|
||||
$keyval = $PSItem -split "\s*=\s*", 2
|
||||
if ($keyval[0] -and $keyval[1]) {
|
||||
$val = $keyval[1]
|
||||
|
||||
# Remove extraneous quotations around a string value.
|
||||
if ("'""".Contains($val.Substring(0, 1))) {
|
||||
$val = $val.Substring(1, $val.Length - 2)
|
||||
}
|
||||
|
||||
$pyvenvConfig[$keyval[0]] = $val
|
||||
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
|
||||
}
|
||||
}
|
||||
}
|
||||
return $pyvenvConfig
|
||||
}
|
||||
|
||||
|
||||
<# Begin Activate script --------------------------------------------------- #>
|
||||
|
||||
# Determine the containing directory of this script
|
||||
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$VenvExecDir = Get-Item -Path $VenvExecPath
|
||||
|
||||
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
|
||||
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
|
||||
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
|
||||
|
||||
# Set values required in priority: CmdLine, ConfigFile, Default
|
||||
# First, get the location of the virtual environment, it might not be
|
||||
# VenvExecDir if specified on the command line.
|
||||
if ($VenvDir) {
|
||||
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
|
||||
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
|
||||
Write-Verbose "VenvDir=$VenvDir"
|
||||
}
|
||||
|
||||
# Next, read the `pyvenv.cfg` file to determine any required value such
|
||||
# as `prompt`.
|
||||
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
|
||||
|
||||
# Next, set the prompt from the command line, or the config file, or
|
||||
# just use the name of the virtual environment folder.
|
||||
if ($Prompt) {
|
||||
Write-Verbose "Prompt specified as argument, using '$Prompt'"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
|
||||
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
|
||||
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
|
||||
$Prompt = $pyvenvCfg['prompt'];
|
||||
}
|
||||
else {
|
||||
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
|
||||
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
|
||||
$Prompt = Split-Path -Path $venvDir -Leaf
|
||||
}
|
||||
}
|
||||
|
||||
Write-Verbose "Prompt = '$Prompt'"
|
||||
Write-Verbose "VenvDir='$VenvDir'"
|
||||
|
||||
# Deactivate any currently active virtual environment, but leave the
|
||||
# deactivate function in place.
|
||||
deactivate -nondestructive
|
||||
|
||||
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
|
||||
# that there is an activated venv.
|
||||
$env:VIRTUAL_ENV = $VenvDir
|
||||
|
||||
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
|
||||
|
||||
Write-Verbose "Setting prompt to '$Prompt'"
|
||||
|
||||
# Set the prompt to include the env name
|
||||
# Make sure _OLD_VIRTUAL_PROMPT is global
|
||||
function global:_OLD_VIRTUAL_PROMPT { "" }
|
||||
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
|
||||
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
|
||||
|
||||
function global:prompt {
|
||||
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
|
||||
_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
$env:VIRTUAL_ENV_PROMPT = $Prompt
|
||||
}
|
||||
|
||||
# Clear PYTHONHOME
|
||||
if (Test-Path -Path Env:PYTHONHOME) {
|
||||
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
Remove-Item -Path Env:PYTHONHOME
|
||||
}
|
||||
|
||||
# Add the venv to the PATH
|
||||
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
||||
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
||||
70
Backend/venv/bin/activate
Normal file
70
Backend/venv/bin/activate
Normal file
@@ -0,0 +1,70 @@
|
||||
# This file must be used with "source bin/activate" *from bash*
|
||||
# You cannot run it directly
|
||||
|
||||
deactivate () {
|
||||
# reset old environment variables
|
||||
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
|
||||
PATH="${_OLD_VIRTUAL_PATH:-}"
|
||||
export PATH
|
||||
unset _OLD_VIRTUAL_PATH
|
||||
fi
|
||||
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
|
||||
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
|
||||
export PYTHONHOME
|
||||
unset _OLD_VIRTUAL_PYTHONHOME
|
||||
fi
|
||||
|
||||
# Call hash to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
hash -r 2> /dev/null
|
||||
|
||||
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
|
||||
PS1="${_OLD_VIRTUAL_PS1:-}"
|
||||
export PS1
|
||||
unset _OLD_VIRTUAL_PS1
|
||||
fi
|
||||
|
||||
unset VIRTUAL_ENV
|
||||
unset VIRTUAL_ENV_PROMPT
|
||||
if [ ! "${1:-}" = "nondestructive" ] ; then
|
||||
# Self destruct!
|
||||
unset -f deactivate
|
||||
fi
|
||||
}
|
||||
|
||||
# unset irrelevant variables
|
||||
deactivate nondestructive
|
||||
|
||||
# on Windows, a path can contain colons and backslashes and has to be converted:
|
||||
if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
|
||||
# transform D:\path\to\venv to /d/path/to/venv on MSYS
|
||||
# and to /cygdrive/d/path/to/venv on Cygwin
|
||||
export VIRTUAL_ENV=$(cygpath /home/gnx/Desktop/Hotel-Booking/Backend/venv)
|
||||
else
|
||||
# use the path as-is
|
||||
export VIRTUAL_ENV=/home/gnx/Desktop/Hotel-Booking/Backend/venv
|
||||
fi
|
||||
|
||||
_OLD_VIRTUAL_PATH="$PATH"
|
||||
PATH="$VIRTUAL_ENV/"bin":$PATH"
|
||||
export PATH
|
||||
|
||||
# unset PYTHONHOME if set
|
||||
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
|
||||
# could use `if (set -u; : $PYTHONHOME) ;` in bash
|
||||
if [ -n "${PYTHONHOME:-}" ] ; then
|
||||
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
|
||||
unset PYTHONHOME
|
||||
fi
|
||||
|
||||
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
|
||||
_OLD_VIRTUAL_PS1="${PS1:-}"
|
||||
PS1='(venv) '"${PS1:-}"
|
||||
export PS1
|
||||
VIRTUAL_ENV_PROMPT='(venv) '
|
||||
export VIRTUAL_ENV_PROMPT
|
||||
fi
|
||||
|
||||
# Call hash to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
hash -r 2> /dev/null
|
||||
27
Backend/venv/bin/activate.csh
Normal file
27
Backend/venv/bin/activate.csh
Normal file
@@ -0,0 +1,27 @@
|
||||
# This file must be used with "source bin/activate.csh" *from csh*.
|
||||
# You cannot run it directly.
|
||||
|
||||
# Created by Davide Di Blasi <davidedb@gmail.com>.
|
||||
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
|
||||
|
||||
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
setenv VIRTUAL_ENV /home/gnx/Desktop/Hotel-Booking/Backend/venv
|
||||
|
||||
set _OLD_VIRTUAL_PATH="$PATH"
|
||||
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
|
||||
|
||||
|
||||
set _OLD_VIRTUAL_PROMPT="$prompt"
|
||||
|
||||
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
|
||||
set prompt = '(venv) '"$prompt"
|
||||
setenv VIRTUAL_ENV_PROMPT '(venv) '
|
||||
endif
|
||||
|
||||
alias pydoc python -m pydoc
|
||||
|
||||
rehash
|
||||
69
Backend/venv/bin/activate.fish
Normal file
69
Backend/venv/bin/activate.fish
Normal file
@@ -0,0 +1,69 @@
|
||||
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
|
||||
# (https://fishshell.com/). You cannot run it directly.
|
||||
|
||||
function deactivate -d "Exit virtual environment and return to normal shell environment"
|
||||
# reset old environment variables
|
||||
if test -n "$_OLD_VIRTUAL_PATH"
|
||||
set -gx PATH $_OLD_VIRTUAL_PATH
|
||||
set -e _OLD_VIRTUAL_PATH
|
||||
end
|
||||
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
|
||||
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
|
||||
set -e _OLD_VIRTUAL_PYTHONHOME
|
||||
end
|
||||
|
||||
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
|
||||
set -e _OLD_FISH_PROMPT_OVERRIDE
|
||||
# prevents error when using nested fish instances (Issue #93858)
|
||||
if functions -q _old_fish_prompt
|
||||
functions -e fish_prompt
|
||||
functions -c _old_fish_prompt fish_prompt
|
||||
functions -e _old_fish_prompt
|
||||
end
|
||||
end
|
||||
|
||||
set -e VIRTUAL_ENV
|
||||
set -e VIRTUAL_ENV_PROMPT
|
||||
if test "$argv[1]" != "nondestructive"
|
||||
# Self-destruct!
|
||||
functions -e deactivate
|
||||
end
|
||||
end
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
set -gx VIRTUAL_ENV /home/gnx/Desktop/Hotel-Booking/Backend/venv
|
||||
|
||||
set -gx _OLD_VIRTUAL_PATH $PATH
|
||||
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
|
||||
|
||||
# Unset PYTHONHOME if set.
|
||||
if set -q PYTHONHOME
|
||||
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
|
||||
set -e PYTHONHOME
|
||||
end
|
||||
|
||||
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
|
||||
# fish uses a function instead of an env var to generate the prompt.
|
||||
|
||||
# Save the current fish_prompt function as the function _old_fish_prompt.
|
||||
functions -c fish_prompt _old_fish_prompt
|
||||
|
||||
# With the original prompt function renamed, we can override with our own.
|
||||
function fish_prompt
|
||||
# Save the return status of the last command.
|
||||
set -l old_status $status
|
||||
|
||||
# Output the venv prompt; color taken from the blue of the Python logo.
|
||||
printf "%s%s%s" (set_color 4B8BBE) '(venv) ' (set_color normal)
|
||||
|
||||
# Restore the return status of the previous command.
|
||||
echo "exit $old_status" | .
|
||||
# Output the original/"old" prompt.
|
||||
_old_fish_prompt
|
||||
end
|
||||
|
||||
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
|
||||
set -gx VIRTUAL_ENV_PROMPT '(venv) '
|
||||
end
|
||||
7
Backend/venv/bin/alembic
Executable file
7
Backend/venv/bin/alembic
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/home/gnx/Desktop/Hotel-Booking/Backend/venv/bin/python3
|
||||
import sys
|
||||
from alembic.config import main
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(main())
|
||||
7
Backend/venv/bin/dotenv
Executable file
7
Backend/venv/bin/dotenv
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/home/gnx/Desktop/Hotel-Booking/Backend/venv/bin/python3
|
||||
import sys
|
||||
from dotenv.__main__ import cli
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(cli())
|
||||
7
Backend/venv/bin/email_validator
Executable file
7
Backend/venv/bin/email_validator
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/home/gnx/Desktop/Hotel-Booking/Backend/venv/bin/python3
|
||||
import sys
|
||||
from email_validator.__main__ import main
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(main())
|
||||
7
Backend/venv/bin/mako-render
Executable file
7
Backend/venv/bin/mako-render
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/home/gnx/Desktop/Hotel-Booking/Backend/venv/bin/python3
|
||||
import sys
|
||||
from mako.cmd import cmdline
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(cmdline())
|
||||
8
Backend/venv/bin/pip
Executable file
8
Backend/venv/bin/pip
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/gnx/Desktop/Hotel-Booking/Backend/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
Backend/venv/bin/pip3
Executable file
8
Backend/venv/bin/pip3
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/gnx/Desktop/Hotel-Booking/Backend/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
Backend/venv/bin/pip3.12
Executable file
8
Backend/venv/bin/pip3.12
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/gnx/Desktop/Hotel-Booking/Backend/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
7
Backend/venv/bin/pyrsa-decrypt
Executable file
7
Backend/venv/bin/pyrsa-decrypt
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/home/gnx/Desktop/Hotel-Booking/Backend/venv/bin/python3
|
||||
import sys
|
||||
from rsa.cli import decrypt
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(decrypt())
|
||||
7
Backend/venv/bin/pyrsa-encrypt
Executable file
7
Backend/venv/bin/pyrsa-encrypt
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/home/gnx/Desktop/Hotel-Booking/Backend/venv/bin/python3
|
||||
import sys
|
||||
from rsa.cli import encrypt
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(encrypt())
|
||||
7
Backend/venv/bin/pyrsa-keygen
Executable file
7
Backend/venv/bin/pyrsa-keygen
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/home/gnx/Desktop/Hotel-Booking/Backend/venv/bin/python3
|
||||
import sys
|
||||
from rsa.cli import keygen
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(keygen())
|
||||
7
Backend/venv/bin/pyrsa-priv2pub
Executable file
7
Backend/venv/bin/pyrsa-priv2pub
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/home/gnx/Desktop/Hotel-Booking/Backend/venv/bin/python3
|
||||
import sys
|
||||
from rsa.util import private_to_public
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(private_to_public())
|
||||
7
Backend/venv/bin/pyrsa-sign
Executable file
7
Backend/venv/bin/pyrsa-sign
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/home/gnx/Desktop/Hotel-Booking/Backend/venv/bin/python3
|
||||
import sys
|
||||
from rsa.cli import sign
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(sign())
|
||||
7
Backend/venv/bin/pyrsa-verify
Executable file
7
Backend/venv/bin/pyrsa-verify
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/home/gnx/Desktop/Hotel-Booking/Backend/venv/bin/python3
|
||||
import sys
|
||||
from rsa.cli import verify
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(verify())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user