diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..9d126f30bfa27d0547dafd11ae7a63bd39ba6eac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,73 @@ +# .dockerignore for Tabble-v3 + +# Git +.git +.gitignore +README.md + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +*.log +logs/ + +# Temporary files +*.tmp +*.temp + +# Documentation (already in image) +docs/ +*.md + +# Development only files +test-*.html +start.py + +# Database backups +*.db.backup +backups/ \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..d32db74f20c056f31096ea3e23fe21687874b347 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# Environment Variables for Tabble-v3 + +# Server Configuration +PORT=7860 +HOST=0.0.0.0 + +# Security +SECRET_KEY=your_secret_key_here_change_in_production + +# Application Settings +DEBUG=False +APP_NAME=Tabble-v3 +APP_VERSION=3.1.0 + +# Database Configuration +# Note: SQLite databases are auto-created based on hotels.csv + +# Firebase Configuration (Optional - for production phone authentication) +# FIREBASE_PRIVATE_KEY_ID=your_firebase_private_key_id +# FIREBASE_PRIVATE_KEY=your_firebase_private_key +# FIREBASE_CLIENT_EMAIL=your_firebase_client_email +# FIREBASE_CLIENT_ID=your_firebase_client_id +# FIREBASE_AUTH_URI=https://accounts.google.com/o/oauth2/auth +# FIREBASE_TOKEN_URI=https://oauth2.googleapis.com/token + +# Hugging Face Spaces +HUGGINGFACE_SPACES=1 + +# CORS Settings (for production, specify allowed origins) +ALLOWED_ORIGINS=["*"] + +# Logging +LOG_LEVEL=INFO \ No newline at end of file diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index a6344aac8c09253b3b630fb776ae94478aa0275b..0000000000000000000000000000000000000000 --- a/.gitattributes +++ /dev/null @@ -1,35 +0,0 @@ -*.7z filter=lfs diff=lfs merge=lfs -text -*.arrow filter=lfs diff=lfs merge=lfs -text -*.bin filter=lfs diff=lfs merge=lfs -text -*.bz2 filter=lfs diff=lfs merge=lfs -text -*.ckpt filter=lfs diff=lfs merge=lfs -text -*.ftz filter=lfs diff=lfs merge=lfs -text -*.gz filter=lfs diff=lfs merge=lfs -text -*.h5 filter=lfs diff=lfs merge=lfs -text -*.joblib filter=lfs diff=lfs merge=lfs -text -*.lfs.* filter=lfs diff=lfs merge=lfs -text -*.mlmodel filter=lfs diff=lfs merge=lfs -text -*.model filter=lfs diff=lfs merge=lfs -text -*.msgpack filter=lfs diff=lfs merge=lfs -text -*.npy filter=lfs diff=lfs merge=lfs -text -*.npz filter=lfs diff=lfs merge=lfs -text -*.onnx filter=lfs diff=lfs merge=lfs -text -*.ot filter=lfs diff=lfs merge=lfs -text -*.parquet filter=lfs diff=lfs merge=lfs -text -*.pb filter=lfs diff=lfs merge=lfs -text -*.pickle filter=lfs diff=lfs merge=lfs -text -*.pkl filter=lfs diff=lfs merge=lfs -text -*.pt filter=lfs diff=lfs merge=lfs -text -*.pth filter=lfs diff=lfs merge=lfs -text -*.rar filter=lfs diff=lfs merge=lfs -text -*.safetensors filter=lfs diff=lfs merge=lfs -text -saved_model/**/* filter=lfs diff=lfs merge=lfs -text -*.tar.* filter=lfs diff=lfs merge=lfs -text -*.tar filter=lfs diff=lfs merge=lfs -text -*.tflite filter=lfs diff=lfs merge=lfs -text -*.tgz filter=lfs diff=lfs merge=lfs -text -*.wasm filter=lfs diff=lfs merge=lfs -text -*.xz filter=lfs diff=lfs merge=lfs -text -*.zip filter=lfs diff=lfs merge=lfs -text -*.zst filter=lfs diff=lfs merge=lfs -text -*tfevents* filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..bb5b2dd7c5d2b14b9aba9d0929af8fb79c8fd257 --- /dev/null +++ b/.gitignore @@ -0,0 +1,68 @@ +# Ignore binary image files +*.jpg +*.jpeg +*.png +*.gif +*.bmp +*.webp +*.ico + +# Ignore specific image directories +app/static/images/dishes/*.jpg +app/static/images/dishes/*.jpeg +app/static/images/dishes/*.png +app/static/images/dishes/*.webp +app/static/images/logo/*.jpg + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Database files +*.db +*.sqlite +*.sqlite3 + +# Logs +*.log +logs/ + +# Firebase +*firebase*.json \ No newline at end of file diff --git a/DEMO_MODE_INSTRUCTIONS.md b/DEMO_MODE_INSTRUCTIONS.md new file mode 100644 index 0000000000000000000000000000000000000000..6c043cc0686da3c39bd3451e5218a1a7966a6f62 --- /dev/null +++ b/DEMO_MODE_INSTRUCTIONS.md @@ -0,0 +1,139 @@ +# Demo Mode Instructions + +This document provides instructions for using the demo mode feature that bypasses customer authentication for demonstration purposes. + +## Overview + +Demo mode allows seamless access to the customer interface without requiring phone authentication. When enabled, accessing `/customer` will automatically redirect to the menu page with pre-configured demo credentials. + +## Features + +- **Automatic Redirect**: Visiting `/customer` automatically redirects to the menu with demo credentials +- **Demo Customer**: Uses hardcoded customer ID (999999) with username "Demo Customer" +- **Demo Indicator**: Shows "🎭 DEMO MODE ACTIVE" badge in the menu header +- **Preserved Functionality**: All menu features work normally (ordering, cart, etc.) +- **Easy Toggle**: Simple configuration flag to enable/disable + +## How to Enable Demo Mode + +### 1. Frontend Configuration +Edit `frontend/src/config/demoConfig.js`: + +```javascript +export const DEMO_CONFIG = { + ENABLED: true, // Set to true to enable demo mode + // ... rest of config +}; +``` + +### 2. Backend Configuration +Edit `app/routers/customer.py`: + +```python +DEMO_MODE_ENABLED = True # Set to True to enable demo mode +``` + +## How to Disable Demo Mode + +### 1. Frontend Configuration +Edit `frontend/src/config/demoConfig.js`: + +```javascript +export const DEMO_CONFIG = { + ENABLED: false, // Set to false to disable demo mode + // ... rest of config +}; +``` + +### 2. Backend Configuration +Edit `app/routers/customer.py`: + +```python +DEMO_MODE_ENABLED = False # Set to False to disable demo mode +``` + +## Demo Credentials + +When demo mode is active, the following credentials are used: + +- **Customer ID**: 999999 +- **Username**: Demo Customer +- **Phone**: +91-DEMO-USER +- **Table Number**: 1 +- **Unique ID**: DEMO-SESSION-ID +- **Visit Count**: 5 (shows as returning customer) + +## Usage Instructions + +### For Demos + +1. **Enable demo mode** using the configuration above +2. **Start the application** (both frontend and backend) +3. **Navigate to** `http://localhost:3000/customer` +4. **Automatic redirect** to menu page with demo credentials +5. **Demo indicator** will be visible in the header +6. **Use normally** - all features work as expected + +### After Demo + +1. **Disable demo mode** using the configuration above +2. **Restart the application** to apply changes +3. **Normal authentication** will be required again + +## Technical Details + +### Files Modified + +- `frontend/src/config/demoConfig.js` - Demo configuration +- `frontend/src/pages/customer/Login.js` - Auto-redirect logic +- `frontend/src/pages/customer/components/HeroBanner.js` - Demo indicator +- `frontend/src/services/api.js` - Demo login API +- `app/routers/customer.py` - Demo login endpoint + +### API Endpoint + +- **POST** `/customer/api/demo-login` +- Creates or returns demo customer with ID 999999 +- Only works when `DEMO_MODE_ENABLED = True` + +### Security Notes + +- Demo mode should **NEVER** be enabled in production +- Demo customer ID (999999) is designed to avoid conflicts +- All demo data is clearly marked and identifiable +- Easy to clean up demo data if needed + +## Troubleshooting + +### Demo Mode Not Working + +1. Check both frontend and backend configuration flags +2. Restart both frontend and backend servers +3. Clear browser cache and localStorage +4. Check browser console for errors + +### Demo Customer Not Created + +1. Ensure backend demo mode is enabled +2. Check database connectivity +3. Verify hotel/database selection is working +4. Check backend logs for errors + +### Normal Authentication Still Required + +1. Verify frontend demo mode is enabled +2. Check that `isDemoModeEnabled()` returns true +3. Restart frontend development server +4. Clear browser cache + +## Reverting Changes + +To completely remove demo mode functionality: + +1. Delete `frontend/src/config/demoConfig.js` +2. Remove demo imports from Login.js and HeroBanner.js +3. Remove demo login endpoint from customer.py +4. Remove demo login method from api.js +5. Restart both servers + +This ensures a clean codebase without any demo-related code. diff --git a/DEPLOYMENT_SUMMARY.md b/DEPLOYMENT_SUMMARY.md new file mode 100644 index 0000000000000000000000000000000000000000..3d04340057faf2981d9edd62bca9db7e12e0d160 --- /dev/null +++ b/DEPLOYMENT_SUMMARY.md @@ -0,0 +1,139 @@ +# 🎯 Tabble-v3 Hugging Face Deployment Summary + +## ✅ Deployment Package Complete + +Your Tabble-v3 restaurant management system is now **ready for Hugging Face Spaces deployment**! + +### 📦 Created Files + +| File | Purpose | Status | +|------|---------|--------| +| `Dockerfile` | Docker configuration for HF Spaces | ✅ Ready | +| `app.py` | Main entry point for the application | ✅ Ready | +| `requirements.txt` | Updated Python dependencies | ✅ Ready | +| `templates/` | Complete web interface templates | ✅ Ready | +| `.dockerignore` | Docker build optimization | ✅ Ready | +| `.env.example` | Environment variables template | ✅ Ready | +| `README_HUGGINGFACE.md` | HF Space metadata | ✅ Ready | +| `HUGGINGFACE_DEPLOYMENT_GUIDE.md` | Deployment instructions | ✅ Ready | + +### 🌟 Key Features Ready + +- **🍽️ Customer Interface**: QR code ordering with phone OTP +- **👨‍🍳 Chef Dashboard**: Real-time order management +- **🏨 Admin Panel**: Complete restaurant management +- **📊 Analytics**: Built-in performance tracking +- **🗄️ Multi-Database**: Support for multiple hotels +- **📱 Responsive Design**: Works on all devices + +## 🚀 Quick Deploy Steps + +### 1. Repository Setup +```bash +# Your repository is ready with all required files +# Just push to GitHub if you haven't already +git add . +git commit -m "Ready for Hugging Face Spaces deployment" +git push origin main +``` + +### 2. Create Hugging Face Space +1. Go to: https://huggingface.co/new-space +2. Choose: **Docker SDK** +3. Connect your GitHub repository +4. Deploy automatically! + +### 3. Access Your App +Once deployed, your app will be available at: +``` +https://[your-username]-tabble-v3-[space-name].hf.space +``` + +## 🎮 Demo Credentials + +### For Testing +- **Hotel Access Code**: `myhotel` +- **Demo Database**: `tabble_new.db` +- **Table Numbers**: 1-20 +- **Phone OTP**: Any 6 digits (demo mode) + +### Admin Panel +- Go to `/admin` +- Enter hotel code: `myhotel` +- Manage dishes, orders, settings + +## 🔧 Production Customization + +### Add Your Restaurant +1. Edit `hotels.csv`: +```csv +hotel_database,password +your_restaurant.db,your_secure_password +``` + +2. Access admin panel and configure: + - Hotel information + - Menu items with images + - Pricing and categories + - Loyalty programs + +## 📊 What You Get + +### Customer Experience +- **QR Code Ordering**: Scan table QR codes +- **Phone Authentication**: Secure OTP login +- **Real-time Cart**: Live order updates +- **Payment Integration**: Ready for payment gateways + +### Staff Tools +- **Chef Dashboard**: Live order notifications +- **Admin Panel**: Complete management control +- **Analytics**: Customer and sales insights +- **Multi-Hotel**: Independent databases + +### Technical Features +- **FastAPI Backend**: High-performance API +- **SQLite Databases**: One per hotel +- **Responsive Templates**: Mobile-friendly +- **Real-time Updates**: Live order tracking + +## 🌍 Ready for Global Use + +Your system supports: +- **Multiple Languages**: Easily customizable templates +- **Different Currencies**: Update symbols in templates +- **Local Regulations**: Configurable tax and pricing +- **Custom Branding**: Upload logos and customize colors + +## 📈 Scaling Options + +### Hugging Face Spaces Tiers +- **Free Tier**: Perfect for testing and small restaurants +- **Pro Tier**: Enhanced performance and resources +- **Enterprise**: Dedicated resources and support + +### External Integrations +- **Payment Gateways**: Stripe, PayPal, local providers +- **SMS Services**: Twilio, AWS SNS for real OTP +- **Cloud Storage**: AWS S3 for dish images +- **Analytics**: Google Analytics, custom tracking + +## 🆘 Support Resources + +- **API Documentation**: Available at `/docs` in your deployed app +- **GitHub Repository**: Issues and discussions +- **Deployment Guide**: `HUGGINGFACE_DEPLOYMENT_GUIDE.md` +- **Technical Docs**: Complete README with all features + +## 🎉 Next Steps + +1. **Deploy**: Create your Hugging Face Space +2. **Test**: Verify all interfaces work +3. **Customize**: Add your restaurant details +4. **Launch**: Start serving customers! + +--- + +**Your modern restaurant management system is ready to revolutionize your business operations!** + +Deploy now and start serving customers with cutting-edge technology! 🚀🍽️ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..d5319f4430c22ae63e2295e10ffcacad5ad35c8b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# Use Python 3.11 slim image as base +FROM python:3.11-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PORT=7860 + +# Set work directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better caching +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Copy the application code +COPY . . + +# Create necessary directories +RUN mkdir -p app/static/images/dishes && \ + mkdir -p templates && \ + chmod 755 app/static/images + +# Initialize the database with sample data +RUN python init_db.py + +# Expose port 7860 (Hugging Face Spaces default) +EXPOSE 7860 + +# Health check +HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:7860/health || exit 1 + +# Start the application +CMD ["python", "app.py"] \ No newline at end of file diff --git a/HUGGINGFACE_DEPLOYMENT_GUIDE.md b/HUGGINGFACE_DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000000000000000000000000000000000000..e4a6fd52342f4dc74e6984b5e57fbda5cb8f73e2 --- /dev/null +++ b/HUGGINGFACE_DEPLOYMENT_GUIDE.md @@ -0,0 +1,93 @@ +# 🤗 Hugging Face Spaces Deployment Guide for Tabble-v3 + +## 🚀 Quick Deployment + +### Step 1: Prepare Repository +```bash +git clone https://github.com/your-username/tabble-v3.git +cd tabble-v3 + +# Verify required files exist: +# ✓ Dockerfile, app.py, requirements.txt +# ✓ templates/, app/, hotels.csv +``` + +### Step 2: Create Hugging Face Space +1. Go to [https://huggingface.co/new-space](https://huggingface.co/new-space) +2. Configure: + - **Space name**: `tabble-v3-restaurant` + - **SDK**: Docker + - **License**: MIT +3. Connect your GitHub repository +4. Add metadata to README.md: + +```yaml +--- +title: Tabble-v3 Restaurant Management +emoji: 🍽️ +colorFrom: blue +colorTo: purple +sdk: docker +app_port: 7860 +license: mit +tags: [restaurant, management, fastapi, qr-code] +--- +``` + +### Step 3: Monitor Build +- Watch build logs in "Logs" tab +- Wait for "Running on http://0.0.0.0:7860" message +- Verify health check at `/health` endpoint + +## 📱 Using Your App + +### Access URLs +- **Home**: `your-space-url/` +- **Customer**: `your-space-url/customer` +- **Chef**: `your-space-url/chef` +- **Admin**: `your-space-url/admin` +- **API Docs**: `your-space-url/docs` + +### Demo Credentials +- **Hotel Access Code**: `myhotel` +- **Tables**: 1-20 +- **Phone OTP**: Any 6 digits + +## 🔧 Customization + +### Add Your Hotel +Edit `hotels.csv`: +```csv +hotel_database,password +your_restaurant.db,secure_password_123 +``` + +### Menu Setup +1. Access admin panel with hotel code +2. Go to "Manage Dishes" +3. Add menu items with images and pricing + +## 🚑 Troubleshooting + +### Common Issues +1. **Build fails**: Check Dockerfile and requirements.txt +2. **App won't start**: Verify port 7860 and database init +3. **Templates missing**: Ensure templates/ directory exists + +### Debug Steps +1. Check Space logs for errors +2. Test `/health` endpoint +3. Verify admin panel loads with demo credentials + +## 📊 Production Ready + +### Security +- Update hotel passwords in `hotels.csv` +- Set `SECRET_KEY` environment variable + +### Features +- Upload menu images to `app/static/images/` +- Customize branding in templates +- Configure real SMS/payment services + +**Your restaurant management system is now live!** 🎉 \ No newline at end of file diff --git a/README.md b/README.md index 3fcc779b8c058d647ae687fecc2ea58f8a602a2a..069eae6939bcfd27ab72bd3264ad4718c2a6d456 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,717 @@ +# Tabble-v3 + +A modern restaurant management system built with Python FastAPI and React. +A comprehensive restaurant management system built with FastAPI (backend) and React (frontend), featuring QR code-based table ordering, phone OTP authentication, real-time order management, and multi-database support for independent hotel operations. + +## 🚀 Quick Deploy on Hugging Face Spaces + +[![Deploy on Hugging Face Spaces](https://huggingface.co/datasets/huggingface/badges/raw/main/deploy-on-spaces-md.svg)](https://huggingface.co/new-space?template=your-username/tabble-v3) + +Tabble-v3 is now optimized for **Hugging Face Spaces** deployment! Deploy your restaurant management system with just a few clicks. + +### 🌐 Live Demo +- **Demo URL**: [Your Space URL after deployment] +- **Admin Panel**: `/admin` (use access code: `myhotel`) +- **Chef Dashboard**: `/chef` +- **Customer Interface**: `/customer` +- **API Documentation**: `/docs` + +### 📦 One-Click Deployment + +1. **Fork this repository** to your GitHub account +2. **Go to [Hugging Face Spaces](https://huggingface.co/new-space)** +3. **Select "Docker" as the SDK** +4. **Connect your GitHub repository** +5. **Wait for automatic build and deployment** +6. **Access your live restaurant management system!** + +That's it! Your restaurant management system will be live and accessible to the world. + +### 🔧 Environment Configuration + +The application comes pre-configured for Hugging Face Spaces with: +- **Port**: 7860 (Hugging Face Spaces default) +- **Demo Database**: Pre-loaded with sample data +- **Sample Hotel**: "Demo Hotel" with access code `myhotel` +- **Templates**: Complete web interface included + +### 🏨 Multi-Hotel Setup + +To add your own hotel: + +1. **Edit `hotels.csv`**: + ```csv + hotel_database,password + tabble_new.db,myhotel + your_hotel.db,your_password + ``` + +2. **Create database** (optional - auto-created on first access): + ```bash + python create_empty_db.py + ``` + +3. **Restart your Space** to apply changes + +### 📱 Usage Guide + +#### For Customers: +1. Scan QR code at your table or visit `/customer` +2. Select your hotel and enter access code +3. Enter table number and phone number +4. Verify OTP and start ordering! + +#### For Restaurant Staff: +- **Chefs**: Visit `/chef` for order management +- **Admins**: Visit `/admin` for complete restaurant control +- **API**: Visit `/docs` for API documentation + +## 🌟 Key Features + +### 🍽️ Customer Interface +- **Phone OTP Authentication**: Secure Firebase-based authentication +- **Real-time Cart Management**: Live cart updates with special offers +- **Today's Specials**: Dynamic special dish recommendations +- **Payment Processing**: Integrated payment with loyalty discounts +- **Order History**: Track past orders and preferences + +### 👨‍🍳 Chef Dashboard +- **Real-time Order Management**: Live order notifications and updates +- **Kitchen Operations**: Streamlined order acceptance and completion +- **Order Status Updates**: Instant status changes reflected across all interfaces + +### 🏨 Admin Panel +- **Complete Restaurant Management**: Full control over restaurant operations +- **Dish Management**: Add, edit, and manage menu items with images +- **Offers & Specials**: Create and manage promotional offers +- **Table Management**: Monitor table occupancy and status +- **Order Tracking**: Complete order lifecycle management +- **Loyalty Program**: Configurable visit-based discount system +- **Selection Offers**: Amount-based discount configuration +- **Settings**: Hotel information and configuration management + +### 📊 Analytics Dashboard +- **Customer Analysis**: Detailed customer behavior insights +- **Dish Performance**: Menu item popularity and sales metrics +- **Chef Performance**: Kitchen efficiency tracking +- **Sales & Revenue**: Comprehensive financial reporting + +### 🗄️ Multi-Database Support +- **Independent Hotel Operations**: Each hotel operates with its own database +- **Database Authentication**: Secure database access with password protection +- **Session-based Management**: Consistent database context across all interfaces +- **Data Isolation**: Complete separation of hotel data for security and privacy + +## 📁 Project Structure + +``` +tabble/ +├── app/ # Backend FastAPI application +│ ├── database.py # Database configuration and models +│ ├── main.py # FastAPI application entry point +│ ├── middleware/ # Custom middleware (CORS, session handling) +│ ├── models/ # SQLAlchemy database models +│ ├── routers/ # API route definitions +│ │ ├── admin.py # Admin panel endpoints +│ │ ├── chef.py # Chef dashboard endpoints +│ │ ├── customer.py # Customer interface endpoints +│ │ └── analytics.py # Analytics and reporting endpoints +│ ├── services/ # Business logic and external services +│ │ ├── firebase_service.py # Firebase authentication +│ │ └── database_service.py # Database operations +│ ├── static/ # Static file serving +│ │ └── images/ # Dish and hotel logo images +│ │ └── dishes/ # Organized by database name +│ └── utils/ # Utility functions and helpers +├── frontend/ # React frontend application +│ ├── src/ +│ │ ├── components/ # Reusable React components +│ │ │ ├── Layout.js # Main layout wrapper +│ │ │ ├── AdminLayout.js # Admin panel layout +│ │ │ └── ChefLayout.js # Chef dashboard layout +│ │ ├── pages/ # Page components +│ │ │ ├── admin/ # Admin interface pages +│ │ │ ├── chef/ # Chef dashboard pages +│ │ │ ├── customer/ # Customer interface pages +│ │ │ └── analysis/ # Analytics dashboard +│ │ ├── services/ # API communication services +│ │ │ └── api.js # Axios configuration and API calls +│ │ ├── App.js # Main React application +│ │ ├── index.js # React DOM entry point +│ │ └── global.css # Global styling +│ ├── public/ # Static assets +│ │ ├── index.html # HTML template +│ │ └── favicon.ico # Application icon +│ ├── package.json # Node.js dependencies +│ ├── .env.example # Environment variables template +│ └── .env # Environment configuration +├── templates/ # Report generation templates +│ └── analysis/ # Analytics report templates +├── hotels.csv # Database registry and passwords +├── init_db.py # Database initialization with sample data +├── create_empty_db.py # Empty database creation utility +├── requirements.txt # Python dependencies +├── run.py # Backend server launcher +└── README.md # Project documentation +``` + +## 🚀 Quick Start Guide + +### Prerequisites + +#### For Windows: +- **Python 3.8+**: Download from [python.org](https://www.python.org/downloads/) +- **Node.js 16+**: Download from [nodejs.org](https://nodejs.org/downloads/) +- **Git**: Download from [git-scm.com](https://git-scm.com/downloads) + +#### For macOS: +- **Python 3.8+**: Install via Homebrew: `brew install python3` +- **Node.js 16+**: Install via Homebrew: `brew install node` +- **Git**: Install via Homebrew: `brew install git` + +### 🔧 Installation & Setup + +#### 1. Clone the Repository +```bash +git clone +cd tabble +``` + +#### 2. Backend Setup + +##### Windows: +```cmd +# Create virtual environment +python -m venv venv + +# Activate virtual environment +venv\Scripts\activate + +# Install dependencies +pip install -r requirements.txt +``` + +##### macOS/Linux: +```bash +# Create virtual environment +python3 -m venv venv + +# Activate virtual environment +source venv/bin/activate + +# Install dependencies +pip install -r requirements.txt +``` + +#### 3. Frontend Setup + +##### Both Windows and macOS: +```bash +# Navigate to frontend directory +cd frontend + +# Copy environment template +cp .env.example .env + +# Install Node.js dependencies +npm install +``` + +#### 4. Configure Environment Variables + +##### Backend (.env in root directory): +```env +SECRET_KEY=your_secret_key_here +``` + +##### Frontend (frontend/.env): +```env +# Backend API Configuration +REACT_APP_API_BASE_URL=http://localhost:8000 + +# Development settings +NODE_ENV=development + +# Firebase Configuration (optional) +# REACT_APP_FIREBASE_API_KEY=your_firebase_api_key +# REACT_APP_FIREBASE_AUTH_DOMAIN=your_project.firebaseapp.com +# REACT_APP_FIREBASE_PROJECT_ID=your_project_id +``` + +## 🗄️ Database Management + +### Understanding the Multi-Database System + +Tabble supports multiple independent hotel databases, allowing each hotel to operate with complete data isolation. Each database contains: + +- **Dishes**: Menu items with pricing, categories, and images +- **Orders**: Customer orders and order items +- **Persons**: Customer information and visit history +- **Tables**: Table management and occupancy status +- **Loyalty Program**: Visit-based discount tiers +- **Selection Offers**: Amount-based promotional offers +- **Settings**: Hotel-specific configuration +- **Feedback**: Customer reviews and ratings + +### Database Registry (hotels.csv) + +The `hotels.csv` file serves as the central registry for all hotel databases: + +```csv +hotel_database,password +tabble_new.db,myhotel + +``` + +### Creating a New Hotel Database + +#### Method 1: Using the create_empty_db.py Script + +##### Windows: +```cmd +# Activate virtual environment +venv\Scripts\activate + +# Run the database creation script +python create_empty_db.py +``` + +##### macOS/Linux: +```bash +# Activate virtual environment +source venv/bin/activate + +# Run the database creation script +python create_empty_db.py +``` + +**Interactive Process:** +1. The script will prompt you for a database name +2. Enter the hotel name (without .db extension) +3. The script creates an empty database with proper schema +4. Manually add the database entry to `hotels.csv` + +**Example:** +``` +Creating a new empty database with the proper schema +Enter name for the new database (without .db extension): newhotel + +Success! Created empty database 'newhotel.db' with the proper schema +``` + +Then add to `hotels.csv`: +```csv +newhotel.db,newhotel123 +``` + +#### Method 2: Initialize with Sample Data + +##### Windows: +```cmd +# Create database with sample data +python init_db.py +``` + +##### macOS/Linux: +```bash +# Create database with sample data +python init_db.py +``` + +**Note:** This creates `tabble_new.db` with sample dishes, users, and configuration. + +### Database Schema Details + +The `create_empty_db.py` script creates the following tables: + +#### Core Tables: +- **dishes**: Menu items with pricing, categories, offers, and visibility +- **persons**: Customer profiles with visit tracking +- **orders**: Order management with status tracking +- **order_items**: Individual items within orders +- **tables**: Table management and occupancy status + +#### Configuration Tables: +- **loyalty_program**: Visit-based discount configuration +- **selection_offers**: Amount-based promotional offers +- **settings**: Hotel information and branding +- **feedback**: Customer reviews and ratings + +### Running the Application + +#### Start Backend Server + +##### Windows: +```cmd +# Activate virtual environment +venv\Scripts\activate + +# Start the FastAPI server +python run.py +``` + +##### macOS/Linux: +```bash +# Activate virtual environment +source venv/bin/activate + +# Start the FastAPI server +python run.py +``` + +The backend will be available at `http://localhost:8000` + +#### Start Frontend Development Server + +##### Both Windows and macOS: +```bash +# Navigate to frontend directory +cd frontend + +# Start React development server +npm start +``` + +The frontend will be available at `http://localhost:3000` + +### 🔗 API Documentation + +Once the backend is running, access the interactive API documentation: +- **Swagger UI**: `http://localhost:8000/docs` +- **ReDoc**: `http://localhost:8000/redoc` + +## 🎯 Key Features Implementation + +### 🍽️ Table Management +- **QR Code Generation**: Automatic QR code creation for each table +- **Real-time Status Monitoring**: Live table occupancy tracking +- **Session-based Occupancy**: Table status changes based on customer interaction +- **Multi-database Support**: Table management per hotel database + +### 📱 Order Processing +- **Real-time Order Tracking**: Live order status updates across all interfaces +- **Kitchen Notifications**: Instant order notifications to chef dashboard +- **Status Synchronization**: Order status changes reflect immediately +- **Payment Integration**: Secure payment processing with loyalty discounts + +### 📊 Analytics and Reporting +- **Custom Report Templates**: Configurable analytics reports +- **PDF Generation**: Automated report exports +- **Performance Metrics**: Comprehensive business intelligence +- **Multi-dimensional Analysis**: Customer, dish, and chef performance tracking + +### 🔐 Authentication & Security +- **Firebase Phone OTP**: Secure customer authentication +- **Database Password Protection**: Hotel database access control +- **Session Management**: Secure session handling across interfaces +- **Data Isolation**: Complete separation of hotel data + +## 🚨 Troubleshooting + +### Common Issues + +#### Backend Issues: +```bash +# If you get "Module not found" errors +pip install -r requirements.txt + +# If database connection fails +python create_empty_db.py + +# If port 8000 is already in use +# Edit run.py and change the port number +``` + +#### Frontend Issues: +```bash +# If npm install fails +npm cache clean --force +npm install + +# If environment variables aren't loading +# Check that .env file exists in frontend directory +cp .env.example .env + +# If API calls fail +# Verify REACT_APP_API_BASE_URL in frontend/.env +``` + +#### Database Issues: +```bash +# If database schema is outdated +python init_db.py --force-reset + +# If hotels.csv is missing entries +# Manually add your database to hotels.csv +``` + +### Platform-Specific Notes + +#### Windows: +- Use `venv\Scripts\activate` to activate virtual environment +- Use `python` command (not `python3`) +- Ensure Python is added to PATH during installation + +#### macOS: +- Use `source venv/bin/activate` to activate virtual environment +- Use `python3` command for Python 3.x +- Install Xcode Command Line Tools if needed: `xcode-select --install` + +## 🔄 Development Workflow + +### Adding a New Hotel Database + +1. **Create Empty Database:** + ```bash + python create_empty_db.py + ``` + +2. **Add to Registry:** + Edit `hotels.csv` and add your database entry: + ```csv + yourhotel.db,yourpassword123 + ``` + +3. **Configure Hotel Settings:** + - Access admin panel: `http://localhost:3000/admin` + - Navigate to Settings + - Configure hotel information + +4. **Add Menu Items:** + - Use admin panel to add dishes + - Upload dish images to `app/static/images/dishes/yourhotel/` + +### Deployment Considerations + +#### Production Environment Variables: +```env +# Backend +SECRET_KEY=your_production_secret_key +DATABASE_URL=your_production_database_url + +# Frontend +REACT_APP_API_BASE_URL=https://your-domain.com/api +NODE_ENV=production +``` + +#### Image Storage: +- Images are stored in `app/static/images/dishes/{database_name}/` +- Ensure proper directory permissions for image uploads +- Consider using cloud storage for production deployments + +## 🤗 Hugging Face Spaces Deployment Guide + +### 🚀 Quick Deployment Steps + +#### 1. Prepare Your Repository +```bash +# Clone or fork this repository +git clone https://github.com/your-username/tabble-v3.git +cd tabble-v3 + +# Ensure all required files are present: +# ✓ Dockerfile +# ✓ app.py +# ✓ requirements.txt +# ✓ templates/ +# ✓ app/ +``` + +#### 2. Create Hugging Face Space + +1. **Go to**: [https://huggingface.co/new-space](https://huggingface.co/new-space) +2. **Fill in details**: + - **Space name**: `tabble-v3-restaurant` (or your preferred name) + - **License**: MIT + - **SDK**: Docker + - **Visibility**: Public (or Private) +3. **Connect Repository**: Link your GitHub repository +4. **Create Space** + +#### 3. Configure Space Settings + +Add this to your Space's `README.md` header: +```yaml --- -title: Tableeee V3 -emoji: 📚 -colorFrom: purple -colorTo: blue +title: Tabble-v3 Restaurant Management +emoji: 🍽️ +colorFrom: blue +colorTo: purple sdk: docker +app_port: 7860 pinned: false +license: mit +short_description: Modern restaurant management system with QR ordering +tags: +- restaurant +- management +- fastapi +- qr-code +- ordering --- +``` + +#### 4. Automatic Build Process + +Hugging Face Spaces will automatically: +1. ✅ Pull your repository +2. ✅ Build Docker image using your Dockerfile +3. ✅ Initialize database with sample data +4. ✅ Start the application on port 7860 +5. ✅ Provide public URL for access + +### 📱 Using Your Deployed App + +#### 📋 Access Points + +| Interface | URL | Description | +|-----------|-----|-------------| +| **Home** | `/` | Main landing page with interface selection | +| **Customer** | `/customer` | QR code ordering interface | +| **Chef** | `/chef` | Kitchen order management | +| **Admin** | `/admin` | Complete restaurant management | +| **API Docs** | `/docs` | Interactive API documentation | +| **Health** | `/health` | System health check | + +#### 🔑 Demo Credentials + +- **Hotel Access Code**: `myhotel` +- **Demo Database**: `tabble_new.db` +- **Sample Tables**: 1-20 +- **Phone OTP**: Any 6 digits (demo mode) + +### 🔧 Customization for Production + +#### 1. Hotel Configuration + +Edit `hotels.csv`: +```csv +hotel_database,password +your_restaurant.db,secure_password_123 +another_hotel.db,different_password_456 +``` + +#### 2. Environment Variables + +Create `.env` file: +```env +SECRET_KEY=your_production_secret_key +HUGGINGFACE_SPACES=1 +PORT=7860 +LOG_LEVEL=INFO +``` + +#### 3. Database Initialization + +Create custom database: +```python +# Run this script to create your hotel database +python create_empty_db.py +``` + +#### 4. Menu Setup + +1. Access admin panel: `your-space-url/admin` +2. Enter your hotel access code +3. Navigate to "Manage Dishes" +4. Add your menu items with images +5. Configure pricing and categories + +### 📊 Monitoring and Analytics + +#### Built-in Features +- ✅ Real-time order tracking +- ✅ Customer analytics dashboard +- ✅ Sales performance metrics +- ✅ Kitchen efficiency reports +- ✅ Revenue analytics + +#### Health Monitoring +- **Health Check**: `your-space-url/health` +- **API Status**: Monitor via `/docs` endpoint +- **Database Status**: Check via admin panel + +### 🚑 Troubleshooting + +#### Common Issues + +1. **Build Failures**: + ```bash + # Check Dockerfile syntax + # Verify requirements.txt dependencies + # Ensure all required files are present + ``` + +2. **Database Issues**: + ```bash + # Check hotels.csv format + # Verify database permissions + # Run database initialization + ``` + +3. **Template Loading**: + ```bash + # Ensure templates/ directory exists + # Check template file paths + # Verify Jinja2 syntax + ``` + +#### Getting Help + +- **API Documentation**: `your-space-url/docs` +- **System Status**: `your-space-url/health` +- **Logs**: Check Hugging Face Spaces logs tab +- **Issues**: Create issue in GitHub repository + +### 🔄 Updates and Maintenance + +#### Updating Your Space + +1. **Push changes** to your GitHub repository +2. **Hugging Face Spaces** will auto-rebuild +3. **Monitor build logs** in Spaces interface +4. **Test functionality** after deployment + +#### Backup Strategies + +1. **Database Backup**: + ```python + # Use admin panel backup feature + # Or manually copy .db files + ``` + +2. **Configuration Backup**: + ```bash + # Backup hotels.csv + # Backup custom templates + # Backup uploaded images + ``` + +### 🎆 Advanced Features + +#### Multi-Language Support +- Modify templates for your language +- Update form labels and messages +- Configure number and currency formats + +#### Custom Branding +- Upload your logo to static files +- Modify CSS in templates +- Update color schemes and fonts + +#### Integration Options +- **Payment Gateways**: Add payment processing +- **SMS Services**: Integrate real OTP services +- **Analytics**: Connect to external analytics tools +- **POS Systems**: API integration capabilities + +--- + +## 📞 Support and Community + +- **Documentation**: Complete API docs at `/docs` +- **GitHub Issues**: Report bugs and request features +- **Community**: Join discussions in GitHub Discussions +- **Updates**: Follow repository for latest features -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +**Ready to revolutionize your restaurant management? Deploy on Hugging Face Spaces now!** 🚀🍽️ diff --git a/README_HUGGINGFACE.md b/README_HUGGINGFACE.md new file mode 100644 index 0000000000000000000000000000000000000000..5296475634c90e3cd3569dbd7ff169b9618d8646 --- /dev/null +++ b/README_HUGGINGFACE.md @@ -0,0 +1,17 @@ +title: Tabble-v3 Restaurant Management System +emoji: 🍽️ +colorFrom: blue +colorTo: purple +sdk: docker +app_port: 7860 +pinned: false +license: mit +short_description: Modern restaurant management system with QR ordering and real-time analytics +tags: +- restaurant +- management +- fastapi +- qr-code +- ordering +- analytics +- multi-database \ No newline at end of file diff --git a/TABLE_MANAGEMENT_IMPLEMENTATION.md b/TABLE_MANAGEMENT_IMPLEMENTATION.md new file mode 100644 index 0000000000000000000000000000000000000000..f5189a3253439dd96bf0e3d2a3a3ec52f9b2b549 --- /dev/null +++ b/TABLE_MANAGEMENT_IMPLEMENTATION.md @@ -0,0 +1,162 @@ +# Table Management Implementation + +## Overview + +This document describes the implementation of database-specific table occupancy management based on user navigation between home page and customer pages. + +## Requirements Implemented + +1. **Database Selection**: Users first select database and verify password from hotels.csv +2. **Table Occupancy Logic**: + - `is_occupied = 0` when user is on home page (table free) + - `is_occupied = 1` when user is on customer page (table occupied) +3. **Database Independence**: All table operations work within the selected database +4. **Hotel Manager Visibility**: Table status is for hotel manager visibility only + +## Implementation Details + +### Backend Changes + +#### 1. New API Endpoint +- **Endpoint**: `PUT /tables/number/{table_number}/free` +- **Purpose**: Set table as free by table number +- **Location**: `app/routers/table.py` + +```python +@router.put("/number/{table_number}/free", response_model=Table) +def set_table_free_by_number(table_number: int, db: Session = Depends(get_db)): + # Implementation details in the file +``` + +#### 2. Database Schema Update +- **Added Field**: `last_occupied_at` to tables table +- **Type**: `DATETIME`, nullable +- **Purpose**: Track when table was last occupied +- **Files Modified**: + - `app/database.py` (SQLAlchemy model) + - `app/models/table.py` (Pydantic models) + +#### 3. Migration Support +- **Script**: `migrate_table_schema.py` +- **Purpose**: Add `last_occupied_at` column to existing databases +- **Usage**: Run before starting the application with existing databases + +### Frontend Changes + +#### 1. API Service Updates +- **File**: `frontend/src/services/api.js` +- **New Methods**: + - `customerService.setTableFreeByNumber(tableNumber)` + - `adminService.setTableFreeByNumber(tableNumber)` + +#### 2. Home Page Updates +- **File**: `frontend/src/pages/Home.js` +- **Changes**: + - Added `freeTableOnHomeReturn()` function + - Automatically frees table when user returns to home page + - Uses selected database for table operations + +#### 3. Customer Menu Updates +- **File**: `frontend/src/pages/customer/Menu.js` +- **Changes**: + - Enhanced back-to-home button to free table before navigation + - Added `beforeunload` event listener for browser close/refresh + - Uses `navigator.sendBeacon` for reliable cleanup + +## Database Independence + +### How It Works +1. **Database Selection**: Users select database on home page +2. **Session Management**: Database credentials stored in localStorage +3. **API Calls**: All table operations use the selected database +4. **Isolation**: Each database maintains its own table occupancy state + +### Storage Keys +- `customerSelectedDatabase`: Selected database name +- `customerDatabasePassword`: Database password +- `tableNumber`: Current table number + +## Table Occupancy Flow + +### User Journey +1. **Home Page**: User selects database and table number + - Table status: `is_occupied = 0` (free) +2. **Navigate to Customer Page**: User enters customer interface + - Table status: `is_occupied = 1` (occupied) + - API call: `PUT /tables/number/{table_number}/occupy` +3. **Return to Home**: User clicks back button or navigates away + - Table status: `is_occupied = 0` (free) + - API call: `PUT /tables/number/{table_number}/free` + +### Cleanup Scenarios +1. **Back Button**: Explicit table freeing before navigation +2. **Browser Close**: `beforeunload` event with `navigator.sendBeacon` +3. **Page Refresh**: `beforeunload` event with `navigator.sendBeacon` +4. **Direct Navigation**: Home page automatically frees table on load + +## Testing + +### Test Script +- **File**: `test_table_management.py` +- **Purpose**: Verify table management functionality +- **Tests**: + - Database selection + - Table creation + - Table occupation + - Table freeing + - Status retrieval + +### Running Tests +```bash +python test_table_management.py +``` + +## Migration + +### For Existing Databases +```bash +python migrate_table_schema.py +``` + +This will: +- Find all .db files in current directory +- Add `last_occupied_at` column if missing +- Preserve existing data + +## API Endpoints Summary + +| Method | Endpoint | Purpose | +|--------|----------|---------| +| PUT | `/tables/number/{table_number}/occupy` | Set table as occupied | +| PUT | `/tables/number/{table_number}/free` | Set table as free | +| GET | `/tables/status/summary` | Get table status summary | +| GET | `/tables/number/{table_number}` | Get table by number | + +## Error Handling + +- **Table Not Found**: Returns 404 error +- **Database Connection**: Graceful fallback and error logging +- **Network Issues**: Silent failure for cleanup operations +- **Invalid Table Number**: Validation and error messages + +## Security Considerations + +- **Database Access**: Password-protected database selection +- **Session Management**: Credentials stored in localStorage +- **API Security**: Database switching requires authentication +- **Data Isolation**: Complete separation between databases + +## Performance Optimizations + +- **Minimal API Calls**: Only when necessary +- **Async Operations**: Non-blocking table updates +- **Error Recovery**: Graceful handling of failed operations +- **Cleanup Efficiency**: `navigator.sendBeacon` for reliable cleanup + +## Future Enhancements + +1. **Real-time Updates**: WebSocket for live table status +2. **Table Reservations**: Advanced booking system +3. **Analytics**: Table utilization tracking +4. **Mobile Support**: Touch-optimized interface +5. **Multi-language**: Internationalization support diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..576d69056f5828077ff5b4e7751dc5ce691f636b --- /dev/null +++ b/app.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +""" +Tabble-v3 Restaurant Management System +Entry point for Hugging Face Spaces deployment +""" + +import uvicorn +import os +import sys +from pathlib import Path + +# Add the app directory to the Python path +app_dir = Path(__file__).parent / "app" +sys.path.insert(0, str(app_dir)) + +def main(): + """Main entry point for the application""" + + # Create static directories if they don't exist + os.makedirs("app/static/images/dishes", exist_ok=True) + os.makedirs("templates", exist_ok=True) + + # Set environment variables for Hugging Face Spaces + os.environ.setdefault("HUGGINGFACE_SPACES", "1") + + # Get port from environment (Hugging Face Spaces uses 7860) + port = int(os.environ.get("PORT", 7860)) + host = os.environ.get("HOST", "0.0.0.0") + + print(f"🚀 Starting Tabble-v3 Restaurant Management System") + print(f"📡 Server: http://{host}:{port}") + print(f"📖 API Documentation: http://{host}:{port}/docs") + print(f"🏥 Health Check: http://{host}:{port}/health") + print("=" * 60) + + # Start the FastAPI application + uvicorn.run( + "app.main:app", + host=host, + port=port, + reload=False, # Disable reload in production + log_level="info", + access_log=True + ) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e1f2a6104d8e27309e2ca707ac373fe37449ca51 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,8 @@ +""" +Tabble-v3 Restaurant Management System +Main application package +""" + +__version__ = "3.1.0" +__author__ = "Tabble Development Team" +__description__ = "Modern restaurant management system with QR ordering and real-time analytics" \ No newline at end of file diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000000000000000000000000000000000000..8fc013596c04e6d218d97cc7b2ae9e03fc41c1b1 --- /dev/null +++ b/app/database.py @@ -0,0 +1,473 @@ +from sqlalchemy import ( + create_engine, + Column, + Integer, + String, + Float, + ForeignKey, + DateTime, + Text, + Boolean, + UniqueConstraint, +) +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, relationship, scoped_session +from datetime import datetime, timezone +import os +import threading +from typing import Dict, Optional +import uuid + +# Base declarative class +Base = declarative_base() + +# Session-based database manager with hotel context +class DatabaseManager: + def __init__(self): + self.sessions: Dict[str, dict] = {} + self.lock = threading.Lock() + self.unified_database = "Tabble.db" + + def get_session_id(self, request_headers: dict) -> str: + """Generate or retrieve session ID from request headers""" + session_id = request_headers.get('x-session-id') + if not session_id: + session_id = str(uuid.uuid4()) + return session_id + + def get_database_connection(self, session_id: str, hotel_id: Optional[int] = None) -> dict: + """Get or create database connection for session with hotel context""" + with self.lock: + if session_id not in self.sessions: + # Create new session with unified database + self.sessions[session_id] = self._create_connection(hotel_id) + elif hotel_id and self.sessions[session_id].get('hotel_id') != hotel_id: + # Update hotel context for existing session + self.sessions[session_id]['hotel_id'] = hotel_id + + return self.sessions[session_id] + + def _create_connection(self, hotel_id: Optional[int] = None) -> dict: + """Create a new database connection to unified database""" + database_url = f"sqlite:///./Tabble.db" + engine = create_engine(database_url, connect_args={"check_same_thread": False}) + session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine) + session_local = scoped_session(session_factory) + + # Create tables in the database if they don't exist + Base.metadata.create_all(bind=engine) + + return { + 'database_name': self.unified_database, + 'database_url': database_url, + 'engine': engine, + 'session_local': session_local, + 'hotel_id': hotel_id + } + + def _dispose_connection(self, session_id: str): + """Dispose of database connection for session""" + if session_id in self.sessions: + connection = self.sessions[session_id] + connection['session_local'].remove() + connection['engine'].dispose() + + def set_hotel_context(self, session_id: str, hotel_id: int) -> bool: + """Set hotel context for a specific session""" + try: + self.get_database_connection(session_id, hotel_id) + print(f"Session {session_id} set to hotel_id: {hotel_id}") + return True + except Exception as e: + print(f"Error setting hotel context for session {session_id}: {e}") + return False + + def get_current_hotel_id(self, session_id: str) -> Optional[int]: + """Get current hotel_id for session""" + if session_id in self.sessions: + return self.sessions[session_id].get('hotel_id') + return None + + def get_current_database(self, session_id: str) -> str: + """Get current database name for session (always Tabble.db)""" + return self.unified_database + + def authenticate_hotel(self, hotel_name: str, password: str) -> Optional[int]: + """Authenticate hotel and return hotel_id""" + try: + # Use global engine to query hotels table + from sqlalchemy.orm import sessionmaker + Session = sessionmaker(bind=engine) + db = Session() + + hotel = db.query(Hotel).filter( + Hotel.hotel_name == hotel_name, + Hotel.password == password + ).first() + + db.close() + + if hotel: + return hotel.id + return None + except Exception as e: + print(f"Error authenticating hotel {hotel_name}: {e}") + return None + + def cleanup_session(self, session_id: str): + """Clean up session resources""" + with self.lock: + if session_id in self.sessions: + self._dispose_connection(session_id) + del self.sessions[session_id] + +# Global database manager instance +db_manager = DatabaseManager() + +# Global variables for database connection (unified database) +CURRENT_DATABASE = "Tabble.db" +DATABASE_URL = f"sqlite:///./Tabble.db" # Using the unified database +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine) +SessionLocal = scoped_session(session_factory) + +# Lock for thread safety when switching databases +db_lock = threading.Lock() + + +# Database models +class Hotel(Base): + __tablename__ = "hotels" + + id = Column(Integer, primary_key=True, index=True) + hotel_name = Column(String, unique=True, index=True, nullable=False) + password = Column(String, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column( + DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + # Relationships + dishes = relationship("Dish", back_populates="hotel") + persons = relationship("Person", back_populates="hotel") + orders = relationship("Order", back_populates="hotel") + tables = relationship("Table", back_populates="hotel") + settings = relationship("Settings", back_populates="hotel") + feedback = relationship("Feedback", back_populates="hotel") + loyalty_tiers = relationship("LoyaltyProgram", back_populates="hotel") + selection_offers = relationship("SelectionOffer", back_populates="hotel") + + +class Dish(Base): + __tablename__ = "dishes" + + id = Column(Integer, primary_key=True, index=True) + hotel_id = Column(Integer, ForeignKey("hotels.id"), nullable=False, index=True) + name = Column(String, index=True) + description = Column(Text, nullable=True) + category = Column(String, index=True) # Now stores JSON array for multiple categories + price = Column(Float) + quantity = Column(Integer, default=0) # Made optional in forms, but keeps default + image_path = Column(String, nullable=True) + discount = Column(Float, default=0) # Discount amount (percentage) + is_offer = Column(Integer, default=0) # 0 = not an offer, 1 = is an offer + is_special = Column(Integer, default=0) # 0 = not special, 1 = today's special + is_vegetarian = Column(Integer, default=1) # 1 = vegetarian, 0 = non-vegetarian + visibility = Column(Integer, default=1) # 1 = visible, 0 = hidden (soft delete) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column( + DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + # Relationships + hotel = relationship("Hotel", back_populates="dishes") + + # Relationship with OrderItem + order_items = relationship("OrderItem", back_populates="dish") + + +class Order(Base): + __tablename__ = "orders" + + id = Column(Integer, primary_key=True, index=True) + hotel_id = Column(Integer, ForeignKey("hotels.id"), nullable=False, index=True) + table_number = Column(Integer) + unique_id = Column(String, index=True) + person_id = Column(Integer, ForeignKey("persons.id"), nullable=True) + status = Column(String, default="pending") # pending, accepted, completed, paid + total_amount = Column(Float, nullable=True) # Final amount paid after all discounts + subtotal_amount = Column(Float, nullable=True) # Original amount before discounts + loyalty_discount_amount = Column(Float, default=0) # Loyalty discount applied + selection_offer_discount_amount = Column(Float, default=0) # Selection offer discount applied + loyalty_discount_percentage = Column(Float, default=0) # Loyalty discount percentage applied + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column( + DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + # Relationships + hotel = relationship("Hotel", back_populates="orders") + items = relationship("OrderItem", back_populates="order") + person = relationship("Person", back_populates="orders") + + +class Person(Base): + __tablename__ = "persons" + + id = Column(Integer, primary_key=True, index=True) + hotel_id = Column(Integer, ForeignKey("hotels.id"), nullable=False, index=True) + username = Column(String, index=True) + password = Column(String) + phone_number = Column(String, index=True, nullable=True) # Added phone number field + visit_count = Column(Integer, default=0) + last_visit = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + # Unique constraints per hotel + __table_args__ = ( + UniqueConstraint('hotel_id', 'username', name='uq_person_hotel_username'), + UniqueConstraint('hotel_id', 'phone_number', name='uq_person_hotel_phone'), + ) + + # Relationships + hotel = relationship("Hotel", back_populates="persons") + orders = relationship("Order", back_populates="person") + + +class OrderItem(Base): + __tablename__ = "order_items" + + id = Column(Integer, primary_key=True, index=True) + hotel_id = Column(Integer, ForeignKey("hotels.id"), nullable=False, index=True) + order_id = Column(Integer, ForeignKey("orders.id")) + dish_id = Column(Integer, ForeignKey("dishes.id")) + quantity = Column(Integer, default=1) + price = Column(Float, nullable=True) # Price at time of order + remarks = Column(Text, nullable=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + # Relationships + order = relationship("Order", back_populates="items") + dish = relationship("Dish", back_populates="order_items") + + +class Feedback(Base): + __tablename__ = "feedback" + + id = Column(Integer, primary_key=True, index=True) + hotel_id = Column(Integer, ForeignKey("hotels.id"), nullable=False, index=True) + order_id = Column(Integer, ForeignKey("orders.id")) + person_id = Column(Integer, ForeignKey("persons.id"), nullable=True) + rating = Column(Integer) # 1-5 stars + comment = Column(Text, nullable=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + # Relationships + hotel = relationship("Hotel", back_populates="feedback") + order = relationship("Order") + person = relationship("Person") + + +class LoyaltyProgram(Base): + __tablename__ = "loyalty_tiers" + + id = Column(Integer, primary_key=True, index=True) + hotel_id = Column(Integer, ForeignKey("hotels.id"), nullable=False, index=True) + visit_count = Column(Integer) # Number of visits required + discount_percentage = Column(Float) # Discount percentage + is_active = Column(Boolean, default=True) # Whether this loyalty tier is active + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column( + DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + # Unique constraint per hotel + __table_args__ = ( + UniqueConstraint('hotel_id', 'visit_count', name='uq_loyalty_hotel_visits'), + ) + + # Relationships + hotel = relationship("Hotel", back_populates="loyalty_tiers") + + +class SelectionOffer(Base): + __tablename__ = "selection_offers" + + id = Column(Integer, primary_key=True, index=True) + hotel_id = Column(Integer, ForeignKey("hotels.id"), nullable=False, index=True) + min_amount = Column(Float) # Minimum order amount to qualify + discount_amount = Column(Float) # Fixed discount amount to apply + is_active = Column(Boolean, default=True) # Whether this offer is active + description = Column(String, nullable=True) # Optional description of the offer + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column( + DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + # Relationships + hotel = relationship("Hotel", back_populates="selection_offers") + + +class Table(Base): + __tablename__ = "tables" + + id = Column(Integer, primary_key=True, index=True) + hotel_id = Column(Integer, ForeignKey("hotels.id"), nullable=False, index=True) + table_number = Column(Integer) # Table number + is_occupied = Column( + Boolean, default=False + ) # Whether the table is currently occupied + current_order_id = Column( + Integer, ForeignKey("orders.id"), nullable=True + ) # Current active order + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column( + DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + # Unique constraint per hotel + __table_args__ = ( + UniqueConstraint('hotel_id', 'table_number', name='uq_table_hotel_number'), + ) + + # Relationships + hotel = relationship("Hotel", back_populates="tables") + current_order = relationship("Order", foreign_keys=[current_order_id]) + + +class Settings(Base): + __tablename__ = "settings" + + id = Column(Integer, primary_key=True, index=True) + hotel_id = Column(Integer, ForeignKey("hotels.id"), nullable=False, index=True) + hotel_name = Column(String, nullable=False, default="Tabble Hotel") + address = Column(String, nullable=True) + contact_number = Column(String, nullable=True) + email = Column(String, nullable=True) + tax_id = Column(String, nullable=True) + logo_path = Column(String, nullable=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column( + DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + # Unique constraint per hotel + __table_args__ = ( + UniqueConstraint('hotel_id', name='uq_settings_hotel'), + ) + + # Relationships + hotel = relationship("Hotel", back_populates="settings") + + +# Function to switch database +def switch_database(database_name): + global CURRENT_DATABASE, DATABASE_URL, engine, SessionLocal + + with db_lock: + if database_name == CURRENT_DATABASE: + return # Already using this database + + # Update global variables + CURRENT_DATABASE = database_name + DATABASE_URL = f"sqlite:///./tabble_new.db" if database_name == "tabble_new.db" else f"sqlite:///./{database_name}" + + # Dispose of the old engine and create a new one + engine.dispose() + engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) + + # Create a new session factory and scoped session + session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine) + SessionLocal.remove() + SessionLocal = scoped_session(session_factory) + + # Create tables in the new database if they don't exist + create_tables() + + print(f"Switched to database: {database_name}") + + +# Get current database name +def get_current_database(): + return CURRENT_DATABASE + + +# Create tables +def create_tables(): + # Create all tables (only creates tables that don't exist) + Base.metadata.create_all(bind=engine) + print("Database tables created/verified successfully") + + +# Get database session (legacy) +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +# Session-aware database functions with hotel context +def get_session_db(session_id: str, hotel_id: Optional[int] = None): + """Get database session for a specific session ID with hotel context""" + connection = db_manager.get_database_connection(session_id, hotel_id) + db = connection['session_local']() + try: + yield db + finally: + db.close() + + +def set_session_hotel_context(session_id: str, hotel_id: int) -> bool: + """Set hotel context for a specific session""" + return db_manager.set_hotel_context(session_id, hotel_id) + + +def get_session_hotel_id(session_id: str) -> Optional[int]: + """Get current hotel_id for a session""" + return db_manager.get_current_hotel_id(session_id) + + +def get_session_current_database(session_id: str) -> str: + """Get current database name for a session (always Tabble.db)""" + return db_manager.get_current_database(session_id) + + +def authenticate_hotel_session(hotel_name: str, password: str) -> Optional[int]: + """Authenticate hotel and return hotel_id""" + return db_manager.authenticate_hotel(hotel_name, password) + + +def cleanup_session_db(session_id: str): + """Clean up database resources for a session""" + db_manager.cleanup_session(session_id) + + +# Helper function to get hotel_id from request +def get_hotel_id_from_request(request) -> int: + """Get hotel_id from request session, raise HTTPException if not found""" + from fastapi import HTTPException + from .middleware import get_session_id + + session_id = get_session_id(request) + hotel_id = get_session_hotel_id(session_id) + + if not hotel_id: + raise HTTPException(status_code=400, detail="No hotel context set") + + return hotel_id diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..0454a2a5103f0315f44f631df0b94d46b64a088d --- /dev/null +++ b/app/main.py @@ -0,0 +1,116 @@ +from fastapi import FastAPI, Request, Depends +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.orm import Session +import uvicorn +import os + +from .database import get_db, create_tables +from .routers import chef, customer, admin, feedback, loyalty, selection_offer, table, analytics, settings +from .middleware import SessionMiddleware + +# Create FastAPI app +app = FastAPI(title="Tabble - Hotel Management App") + +# Add CORS middleware to allow cross-origin requests +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Allow all origins + allow_credentials=True, + allow_methods=["*"], # Allow all methods + allow_headers=["*"], # Allow all headers +) + +# Add session middleware for database management +app.add_middleware(SessionMiddleware, require_database=True) + +# Mount static files +app.mount("/static", StaticFiles(directory="app/static"), name="static") + +# Setup templates +templates = Jinja2Templates(directory="templates") + +# Include routers +app.include_router(chef.router) +app.include_router(customer.router) +app.include_router(admin.router) +app.include_router(feedback.router) +app.include_router(loyalty.router) +app.include_router(selection_offer.router) +app.include_router(table.router) +app.include_router(analytics.router) +app.include_router(settings.router) + +# Create database tables +create_tables() + +# Check if we have the React build folder +react_build_dir = "frontend/build" +has_react_build = os.path.isdir(react_build_dir) + +if has_react_build: + # Mount the React build folder + app.mount("/", StaticFiles(directory=react_build_dir, html=True), name="react") + + +# Health check endpoint for Render +@app.get("/health") +async def health_check(): + return {"status": "healthy", "message": "Tabble API is running"} + + +# Root route - serve React app in production, otherwise serve index.html template +@app.get("/", response_class=HTMLResponse) +async def root(request: Request): + if has_react_build: + return FileResponse(f"{react_build_dir}/index.html") + return templates.TemplateResponse("index.html", {"request": request}) + + +# Chef page +@app.get("/chef", response_class=HTMLResponse) +async def chef_page(request: Request): + return templates.TemplateResponse("chef/index.html", {"request": request}) + + +# Chef orders page +@app.get("/chef/orders", response_class=HTMLResponse) +async def chef_orders_page(request: Request): + return templates.TemplateResponse("chef/orders.html", {"request": request}) + + +# Customer login page +@app.get("/customer", response_class=HTMLResponse) +async def customer_login_page(request: Request): + return templates.TemplateResponse("customer/login.html", {"request": request}) + + +# Customer menu page +@app.get("/customer/menu", response_class=HTMLResponse) +async def customer_menu_page(request: Request, table_number: int, unique_id: str): + return templates.TemplateResponse( + "customer/menu.html", + {"request": request, "table_number": table_number, "unique_id": unique_id}, + ) + + +# Admin page +@app.get("/admin", response_class=HTMLResponse) +async def admin_page(request: Request): + return templates.TemplateResponse("admin/index.html", {"request": request}) + + +# Admin dishes page +@app.get("/admin/dishes", response_class=HTMLResponse) +async def admin_dishes_page(request: Request): + return templates.TemplateResponse("admin/dishes.html", {"request": request}) + + + + + +if __name__ == "__main__": + PORT = os.getenv("PORT", 8000) + uvicorn.run("app.main:app", host="0.0.0.0", port=PORT, reload=True) diff --git a/app/middleware/__init__.py b/app/middleware/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0caf9b99863129f3358daf5210783ca3630117d6 --- /dev/null +++ b/app/middleware/__init__.py @@ -0,0 +1,3 @@ +from .session_middleware import SessionMiddleware, get_session_id + +__all__ = ['SessionMiddleware', 'get_session_id'] diff --git a/app/middleware/session_middleware.py b/app/middleware/session_middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..99ddf2b24937562193b7b09e5855408750b7f113 --- /dev/null +++ b/app/middleware/session_middleware.py @@ -0,0 +1,125 @@ +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse +import uuid +from typing import Callable +from ..database import db_manager + + +class SessionMiddleware(BaseHTTPMiddleware): + """Middleware to handle session-based database management""" + + def __init__(self, app, require_database: bool = True): + super().__init__(app) + self.require_database = require_database + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + # Skip validation for OPTIONS requests (CORS preflight) + if request.method == "OPTIONS": + response = await call_next(request) + return response + + # Get or generate session ID + session_id = request.headers.get('x-session-id') + if not session_id: + session_id = str(uuid.uuid4()) + + # Add session ID to request state + request.state.session_id = session_id + + # Check if this is a database-related endpoint + path = request.url.path + is_database_endpoint = ( + path.startswith('/settings/') or + path.startswith('/customer/api/') or + path.startswith('/chef/') or + path.startswith('/admin/') or + path.startswith('/analytics/') or + path.startswith('/tables/') or + path.startswith('/feedback/') or + path.startswith('/loyalty/') or + path.startswith('/selection-offers/') + ) + + # Skip session validation for certain endpoints + skip_validation_endpoints = [ + '/settings/databases', + '/settings/hotels', + '/settings/switch-database', + '/settings/switch-hotel', + '/settings/current-database', + '/settings/current-hotel' + ] + + # Skip validation for admin and chef routes - they handle their own database selection + skip_validation_paths = [ + '/admin/', + '/chef/' + ] + + # Check if path should skip validation + should_skip_path = any(path.startswith(skip_path) for skip_path in skip_validation_paths) + + should_validate = ( + is_database_endpoint and + path not in skip_validation_endpoints and + not should_skip_path and + self.require_database + ) + + if should_validate: + # Check if session has a valid hotel context + current_hotel_id = db_manager.get_current_hotel_id(session_id) + if not current_hotel_id: + # Check if there's stored hotel credentials in headers + stored_hotel_name = request.headers.get('x-hotel-name') + stored_password = request.headers.get('x-hotel-password') + + if stored_hotel_name and stored_password: + # Try to verify and set hotel context + try: + # Authenticate hotel using the database manager + hotel_id = db_manager.authenticate_hotel(stored_hotel_name, stored_password) + + if hotel_id: + # Valid credentials, set hotel context + db_manager.set_hotel_context(session_id, hotel_id) + else: + # Invalid credentials + return JSONResponse( + status_code=401, + content={ + "detail": "Invalid hotel credentials", + "error_code": "HOTEL_AUTH_FAILED" + } + ) + except Exception as e: + return JSONResponse( + status_code=500, + content={ + "detail": f"Hotel authentication failed: {str(e)}", + "error_code": "HOTEL_VERIFICATION_ERROR" + } + ) + else: + # No hotel selected + return JSONResponse( + status_code=400, + content={ + "detail": "No hotel selected. Please select a hotel first.", + "error_code": "HOTEL_NOT_SELECTED" + } + ) + + # Process the request + response = await call_next(request) + + # Add session ID to response headers + response.headers["x-session-id"] = session_id + + return response + + +def get_session_id(request: Request) -> str: + """Helper function to get session ID from request""" + return getattr(request.state, 'session_id', str(uuid.uuid4())) diff --git a/app/models/database_config.py b/app/models/database_config.py new file mode 100644 index 0000000000000000000000000000000000000000..2932cc8c95ee881283d7d07daf824a6f6ab384a2 --- /dev/null +++ b/app/models/database_config.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel +from typing import List, Optional + + +class DatabaseEntry(BaseModel): + database_name: str + password: str + + +class DatabaseList(BaseModel): + databases: List[DatabaseEntry] + + +class DatabaseSelectRequest(BaseModel): + database_name: str + password: str + + +class DatabaseSelectResponse(BaseModel): + success: bool + message: str diff --git a/app/models/dish.py b/app/models/dish.py new file mode 100644 index 0000000000000000000000000000000000000000..c10f09719eac88c5faac9e0716a058b3717c65e1 --- /dev/null +++ b/app/models/dish.py @@ -0,0 +1,42 @@ +from pydantic import BaseModel, validator, field_serializer +from typing import Optional, List, Union +from datetime import datetime +import json + +class DishBase(BaseModel): + name: str + description: Optional[str] = None + category: str # Keep as string - will store JSON array format + price: float + quantity: Optional[int] = 0 # Made optional for forms + discount: Optional[float] = 0 + is_offer: Optional[int] = 0 + is_special: Optional[int] = 0 + is_vegetarian: Optional[int] = 1 # 1 = vegetarian, 0 = non-vegetarian + visibility: Optional[int] = 1 + +class DishCreate(DishBase): + pass + +class DishUpdate(DishBase): + name: Optional[str] = None + description: Optional[str] = None + category: Optional[str] = None + price: Optional[float] = None + quantity: Optional[int] = None + image_path: Optional[str] = None + discount: Optional[float] = None + is_offer: Optional[int] = None + is_special: Optional[int] = None + is_vegetarian: Optional[int] = None + visibility: Optional[int] = None + +class Dish(DishBase): + id: int + image_path: Optional[str] = None + visibility: int = 1 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True # Updated from orm_mode for Pydantic V2 diff --git a/app/models/feedback.py b/app/models/feedback.py new file mode 100644 index 0000000000000000000000000000000000000000..35bf68da3166a059a1dcf708afb4538fb0d7c73c --- /dev/null +++ b/app/models/feedback.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + + +class FeedbackBase(BaseModel): + order_id: int + rating: int + comment: Optional[str] = None + person_id: Optional[int] = None + + +class FeedbackCreate(FeedbackBase): + pass + + +class Feedback(FeedbackBase): + id: int + created_at: datetime + + class Config: + from_attributes = True # Updated from orm_mode for Pydantic V2 diff --git a/app/models/loyalty.py b/app/models/loyalty.py new file mode 100644 index 0000000000000000000000000000000000000000..a4abc24d8b199a37fb08c6b94bcef9cdd908ca91 --- /dev/null +++ b/app/models/loyalty.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + + +class LoyaltyProgramBase(BaseModel): + visit_count: int + discount_percentage: float + is_active: bool = True + + +class LoyaltyProgramCreate(LoyaltyProgramBase): + pass + + +class LoyaltyProgramUpdate(BaseModel): + visit_count: Optional[int] = None + discount_percentage: Optional[float] = None + is_active: Optional[bool] = None + + +class LoyaltyProgram(LoyaltyProgramBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True # Updated from orm_mode for Pydantic V2 diff --git a/app/models/order.py b/app/models/order.py new file mode 100644 index 0000000000000000000000000000000000000000..7f1d42f138541131b65326e56ee8c22524c5ceda --- /dev/null +++ b/app/models/order.py @@ -0,0 +1,65 @@ +from pydantic import BaseModel +from typing import List, Optional +from datetime import datetime +from .dish import Dish + + +class OrderItemBase(BaseModel): + dish_id: int + quantity: int = 1 + remarks: Optional[str] = None + + +class OrderItemCreate(OrderItemBase): + pass + + +class OrderItem(OrderItemBase): + id: int + order_id: int + created_at: datetime + dish: Optional[Dish] = None + + # Add dish_name property to ensure it's always available + @property + def dish_name(self) -> str: + if self.dish: + return self.dish.name + return "Unknown Dish" + + class Config: + from_attributes = True # Updated from orm_mode for Pydantic V2 + + +class OrderBase(BaseModel): + table_number: int + unique_id: str + + +class OrderCreate(OrderBase): + items: List[OrderItemCreate] + username: Optional[str] = None + password: Optional[str] = None + + +class OrderUpdate(BaseModel): + status: str + + +class Order(OrderBase): + id: int + status: str + created_at: datetime + updated_at: datetime + items: List[OrderItem] = [] + person_id: Optional[int] = None + person_name: Optional[str] = None + visit_count: Optional[int] = None + total_amount: Optional[float] = None + subtotal_amount: Optional[float] = None + loyalty_discount_amount: Optional[float] = 0 + selection_offer_discount_amount: Optional[float] = 0 + loyalty_discount_percentage: Optional[float] = 0 + + class Config: + from_attributes = True # Updated from orm_mode for Pydantic V2 diff --git a/app/models/selection_offer.py b/app/models/selection_offer.py new file mode 100644 index 0000000000000000000000000000000000000000..2e29dd93e937abc2e06c2a5ad1eb0caaee73e5a8 --- /dev/null +++ b/app/models/selection_offer.py @@ -0,0 +1,30 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + + +class SelectionOfferBase(BaseModel): + min_amount: float + discount_amount: float + is_active: bool = True + description: Optional[str] = None + + +class SelectionOfferCreate(SelectionOfferBase): + pass + + +class SelectionOfferUpdate(BaseModel): + min_amount: Optional[float] = None + discount_amount: Optional[float] = None + is_active: Optional[bool] = None + description: Optional[str] = None + + +class SelectionOffer(SelectionOfferBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True # Updated from orm_mode for Pydantic V2 diff --git a/app/models/settings.py b/app/models/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..45bc272a586c4d6bb929298e887e47f01ce6595f --- /dev/null +++ b/app/models/settings.py @@ -0,0 +1,34 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + + +class SettingsBase(BaseModel): + hotel_name: str + address: Optional[str] = None + contact_number: Optional[str] = None + email: Optional[str] = None + tax_id: Optional[str] = None + logo_path: Optional[str] = None + + +class SettingsCreate(SettingsBase): + pass + + +class SettingsUpdate(BaseModel): + hotel_name: Optional[str] = None + address: Optional[str] = None + contact_number: Optional[str] = None + email: Optional[str] = None + tax_id: Optional[str] = None + logo_path: Optional[str] = None + + +class Settings(SettingsBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True # Updated from orm_mode for Pydantic V2 diff --git a/app/models/table.py b/app/models/table.py new file mode 100644 index 0000000000000000000000000000000000000000..4527ce974c805025892e00713227eeb02e87f1e1 --- /dev/null +++ b/app/models/table.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + + +class TableBase(BaseModel): + table_number: int + is_occupied: bool = False + current_order_id: Optional[int] = None + + +class TableCreate(TableBase): + pass + + +class TableUpdate(BaseModel): + is_occupied: Optional[bool] = None + current_order_id: Optional[int] = None + + +class Table(TableBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True # Updated from orm_mode for Pydantic V2 + + +class TableStatus(BaseModel): + total_tables: int + occupied_tables: int + free_tables: int diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000000000000000000000000000000000000..ac03ae35d3e98ed599fa2795a40dd8a6ee830c17 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,39 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + +class PersonBase(BaseModel): + username: str + +class PersonCreate(PersonBase): + password: str + table_number: int + phone_number: Optional[str] = None + +class PersonLogin(PersonBase): + password: str + table_number: int + +class PhoneAuthRequest(BaseModel): + phone_number: str + table_number: int + +class PhoneVerifyRequest(BaseModel): + phone_number: str + verification_code: str + table_number: int + +class UsernameRequest(BaseModel): + phone_number: str + username: str + table_number: int + +class Person(PersonBase): + id: int + visit_count: int + last_visit: datetime + created_at: datetime + phone_number: Optional[str] = None + + class Config: + from_attributes = True # Updated from orm_mode for Pydantic V2 diff --git a/app/routers/admin.py b/app/routers/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..5b77e9016931c921d4378a3cd334326f0d24a73c --- /dev/null +++ b/app/routers/admin.py @@ -0,0 +1,643 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Request +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session +from typing import List, Optional +import os +import shutil +from datetime import datetime, timezone +from ..utils.pdf_generator import generate_bill_pdf, generate_multi_order_bill_pdf + +from ..database import get_db, Order, Dish, OrderItem, Person, Settings, get_session_db, get_session_current_database, get_hotel_id_from_request +from ..models.order import Order as OrderModel +from ..models.dish import Dish as DishModel, DishCreate, DishUpdate +from ..middleware import get_session_id + +router = APIRouter( + prefix="/admin", + tags=["admin"], + responses={404: {"description": "Not found"}}, +) + + +# Dependency to get session-aware database +def get_session_database(request: Request): + session_id = get_session_id(request) + return next(get_session_db(session_id)) + + +# Get all orders with customer information +@router.get("/orders", response_model=List[OrderModel]) +def get_all_orders(request: Request, status: str = None, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + + query = db.query(Order).filter(Order.hotel_id == hotel_id) + + if status: + query = query.filter(Order.status == status) + + # Order by most recent first + orders = query.order_by(Order.created_at.desc()).all() + + # Load person information for each order + for order in orders: + if order.person_id: + person = db.query(Person).filter( + Person.hotel_id == hotel_id, + Person.id == order.person_id + ).first() + if person: + # Add person information to the order + order.person_name = person.username + order.visit_count = person.visit_count + + # Load dish information for each order item + for item in order.items: + if not hasattr(item, "dish") or item.dish is None: + dish = db.query(Dish).filter( + Dish.hotel_id == hotel_id, + Dish.id == item.dish_id + ).first() + if dish: + item.dish = dish + + return orders + + +# Get all dishes (only visible ones) +@router.get("/api/dishes", response_model=List[DishModel]) +def get_all_dishes( + request: Request, + is_offer: Optional[int] = None, + is_special: Optional[int] = None, + db: Session = Depends(get_session_database), +): + hotel_id = get_hotel_id_from_request(request) + + query = db.query(Dish).filter( + Dish.hotel_id == hotel_id, + Dish.visibility == 1 + ) # Only visible dishes for this hotel + + if is_offer is not None: + query = query.filter(Dish.is_offer == is_offer) + + if is_special is not None: + query = query.filter(Dish.is_special == is_special) + + dishes = query.all() + return dishes + + +# Get offer dishes (only visible ones) +@router.get("/api/offers", response_model=List[DishModel]) +def get_offer_dishes(request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + dishes = db.query(Dish).filter( + Dish.hotel_id == hotel_id, + Dish.is_offer == 1, + Dish.visibility == 1 + ).all() + return dishes + + +# Get special dishes (only visible ones) +@router.get("/api/specials", response_model=List[DishModel]) +def get_special_dishes(request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + dishes = db.query(Dish).filter( + Dish.hotel_id == hotel_id, + Dish.is_special == 1, + Dish.visibility == 1 + ).all() + return dishes + + +# Get dish by ID (only if visible) +@router.get("/api/dishes/{dish_id}", response_model=DishModel) +def get_dish(dish_id: int, request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + dish = db.query(Dish).filter( + Dish.hotel_id == hotel_id, + Dish.id == dish_id, + Dish.visibility == 1 + ).first() + if dish is None: + raise HTTPException(status_code=404, detail="Dish not found") + return dish + + +# Get all categories +@router.get("/api/categories") +def get_all_categories(request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + categories = db.query(Dish.category).filter( + Dish.hotel_id == hotel_id + ).distinct().all() + + # Parse JSON categories and flatten them + import json + unique_categories = set() + + for category_tuple in categories: + category_str = category_tuple[0] + if category_str: + try: + # Try to parse as JSON array + category_list = json.loads(category_str) + if isinstance(category_list, list): + unique_categories.update(category_list) + else: + unique_categories.add(category_str) + except (json.JSONDecodeError, TypeError): + # If not JSON, treat as single category + unique_categories.add(category_str) + + return sorted(list(unique_categories)) + + +# Create new category +@router.post("/api/categories") +def create_category(request: Request, category_name: str = Form(...), db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + + # Check if category already exists for this hotel + existing_category = ( + db.query(Dish.category).filter( + Dish.hotel_id == hotel_id, + Dish.category == category_name + ).first() + ) + if existing_category: + raise HTTPException(status_code=400, detail="Category already exists") + + return {"message": "Category created successfully", "category": category_name} + + +# Create new dish +@router.post("/api/dishes", response_model=DishModel) +async def create_dish( + request: Request, + name: str = Form(...), + description: Optional[str] = Form(None), + category: Optional[str] = Form(None), # Made optional + new_category: Optional[str] = Form(None), # New field for custom category + categories: Optional[str] = Form(None), # JSON array of multiple categories + price: float = Form(...), + quantity: Optional[int] = Form(0), # Made optional with default + discount: Optional[float] = Form(0), # Discount amount (percentage) + is_offer: Optional[int] = Form(0), # Whether this dish is part of offers + is_special: Optional[int] = Form(0), # Whether this dish is today's special + is_vegetarian: int = Form(...), # Required: 1 = vegetarian, 0 = non-vegetarian + image: Optional[UploadFile] = File(None), + db: Session = Depends(get_session_database), +): + hotel_id = get_hotel_id_from_request(request) + + # Handle categories - support both single and multiple categories + import json + final_category = None + + if categories: + # Multiple categories provided as JSON array + try: + category_list = json.loads(categories) + final_category = json.dumps(category_list) # Store as JSON string + except json.JSONDecodeError: + final_category = json.dumps([categories]) # Fallback to single category + elif new_category: + # Single new category + final_category = json.dumps([new_category]) + elif category: + # Single existing category + final_category = json.dumps([category]) + else: + # Default category if none provided + final_category = json.dumps(["Uncategorized"]) + + # Create dish object + db_dish = Dish( + hotel_id=hotel_id, + name=name, + description=description, + category=final_category, + price=price, + quantity=quantity, + discount=discount, + is_offer=is_offer, + is_special=is_special, + is_vegetarian=is_vegetarian, + ) + + # Save dish to database + db.add(db_dish) + db.commit() + db.refresh(db_dish) + + # Handle image upload if provided + if image: + # Get hotel info for organizing images + from ..database import Hotel + hotel = db.query(Hotel).filter(Hotel.id == hotel_id).first() + hotel_name_for_path = hotel.hotel_name if hotel else f"hotel_{hotel_id}" + + # Create directory structure: app/static/images/dishes/{hotel_name} + hotel_images_dir = f"app/static/images/dishes/{hotel_name_for_path}" + os.makedirs(hotel_images_dir, exist_ok=True) + + # Save image with hotel-specific path + image_path = f"{hotel_images_dir}/{db_dish.id}_{image.filename}" + with open(image_path, "wb") as buffer: + shutil.copyfileobj(image.file, buffer) + + # Update dish with image path (URL path for serving) + db_dish.image_path = f"/static/images/dishes/{hotel_name_for_path}/{db_dish.id}_{image.filename}" + db.commit() + db.refresh(db_dish) + + return db_dish + + +# Update dish +@router.put("/api/dishes/{dish_id}", response_model=DishModel) +async def update_dish( + dish_id: int, + request: Request, + name: Optional[str] = Form(None), + description: Optional[str] = Form(None), + category: Optional[str] = Form(None), + new_category: Optional[str] = Form(None), # New field for custom category + categories: Optional[str] = Form(None), # JSON array of multiple categories + price: Optional[float] = Form(None), + quantity: Optional[int] = Form(None), + discount: Optional[float] = Form(None), # Discount amount (percentage) + is_offer: Optional[int] = Form(None), # Whether this dish is part of offers + is_special: Optional[int] = Form(None), # Whether this dish is today's special + is_vegetarian: Optional[int] = Form(None), # 1 = vegetarian, 0 = non-vegetarian + image: Optional[UploadFile] = File(None), + db: Session = Depends(get_session_database), +): + hotel_id = get_hotel_id_from_request(request) + + # Get existing dish for this hotel + db_dish = db.query(Dish).filter( + Dish.hotel_id == hotel_id, + Dish.id == dish_id + ).first() + if db_dish is None: + raise HTTPException(status_code=404, detail="Dish not found") + + # Update fields if provided + if name: + db_dish.name = name + if description: + db_dish.description = description + + # Handle categories - support both single and multiple categories + import json + if categories: + # Multiple categories provided as JSON array + try: + category_list = json.loads(categories) + db_dish.category = json.dumps(category_list) + except json.JSONDecodeError: + db_dish.category = json.dumps([categories]) + elif new_category: # Use new category if provided + db_dish.category = json.dumps([new_category]) + elif category: + db_dish.category = json.dumps([category]) + + if price: + db_dish.price = price + if quantity is not None: # Allow 0 quantity + db_dish.quantity = quantity + if discount is not None: + db_dish.discount = discount + if is_offer is not None: + db_dish.is_offer = is_offer + if is_special is not None: + db_dish.is_special = is_special + if is_vegetarian is not None: + db_dish.is_vegetarian = is_vegetarian + + # Handle image upload if provided + if image: + # Get current database name for organizing images + session_id = get_session_id(request) + current_db = get_session_current_database(session_id) + + # Create directory structure: app/static/images/dishes/{db_name} + db_images_dir = f"app/static/images/dishes/{current_db}" + os.makedirs(db_images_dir, exist_ok=True) + + # Save image with database-specific path + image_path = f"{db_images_dir}/{db_dish.id}_{image.filename}" + with open(image_path, "wb") as buffer: + shutil.copyfileobj(image.file, buffer) + + # Update dish with image path (URL path for serving) + db_dish.image_path = f"/static/images/dishes/{current_db}/{db_dish.id}_{image.filename}" + + # Update timestamp + db_dish.updated_at = datetime.now(timezone.utc) + + # Save changes + db.commit() + db.refresh(db_dish) + + return db_dish + + +# Soft delete dish (set visibility to 0) +@router.delete("/api/dishes/{dish_id}") +def delete_dish(dish_id: int, request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + + db_dish = db.query(Dish).filter( + Dish.hotel_id == hotel_id, + Dish.id == dish_id, + Dish.visibility == 1 + ).first() + if db_dish is None: + raise HTTPException(status_code=404, detail="Dish not found") + + # Soft delete: set visibility to 0 instead of actually deleting + db_dish.visibility = 0 + db_dish.updated_at = datetime.now(timezone.utc) + db.commit() + + return {"message": "Dish deleted successfully"} + + +# Get order statistics +@router.get("/stats/orders") +def get_order_stats(request: Request, db: Session = Depends(get_session_database)): + from sqlalchemy import func, and_ + + # Get today's date range (start and end of today in UTC) + today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + today_end = datetime.now(timezone.utc).replace(hour=23, minute=59, second=59, microsecond=999999) + + # Overall statistics + total_orders = db.query(Order).count() + pending_orders = db.query(Order).filter(Order.status == "pending").count() + completed_orders = db.query(Order).filter(Order.status == "completed").count() + payment_requested = ( + db.query(Order).filter(Order.status == "payment_requested").count() + ) + paid_orders = db.query(Order).filter(Order.status == "paid").count() + + # Today's statistics + total_orders_today = db.query(Order).filter( + and_(Order.created_at >= today_start, Order.created_at <= today_end) + ).count() + + pending_orders_today = db.query(Order).filter( + and_( + Order.status == "pending", + Order.created_at >= today_start, + Order.created_at <= today_end + ) + ).count() + + completed_orders_today = db.query(Order).filter( + and_( + Order.status == "completed", + Order.created_at >= today_start, + Order.created_at <= today_end + ) + ).count() + + paid_orders_today = db.query(Order).filter( + and_( + Order.status == "paid", + Order.created_at >= today_start, + Order.created_at <= today_end + ) + ).count() + + # Calculate today's revenue from paid orders + revenue_today_query = ( + db.query( + func.sum(Dish.price * OrderItem.quantity).label("revenue_today") + ) + .join(OrderItem, Dish.id == OrderItem.dish_id) + .join(Order, OrderItem.order_id == Order.id) + .filter(Order.status == "paid") + .filter( + and_( + Order.created_at >= today_start, + Order.created_at <= today_end + ) + ) + ) + + revenue_today_result = revenue_today_query.first() + revenue_today = revenue_today_result.revenue_today if revenue_today_result.revenue_today else 0 + + return { + "total_orders": total_orders, + "pending_orders": pending_orders, + "completed_orders": completed_orders, + "payment_requested": payment_requested, + "paid_orders": paid_orders, + "total_orders_today": total_orders_today, + "pending_orders_today": pending_orders_today, + "completed_orders_today": completed_orders_today, + "paid_orders_today": paid_orders_today, + "revenue_today": round(revenue_today, 2), + } + + +# Mark order as paid +@router.put("/orders/{order_id}/paid") +def mark_order_paid(order_id: int, request: Request, db: Session = Depends(get_session_database)): + db_order = db.query(Order).filter(Order.id == order_id).first() + if db_order is None: + raise HTTPException(status_code=404, detail="Order not found") + + # Allow marking as paid from any status + db_order.status = "paid" + db_order.updated_at = datetime.now(timezone.utc) + + db.commit() + + return {"message": "Order marked as paid"} + + +# Generate bill PDF for a single order +@router.get("/orders/{order_id}/bill") +def generate_bill(order_id: int, request: Request, db: Session = Depends(get_session_database)): + # Get order with all details + db_order = db.query(Order).filter(Order.id == order_id).first() + if db_order is None: + raise HTTPException(status_code=404, detail="Order not found") + + # Load person information if available + if db_order.person_id: + person = db.query(Person).filter(Person.id == db_order.person_id).first() + if person: + db_order.person_name = person.username + + # Load dish information for each order item + for item in db_order.items: + if not hasattr(item, "dish") or item.dish is None: + dish = db.query(Dish).filter(Dish.id == item.dish_id).first() + if dish: + item.dish = dish + + # Get hotel ID from request + hotel_id = get_hotel_id_from_request(request) + + # Get hotel settings for this specific hotel + settings = db.query(Settings).filter(Settings.hotel_id == hotel_id).first() + if not settings: + # Create default settings if none exist for this hotel + settings = Settings( + hotel_id=hotel_id, + hotel_name="Tabble Hotel", + address="123 Main Street, City", + contact_number="+1 123-456-7890", + email="info@tabblehotel.com", + ) + db.add(settings) + db.commit() + db.refresh(settings) + + # Generate PDF + pdf_buffer = generate_bill_pdf(db_order, settings) + + # Return PDF as a downloadable file + filename = f"bill_order_{order_id}_{datetime.now().strftime('%Y%m%d%H%M%S')}.pdf" + + return StreamingResponse( + pdf_buffer, + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + + +# Generate bill PDF for multiple orders +@router.post("/orders/multi-bill") +def generate_multi_bill(order_ids: List[int], request: Request, db: Session = Depends(get_session_database)): + if not order_ids: + raise HTTPException(status_code=400, detail="No order IDs provided") + + orders = [] + + # Get all orders with details + for order_id in order_ids: + db_order = db.query(Order).filter(Order.id == order_id).first() + if db_order is None: + raise HTTPException(status_code=404, detail=f"Order {order_id} not found") + + # Load person information if available + if db_order.person_id: + person = db.query(Person).filter(Person.id == db_order.person_id).first() + if person: + db_order.person_name = person.username + + # Load dish information for each order item + for item in db_order.items: + if not hasattr(item, "dish") or item.dish is None: + dish = db.query(Dish).filter(Dish.id == item.dish_id).first() + if dish: + item.dish = dish + + orders.append(db_order) + + # Get hotel ID from request + hotel_id = get_hotel_id_from_request(request) + + # Get hotel settings for this specific hotel + settings = db.query(Settings).filter(Settings.hotel_id == hotel_id).first() + if not settings: + # Create default settings if none exist for this hotel + settings = Settings( + hotel_id=hotel_id, + hotel_name="Tabble Hotel", + address="123 Main Street, City", + contact_number="+1 123-456-7890", + email="info@tabblehotel.com", + ) + db.add(settings) + db.commit() + db.refresh(settings) + + # Generate PDF for multiple orders + pdf_buffer = generate_multi_order_bill_pdf(orders, settings) + + # Create a filename with all order IDs + order_ids_str = "-".join([str(order_id) for order_id in order_ids]) + filename = f"bill_orders_{order_ids_str}_{datetime.now().strftime('%Y%m%d%H%M%S')}.pdf" + + return StreamingResponse( + pdf_buffer, + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + + +# Merge two orders +@router.post("/orders/merge") +def merge_orders(source_order_id: int, target_order_id: int, request: Request, db: Session = Depends(get_session_database)): + # Get both orders + source_order = db.query(Order).filter(Order.id == source_order_id).first() + target_order = db.query(Order).filter(Order.id == target_order_id).first() + + if not source_order: + raise HTTPException(status_code=404, detail=f"Source order {source_order_id} not found") + + if not target_order: + raise HTTPException(status_code=404, detail=f"Target order {target_order_id} not found") + + # Check if both orders are completed or paid + valid_statuses = ["completed", "paid"] + if source_order.status not in valid_statuses: + raise HTTPException(status_code=400, detail=f"Source order must be completed or paid, current status: {source_order.status}") + + if target_order.status not in valid_statuses: + raise HTTPException(status_code=400, detail=f"Target order must be completed or paid, current status: {target_order.status}") + + # Move all items from source order to target order + for item in source_order.items: + # Update the order_id to point to the target order + item.order_id = target_order.id + + # Update the target order's updated_at timestamp + target_order.updated_at = datetime.now(timezone.utc) + + # Delete the source order (but keep its items which now belong to the target order) + db.delete(source_order) + + # Commit changes + db.commit() + + # Refresh the target order to include the new items + db.refresh(target_order) + + return {"message": f"Orders merged successfully. Items from order #{source_order_id} have been moved to order #{target_order_id}"} + + +# Get completed orders for billing (paid orders) +@router.get("/orders/completed-for-billing", response_model=List[OrderModel]) +def get_completed_orders_for_billing(request: Request, db: Session = Depends(get_session_database)): + # Get paid orders ordered by most recent first + orders = db.query(Order).filter(Order.status == "paid").order_by(Order.created_at.desc()).all() + + # Load person information for each order + for order in orders: + if order.person_id: + person = db.query(Person).filter(Person.id == order.person_id).first() + if person: + # Add person information to the order + order.person_name = person.username + order.visit_count = person.visit_count + + # Load dish information for each order item + for item in order.items: + if not hasattr(item, "dish") or item.dish is None: + dish = db.query(Dish).filter(Dish.id == item.dish_id).first() + if dish: + item.dish = dish + + return orders diff --git a/app/routers/analytics.py b/app/routers/analytics.py new file mode 100644 index 0000000000000000000000000000000000000000..63cc9e790b8cf6e65f066e1ccfc7ef8901c6a0d4 --- /dev/null +++ b/app/routers/analytics.py @@ -0,0 +1,573 @@ +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session +from sqlalchemy import func, desc, extract +from typing import List, Dict, Any +from datetime import datetime, timedelta, timezone +import calendar + +from ..database import get_db, Dish, Order, OrderItem, Person, Table, Feedback, get_session_db +from ..models.dish import Dish as DishModel +from ..models.order import Order as OrderModel +from ..models.user import Person as PersonModel +from ..models.feedback import Feedback as FeedbackModel +from ..middleware import get_session_id + +router = APIRouter( + prefix="/analytics", + tags=["analytics"], + responses={404: {"description": "Not found"}}, +) + + +# Dependency to get session-aware database +def get_session_database(request: Request): + session_id = get_session_id(request) + return next(get_session_db(session_id)) + + +# Get overall dashboard statistics +@router.get("/dashboard") +def get_dashboard_stats( + request: Request, + start_date: str = None, + end_date: str = None, + db: Session = Depends(get_session_database) +): + # Parse date strings to datetime objects if provided + start_datetime = None + end_datetime = None + + if start_date: + try: + start_datetime = datetime.fromisoformat(start_date.replace('Z', '+00:00')) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid start_date format. Use ISO format (YYYY-MM-DDTHH:MM:SS)") + + if end_date: + try: + end_datetime = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid end_date format. Use ISO format (YYYY-MM-DDTHH:MM:SS)") + + # Base query for orders + orders_query = db.query(Order) + + # Apply date filters if provided + if start_datetime: + orders_query = orders_query.filter(Order.created_at >= start_datetime) + + if end_datetime: + orders_query = orders_query.filter(Order.created_at <= end_datetime) + + # Total sales + total_sales_query = ( + db.query( + func.sum(Dish.price * OrderItem.quantity).label("total_sales") + ) + .join(OrderItem, Dish.id == OrderItem.dish_id) + .join(Order, OrderItem.order_id == Order.id) + .filter(Order.status == "paid") + ) + + # Apply date filters to sales query + if start_datetime: + total_sales_query = total_sales_query.filter(Order.created_at >= start_datetime) + + if end_datetime: + total_sales_query = total_sales_query.filter(Order.created_at <= end_datetime) + + total_sales_result = total_sales_query.first() + total_sales = total_sales_result.total_sales if total_sales_result.total_sales else 0 + + # Total customers (only count those who placed orders in the date range) + if start_datetime or end_datetime: + # Get unique person_ids from filtered orders + person_subquery = orders_query.with_entities(Order.person_id).distinct().subquery() + total_customers = db.query(Person).filter(Person.id.in_(person_subquery)).count() + else: + total_customers = db.query(Person).count() + + # Total orders + total_orders = orders_query.count() + + # Total dishes + total_dishes = db.query(Dish).count() + + # Average order value + avg_order_value_query = ( + db.query( + func.avg( + db.query(func.sum(Dish.price * OrderItem.quantity)) + .join(OrderItem, Dish.id == OrderItem.dish_id) + .filter(OrderItem.order_id == Order.id) + .scalar_subquery() + ).label("avg_order_value") + ) + .filter(Order.status == "paid") + ) + + # Apply date filters to avg order value query + if start_datetime: + avg_order_value_query = avg_order_value_query.filter(Order.created_at >= start_datetime) + + if end_datetime: + avg_order_value_query = avg_order_value_query.filter(Order.created_at <= end_datetime) + + avg_order_value_result = avg_order_value_query.first() + avg_order_value = avg_order_value_result.avg_order_value if avg_order_value_result.avg_order_value else 0 + + # Return all stats + return { + "total_sales": round(total_sales, 2), + "total_customers": total_customers, + "total_orders": total_orders, + "total_dishes": total_dishes, + "avg_order_value": round(avg_order_value, 2), + "date_range": { + "start_date": start_date, + "end_date": end_date + } + } + + +# Get top customers by order count +@router.get("/top-customers") +def get_top_customers(request: Request, limit: int = 10, db: Session = Depends(get_session_database)): + # Get customers with most orders + top_customers_by_orders = ( + db.query( + Person.id, + Person.username, + Person.visit_count, + Person.last_visit, + func.count(Order.id).label("order_count"), + func.sum( + db.query(func.sum(Dish.price * OrderItem.quantity)) + .join(OrderItem, Dish.id == OrderItem.dish_id) + .filter(OrderItem.order_id == Order.id) + .scalar_subquery() + ).label("total_spent"), + ) + .join(Order, Person.id == Order.person_id) + .group_by(Person.id) + .order_by(desc("order_count")) + .limit(limit) + .all() + ) + + # Format the results + result = [] + for customer in top_customers_by_orders: + result.append({ + "id": customer.id, + "username": customer.username, + "visit_count": customer.visit_count, + "last_visit": customer.last_visit, + "order_count": customer.order_count, + "total_spent": round(customer.total_spent, 2) if customer.total_spent else 0, + "avg_order_value": round(customer.total_spent / customer.order_count, 2) if customer.total_spent else 0, + }) + + return result + + +# Get top selling dishes +@router.get("/top-dishes") +def get_top_dishes(request: Request, limit: int = 10, db: Session = Depends(get_session_database)): + # Get dishes with most orders + top_dishes = ( + db.query( + Dish.id, + Dish.name, + Dish.category, + Dish.price, + func.sum(OrderItem.quantity).label("total_ordered"), + func.sum(Dish.price * OrderItem.quantity).label("total_revenue"), + ) + .join(OrderItem, Dish.id == OrderItem.dish_id) + .join(Order, OrderItem.order_id == Order.id) + .filter(Order.status == "paid") + .group_by(Dish.id) + .order_by(desc("total_ordered")) + .limit(limit) + .all() + ) + + # Format the results + result = [] + for dish in top_dishes: + result.append({ + "id": dish.id, + "name": dish.name, + "category": dish.category, + "price": dish.price, + "total_ordered": dish.total_ordered, + "total_revenue": round(dish.total_revenue, 2), + }) + + return result + + +# Get sales by category +@router.get("/sales-by-category") +def get_sales_by_category(request: Request, db: Session = Depends(get_session_database)): + # Get sales by category + sales_by_category = ( + db.query( + Dish.category, + func.sum(OrderItem.quantity).label("total_ordered"), + func.sum(Dish.price * OrderItem.quantity).label("total_revenue"), + ) + .join(OrderItem, Dish.id == OrderItem.dish_id) + .join(Order, OrderItem.order_id == Order.id) + .filter(Order.status == "paid") + .group_by(Dish.category) + .order_by(desc("total_revenue")) + .all() + ) + + # Format the results + result = [] + for category in sales_by_category: + result.append({ + "category": category.category, + "total_ordered": category.total_ordered, + "total_revenue": round(category.total_revenue, 2), + }) + + return result + + +# Get sales over time (daily for the last 30 days) +@router.get("/sales-over-time") +def get_sales_over_time(request: Request, days: int = 30, db: Session = Depends(get_session_database)): + # Calculate the date range + end_date = datetime.now(timezone.utc) + start_date = end_date - timedelta(days=days) + + # Get sales by day + sales_by_day = ( + db.query( + func.date(Order.created_at).label("date"), + func.count(Order.id).label("order_count"), + func.sum( + db.query(func.sum(Dish.price * OrderItem.quantity)) + .join(OrderItem, Dish.id == OrderItem.dish_id) + .filter(OrderItem.order_id == Order.id) + .scalar_subquery() + ).label("total_sales"), + ) + .filter(Order.status == "paid") + .filter(Order.created_at >= start_date) + .filter(Order.created_at <= end_date) + .group_by(func.date(Order.created_at)) + .order_by(func.date(Order.created_at)) + .all() + ) + + # Create a dictionary with all dates in the range + date_range = {} + current_date = start_date + while current_date <= end_date: + date_str = current_date.strftime("%Y-%m-%d") + date_range[date_str] = {"order_count": 0, "total_sales": 0} + current_date += timedelta(days=1) + + # Fill in the actual data + for day in sales_by_day: + date_str = day.date.strftime("%Y-%m-%d") if isinstance(day.date, datetime) else day.date + date_range[date_str] = { + "order_count": day.order_count, + "total_sales": round(day.total_sales, 2) if day.total_sales else 0, + } + + # Convert to list format + result = [] + for date_str, data in date_range.items(): + result.append({ + "date": date_str, + "order_count": data["order_count"], + "total_sales": data["total_sales"], + }) + + return result + + +# Get chef performance metrics +@router.get("/chef-performance") +def get_chef_performance(request: Request, days: int = 30, db: Session = Depends(get_session_database)): + # Calculate the date range + end_date = datetime.now(timezone.utc) + start_date = end_date - timedelta(days=days) + + # Get completed orders count and average time to complete + completed_orders = ( + db.query(Order) + .filter(Order.status.in_(["completed", "paid"])) + .filter(Order.created_at >= start_date) + .filter(Order.created_at <= end_date) + .all() + ) + + total_completed = len(completed_orders) + + # Calculate average items per order + avg_items_per_order_query = ( + db.query( + func.avg( + db.query(func.count(OrderItem.id)) + .filter(OrderItem.order_id == Order.id) + .scalar_subquery() + ).label("avg_items") + ) + .filter(Order.status.in_(["completed", "paid"])) + .filter(Order.created_at >= start_date) + .filter(Order.created_at <= end_date) + .first() + ) + + avg_items_per_order = avg_items_per_order_query.avg_items if avg_items_per_order_query.avg_items else 0 + + # Get busiest day of week + busiest_day_query = ( + db.query( + extract('dow', Order.created_at).label("day_of_week"), + func.count(Order.id).label("order_count") + ) + .filter(Order.created_at >= start_date) + .filter(Order.created_at <= end_date) + .group_by(extract('dow', Order.created_at)) + .order_by(desc("order_count")) + .first() + ) + + busiest_day = None + if busiest_day_query: + # Convert day number to day name (0 = Sunday, 1 = Monday, etc.) + day_names = list(calendar.day_name) + day_number = int(busiest_day_query.day_of_week) + busiest_day = day_names[day_number] + + return { + "total_completed_orders": total_completed, + "avg_items_per_order": round(avg_items_per_order, 2), + "busiest_day": busiest_day, + } + + +# Get table utilization statistics +@router.get("/table-utilization") +def get_table_utilization(request: Request, db: Session = Depends(get_session_database)): + # Get all tables + tables = db.query(Table).all() + + # Get order count by table + table_orders = ( + db.query( + Order.table_number, + func.count(Order.id).label("order_count"), + func.sum( + db.query(func.sum(Dish.price * OrderItem.quantity)) + .join(OrderItem, Dish.id == OrderItem.dish_id) + .filter(OrderItem.order_id == Order.id) + .scalar_subquery() + ).label("total_revenue"), + ) + .group_by(Order.table_number) + .all() + ) + + # Create a dictionary with all tables + table_stats = {} + for table in tables: + table_stats[table.table_number] = { + "table_number": table.table_number, + "is_occupied": table.is_occupied, + "order_count": 0, + "total_revenue": 0, + } + + # Fill in the actual data + for table in table_orders: + if table.table_number in table_stats: + table_stats[table.table_number]["order_count"] = table.order_count + table_stats[table.table_number]["total_revenue"] = round(table.total_revenue, 2) if table.total_revenue else 0 + + # Convert to list format + result = list(table_stats.values()) + + # Sort by order count (descending) + result.sort(key=lambda x: x["order_count"], reverse=True) + + return result + + +# Get customer visit frequency analysis +@router.get("/customer-frequency") +def get_customer_frequency( + request: Request, + start_date: str = None, + end_date: str = None, + db: Session = Depends(get_session_database) +): + # Parse date strings to datetime objects if provided + start_datetime = None + end_datetime = None + + if start_date: + try: + start_datetime = datetime.fromisoformat(start_date.replace('Z', '+00:00')) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid start_date format. Use ISO format (YYYY-MM-DDTHH:MM:SS)") + + if end_date: + try: + end_datetime = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid end_date format. Use ISO format (YYYY-MM-DDTHH:MM:SS)") + + # Get visit count distribution + visit_counts_query = db.query(Person.visit_count) + + # Apply date filters if provided + if start_datetime or end_datetime: + # Get person IDs who placed orders in the date range + orders_query = db.query(Order.person_id).distinct() + + if start_datetime: + orders_query = orders_query.filter(Order.created_at >= start_datetime) + + if end_datetime: + orders_query = orders_query.filter(Order.created_at <= end_datetime) + + person_ids = [result[0] for result in orders_query.all() if result[0] is not None] + visit_counts_query = visit_counts_query.filter(Person.id.in_(person_ids)) + + visit_counts = visit_counts_query.all() + + # Create frequency buckets + frequency_buckets = { + "1 visit": 0, + "2-3 visits": 0, + "4-5 visits": 0, + "6-10 visits": 0, + "11+ visits": 0, + } + + # Fill the buckets + for visit in visit_counts: + count = visit.visit_count + if count == 1: + frequency_buckets["1 visit"] += 1 + elif 2 <= count <= 3: + frequency_buckets["2-3 visits"] += 1 + elif 4 <= count <= 5: + frequency_buckets["4-5 visits"] += 1 + elif 6 <= count <= 10: + frequency_buckets["6-10 visits"] += 1 + else: + frequency_buckets["11+ visits"] += 1 + + # Convert to list format + result = [] + for bucket, count in frequency_buckets.items(): + result.append({ + "frequency": bucket, + "customer_count": count, + }) + + return result + + +# Get feedback analysis +@router.get("/feedback-analysis") +def get_feedback_analysis( + request: Request, + start_date: str = None, + end_date: str = None, + db: Session = Depends(get_session_database) +): + # Parse date strings to datetime objects if provided + start_datetime = None + end_datetime = None + + if start_date: + try: + start_datetime = datetime.fromisoformat(start_date.replace('Z', '+00:00')) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid start_date format. Use ISO format (YYYY-MM-DDTHH:MM:SS)") + + if end_date: + try: + end_datetime = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid end_date format. Use ISO format (YYYY-MM-DDTHH:MM:SS)") + + # Base query for feedback + feedback_query = db.query(Feedback) + + # Apply date filters if provided + if start_datetime: + feedback_query = feedback_query.filter(Feedback.created_at >= start_datetime) + + if end_datetime: + feedback_query = feedback_query.filter(Feedback.created_at <= end_datetime) + + # Get all feedback + all_feedback = feedback_query.all() + + # Calculate average rating + total_ratings = len(all_feedback) + sum_ratings = sum(feedback.rating for feedback in all_feedback) + avg_rating = round(sum_ratings / total_ratings, 1) if total_ratings > 0 else 0 + + # Count ratings by score + rating_counts = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0} + for feedback in all_feedback: + rating_counts[feedback.rating] = rating_counts.get(feedback.rating, 0) + 1 + + # Calculate rating percentages + rating_percentages = {} + for rating, count in rating_counts.items(): + rating_percentages[rating] = round((count / total_ratings) * 100, 1) if total_ratings > 0 else 0 + + # Get recent feedback with comments + recent_feedback = ( + db.query(Feedback, Person.username) + .outerjoin(Person, Feedback.person_id == Person.id) + .filter(Feedback.comment != None) + .filter(Feedback.comment != "") + ) + + # Apply date filters if provided + if start_datetime: + recent_feedback = recent_feedback.filter(Feedback.created_at >= start_datetime) + + if end_datetime: + recent_feedback = recent_feedback.filter(Feedback.created_at <= end_datetime) + + recent_feedback = recent_feedback.order_by(Feedback.created_at.desc()).limit(10).all() + + # Format recent feedback + formatted_feedback = [] + for feedback, username in recent_feedback: + formatted_feedback.append({ + "id": feedback.id, + "rating": feedback.rating, + "comment": feedback.comment, + "username": username or "Anonymous", + "created_at": feedback.created_at.isoformat(), + }) + + # Return analysis + return { + "total_feedback": total_ratings, + "average_rating": avg_rating, + "rating_counts": rating_counts, + "rating_percentages": rating_percentages, + "recent_comments": formatted_feedback, + "date_range": { + "start_date": start_date, + "end_date": end_date + } + } diff --git a/app/routers/chef.py b/app/routers/chef.py new file mode 100644 index 0000000000000000000000000000000000000000..4a006c87a8c3bad6bef1041b73240cc392cbc654 --- /dev/null +++ b/app/routers/chef.py @@ -0,0 +1,93 @@ +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session +from typing import List +from datetime import datetime, timezone + +from ..database import get_db, Dish, Order, OrderItem, get_session_db, get_hotel_id_from_request +from ..models.dish import Dish as DishModel +from ..models.order import Order as OrderModel +from ..middleware import get_session_id + +router = APIRouter( + prefix="/chef", + tags=["chef"], + responses={404: {"description": "Not found"}}, +) + + +# Dependency to get session-aware database +def get_session_database(request: Request): + session_id = get_session_id(request) + return next(get_session_db(session_id)) + +# Add an API endpoint to get completed orders count +@router.get("/api/completed-orders-count") +def get_completed_orders_count(request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + completed_orders = db.query(Order).filter( + Order.hotel_id == hotel_id, + Order.status == "completed" + ).count() + return {"count": completed_orders} + +# Get pending orders (orders that need to be accepted) +@router.get("/orders/pending", response_model=List[OrderModel]) +def get_pending_orders(request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + orders = db.query(Order).filter( + Order.hotel_id == hotel_id, + Order.status == "pending" + ).all() + return orders + +# Get accepted orders (orders that have been accepted but not completed) +@router.get("/orders/accepted", response_model=List[OrderModel]) +def get_accepted_orders(request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + orders = db.query(Order).filter( + Order.hotel_id == hotel_id, + Order.status == "accepted" + ).all() + return orders + +# Accept an order +@router.put("/orders/{order_id}/accept") +def accept_order(order_id: int, request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + db_order = db.query(Order).filter( + Order.hotel_id == hotel_id, + Order.id == order_id + ).first() + if db_order is None: + raise HTTPException(status_code=404, detail="Order not found") + + if db_order.status != "pending": + raise HTTPException(status_code=400, detail="Order is not in pending status") + + db_order.status = "accepted" + db_order.updated_at = datetime.now(timezone.utc) + + db.commit() + + return {"message": "Order accepted successfully"} + +# Mark order as completed (only accepted orders can be completed) +@router.put("/orders/{order_id}/complete") +def complete_order(order_id: int, request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + db_order = db.query(Order).filter( + Order.hotel_id == hotel_id, + Order.id == order_id + ).first() + if db_order is None: + raise HTTPException(status_code=404, detail="Order not found") + + if db_order.status != "accepted": + raise HTTPException(status_code=400, detail="Order must be accepted before it can be completed") + + db_order.status = "completed" + db_order.updated_at = datetime.now(timezone.utc) + + db.commit() + + return {"message": "Order marked as completed"} diff --git a/app/routers/customer.py b/app/routers/customer.py new file mode 100644 index 0000000000000000000000000000000000000000..1467e3202f2ffb2cbd58b40d0424ed2f04dd5718 --- /dev/null +++ b/app/routers/customer.py @@ -0,0 +1,728 @@ +from fastapi import APIRouter, Depends, HTTPException, Request, status, Query +from sqlalchemy.orm import Session +from typing import List, Dict, Any +import uuid +from datetime import datetime, timezone, timedelta + +from ..database import get_db, Dish, Order, OrderItem, Person, get_session_db, get_hotel_id_from_request +from ..models.dish import Dish as DishModel +from ..models.order import OrderCreate, Order as OrderModel +from ..models.user import ( + PersonCreate, + PersonLogin, + Person as PersonModel, + PhoneAuthRequest, + PhoneVerifyRequest, + UsernameRequest +) +from ..services import firebase_auth +from ..middleware import get_session_id + +# Demo mode configuration +DEMO_MODE_ENABLED = True # Set to False to disable demo mode +DEMO_CUSTOMER_ID = 999999 +DEMO_CUSTOMER_USERNAME = "Demo Customer" +DEMO_CUSTOMER_PHONE = "+91-DEMO-USER" + +router = APIRouter( + prefix="/customer", + tags=["customer"], + responses={404: {"description": "Not found"}}, +) + + +# Dependency to get session-aware database +def get_session_database(request: Request): + session_id = get_session_id(request) + return next(get_session_db(session_id)) + + +# Demo mode endpoint - creates or returns demo customer +@router.post("/api/demo-login", response_model=Dict[str, Any]) +def demo_login(request: Request, db: Session = Depends(get_session_database)): + """ + Demo mode login - bypasses authentication and returns a demo customer + """ + if not DEMO_MODE_ENABLED: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Demo mode is disabled" + ) + + hotel_id = get_hotel_id_from_request(request) + + # Check if demo customer already exists for this hotel + demo_user = db.query(Person).filter( + Person.hotel_id == hotel_id, + Person.id == DEMO_CUSTOMER_ID + ).first() + + if not demo_user: + # Create demo customer + demo_user = Person( + id=DEMO_CUSTOMER_ID, + hotel_id=hotel_id, + username=DEMO_CUSTOMER_USERNAME, + password="demo", # Demo password + phone_number=DEMO_CUSTOMER_PHONE, + visit_count=5, # Show as returning customer + last_visit=datetime.now(timezone.utc), + created_at=datetime.now(timezone.utc) + ) + db.add(demo_user) + db.commit() + db.refresh(demo_user) + else: + # Update last visit time + demo_user.last_visit = datetime.now(timezone.utc) + db.commit() + + return { + "success": True, + "message": "Demo login successful", + "user_exists": True, + "user_id": demo_user.id, + "username": demo_user.username, + "demo_mode": True + } + + +# Get all dishes for menu (only visible ones) +@router.get("/api/menu", response_model=List[DishModel]) +def get_menu(request: Request, category: str = None, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + + if category: + # Filter dishes that contain the specified category in their JSON array + import json + all_dishes = db.query(Dish).filter( + Dish.hotel_id == hotel_id, + Dish.visibility == 1 + ).all() + + filtered_dishes = [] + for dish in all_dishes: + try: + dish_categories = json.loads(dish.category) if dish.category else [] + if isinstance(dish_categories, list) and category in dish_categories: + filtered_dishes.append(dish) + elif isinstance(dish_categories, str) and dish_categories == category: + filtered_dishes.append(dish) + except (json.JSONDecodeError, TypeError): + # Backward compatibility: treat as single category + if dish.category == category: + filtered_dishes.append(dish) + + return filtered_dishes + else: + dishes = db.query(Dish).filter( + Dish.hotel_id == hotel_id, + Dish.visibility == 1 + ).all() + return dishes + + +# Get offer dishes (only visible ones) +@router.get("/api/offers", response_model=List[DishModel]) +def get_offers(request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + dishes = db.query(Dish).filter( + Dish.hotel_id == hotel_id, + Dish.is_offer == 1, + Dish.visibility == 1 + ).all() + return dishes + + +# Get special dishes (only visible ones) +@router.get("/api/specials", response_model=List[DishModel]) +def get_specials(request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + dishes = db.query(Dish).filter( + Dish.hotel_id == hotel_id, + Dish.is_special == 1, + Dish.visibility == 1 + ).all() + return dishes + + +# Get all dish categories (only from visible dishes) +@router.get("/api/categories") +def get_categories(request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + categories = db.query(Dish.category).filter( + Dish.hotel_id == hotel_id, + Dish.visibility == 1 + ).distinct().all() + + # Parse JSON categories and flatten them + import json + unique_categories = set() + + for category_tuple in categories: + category_str = category_tuple[0] + if category_str: + try: + # Try to parse as JSON array + category_list = json.loads(category_str) + if isinstance(category_list, list): + unique_categories.update(category_list) + else: + unique_categories.add(category_str) + except (json.JSONDecodeError, TypeError): + # If not JSON, treat as single category + unique_categories.add(category_str) + + return sorted(list(unique_categories)) + + +# Register a new user or update existing user +@router.post("/api/register", response_model=PersonModel) +def register_user(user: PersonCreate, request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + + # Check if user already exists for this hotel + db_user = db.query(Person).filter( + Person.hotel_id == hotel_id, + Person.username == user.username + ).first() + + if db_user: + # Update existing user's last visit time (visit count updated only when order is placed) + db_user.last_visit = datetime.now(timezone.utc) + db.commit() + db.refresh(db_user) + return db_user + else: + # Create new user (visit count will be incremented when first order is placed) + db_user = Person( + hotel_id=hotel_id, + username=user.username, + password=user.password, # In a real app, you should hash this password + visit_count=0, + last_visit=datetime.now(timezone.utc), + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + + +# Login user +@router.post("/api/login", response_model=Dict[str, Any]) +def login_user(user_data: PersonLogin, request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + + # Find user by username for this hotel + db_user = db.query(Person).filter( + Person.hotel_id == hotel_id, + Person.username == user_data.username + ).first() + + if not db_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username" + ) + + # Check password (in a real app, you would verify hashed passwords) + if db_user.password != user_data.password: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password" + ) + + # Update last visit time (but not visit count - that's only updated when order is placed) + db_user.last_visit = datetime.now(timezone.utc) + db.commit() + + # Return user info and a success message + return { + "user": { + "id": db_user.id, + "username": db_user.username, + "visit_count": db_user.visit_count, + }, + "message": "Login successful", + } + + +# Create new order +@router.post("/api/orders", response_model=OrderModel) +def create_order( + order: OrderCreate, request: Request, person_id: int = Query(None), db: Session = Depends(get_session_database) +): + hotel_id = get_hotel_id_from_request(request) + + # If person_id is not provided but we have a username/password, try to find or create the user + if not person_id and hasattr(order, "username") and hasattr(order, "password"): + # Check if user exists for this hotel + db_user = db.query(Person).filter( + Person.hotel_id == hotel_id, + Person.username == order.username + ).first() + + if db_user: + # Update existing user's visit count + db_user.visit_count += 1 + db_user.last_visit = datetime.now(timezone.utc) + db.commit() + person_id = db_user.id + else: + # Create new user (visit count starts at 1 since they're placing their first order) + db_user = Person( + hotel_id=hotel_id, + username=order.username, + password=order.password, + visit_count=1, + last_visit=datetime.now(timezone.utc), + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + person_id = db_user.id + elif person_id: + # If person_id is provided (normal flow), increment visit count for that user + db_user = db.query(Person).filter( + Person.hotel_id == hotel_id, + Person.id == person_id + ).first() + if db_user: + db_user.visit_count += 1 + db_user.last_visit = datetime.now(timezone.utc) + db.commit() + + # Create order + db_order = Order( + hotel_id=hotel_id, + table_number=order.table_number, + unique_id=order.unique_id, + person_id=person_id, # Link order to person if provided + status="pending", + ) + db.add(db_order) + db.commit() + db.refresh(db_order) + + # Mark the table as occupied + from ..database import Table + + db_table = db.query(Table).filter( + Table.hotel_id == hotel_id, + Table.table_number == order.table_number + ).first() + if db_table: + db_table.is_occupied = True + db_table.current_order_id = db_order.id + db.commit() + + # Create order items + for item in order.items: + # Get the dish to include its information and verify it belongs to this hotel + dish = db.query(Dish).filter( + Dish.hotel_id == hotel_id, + Dish.id == item.dish_id + ).first() + if not dish: + continue # Skip if dish doesn't exist or doesn't belong to this hotel + + db_item = OrderItem( + hotel_id=hotel_id, + order_id=db_order.id, + dish_id=item.dish_id, + quantity=item.quantity, + price=dish.price, # Store price at time of order + remarks=item.remarks, + ) + db.add(db_item) + + db.commit() + db.refresh(db_order) + + return db_order + + +# Get order status +@router.get("/api/orders/{order_id}", response_model=OrderModel) +def get_order(order_id: int, request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + + # Use joinedload to load the dish relationship for each order item + order = db.query(Order).filter( + Order.hotel_id == hotel_id, + Order.id == order_id + ).first() + if order is None: + raise HTTPException(status_code=404, detail="Order not found") + + # Explicitly load dish information for each order item + for item in order.items: + if not hasattr(item, "dish") or item.dish is None: + dish = db.query(Dish).filter( + Dish.hotel_id == hotel_id, + Dish.id == item.dish_id + ).first() + if dish: + item.dish = dish + + return order + + +# Get orders by person_id +@router.get("/api/person/{person_id}/orders", response_model=List[OrderModel]) +def get_person_orders(person_id: int, request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + + # Get all orders for a specific person in this hotel + orders = ( + db.query(Order) + .filter( + Order.hotel_id == hotel_id, + Order.person_id == person_id + ) + .order_by(Order.created_at.desc()) + .all() + ) + + # Explicitly load dish information for each order item + for order in orders: + for item in order.items: + if not hasattr(item, "dish") or item.dish is None: + dish = db.query(Dish).filter( + Dish.hotel_id == hotel_id, + Dish.id == item.dish_id + ).first() + if dish: + item.dish = dish + + return orders + + +# Request payment for order +@router.put("/api/orders/{order_id}/payment") +def request_payment(order_id: int, request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + + try: + # Check if order exists and is not already paid + db_order = db.query(Order).filter( + Order.hotel_id == hotel_id, + Order.id == order_id + ).first() + if db_order is None: + raise HTTPException(status_code=404, detail="Order not found") + + # Check if order is already paid + if db_order.status == "paid": + return {"message": "Order is already paid"} + + # Check if order is completed (ready for payment) + if db_order.status != "completed": + raise HTTPException( + status_code=400, + detail="Order must be completed before payment can be processed" + ) + + # Calculate order totals and apply discounts + from ..database import LoyaltyProgram, SelectionOffer, Person + + # Calculate subtotal from order items + subtotal = 0 + for item in db_order.items: + if item.dish: + subtotal += item.dish.price * item.quantity + + # Initialize discount amounts + loyalty_discount_amount = 0 + loyalty_discount_percentage = 0 + selection_offer_discount_amount = 0 + + # Apply loyalty discount if customer is registered + if db_order.person_id: + person = db.query(Person).filter(Person.id == db_order.person_id).first() + if person: + # Get applicable loyalty discount + loyalty_tier = ( + db.query(LoyaltyProgram) + .filter( + LoyaltyProgram.hotel_id == hotel_id, + LoyaltyProgram.visit_count == person.visit_count, + LoyaltyProgram.is_active == True, + ) + .first() + ) + + if loyalty_tier: + loyalty_discount_percentage = loyalty_tier.discount_percentage + loyalty_discount_amount = subtotal * (loyalty_discount_percentage / 100) + + # Apply selection offer discount + selection_offer = ( + db.query(SelectionOffer) + .filter( + SelectionOffer.hotel_id == hotel_id, + SelectionOffer.min_amount <= subtotal, + SelectionOffer.is_active == True, + ) + .order_by(SelectionOffer.min_amount.desc()) + .first() + ) + + if selection_offer: + selection_offer_discount_amount = selection_offer.discount_amount + + # Calculate final total after discounts + final_total = subtotal - loyalty_discount_amount - selection_offer_discount_amount + + # Ensure final total is not negative + final_total = max(0, final_total) + + # Update order with calculated amounts + db_order.status = "paid" + db_order.subtotal_amount = subtotal + db_order.loyalty_discount_amount = loyalty_discount_amount + db_order.loyalty_discount_percentage = loyalty_discount_percentage + db_order.selection_offer_discount_amount = selection_offer_discount_amount + db_order.total_amount = final_total + db_order.updated_at = datetime.now(timezone.utc) + + # Check if this is the last unpaid order for this table + from ..database import Table + + # Get all orders for this table that are not paid + table_unpaid_orders = db.query(Order).filter( + Order.table_number == db_order.table_number, + Order.status != "paid", + Order.status != "cancelled" + ).all() + + # If this is the only unpaid order, mark table as free + if len(table_unpaid_orders) == 1 and table_unpaid_orders[0].id == order_id: + db_table = db.query(Table).filter(Table.table_number == db_order.table_number).first() + if db_table: + db_table.is_occupied = False + db_table.current_order_id = None + db_table.updated_at = datetime.now(timezone.utc) + + # Commit the transaction + db.commit() + db.refresh(db_order) + + return {"message": "Payment completed successfully", "order_id": order_id} + + except HTTPException: + # Re-raise HTTP exceptions + db.rollback() + raise + except Exception as e: + # Handle any other exceptions + db.rollback() + print(f"Error processing payment for order {order_id}: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error processing payment: {str(e)}" + ) + + +# Cancel order +@router.put("/api/orders/{order_id}/cancel") +def cancel_order(order_id: int, request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + + db_order = db.query(Order).filter( + Order.hotel_id == hotel_id, + Order.id == order_id + ).first() + if db_order is None: + raise HTTPException(status_code=404, detail="Order not found") + + # Check if order is in pending status (not accepted or completed) + if db_order.status != "pending": + raise HTTPException( + status_code=400, + detail="Only pending orders can be cancelled. Orders that have been accepted by the chef cannot be cancelled." + ) + + # Update order status to cancelled + current_time = datetime.now(timezone.utc) + db_order.status = "cancelled" + db_order.updated_at = current_time + + # Mark the table as free if this was the current order + from ..database import Table + + db_table = db.query(Table).filter(Table.table_number == db_order.table_number).first() + if db_table and db_table.current_order_id == db_order.id: + db_table.is_occupied = False + db_table.current_order_id = None + db_table.updated_at = current_time + + db.commit() + + return {"message": "Order cancelled successfully"} + + +# Get person details +@router.get("/api/person/{person_id}", response_model=PersonModel) +def get_person(person_id: int, request: Request, db: Session = Depends(get_session_database)): + person = db.query(Person).filter(Person.id == person_id).first() + if not person: + raise HTTPException(status_code=404, detail="Person not found") + return person + + +# Phone authentication endpoints +@router.post("/api/phone-auth", response_model=Dict[str, Any]) +def phone_auth(auth_request: PhoneAuthRequest, request: Request, db: Session = Depends(get_session_database)): + """ + Initiate phone authentication by sending OTP + """ + try: + # Validate phone number format + if not auth_request.phone_number.startswith("+91"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Phone number must start with +91" + ) + + # Send OTP via Firebase + result = firebase_auth.verify_phone_number(auth_request.phone_number) + + print(f"Phone auth initiated for: {auth_request.phone_number}, table: {auth_request.table_number}") + + return { + "success": True, + "message": "Verification code sent successfully", + "session_info": result.get("sessionInfo", "firebase-verification-token") + } + except HTTPException as e: + print(f"HTTP Exception in phone_auth: {e.detail}") + raise e + except Exception as e: + print(f"Exception in phone_auth: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to send verification code: {str(e)}" + ) + + +@router.post("/api/verify-otp", response_model=Dict[str, Any]) +def verify_otp(verify_request: PhoneVerifyRequest, request: Request, db: Session = Depends(get_session_database)): + """ + Verify OTP and authenticate user + """ + try: + print(f"Verifying OTP for phone: {verify_request.phone_number}") + + # Verify OTP via Firebase + # Note: The actual OTP verification is done on the client side with Firebase + # This is just a validation step + firebase_auth.verify_otp( + verify_request.phone_number, + verify_request.verification_code + ) + + # Check if user exists in database for this hotel + hotel_id = get_hotel_id_from_request(request) + user = db.query(Person).filter( + Person.hotel_id == hotel_id, + Person.phone_number == verify_request.phone_number + ).first() + + if user: + print(f"Existing user found: {user.username}") + # Existing user - update last visit time (visit count updated only when order is placed) + user.last_visit = datetime.now(timezone.utc) + db.commit() + db.refresh(user) + + return { + "success": True, + "message": "Authentication successful", + "user_exists": True, + "user_id": user.id, + "username": user.username + } + else: + print(f"New user with phone: {verify_request.phone_number}") + # New user - return flag to collect username + return { + "success": True, + "message": "Authentication successful, but user not found", + "user_exists": False + } + + except HTTPException as e: + print(f"HTTP Exception in verify_otp: {e.detail}") + raise e + except Exception as e: + print(f"Exception in verify_otp: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to verify OTP: {str(e)}" + ) + + +@router.post("/api/register-phone-user", response_model=Dict[str, Any]) +def register_phone_user(user_request: UsernameRequest, request: Request, db: Session = Depends(get_session_database)): + """ + Register a new user after phone authentication + """ + try: + hotel_id = get_hotel_id_from_request(request) + print(f"Registering new user with phone: {user_request.phone_number}, username: {user_request.username}") + + # Check if username already exists for this hotel + existing_user = db.query(Person).filter( + Person.hotel_id == hotel_id, + Person.username == user_request.username + ).first() + if existing_user: + print(f"Username already exists: {user_request.username}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already exists" + ) + + # Check if phone number already exists for this hotel + phone_user = db.query(Person).filter( + Person.hotel_id == hotel_id, + Person.phone_number == user_request.phone_number + ).first() + if phone_user: + print(f"Phone number already registered: {user_request.phone_number}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Phone number already registered" + ) + + # Create new user (visit count will be incremented when first order is placed) + new_user = Person( + hotel_id=hotel_id, + username=user_request.username, + password="", # No password needed for phone auth + phone_number=user_request.phone_number, + visit_count=0, + last_visit=datetime.now(timezone.utc) + ) + + db.add(new_user) + db.commit() + db.refresh(new_user) + + print(f"User registered successfully: {new_user.id}, {new_user.username}") + + return { + "success": True, + "message": "User registered successfully", + "user_id": new_user.id, + "username": new_user.username + } + + except HTTPException as e: + print(f"HTTP Exception in register_phone_user: {e.detail}") + raise e + except Exception as e: + print(f"Exception in register_phone_user: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to register user: {str(e)}" + ) diff --git a/app/routers/feedback.py b/app/routers/feedback.py new file mode 100644 index 0000000000000000000000000000000000000000..17c8018a191d5e4e4caf71354db7413fe8717c07 --- /dev/null +++ b/app/routers/feedback.py @@ -0,0 +1,87 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy.orm import Session +from typing import List +from datetime import datetime, timezone + +from ..database import get_db, Feedback as FeedbackModel, Order, Person, get_session_db, get_hotel_id_from_request +from ..models.feedback import Feedback, FeedbackCreate +from ..middleware import get_session_id + +router = APIRouter( + prefix="/feedback", + tags=["feedback"], + responses={404: {"description": "Not found"}}, +) + + +# Dependency to get session-aware database +def get_session_database(request: Request): + session_id = get_session_id(request) + return next(get_session_db(session_id)) + + +# Create new feedback +@router.post("/", response_model=Feedback) +def create_feedback(feedback: FeedbackCreate, request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + + # Check if order exists for this hotel + db_order = db.query(Order).filter( + Order.hotel_id == hotel_id, + Order.id == feedback.order_id + ).first() + if not db_order: + raise HTTPException(status_code=404, detail="Order not found") + + # Check if person exists if person_id is provided + if feedback.person_id: + db_person = db.query(Person).filter( + Person.hotel_id == hotel_id, + Person.id == feedback.person_id + ).first() + if not db_person: + raise HTTPException(status_code=404, detail="Person not found") + + # Create feedback + db_feedback = FeedbackModel( + hotel_id=hotel_id, + order_id=feedback.order_id, + person_id=feedback.person_id, + rating=feedback.rating, + comment=feedback.comment, + created_at=datetime.now(timezone.utc), + ) + db.add(db_feedback) + db.commit() + db.refresh(db_feedback) + return db_feedback + + +# Get all feedback +@router.get("/", response_model=List[Feedback]) +def get_all_feedback(request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + return db.query(FeedbackModel).filter(FeedbackModel.hotel_id == hotel_id).all() + + +# Get feedback by order_id +@router.get("/order/{order_id}", response_model=Feedback) +def get_feedback_by_order(order_id: int, request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + db_feedback = db.query(FeedbackModel).filter( + FeedbackModel.hotel_id == hotel_id, + FeedbackModel.order_id == order_id + ).first() + if not db_feedback: + raise HTTPException(status_code=404, detail="Feedback not found") + return db_feedback + + +# Get feedback by person_id +@router.get("/person/{person_id}", response_model=List[Feedback]) +def get_feedback_by_person(person_id: int, request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + return db.query(FeedbackModel).filter( + FeedbackModel.hotel_id == hotel_id, + FeedbackModel.person_id == person_id + ).all() diff --git a/app/routers/loyalty.py b/app/routers/loyalty.py new file mode 100644 index 0000000000000000000000000000000000000000..15e4c6d44c539d95e3d19cdd5eb5c149c59c3cae --- /dev/null +++ b/app/routers/loyalty.py @@ -0,0 +1,172 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy.orm import Session +from typing import List +from datetime import datetime, timezone + +from ..database import get_db, LoyaltyProgram as LoyaltyProgramModel, get_session_db, get_hotel_id_from_request +from ..models.loyalty import LoyaltyProgram, LoyaltyProgramCreate, LoyaltyProgramUpdate +from ..middleware import get_session_id + +router = APIRouter( + prefix="/loyalty", + tags=["loyalty"], + responses={404: {"description": "Not found"}}, +) + + +# Dependency to get session-aware database +def get_session_database(request: Request): + session_id = get_session_id(request) + return next(get_session_db(session_id)) + + +# Get all loyalty program tiers +@router.get("/", response_model=List[LoyaltyProgram]) +def get_all_loyalty_tiers(request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + return db.query(LoyaltyProgramModel).filter(LoyaltyProgramModel.hotel_id == hotel_id).order_by(LoyaltyProgramModel.visit_count).all() + + +# Get active loyalty program tiers +@router.get("/active", response_model=List[LoyaltyProgram]) +def get_active_loyalty_tiers(request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + return ( + db.query(LoyaltyProgramModel) + .filter( + LoyaltyProgramModel.hotel_id == hotel_id, + LoyaltyProgramModel.is_active == True + ) + .order_by(LoyaltyProgramModel.visit_count) + .all() + ) + + +# Get loyalty tier by ID +@router.get("/{tier_id}", response_model=LoyaltyProgram) +def get_loyalty_tier(tier_id: int, request: Request, db: Session = Depends(get_session_database)): + db_tier = ( + db.query(LoyaltyProgramModel).filter(LoyaltyProgramModel.id == tier_id).first() + ) + if not db_tier: + raise HTTPException(status_code=404, detail="Loyalty tier not found") + return db_tier + + +# Create new loyalty tier +@router.post("/", response_model=LoyaltyProgram) +def create_loyalty_tier(tier: LoyaltyProgramCreate, request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + + # Check if a tier with this visit count already exists for this hotel + existing_tier = ( + db.query(LoyaltyProgramModel) + .filter( + LoyaltyProgramModel.hotel_id == hotel_id, + LoyaltyProgramModel.visit_count == tier.visit_count + ) + .first() + ) + if existing_tier: + raise HTTPException( + status_code=400, + detail=f"Loyalty tier with visit count {tier.visit_count} already exists", + ) + + # Create new tier + db_tier = LoyaltyProgramModel( + hotel_id=hotel_id, + visit_count=tier.visit_count, + discount_percentage=tier.discount_percentage, + is_active=tier.is_active, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + db.add(db_tier) + db.commit() + db.refresh(db_tier) + return db_tier + + +# Update loyalty tier +@router.put("/{tier_id}", response_model=LoyaltyProgram) +def update_loyalty_tier( + tier_id: int, tier_update: LoyaltyProgramUpdate, request: Request, db: Session = Depends(get_session_database) +): + db_tier = ( + db.query(LoyaltyProgramModel).filter(LoyaltyProgramModel.id == tier_id).first() + ) + if not db_tier: + raise HTTPException(status_code=404, detail="Loyalty tier not found") + + # Check if updating visit count and if it already exists + if ( + tier_update.visit_count is not None + and tier_update.visit_count != db_tier.visit_count + ): + existing_tier = ( + db.query(LoyaltyProgramModel) + .filter( + LoyaltyProgramModel.visit_count == tier_update.visit_count, + LoyaltyProgramModel.id != tier_id, + ) + .first() + ) + if existing_tier: + raise HTTPException( + status_code=400, + detail=f"Loyalty tier with visit count {tier_update.visit_count} already exists", + ) + db_tier.visit_count = tier_update.visit_count + + # Update other fields if provided + if tier_update.discount_percentage is not None: + db_tier.discount_percentage = tier_update.discount_percentage + if tier_update.is_active is not None: + db_tier.is_active = tier_update.is_active + + db_tier.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(db_tier) + return db_tier + + +# Delete loyalty tier +@router.delete("/{tier_id}") +def delete_loyalty_tier(tier_id: int, request: Request, db: Session = Depends(get_session_database)): + db_tier = ( + db.query(LoyaltyProgramModel).filter(LoyaltyProgramModel.id == tier_id).first() + ) + if not db_tier: + raise HTTPException(status_code=404, detail="Loyalty tier not found") + + db.delete(db_tier) + db.commit() + return {"message": "Loyalty tier deleted successfully"} + + +# Get applicable discount for a visit count +@router.get("/discount/{visit_count}") +def get_discount_for_visit_count(visit_count: int, request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + + # Find the tier that exactly matches the visit count for this hotel + applicable_tier = ( + db.query(LoyaltyProgramModel) + .filter( + LoyaltyProgramModel.hotel_id == hotel_id, + LoyaltyProgramModel.visit_count == visit_count, + LoyaltyProgramModel.is_active == True, + ) + .first() + ) + + if not applicable_tier: + return {"discount_percentage": 0, "message": "No applicable loyalty discount"} + + return { + "discount_percentage": applicable_tier.discount_percentage, + "tier_id": applicable_tier.id, + "visit_count": applicable_tier.visit_count, + "message": f"Loyalty discount of {applicable_tier.discount_percentage}% applied for {visit_count} visits", + } diff --git a/app/routers/selection_offer.py b/app/routers/selection_offer.py new file mode 100644 index 0000000000000000000000000000000000000000..f64ddb6551ffb73270a9765fbf4a0d9dea7edfb3 --- /dev/null +++ b/app/routers/selection_offer.py @@ -0,0 +1,198 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy.orm import Session +from typing import List +from datetime import datetime, timezone + +from ..database import get_db, SelectionOffer as SelectionOfferModel, get_session_db, get_hotel_id_from_request +from ..models.selection_offer import ( + SelectionOffer, + SelectionOfferCreate, + SelectionOfferUpdate, +) +from ..middleware import get_session_id + +router = APIRouter( + prefix="/selection-offers", + tags=["selection-offers"], + responses={404: {"description": "Not found"}}, +) + + +# Dependency to get session-aware database +def get_session_database(request: Request): + session_id = get_session_id(request) + return next(get_session_db(session_id)) + + +# Get all selection offers +@router.get("/", response_model=List[SelectionOffer]) +def get_all_selection_offers(request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + return db.query(SelectionOfferModel).filter(SelectionOfferModel.hotel_id == hotel_id).order_by(SelectionOfferModel.min_amount).all() + + +# Get active selection offers +@router.get("/active", response_model=List[SelectionOffer]) +def get_active_selection_offers(request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + return ( + db.query(SelectionOfferModel) + .filter( + SelectionOfferModel.hotel_id == hotel_id, + SelectionOfferModel.is_active == True + ) + .order_by(SelectionOfferModel.min_amount) + .all() + ) + + +# Get selection offer by ID +@router.get("/{offer_id}", response_model=SelectionOffer) +def get_selection_offer(offer_id: int, request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + db_offer = ( + db.query(SelectionOfferModel).filter( + SelectionOfferModel.hotel_id == hotel_id, + SelectionOfferModel.id == offer_id + ).first() + ) + if not db_offer: + raise HTTPException(status_code=404, detail="Selection offer not found") + return db_offer + + +# Create new selection offer +@router.post("/", response_model=SelectionOffer) +def create_selection_offer(offer: SelectionOfferCreate, request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + + # Check if an offer with this min_amount already exists for this hotel + existing_offer = ( + db.query(SelectionOfferModel) + .filter( + SelectionOfferModel.hotel_id == hotel_id, + SelectionOfferModel.min_amount == offer.min_amount + ) + .first() + ) + if existing_offer: + raise HTTPException( + status_code=400, + detail=f"Selection offer with minimum amount {offer.min_amount} already exists", + ) + + # Create new offer + db_offer = SelectionOfferModel( + hotel_id=hotel_id, + min_amount=offer.min_amount, + discount_amount=offer.discount_amount, + is_active=offer.is_active, + description=offer.description, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + db.add(db_offer) + db.commit() + db.refresh(db_offer) + return db_offer + + +# Update selection offer +@router.put("/{offer_id}", response_model=SelectionOffer) +def update_selection_offer( + offer_id: int, offer_update: SelectionOfferUpdate, request: Request, db: Session = Depends(get_session_database) +): + hotel_id = get_hotel_id_from_request(request) + + db_offer = ( + db.query(SelectionOfferModel).filter( + SelectionOfferModel.hotel_id == hotel_id, + SelectionOfferModel.id == offer_id + ).first() + ) + if not db_offer: + raise HTTPException(status_code=404, detail="Selection offer not found") + + # Check if updating min_amount and if it already exists for this hotel + if ( + offer_update.min_amount is not None + and offer_update.min_amount != db_offer.min_amount + ): + existing_offer = ( + db.query(SelectionOfferModel) + .filter( + SelectionOfferModel.hotel_id == hotel_id, + SelectionOfferModel.min_amount == offer_update.min_amount, + SelectionOfferModel.id != offer_id, + ) + .first() + ) + if existing_offer: + raise HTTPException( + status_code=400, + detail=f"Selection offer with minimum amount {offer_update.min_amount} already exists", + ) + db_offer.min_amount = offer_update.min_amount + + # Update other fields if provided + if offer_update.discount_amount is not None: + db_offer.discount_amount = offer_update.discount_amount + if offer_update.is_active is not None: + db_offer.is_active = offer_update.is_active + if offer_update.description is not None: + db_offer.description = offer_update.description + + db_offer.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(db_offer) + return db_offer + + +# Delete selection offer +@router.delete("/{offer_id}") +def delete_selection_offer(offer_id: int, request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + + db_offer = ( + db.query(SelectionOfferModel).filter( + SelectionOfferModel.hotel_id == hotel_id, + SelectionOfferModel.id == offer_id + ).first() + ) + if not db_offer: + raise HTTPException(status_code=404, detail="Selection offer not found") + + db.delete(db_offer) + db.commit() + return {"message": "Selection offer deleted successfully"} + + +# Get applicable discount for an order amount +@router.get("/discount/{order_amount}") +def get_discount_for_order_amount(order_amount: float, request: Request, db: Session = Depends(get_session_database)): + hotel_id = get_hotel_id_from_request(request) + + # Find the highest tier that the order amount qualifies for this hotel + applicable_offer = ( + db.query(SelectionOfferModel) + .filter( + SelectionOfferModel.hotel_id == hotel_id, + SelectionOfferModel.min_amount <= order_amount, + SelectionOfferModel.is_active == True, + ) + .order_by(SelectionOfferModel.min_amount.desc()) + .first() + ) + + if not applicable_offer: + return { + "discount_amount": 0, + "message": "No applicable selection offer discount", + } + + return { + "discount_amount": applicable_offer.discount_amount, + "offer_id": applicable_offer.id, + "min_amount": applicable_offer.min_amount, + "message": f"Selection offer discount of ₹{applicable_offer.discount_amount} applied", + } diff --git a/app/routers/settings.py b/app/routers/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..7145c3f6754e704d83275853fdf555316b63d3a3 --- /dev/null +++ b/app/routers/settings.py @@ -0,0 +1,207 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Request +from sqlalchemy.orm import Session +from typing import Optional, List +import os +import shutil +import csv +from datetime import datetime, timezone + +from ..database import ( + get_db, Settings, Hotel, switch_database, get_current_database, + get_session_db, get_session_current_database, set_session_hotel_context, + get_session_hotel_id, authenticate_hotel_session +) +from ..models.settings import Settings as SettingsModel, SettingsUpdate +from ..models.database_config import DatabaseEntry, DatabaseList, DatabaseSelectRequest, DatabaseSelectResponse +from ..middleware import get_session_id + +router = APIRouter( + prefix="/settings", + tags=["settings"], + responses={404: {"description": "Not found"}}, +) + + +# Dependency to get session-aware database +def get_session_database(request: Request): + session_id = get_session_id(request) + return next(get_session_db(session_id)) + + +# Get available hotels from hotels.csv +@router.get("/hotels", response_model=DatabaseList) +def get_hotels(): + try: + hotels = [] + with open("hotels.csv", "r") as file: + reader = csv.DictReader(file) + for row in reader: + hotels.append(DatabaseEntry( + database_name=row["hotel_name"], # Using hotel_name instead of hotel_database + password=row["password"] + )) + + # Return only hotel names, not passwords + return {"databases": [{"database_name": hotel.database_name, "password": "********"} for hotel in hotels]} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error reading hotel configuration: {str(e)}") + + +# Legacy endpoint for backward compatibility +@router.get("/databases", response_model=DatabaseList) +def get_databases(): + return get_hotels() + + +# Get current hotel info +@router.get("/current-hotel") +def get_current_hotel(request: Request): + session_id = get_session_id(request) + hotel_id = get_session_hotel_id(session_id) + if hotel_id: + # Get hotel name from database + db = next(get_session_database(request)) + hotel = db.query(Hotel).filter(Hotel.id == hotel_id).first() + if hotel: + return {"hotel_name": hotel.hotel_name, "hotel_id": hotel.id} + return {"hotel_name": None, "hotel_id": None} + + +# Legacy endpoint for backward compatibility +@router.get("/current-database") +def get_current_db(request: Request): + session_id = get_session_id(request) + return {"database_name": get_session_current_database(session_id)} + + +# Switch hotel +@router.post("/switch-hotel", response_model=DatabaseSelectResponse) +def select_hotel(request_data: DatabaseSelectRequest, request: Request): + try: + session_id = get_session_id(request) + + # Authenticate hotel using hotel_name and password + hotel_id = authenticate_hotel_session(request_data.database_name, request_data.password) + + if hotel_id: + # Set hotel context for this session + success = set_session_hotel_context(session_id, hotel_id) + if success: + return { + "success": True, + "message": f"Successfully switched to hotel: {request_data.database_name}" + } + else: + raise HTTPException(status_code=500, detail="Failed to set hotel context") + else: + raise HTTPException(status_code=401, detail="Invalid hotel credentials") + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error switching hotel: {str(e)}") + + +# Legacy endpoint for backward compatibility +@router.post("/switch-database", response_model=DatabaseSelectResponse) +def select_database(request_data: DatabaseSelectRequest, request: Request): + return select_hotel(request_data, request) + + +# Get hotel settings +@router.get("/", response_model=SettingsModel) +def get_settings(request: Request, db: Session = Depends(get_session_database)): + session_id = get_session_id(request) + hotel_id = get_session_hotel_id(session_id) + + if not hotel_id: + raise HTTPException(status_code=400, detail="No hotel context set") + + # Get settings for the current hotel + settings = db.query(Settings).filter(Settings.hotel_id == hotel_id).first() + + if not settings: + # Get hotel info for default settings + hotel = db.query(Hotel).filter(Hotel.id == hotel_id).first() + if not hotel: + raise HTTPException(status_code=404, detail="Hotel not found") + + # Create default settings for this hotel + settings = Settings( + hotel_id=hotel_id, + hotel_name=hotel.hotel_name, + address="123 Main Street, City", + contact_number="+1 123-456-7890", + email="info@tabblehotel.com", + ) + db.add(settings) + db.commit() + db.refresh(settings) + + return settings + + +# Update hotel settings +@router.put("/", response_model=SettingsModel) +async def update_settings( + request: Request, + hotel_name: str = Form(...), + address: Optional[str] = Form(None), + contact_number: Optional[str] = Form(None), + email: Optional[str] = Form(None), + tax_id: Optional[str] = Form(None), + logo: Optional[UploadFile] = File(None), + db: Session = Depends(get_session_database) +): + session_id = get_session_id(request) + hotel_id = get_session_hotel_id(session_id) + + if not hotel_id: + raise HTTPException(status_code=400, detail="No hotel context set") + + # Get existing settings for this hotel or create new + settings = db.query(Settings).filter(Settings.hotel_id == hotel_id).first() + + if not settings: + settings = Settings( + hotel_id=hotel_id, + hotel_name=hotel_name, + address=address, + contact_number=contact_number, + email=email, + tax_id=tax_id, + ) + db.add(settings) + else: + # Update fields + settings.hotel_name = hotel_name + settings.address = address + settings.contact_number = contact_number + settings.email = email + settings.tax_id = tax_id + + # Handle logo upload if provided + if logo: + # Get hotel info for organizing logos + hotel = db.query(Hotel).filter(Hotel.id == hotel_id).first() + hotel_name_for_path = hotel.hotel_name if hotel else f"hotel_{hotel_id}" + + # Create directory structure: app/static/images/logo/{hotel_name} + hotel_logo_dir = f"app/static/images/logo/{hotel_name_for_path}" + os.makedirs(hotel_logo_dir, exist_ok=True) + + # Save logo with hotel-specific path + logo_path = f"{hotel_logo_dir}/hotel_logo_{logo.filename}" + with open(logo_path, "wb") as buffer: + shutil.copyfileobj(logo.file, buffer) + + # Update settings with logo path (URL path for serving) + settings.logo_path = f"/static/images/logo/{hotel_name_for_path}/hotel_logo_{logo.filename}" + + # Update timestamp + settings.updated_at = datetime.now(timezone.utc) + + db.commit() + db.refresh(settings) + + return settings diff --git a/app/routers/table.py b/app/routers/table.py new file mode 100644 index 0000000000000000000000000000000000000000..870f040d58065811f2a16e8f0205f70e3cdd23b5 --- /dev/null +++ b/app/routers/table.py @@ -0,0 +1,254 @@ +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session +from typing import List +from datetime import datetime, timezone + +from ..database import get_db, Table as TableModel, Order, get_session_db +from ..models.table import Table, TableCreate, TableUpdate, TableStatus +from ..middleware import get_session_id + +router = APIRouter( + prefix="/tables", + tags=["tables"], + responses={404: {"description": "Not found"}}, +) + + +# Dependency to get session-aware database +def get_session_database(request: Request): + session_id = get_session_id(request) + return next(get_session_db(session_id)) + + +# Get all tables +@router.get("/", response_model=List[Table]) +def get_all_tables(request: Request, db: Session = Depends(get_session_database)): + return db.query(TableModel).order_by(TableModel.table_number).all() + + +# Get table by ID +@router.get("/{table_id}", response_model=Table) +def get_table(table_id: int, request: Request, db: Session = Depends(get_session_database)): + db_table = db.query(TableModel).filter(TableModel.id == table_id).first() + if not db_table: + raise HTTPException(status_code=404, detail="Table not found") + return db_table + + +# Get table by table number +@router.get("/number/{table_number}", response_model=Table) +def get_table_by_number(table_number: int, request: Request, db: Session = Depends(get_session_database)): + db_table = ( + db.query(TableModel).filter(TableModel.table_number == table_number).first() + ) + if not db_table: + raise HTTPException(status_code=404, detail="Table not found") + return db_table + + +# Create new table +@router.post("/", response_model=Table) +def create_table(table: TableCreate, request: Request, db: Session = Depends(get_session_database)): + # Check if a table with this number already exists + existing_table = ( + db.query(TableModel) + .filter(TableModel.table_number == table.table_number) + .first() + ) + if existing_table: + raise HTTPException( + status_code=400, + detail=f"Table with number {table.table_number} already exists", + ) + + # Create new table + db_table = TableModel( + table_number=table.table_number, + is_occupied=table.is_occupied, + current_order_id=table.current_order_id, + last_occupied_at=table.last_occupied_at, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + db.add(db_table) + db.commit() + db.refresh(db_table) + return db_table + + +# Update table +@router.put("/{table_id}", response_model=Table) +def update_table( + table_id: int, table_update: TableUpdate, request: Request, db: Session = Depends(get_session_database) +): + db_table = db.query(TableModel).filter(TableModel.id == table_id).first() + if not db_table: + raise HTTPException(status_code=404, detail="Table not found") + + # Update fields if provided + if table_update.is_occupied is not None: + db_table.is_occupied = table_update.is_occupied + if table_update.current_order_id is not None: + db_table.current_order_id = table_update.current_order_id + + db_table.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(db_table) + return db_table + + +# Delete table +@router.delete("/{table_id}") +def delete_table(table_id: int, request: Request, db: Session = Depends(get_session_database)): + db_table = db.query(TableModel).filter(TableModel.id == table_id).first() + if not db_table: + raise HTTPException(status_code=404, detail="Table not found") + + # Check if table is currently occupied + if db_table.is_occupied: + raise HTTPException( + status_code=400, detail="Cannot delete a table that is currently occupied" + ) + + db.delete(db_table) + db.commit() + return {"message": "Table deleted successfully"} + + +# Get table status (total, occupied, free) +@router.get("/status/summary", response_model=TableStatus) +def get_table_status(request: Request, db: Session = Depends(get_session_database)): + total_tables = db.query(TableModel).count() + occupied_tables = ( + db.query(TableModel).filter(TableModel.is_occupied == True).count() + ) + free_tables = total_tables - occupied_tables + + return { + "total_tables": total_tables, + "occupied_tables": occupied_tables, + "free_tables": free_tables, + } + + +# Set table as occupied +@router.put("/{table_id}/occupy", response_model=Table) +def set_table_occupied( + table_id: int, order_id: int = None, request: Request = None, db: Session = Depends(get_session_database) +): + db_table = db.query(TableModel).filter(TableModel.id == table_id).first() + if not db_table: + raise HTTPException(status_code=404, detail="Table not found") + + # Check if table is already occupied + if db_table.is_occupied: + raise HTTPException(status_code=400, detail="Table is already occupied") + + # Update table status + db_table.is_occupied = True + + # Link to order if provided + if order_id: + # Verify order exists + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + db_table.current_order_id = order_id + + db_table.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(db_table) + return db_table + + +# Set table as free +@router.put("/{table_id}/free", response_model=Table) +def set_table_free(table_id: int, request: Request, db: Session = Depends(get_session_database)): + db_table = db.query(TableModel).filter(TableModel.id == table_id).first() + if not db_table: + raise HTTPException(status_code=404, detail="Table not found") + + # Check if table is already free + if not db_table.is_occupied: + raise HTTPException(status_code=400, detail="Table is already free") + + # Update table status + db_table.is_occupied = False + db_table.current_order_id = None + db_table.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(db_table) + return db_table + + +# Set table as occupied by table number +@router.put("/number/{table_number}/occupy", response_model=Table) +def set_table_occupied_by_number(table_number: int, request: Request, db: Session = Depends(get_session_database)): + db_table = ( + db.query(TableModel).filter(TableModel.table_number == table_number).first() + ) + if not db_table: + raise HTTPException(status_code=404, detail="Table not found") + + # Update table status (even if already occupied, just update the timestamp) + db_table.is_occupied = True + db_table.last_occupied_at = datetime.now(timezone.utc) + db_table.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(db_table) + return db_table + + +# Set table as free by table number +@router.put("/number/{table_number}/free", response_model=Table) +def set_table_free_by_number(table_number: int, request: Request, db: Session = Depends(get_session_database)): + db_table = ( + db.query(TableModel).filter(TableModel.table_number == table_number).first() + ) + if not db_table: + raise HTTPException(status_code=404, detail="Table not found") + + # Update table status to free (don't check if already free, just update) + db_table.is_occupied = False + db_table.current_order_id = None + db_table.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(db_table) + return db_table + + +# Create multiple tables at once +@router.post("/batch", response_model=List[Table]) +def create_tables_batch(num_tables: int, request: Request, db: Session = Depends(get_session_database)): + if num_tables <= 0: + raise HTTPException( + status_code=400, detail="Number of tables must be greater than 0" + ) + + # Get the highest existing table number + highest_table = ( + db.query(TableModel).order_by(TableModel.table_number.desc()).first() + ) + start_number = 1 + if highest_table: + start_number = highest_table.table_number + 1 + + # Create tables + new_tables = [] + for i in range(start_number, start_number + num_tables): + db_table = TableModel( + table_number=i, + is_occupied=False, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + db.add(db_table) + new_tables.append(db_table) + + db.commit() + + # Refresh all tables + for table in new_tables: + db.refresh(table) + + return new_tables diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a70b3029a5ce0e22988567ea083fca68daa2141b --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +# Services package diff --git a/app/services/firebase_auth.py b/app/services/firebase_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..9eac4b7cf9f66cd541dfd559b4127066d43e3688 --- /dev/null +++ b/app/services/firebase_auth.py @@ -0,0 +1,100 @@ +import firebase_admin +from firebase_admin import credentials, auth +import os +import json +from fastapi import HTTPException, status + +# Initialize Firebase Admin SDK +cred_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + "app", "tabble-v1-firebase-adminsdk-fbsvc-8024adcbdf.json") + +# Global variable to track initialization +firebase_initialized = False + +try: + # Check if Firebase is already initialized + try: + firebase_app = firebase_admin.get_app() + firebase_initialized = True + print("Firebase already initialized") + except ValueError: + # Initialize Firebase if not already initialized + cred = credentials.Certificate(cred_path) + firebase_app = firebase_admin.initialize_app(cred) + firebase_initialized = True + print("Firebase initialized successfully") +except Exception as e: + print(f"Firebase initialization error: {e}") + # Continue without crashing, but authentication will fail + +# Firebase Authentication functions +def verify_phone_number(phone_number): + """ + Verify a phone number and send OTP + Returns a session info token that will be used to verify the OTP + """ + try: + # Check if Firebase is initialized + if not firebase_initialized: + print("Firebase is not initialized, using mock verification") + + # Validate phone number format (should start with +91) + if not phone_number.startswith("+91"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Phone number must start with +91" + ) + + # In a real implementation with Firebase Admin SDK, we would use: + # session_info = auth.create_session_cookie(...) + # But for this implementation, we'll let the client-side Firebase handle the actual SMS sending + + print(f"Phone verification requested for: {phone_number}") + return {"sessionInfo": "firebase-verification-token", "success": True} + + except HTTPException as e: + # Re-raise HTTP exceptions + raise e + except Exception as e: + print(f"Error in verify_phone_number: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to send verification code: {str(e)}" + ) + +def verify_otp(phone_number, otp, session_info=None): + """ + Verify the OTP sent to the phone number + Returns a Firebase ID token if verification is successful + + Note: In this implementation, the actual OTP verification is done on the client side + using Firebase Authentication. This function is just for validating the format and + returning a success response. + """ + try: + # Check if Firebase is initialized + if not firebase_initialized: + print("Firebase is not initialized, using mock verification") + + # Validate OTP format + if not otp.isdigit() or len(otp) != 6: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid OTP format. Must be 6 digits." + ) + + # In a real implementation with Firebase Admin SDK, we would verify the OTP + # But for this implementation, we trust that the client-side Firebase has already verified it + + print(f"OTP verification successful for: {phone_number}") + return {"idToken": "firebase-id-token", "phone_number": phone_number, "success": True} + + except HTTPException as e: + # Re-raise HTTP exceptions + raise e + except Exception as e: + print(f"Error in verify_otp: {str(e)}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Failed to verify OTP: {str(e)}" + ) diff --git a/app/services/optimized_queries.py b/app/services/optimized_queries.py new file mode 100644 index 0000000000000000000000000000000000000000..4c308002dbec8a0989ceec0a1b9755c8c4e0aa63 --- /dev/null +++ b/app/services/optimized_queries.py @@ -0,0 +1,313 @@ +""" +Optimized database queries for better performance +""" +from sqlalchemy.orm import Session, joinedload, selectinload +from sqlalchemy import and_, or_, func, text +from typing import List, Optional, Dict, Any +from datetime import datetime, timedelta +import logging + +from ..database import Order, OrderItem, Dish, Person, Table + +logger = logging.getLogger(__name__) + +class OptimizedQueryService: + """Service for optimized database queries with caching and performance improvements""" + + def __init__(self): + self.query_cache = {} + self.cache_ttl = { + 'menu': 300, # 5 minutes + 'categories': 900, # 15 minutes + 'specials': 300, # 5 minutes + 'offers': 300, # 5 minutes + } + + def get_menu_optimized(self, db: Session, category: Optional[str] = None) -> List[Dict]: + """Optimized menu query with eager loading and caching""" + try: + # Build optimized query + query = db.query(Dish).filter( + Dish.is_visible == True + ) + + if category and category != 'All': + query = query.filter(Dish.category == category) + + # Order by category and name for consistent results + query = query.order_by(Dish.category, Dish.name) + + # Execute query + dishes = query.all() + + # Convert to dict for JSON serialization + result = [] + for dish in dishes: + dish_dict = { + 'id': dish.id, + 'name': dish.name, + 'description': dish.description, + 'price': float(dish.price), + 'category': dish.category, + 'image_path': dish.image_path, + 'is_offer': dish.is_offer, + 'discount': float(dish.discount) if dish.discount else 0, + 'is_visible': dish.is_visible, + 'created_at': dish.created_at.isoformat() if dish.created_at else None + } + result.append(dish_dict) + + return result + + except Exception as e: + logger.error(f"Error in get_menu_optimized: {str(e)}") + raise + + def get_orders_optimized(self, db: Session, person_id: Optional[int] = None, + table_number: Optional[int] = None, + status: Optional[str] = None) -> List[Dict]: + """Optimized order query with eager loading of related data""" + try: + # Build base query with eager loading + query = db.query(Order).options( + selectinload(Order.items).selectinload(OrderItem.dish), + joinedload(Order.person) + ) + + # Apply filters + filters = [] + if person_id: + filters.append(Order.person_id == person_id) + if table_number: + filters.append(Order.table_number == table_number) + if status: + filters.append(Order.status == status) + + if filters: + query = query.filter(and_(*filters)) + + # Order by creation time (newest first) + query = query.order_by(Order.created_at.desc()) + + # Execute query + orders = query.all() + + # Convert to dict with optimized serialization + result = [] + for order in orders: + order_dict = { + 'id': order.id, + 'table_number': order.table_number, + 'unique_id': order.unique_id, + 'person_id': order.person_id, + 'status': order.status, + 'created_at': order.created_at.isoformat() if order.created_at else None, + 'updated_at': order.updated_at.isoformat() if order.updated_at else None, + 'items': [] + } + + # Add order items + for item in order.items: + item_dict = { + 'id': item.id, + 'dish_id': item.dish_id, + 'dish_name': item.dish.name if item.dish else 'Unknown', + 'quantity': item.quantity, + 'price': float(item.price), + 'remarks': item.remarks, + 'position': item.position + } + order_dict['items'].append(item_dict) + + result.append(order_dict) + + return result + + except Exception as e: + logger.error(f"Error in get_orders_optimized: {str(e)}") + raise + + def get_chef_orders_optimized(self, db: Session, status: str) -> List[Dict]: + """Optimized chef order query with minimal data transfer""" + try: + # Use raw SQL for better performance on chef queries + sql = text(""" + SELECT + o.id, + o.table_number, + o.status, + o.created_at, + o.updated_at, + COUNT(oi.id) as item_count, + GROUP_CONCAT( + CONCAT(d.name, ' (', oi.quantity, ')') + SEPARATOR ', ' + ) as items_summary + FROM orders o + LEFT JOIN order_items oi ON o.id = oi.order_id + LEFT JOIN dishes d ON oi.dish_id = d.id + WHERE o.status = :status + GROUP BY o.id, o.table_number, o.status, o.created_at, o.updated_at + ORDER BY o.created_at ASC + """) + + result = db.execute(sql, {'status': status}).fetchall() + + # Convert to dict + orders = [] + for row in result: + order_dict = { + 'id': row.id, + 'table_number': row.table_number, + 'status': row.status, + 'created_at': row.created_at.isoformat() if row.created_at else None, + 'updated_at': row.updated_at.isoformat() if row.updated_at else None, + 'item_count': row.item_count, + 'items_summary': row.items_summary or '' + } + orders.append(order_dict) + + return orders + + except Exception as e: + logger.error(f"Error in get_chef_orders_optimized: {str(e)}") + # Fallback to regular query + return self._get_chef_orders_fallback(db, status) + + def _get_chef_orders_fallback(self, db: Session, status: str) -> List[Dict]: + """Fallback method for chef orders if raw SQL fails""" + try: + orders = db.query(Order).options( + selectinload(Order.items).selectinload(OrderItem.dish) + ).filter(Order.status == status).order_by(Order.created_at.asc()).all() + + result = [] + for order in orders: + items_summary = ', '.join([ + f"{item.dish.name if item.dish else 'Unknown'} ({item.quantity})" + for item in order.items + ]) + + order_dict = { + 'id': order.id, + 'table_number': order.table_number, + 'status': order.status, + 'created_at': order.created_at.isoformat() if order.created_at else None, + 'updated_at': order.updated_at.isoformat() if order.updated_at else None, + 'item_count': len(order.items), + 'items_summary': items_summary + } + result.append(order_dict) + + return result + + except Exception as e: + logger.error(f"Error in chef orders fallback: {str(e)}") + raise + + def get_table_status_optimized(self, db: Session) -> List[Dict]: + """Optimized table status query""" + try: + # Use raw SQL for better performance + sql = text(""" + SELECT + t.table_number, + t.is_occupied, + t.current_order_id, + t.updated_at, + o.status as order_status, + COUNT(oi.id) as item_count + FROM tables t + LEFT JOIN orders o ON t.current_order_id = o.id + LEFT JOIN order_items oi ON o.id = oi.order_id + GROUP BY t.table_number, t.is_occupied, t.current_order_id, t.updated_at, o.status + ORDER BY t.table_number + """) + + result = db.execute(sql).fetchall() + + tables = [] + for row in result: + table_dict = { + 'table_number': row.table_number, + 'is_occupied': bool(row.is_occupied), + 'current_order_id': row.current_order_id, + 'updated_at': row.updated_at.isoformat() if row.updated_at else None, + 'order_status': row.order_status, + 'item_count': row.item_count or 0 + } + tables.append(table_dict) + + return tables + + except Exception as e: + logger.error(f"Error in get_table_status_optimized: {str(e)}") + raise + + def get_analytics_data_optimized(self, db: Session, start_date: datetime, + end_date: datetime) -> Dict[str, Any]: + """Optimized analytics query with aggregations""" + try: + # Use raw SQL for complex aggregations + sql = text(""" + SELECT + DATE(o.created_at) as order_date, + COUNT(DISTINCT o.id) as total_orders, + COUNT(DISTINCT o.table_number) as unique_tables, + SUM(oi.quantity * oi.price) as total_revenue, + AVG(oi.quantity * oi.price) as avg_order_value, + d.category, + COUNT(oi.id) as items_sold + FROM orders o + JOIN order_items oi ON o.id = oi.order_id + JOIN dishes d ON oi.dish_id = d.id + WHERE o.created_at BETWEEN :start_date AND :end_date + AND o.status = 'paid' + GROUP BY DATE(o.created_at), d.category + ORDER BY order_date DESC, d.category + """) + + result = db.execute(sql, { + 'start_date': start_date, + 'end_date': end_date + }).fetchall() + + # Process results + analytics = { + 'daily_stats': {}, + 'category_stats': {}, + 'summary': { + 'total_orders': 0, + 'total_revenue': 0, + 'avg_order_value': 0 + } + } + + for row in result: + date_str = row.order_date.isoformat() + + if date_str not in analytics['daily_stats']: + analytics['daily_stats'][date_str] = { + 'orders': row.total_orders, + 'revenue': float(row.total_revenue), + 'avg_value': float(row.avg_order_value), + 'unique_tables': row.unique_tables + } + + category = row.category + if category not in analytics['category_stats']: + analytics['category_stats'][category] = { + 'items_sold': 0, + 'revenue': 0 + } + + analytics['category_stats'][category]['items_sold'] += row.items_sold + + return analytics + + except Exception as e: + logger.error(f"Error in get_analytics_data_optimized: {str(e)}") + raise + +# Create singleton instance +optimized_queries = OptimizedQueryService() diff --git a/app/static/images/README.md b/app/static/images/README.md new file mode 100644 index 0000000000000000000000000000000000000000..a420aacac35f4eb6cc24ef91c7845ecb24bda7ad --- /dev/null +++ b/app/static/images/README.md @@ -0,0 +1,27 @@ +# Static Images Directory + +This directory contains dish images organized by database name. + +## Structure: +``` +app/static/images/dishes/ +├── tabble_new/ # Demo hotel images +│ ├── dish1.jpg +│ ├── dish2.jpg +│ └── ... +└── your_hotel/ # Your hotel images + ├── dish1.jpg + ├── dish2.jpg + └── ... +``` + +## Upload Guidelines: +- Use JPEG or PNG format +- Recommended size: 400x300 pixels +- Keep file size under 500KB for better performance +- Use descriptive filenames + +## Access URLs: +Images can be accessed at: `/static/images/dishes/{database_name}/{filename}` + +Example: `/static/images/dishes/tabble_new/chicken_biryani.jpg` \ No newline at end of file diff --git a/app/static/images/dishes/7_50050-five-minute-ice-cream-DDMFS-4x3-076-fbf49ca6248e4dceb3f43a4f02823dd9.webp b/app/static/images/dishes/7_50050-five-minute-ice-cream-DDMFS-4x3-076-fbf49ca6248e4dceb3f43a4f02823dd9.webp new file mode 100644 index 0000000000000000000000000000000000000000..028708df39db6dd552176ba6e5eb53694ad13d15 Binary files /dev/null and b/app/static/images/dishes/7_50050-five-minute-ice-cream-DDMFS-4x3-076-fbf49ca6248e4dceb3f43a4f02823dd9.webp differ diff --git a/app/static/images/dishes/7_download.webp b/app/static/images/dishes/7_download.webp new file mode 100644 index 0000000000000000000000000000000000000000..5ccbf46dbefe99b4cd664c0c3e18cad3903d6a96 Binary files /dev/null and b/app/static/images/dishes/7_download.webp differ diff --git a/app/tabble-v1-firebase-adminsdk-fbsvc-8024adcbdf.json b/app/tabble-v1-firebase-adminsdk-fbsvc-8024adcbdf.json new file mode 100644 index 0000000000000000000000000000000000000000..f586c1462875755058033df33c3fbd8536cd2754 --- /dev/null +++ b/app/tabble-v1-firebase-adminsdk-fbsvc-8024adcbdf.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "tabble-v1", + "private_key_id": "8024adcbdf26bf1cac14997f39331ee88fb00a86", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDk0yMPULQZ95J0\n8Ksg5l8mg9VcpKs+tAGSGvVrmfLDHVaVIanYGmd6yJ+mZd8ZTXf5G38IlmBzK1nV\nwGsv7WPBQPSS5wDyJgj2iULuKNO09YxFXwEwzut5iQNlw81YB0K7UkIE3Psk6DY+\naaxRgToid0T3Ppbkav0FvrbU9/3/PEoIeL/i7bBo1lDnuzsAy1cycmueWA1ZdM3v\naKU6EjlXYfbo2kZPNfHplPg32JWknQy/SPeY26aBX9O07Qh/dT/DxET/OXpWaEJz\nQhuTuPHtIdFDXjIoYDQoBAKsvzxk0LnHKZ60MhkV2qoXQjPAAwah+Ki4bPEqmGRR\nsiZgoOXxAgMBAAECggEAKlJfuWkuil/3E1BhAlKBdjzrZTlin9QIt1ZrnmVomExQ\nk4AYqwrFKVk6Z/sO/p3MqwL6JaM0fxCdSrLOUFy6dseWBKabju3YegqsmaJs95rz\nwO/fp0CaHk4oVfXXQKkFH2LJKZ+ahrq5L6V5VNDPKQlAtO77Vw9vsVSS+cRNUtug\n3a8VgrBM8hDcD3Ej78XeEHI33kOgJQ4mL9Dp8zAJFrR0X3oTAI4NtYOPcgH5l2Ng\nYXZKmigQR1jIWXb5AiKaHnFdBaatyrAvWtvT1/Zsp0NYvaRoefdcfG5KIoZoRLpb\nA7sfOp1c/JPRlye1CdwggJzORLjwSbfiBbGaDvVb7QKBgQD+JfpGP95KoEO7v7hH\nPSwb9db/dzB5/3CRbq7/G9eq0J7Zl+/UeKMfJmIHQP3p9/GvPnQHn7pih9AcoMsb\nyhURGeJ5FnLVytWwPQDat4IlyTUhqaACcElogBHcgFbgJxQLfaQiQcyR6vOjAE8L\nlFEDTMFXf8LXrlkZ0H9XtXPwRQKBgQDmfe1giRIehboRqD9eS4BQtC1L5zbQx8lW\nmj8a/QXySWBMSHFKZjWChWrg1nfsGTSWGlE4uMznP+p2nuOS6jHAATf2GJZVBVm/\nPnYfcpfHGA94a3CJVWjaTp5cE1D4FRFnUS4HgY2w0T3sD9zzU8L8XQ+X2zsTviCT\n252VmJYnvQKBgQDXWICvg7CsVQ3viSzxGBFHA9EQGAM4bEwKvtmDCil88FaZE1fB\nFhNJ8rD/an97/36HOgkA6MP6dw/NIiXXvyyImAFBDtdw9fSI57fQm8uojsv5YQxW\n5KQe6t23k/uI5TPj5Krt6AkZ3xZgGIPh0OOwQxpUNMp5DJ8s83DjdbnubQKBgQC2\nyL5qg8j+s4XvYsF+AdnsJjaVrvJld0pPh1rsCCOjFFVtqTVOjud4bl1nmCzZ6tMt\nBgnLNaIo8SL6lt5aL6bsYQsD+lOdcPTPGLWMEtASbx41nN5NypGwLhCfbCIV2n9G\ns7YQ9chrpEO65ImP3akPgK1Q++ZJrckf+FVrwOmy8QKBgBv1rjNyXm3FplH0hq44\nfRcTJorvOk2ac3Hew5Czzk6ODJovIr+GPRQ2/9wF/NMBD9bfbwcvmp7vFqkTEl5f\nD/xJOwxZ06c3LBYi9tp5yrpuiqvfNZ0dUrCEY20KUktvNxfTigEGCgTmJ8+AEPlx\nFIJyQJtUD6yk6TGx7G9wYOPT\n-----END PRIVATE KEY-----\n", + "client_email": "firebase-adminsdk-fbsvc@tabble-v1.iam.gserviceaccount.com", + "client_id": "116848039876810333298", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40tabble-v1.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..dd7ee44cc225898f78b85cc4725f87b743bfab91 --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1 @@ +# Utils package diff --git a/app/utils/pdf_generator.py b/app/utils/pdf_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..92efed1bf912eb7668b03bd48a23a8fc2773d1e3 --- /dev/null +++ b/app/utils/pdf_generator.py @@ -0,0 +1,285 @@ +from reportlab.lib import colors +from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.units import inch +from io import BytesIO +from datetime import datetime +from typing import List + +def generate_bill_pdf(order, settings): + """ + Generate a PDF bill for a single order + + Args: + order: The order object with all details + settings: The hotel settings object + + Returns: + BytesIO: A buffer containing the PDF data + """ + # Convert single order to list and use the multi-order function + return generate_multi_order_bill_pdf([order], settings) + +def generate_multi_order_bill_pdf(orders: List, settings): + """ + Generate a PDF bill for multiple orders in a receipt-like format + + Args: + orders: List of order objects with all details + settings: The hotel settings object + + Returns: + BytesIO: A buffer containing the PDF data + """ + buffer = BytesIO() + # Use a narrower page size to mimic a receipt + doc = SimpleDocTemplate( + buffer, + pagesize=(4*inch, 11*inch), # Typical receipt width + rightMargin=10, + leftMargin=10, + topMargin=10, + bottomMargin=10 + ) + + # Create styles + styles = getSampleStyleSheet() + styles.add(ParagraphStyle( + name='HotelName', + fontName='Helvetica-Bold', + fontSize=14, + alignment=1, # Center alignment + spaceAfter=2 + )) + styles.add(ParagraphStyle( + name='HotelTagline', + fontName='Helvetica', + fontSize=9, + alignment=1, # Center alignment + spaceAfter=2 + )) + styles.add(ParagraphStyle( + name='HotelAddress', + fontName='Helvetica', + fontSize=8, + alignment=1, # Center alignment + spaceAfter=1 + )) + styles.add(ParagraphStyle( + name='BillInfo', + fontName='Helvetica', + fontSize=8, + alignment=0, # Left alignment + spaceAfter=1 + )) + styles.add(ParagraphStyle( + name='BillInfoRight', + fontName='Helvetica', + fontSize=8, + alignment=2, # Right alignment + spaceAfter=1 + )) + styles.add(ParagraphStyle( + name='TableHeader', + fontName='Helvetica-Bold', + fontSize=8, + alignment=0 + )) + styles.add(ParagraphStyle( + name='ItemName', + fontName='Helvetica', + fontSize=8, + alignment=0 + )) + styles.add(ParagraphStyle( + name='ItemValue', + fontName='Helvetica', + fontSize=8, + alignment=2 # Right alignment + )) + styles.add(ParagraphStyle( + name='Total', + fontName='Helvetica-Bold', + fontSize=9, + alignment=1 # Center alignment + )) + styles.add(ParagraphStyle( + name='Footer', + fontName='Helvetica', + fontSize=7, + alignment=1, # Center alignment + textColor=colors.black + )) + + # Create content elements + elements = [] + + # We're not using the logo in this receipt-style bill + # Add hotel name and info + elements.append(Paragraph(settings.hotel_name.upper(), styles['HotelName'])) + + # Add tagline (if any, otherwise use a default) + tagline = "AN AUTHENTIC CUISINE SINCE 2000" + elements.append(Paragraph(tagline, styles['HotelTagline'])) + + # Add address with formatting similar to the image + if settings.address: + elements.append(Paragraph(settings.address, styles['HotelAddress'])) + + # Add contact info + if settings.contact_number: + elements.append(Paragraph(f"Contact: {settings.contact_number}", styles['HotelAddress'])) + + # Add tax ID (GSTIN) + if settings.tax_id: + elements.append(Paragraph(f"GSTIN: {settings.tax_id}", styles['HotelAddress'])) + + # Add a separator line + elements.append(Paragraph("_" * 50, styles['HotelAddress'])) + + # Add bill details in a more receipt-like format + # Use the first order for common details + first_order = orders[0] + + # Create a table for the bill header info + # Get customer name if available + customer_name = "" + if hasattr(first_order, 'person_name') and first_order.person_name: + customer_name = first_order.person_name + + bill_info_data = [ + ["Name:", customer_name], + [f"Date: {datetime.now().strftime('%d/%m/%y')}", f"Dine In: {first_order.table_number}"], + [f"{datetime.now().strftime('%H:%M')}", f"Bill No.: {first_order.id}"] + ] + + bill_info_table = Table(bill_info_data, colWidths=[doc.width/2-20, doc.width/2-20]) + bill_info_table.setStyle(TableStyle([ + ('FONT', (0, 0), (-1, -1), 'Helvetica', 8), + ('ALIGN', (0, 0), (0, -1), 'LEFT'), + ('ALIGN', (1, 0), (1, -1), 'RIGHT'), + ('LINEBELOW', (0, 0), (1, 0), 0.5, colors.black), + ])) + + elements.append(bill_info_table) + elements.append(Paragraph("_" * 50, styles['HotelAddress'])) + + # Create header for items table + items_header = [["Item", "Qty.", "Price", "Amount"]] + items_header_table = Table(items_header, colWidths=[doc.width*0.4, doc.width*0.15, doc.width*0.2, doc.width*0.25]) + items_header_table.setStyle(TableStyle([ + ('FONT', (0, 0), (-1, -1), 'Helvetica-Bold', 8), + ('ALIGN', (0, 0), (0, -1), 'LEFT'), + ('ALIGN', (1, 0), (-1, -1), 'RIGHT'), + ('LINEBELOW', (0, 0), (-1, 0), 0.5, colors.black), + ])) + + elements.append(items_header_table) + + # Add all order items + total_items = 0 + subtotal_amount = 0 + total_loyalty_discount = 0 + total_selection_discount = 0 + grand_total = 0 + + for order in orders: + order_data = [] + + for item in order.items: + dish_name = item.dish.name if item.dish else "Unknown Dish" + price = item.dish.price if item.dish else 0 + quantity = item.quantity + total = price * quantity + subtotal_amount += total + total_items += quantity + + order_data.append([ + dish_name, + str(quantity), + f"{price:.2f}", + f"{total:.2f}" + ]) + + # Accumulate discount amounts from order records + if hasattr(order, 'loyalty_discount_amount') and order.loyalty_discount_amount: + total_loyalty_discount += order.loyalty_discount_amount + if hasattr(order, 'selection_offer_discount_amount') and order.selection_offer_discount_amount: + total_selection_discount += order.selection_offer_discount_amount + + # Use stored total_amount if available, otherwise calculate from subtotal + if hasattr(order, 'total_amount') and order.total_amount is not None: + grand_total += order.total_amount + else: + # Fallback to original calculation if no stored total + grand_total += subtotal_amount + + # Create the table for this order's items + if order_data: + items_table = Table(order_data, colWidths=[doc.width*0.4, doc.width*0.15, doc.width*0.2, doc.width*0.25]) + items_table.setStyle(TableStyle([ + ('FONT', (0, 0), (-1, -1), 'Helvetica', 8), + ('ALIGN', (0, 0), (0, -1), 'LEFT'), + ('ALIGN', (1, 0), (-1, -1), 'RIGHT'), + ])) + + elements.append(items_table) + + # Add a separator line + elements.append(Paragraph("_" * 50, styles['HotelAddress'])) + + # Add totals section with discounts + totals_data = [ + [f"Total Qty: {total_items}", f"Sub Total", f"${subtotal_amount:.2f}"], + ] + + # Add loyalty discount if applicable + if total_loyalty_discount > 0: + totals_data.append(["", f"Loyalty Discount", f"-${total_loyalty_discount:.2f}"]) + + # Add selection offer discount if applicable + if total_selection_discount > 0: + totals_data.append(["", f"Offer Discount", f"-${total_selection_discount:.2f}"]) + + # Calculate amount after discounts + amount_after_discounts = subtotal_amount - total_loyalty_discount - total_selection_discount + + # Calculate tax on discounted amount (assuming 5% CGST and 5% SGST) + tax_rate = 0.05 # 5% + cgst = amount_after_discounts * tax_rate + sgst = amount_after_discounts * tax_rate + + # Add tax lines + totals_data.extend([ + ["", f"CGST (5%)", f"${cgst:.2f}"], + ["", f"SGST (5%)", f"${sgst:.2f}"], + ]) + + # Calculate final total including tax + final_total = amount_after_discounts + cgst + sgst + + totals_table = Table(totals_data, colWidths=[doc.width*0.4, doc.width*0.35, doc.width*0.25]) + totals_table.setStyle(TableStyle([ + ('FONT', (0, 0), (-1, -1), 'Helvetica', 8), + ('ALIGN', (0, 0), (0, -1), 'LEFT'), + ('ALIGN', (1, 0), (1, -1), 'RIGHT'), + ('ALIGN', (2, 0), (2, -1), 'RIGHT'), + ])) + + elements.append(totals_table) + + # Add grand total with emphasis + elements.append(Paragraph("_" * 50, styles['HotelAddress'])) + elements.append(Paragraph(f"Grand Total ${final_total:.2f}", styles['Total'])) + elements.append(Paragraph("_" * 50, styles['HotelAddress'])) + + # Add license info and thank you message + elements.append(Spacer(1, 5)) + elements.append(Paragraph("FSSAI Lic No: 12018033000205", styles['Footer'])) + elements.append(Paragraph("!!! Thank You !!! Visit Again !!!", styles['Footer'])) + + # Build the PDF + doc.build(elements) + buffer.seek(0) + + return buffer diff --git a/hotels.csv b/hotels.csv new file mode 100644 index 0000000000000000000000000000000000000000..e9747765ac4f3443ee4ed7f6b4618678a294abbb --- /dev/null +++ b/hotels.csv @@ -0,0 +1,6 @@ +hotel_name,password,hotel_id +tabble_new,myhotel,1 +anifa,anifa123,2 +hotelgood,hotelgood123,3 +hotelmoon,moon123,4 +shine,shine123,5 \ No newline at end of file diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000000000000000000000000000000000000..63b914377434e1c16d19d8f28e80d769b97fb83a --- /dev/null +++ b/init_db.py @@ -0,0 +1,356 @@ +from app.database import ( + create_tables, + SessionLocal, + Dish, + Person, + Base, + LoyaltyProgram, + SelectionOffer, + Table, +) +from sqlalchemy import create_engine +from datetime import datetime, timezone +import os +import sys + + +def init_db(force_reset=False): + # Check if force_reset is enabled + if force_reset: + # Drop all tables and recreate them + print("Forcing database reset...") + Base.metadata.drop_all( + bind=create_engine( + "sqlite:///./tabble_new.db", connect_args={"check_same_thread": False} + ) + ) + + # Create tables + create_tables() + + # Create a database session + db = SessionLocal() + + # Check if dishes already exist + existing_dishes = db.query(Dish).count() + if existing_dishes > 0: + print("Database already contains data. Skipping initialization.") + return + + # Add sample dishes + sample_dishes = [ + # Regular dishes + Dish( + name="Margherita Pizza", + description="Classic pizza with tomato sauce, mozzarella, and basil", + category='["Main Course", "Italian"]', + price=12.99, + quantity=20, + image_path="/static/images/default-dish.jpg", + discount=0, + is_offer=0, + is_special=0, + is_vegetarian=1, + ), + Dish( + name="Caesar Salad", + description="Fresh romaine lettuce with Caesar dressing, croutons, and parmesan", + category='["Appetizer", "Salad"]', + price=8.99, + quantity=15, + image_path="/static/images/default-dish.jpg", + discount=0, + is_offer=0, + is_special=0, + is_vegetarian=1, + ), + Dish( + name="Chocolate Cake", + description="Rich chocolate cake with ganache frosting", + category="Dessert", + price=6.99, + quantity=10, + image_path="/static/images/default-dish.jpg", + discount=0, + is_offer=0, + is_special=0, + ), + Dish( + name="Iced Tea", + description="Refreshing iced tea with lemon", + category="Beverage", + price=3.99, + quantity=30, + image_path="/static/images/default-dish.jpg", + discount=0, + is_offer=0, + is_special=0, + ), + Dish( + name="Chicken Alfredo", + description="Fettuccine pasta with creamy Alfredo sauce and grilled chicken", + category="Main Course", + price=15.99, + quantity=12, + image_path="/static/images/default-dish.jpg", + discount=0, + is_offer=0, + ), + Dish( + name="Garlic Bread", + description="Toasted bread with garlic butter and herbs", + category="Appetizer", + price=4.99, + quantity=25, + image_path="/static/images/default-dish.jpg", + discount=0, + is_offer=0, + is_special=0, + ), + # Special offer dishes + Dish( + name="Weekend Special Pizza", + description="Deluxe pizza with premium toppings and extra cheese", + category="Main Course", + price=18.99, + quantity=15, + image_path="/static/images/default-dish.jpg", + discount=20, + is_offer=1, + is_special=0, + ), + Dish( + name="Seafood Pasta", + description="Fresh pasta with mixed seafood in a creamy sauce", + category="Main Course", + price=22.99, + quantity=10, + image_path="/static/images/default-dish.jpg", + discount=15, + is_offer=1, + is_special=0, + ), + Dish( + name="Tiramisu", + description="Classic Italian dessert with coffee-soaked ladyfingers and mascarpone cream", + category="Dessert", + price=9.99, + quantity=8, + image_path="/static/images/default-dish.jpg", + discount=25, + is_offer=1, + is_special=0, + ), + # Today's special dishes + Dish( + name="Chef's Special Steak", + description="Prime cut steak cooked to perfection with special house seasoning", + category="Main Course", + price=24.99, + quantity=12, + image_path="/static/images/default-dish.jpg", + discount=0, + is_offer=0, + is_special=1, + ), + Dish( + name="Truffle Mushroom Risotto", + description="Creamy risotto with wild mushrooms and truffle oil", + category="Main Course", + price=16.99, + quantity=10, + image_path="/static/images/default-dish.jpg", + discount=0, + is_offer=0, + is_special=1, + ), + Dish( + name="Chocolate Lava Cake", + description="Warm chocolate cake with a molten center, served with vanilla ice cream", + category="Dessert", + price=8.99, + quantity=15, + image_path="/static/images/default-dish.jpg", + discount=0, + is_offer=0, + is_special=1, + ), + ] + + # Add dishes to database + for dish in sample_dishes: + db.add(dish) + + # Add sample users + sample_users = [ + Person( + username="john_doe", + password="password123", + visit_count=1, + last_visit=datetime.now(timezone.utc), + ), + Person( + username="jane_smith", + password="password456", + visit_count=3, + last_visit=datetime.now(timezone.utc), + ), + Person( + username="guest", + password="guest", + visit_count=5, + last_visit=datetime.now(timezone.utc), + ), + ] + + # Add users to database + for user in sample_users: + db.add(user) + + # Add sample loyalty program tiers + sample_loyalty_tiers = [ + LoyaltyProgram( + visit_count=3, + discount_percentage=5.0, + is_active=True, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), + LoyaltyProgram( + visit_count=5, + discount_percentage=10.0, + is_active=True, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), + LoyaltyProgram( + visit_count=10, + discount_percentage=15.0, + is_active=True, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), + LoyaltyProgram( + visit_count=20, + discount_percentage=20.0, + is_active=True, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), + ] + + # Add loyalty tiers to database + for tier in sample_loyalty_tiers: + db.add(tier) + + # Add sample selection offers + sample_selection_offers = [ + SelectionOffer( + min_amount=50.0, + discount_amount=5.0, + is_active=True, + description="Spend $50, get $5 off", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), + SelectionOffer( + min_amount=100.0, + discount_amount=15.0, + is_active=True, + description="Spend $100, get $15 off", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), + SelectionOffer( + min_amount=150.0, + discount_amount=25.0, + is_active=True, + description="Spend $150, get $25 off", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), + ] + + # Add selection offers to database + for offer in sample_selection_offers: + db.add(offer) + + # Add sample tables + sample_tables = [ + Table( + table_number=1, + is_occupied=False, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), + Table( + table_number=2, + is_occupied=False, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), + Table( + table_number=3, + is_occupied=False, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), + Table( + table_number=4, + is_occupied=False, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), + Table( + table_number=5, + is_occupied=False, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), + Table( + table_number=6, + is_occupied=False, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), + Table( + table_number=7, + is_occupied=False, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), + Table( + table_number=8, + is_occupied=False, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), + ] + + # Add tables to database + for table in sample_tables: + db.add(table) + + # Commit changes + db.commit() + + print("Database initialized with sample data:") + print("- Added", len(sample_dishes), "sample dishes") + print("- Added", len(sample_users), "sample users") + print("- Added", len(sample_loyalty_tiers), "loyalty program tiers") + print("- Added", len(sample_selection_offers), "selection offers") + print("- Added", len(sample_tables), "tables") + + # Close session + db.close() + + +if __name__ == "__main__": + # Create static/images directory if it doesn't exist + os.makedirs("app/static/images", exist_ok=True) + + # Check for force reset flag + force_reset = "--force-reset" in sys.argv + + # Initialize database + init_db(force_reset) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..b12a8f717e8b5e3b2a1d629e47334fbed9312f6f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "Tabble-v3", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000000000000000000000000000000000000..131feafbaefeeea75d14954ed0eaa161fe268cbf --- /dev/null +++ b/render.yaml @@ -0,0 +1,14 @@ +services: + - type: web + name: tabble-backend + env: python + buildCommand: pip install -r requirements.txt + startCommand: python start.py + envVars: + - key: RENDER + value: "true" + - key: PYTHON_VERSION + value: "3.11.9" + - key: PORT + generateValue: true + healthCheckPath: /health diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..aad51ddc8ef6bb0dbbcd85a447f15854abdeb8df --- /dev/null +++ b/requirements.txt @@ -0,0 +1,22 @@ +# Core FastAPI dependencies +fastapi==0.104.1 +uvicorn[standard]==0.23.2 + +# Database +sqlalchemy==2.0.27 + +# Web framework utilities +python-multipart==0.0.6 +jinja2==3.1.2 +python-dotenv==1.0.0 + +# PDF and image processing +reportlab==4.0.7 +pillow==10.1.0 + +# Firebase authentication +firebase-admin + +# Additional dependencies for production +requests==2.31.0 +aiofiles==23.2.1 diff --git a/run.py b/run.py new file mode 100644 index 0000000000000000000000000000000000000000..a4cd9342aceae2acf61bf45ac70c0abdeb5d767e --- /dev/null +++ b/run.py @@ -0,0 +1,49 @@ +import uvicorn +import os +import socket + + +def get_ip_address(): + """Get the local IP address of the machine.""" + try: + # Create a socket connection to an external server + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + # Doesn't need to be reachable + s.connect(("8.8.8.8", 80)) + ip_address = s.getsockname()[0] + s.close() + return ip_address + except Exception as e: + print(f"Error getting IP address: {e}") + return "127.0.0.1" # Return localhost if there's an error + + +if __name__ == "__main__": + # Create static/images directory if it doesn't exist + os.makedirs("app/static/images", exist_ok=True) + + # Check for force reset flag + + # Get the IP address + ip_address = get_ip_address() + + # Display access information + print("\n" + "=" * 50) + + print(f"Access from other devices at: http://{ip_address}:8000") + print("=" * 50 + "\n") + + # Get port from environment variable (for Render deployment) or default to 8000 + port = int(os.environ.get("PORT", 8000)) + + # Check if running in production (Render sets this) + is_production = os.environ.get("RENDER") is not None + + if is_production: + print(f"Starting production server on port {port}") + # Production mode - no reload, bind to 0.0.0.0 + uvicorn.run("app.main:app", host="0.0.0.0", port=port, reload=False) + else: + # Development mode - Run the application on your IP address + # Using 0.0.0.0 allows connections from any IP + uvicorn.run("app.main:app", host="0.0.0.0", port=port, reload=True) \ No newline at end of file diff --git a/start.py b/start.py new file mode 100644 index 0000000000000000000000000000000000000000..3dfe30d19d63e15ef4b6f4e496ecc2d1493c15b4 --- /dev/null +++ b/start.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +""" +Render deployment startup script for Tabble Backend +""" +import os +import uvicorn + +if __name__ == "__main__": + # Create static/images directory if it doesn't exist + os.makedirs("app/static/images", exist_ok=True) + + # Get port from environment variable (Render provides this) + port = int(os.environ.get("PORT", 8000)) + + print(f"Starting Tabble Backend on port {port}") + + # Run the application + # In production, we don't need reload and should bind to 0.0.0.0 + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=port, + reload=False, # No reload in production + access_log=True, + log_level="info" + ) diff --git a/templates/admin/dishes.html b/templates/admin/dishes.html new file mode 100644 index 0000000000000000000000000000000000000000..a9fdc5997ff0d5dff8bc12c91303452a82d1bc57 --- /dev/null +++ b/templates/admin/dishes.html @@ -0,0 +1,431 @@ +{% extends "base.html" %} + +{% block title %}Dishes Management - Admin Dashboard{% endblock %} + +{% block content %} +
+
+
+
+

+ + Dishes Management +

+
+ + Back to Dashboard + + +
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ + +
+ +
+
+ Chicken Biryani +
+
+
Chicken Biryani
+
+ Main Course + Available +
+
+

+ Aromatic basmati rice cooked with tender chicken and traditional spices +

+
+ ₹250 +
+ + 4.5 (24 reviews) +
+
+
+ + + +
+
+
+
+ + +
+
+ Paneer Tikka +
+
+
Paneer Tikka
+
+ Starters + Available +
+
+

+ Marinated cottage cheese cubes grilled to perfection with Indian spices +

+
+ ₹180 +
+ + 4.2 (18 reviews) +
+
+
+ + + +
+
+
+
+ + +
+
+ Mango Lassi +
+
+
Mango Lassi
+
+ Beverages + Unavailable +
+
+

+ Refreshing yogurt-based drink with fresh mango pulp and a hint of cardamom +

+
+ ₹80 +
+ + 4.7 (35 reviews) +
+
+
+ + + +
+
+
+
+
+ + + +
+ + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/templates/admin/index.html b/templates/admin/index.html new file mode 100644 index 0000000000000000000000000000000000000000..dae4110bf75938a4914489e8400791918326686b --- /dev/null +++ b/templates/admin/index.html @@ -0,0 +1,373 @@ +{% extends "base.html" %} + +{% block title %}Admin Dashboard - Tabble-v3{% endblock %} + +{% block content %} +
+
+
+
+

+ + Admin Dashboard +

+
+ + System Online + + +
+
+
+
+ + +
+
+
+
+ +

24

+ Total Customers +
+
+
+ +
+
+
+ +

18

+ Orders Today +
+
+
+ +
+
+
+ +

45

+ Menu Items +
+
+
+ +
+
+
+ +

₹12,450

+ Today's Revenue +
+
+
+
+ + +
+ +
+
+
+
+ + Restaurant Management +
+
+
+
+ +
+ +
+
+ +
+
+ +
+
+
+
+
+ + +
+
+
+
+ + Settings & Configuration +
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+ + +
+
+
+
+
+ + Quick Analytics +
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
Today's Overview
+
+
+ Peak Hour +
2:00 PM
+
+
+ Avg Order Value +
₹690
+
+
+ Table Turnover +
3.2x
+
+
+
+
+
+
+ +
+
+
+
+ + Quick Actions +
+
+
+
+ + + + +
+
+
+
+
+ + +
+
+
+
+
+ + Recent Activity +
+ +
+
+
+
+
+ + New order #1023 from Table 5 +
+ 2 minutes ago +
+
+
+ + New customer registration: +91 9876543210 +
+ 5 minutes ago +
+
+
+ + Order #1022 completed by chef +
+ 8 minutes ago +
+
+
+ + Dish "Chicken Biryani" updated +
+ 15 minutes ago +
+
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000000000000000000000000000000000000..1c2a3a6bcb70456d3944d958788818a3168685c0 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,132 @@ + + + + + + {% block title %}Tabble-v3 Restaurant Management{% endblock %} + + + + + + + + + + + + {% block extra_css %}{% endblock %} + + + + + + +
+ {% block content %}{% endblock %} +
+ + + + + + + + + {% block extra_js %}{% endblock %} + + \ No newline at end of file diff --git a/templates/chef/index.html b/templates/chef/index.html new file mode 100644 index 0000000000000000000000000000000000000000..bf25da40a7f7c1117a601e4e41440f7d0d280097 --- /dev/null +++ b/templates/chef/index.html @@ -0,0 +1,183 @@ +{% extends "base.html" %} + +{% block title %}Chef Dashboard - Tabble-v3{% endblock %} + +{% block content %} +
+
+
+
+

+ + Chef Dashboard +

+
+ Kitchen Online +
+
+
+
+ +
+ +
+
+
+ +
0
+ Pending Orders +
+
+
+ +
+
+
+ +
0
+ Cooking +
+
+
+ +
+
+
+ +
0
+ Completed Today +
+
+
+ +
+
+
+ +
0
+ Total Dishes +
+
+
+
+ +
+
+
+
+
+ + Live Orders +
+ +
+
+
+
+ +

No orders at the moment

+ Orders will appear here automatically +
+
+
+
+
+
+ + +
+
+
+
+
+ + Quick Actions +
+
+
+
+ +
+ +
+ + +
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/templates/chef/orders.html b/templates/chef/orders.html new file mode 100644 index 0000000000000000000000000000000000000000..9ba7009782ad54118697ac2299fa6e13fbadf36b --- /dev/null +++ b/templates/chef/orders.html @@ -0,0 +1,230 @@ +{% extends "base.html" %} + +{% block title %}Orders Management - Chef Dashboard{% endblock %} + +{% block content %} +
+
+
+
+

+ + Orders Management +

+
+ + Back to Dashboard + + +
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+ + +
+
+
+ +
+
+
+
Order #1001 - Table 5
+ 2 minutes ago +
+ Pending +
+
+
+
+
Order Items:
+
    +
  • • 2x Chicken Biryani
  • +
  • • 1x Vegetable Curry
  • +
  • • 3x Naan Bread
  • +
+

Special Instructions: Extra spicy, no onions

+
+
+

Total: ₹450

+
+ + + +
+
+
+
+
+ + +
+
+
+
Order #1000 - Table 3
+ 15 minutes ago +
+ Cooking +
+
+
+
+
Order Items:
+
    +
  • • 1x Fish Curry
  • +
  • • 2x Rice
  • +
  • • 1x Mango Lassi
  • +
+

Special Instructions: Medium spice level

+
+
+

Total: ₹320

+
+ + +
+
+
+
+
+ + + +
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/templates/customer/login.html b/templates/customer/login.html new file mode 100644 index 0000000000000000000000000000000000000000..5355f87d6fbc887ca4f888f1b645a328a08d7d1f --- /dev/null +++ b/templates/customer/login.html @@ -0,0 +1,285 @@ +{% extends "base.html" %} + +{% block title %}Customer Login - Tabble-v3{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/templates/customer/menu.html b/templates/customer/menu.html new file mode 100644 index 0000000000000000000000000000000000000000..d895daecece77c723bc5bbcac4bc8ddffe593846 --- /dev/null +++ b/templates/customer/menu.html @@ -0,0 +1,386 @@ +{% extends "base.html" %} + +{% block title %}Menu - Table {{ table_number }} - Tabble-v3{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + + + +
+ +
+
+
+
+
+
+ Dining at: + Demo Restaurant +
+
+ Table {{ table_number }} +
+ Connected +
+
+
+
+
+
+
+
+ + +
+
+
+ + +
+
+
+ + + + + +
+
+
+ + + + + + +
+ + + + + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000000000000000000000000000000000000..617e83e2e00135c2e3578e5195b64fe6a37f94d0 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,195 @@ +{% extends "base.html" %} + +{% block title %}Tabble-v3 - Modern Restaurant Management System{% endblock %} + +{% block content %} + +
+
+

+ + Welcome to Tabble-v3 +

+

+ Modern Restaurant Management System with QR Code Ordering, Real-time Analytics, and Multi-Database Support +

+
+
+
+ + Powered by FastAPI, SQLAlchemy & Firebase +
+
+
+
+
+ + +
+
+
+

Choose Your Interface

+

Access different areas of the restaurant management system

+
+
+ +
+ +
+
+
+
+ +
+
Customer Interface
+

+ QR code ordering, real-time cart management, payment processing with loyalty discounts +

+
    +
  • Phone OTP Authentication
  • +
  • Live Cart Updates
  • +
  • Special Offers
  • +
  • Order History
  • +
+ + Customer Login + +
+
+
+ + +
+
+
+
+ +
+
Chef Dashboard
+

+ Real-time order management, kitchen operations, and instant status updates +

+
    +
  • Live Order Notifications
  • +
  • Kitchen Operations
  • +
  • Status Management
  • +
  • Order Queue
  • +
+ + Chef Dashboard + +
+
+
+ + +
+
+
+
+ +
+
Admin Panel
+

+ Complete restaurant management, analytics, and multi-database operations +

+
    +
  • Menu Management
  • +
  • Order Tracking
  • +
  • Analytics Dashboard
  • +
  • Settings Control
  • +
+ + Admin Panel + +
+
+
+
+ + +
+
+

Key Features

+

Everything you need for modern restaurant management

+
+
+ +
+
+
+ +
QR Code Ordering
+ Contactless table ordering system +
+
+
+
+ +
Multi-Database
+ Independent hotel operations +
+
+
+
+ +
Real-time Analytics
+ Business intelligence dashboard +
+
+
+
+ +
Secure Authentication
+ Firebase phone OTP system +
+
+
+ + +
+
+
+
+
API Documentation
+

+ Explore the comprehensive API documentation for developers +

+ +
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/test-image.html b/test-image.html new file mode 100644 index 0000000000000000000000000000000000000000..1a5469a94691a5449fc3aedf1ac1a713a06e0c5b --- /dev/null +++ b/test-image.html @@ -0,0 +1,39 @@ + + + + Create Test Image + + + + + + diff --git a/unified_database_schema.md b/unified_database_schema.md new file mode 100644 index 0000000000000000000000000000000000000000..261d3e8606b487effd788b2440ec26561cc4a5c3 --- /dev/null +++ b/unified_database_schema.md @@ -0,0 +1,225 @@ +# Unified Database Schema Design + +## Overview +Refactor from multiple hotel-specific databases to a single unified `Tabble.db` with hotel_id discrimination. + +## Current vs Target Architecture + +### Current Architecture +- **Multiple Databases:** Each hotel has separate .db file +- **Authentication:** database_name + password +- **Data Isolation:** Separate database files +- **Connection Management:** DatabaseManager switches between databases per session + +### Target Architecture +- **Single Database:** Tabble.db +- **Authentication:** hotel_name + password (from hotels.csv) +- **Data Isolation:** hotel_id foreign key filtering +- **Connection Management:** Single connection with hotel_id context + +## Hotels Registry Table + +```sql +CREATE TABLE hotels ( + id INTEGER PRIMARY KEY, + hotel_name VARCHAR NOT NULL UNIQUE, + password VARCHAR NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +**Data Migration from hotels.csv:** +```csv +hotel_name,password,hotel_id +tabble_new,myhotel,1 +anifa,anifa123,2 +hotelgood,hotelgood123,3 +hotelmoon,moon123,4 +shine,shine123,5 +``` + +## Updated Table Schemas + +### 1. Dishes Table +```sql +CREATE TABLE dishes ( + id INTEGER PRIMARY KEY, + hotel_id INTEGER NOT NULL, + name VARCHAR, + description TEXT, + category VARCHAR, + price FLOAT, + quantity INTEGER DEFAULT 0, + image_path VARCHAR, + discount FLOAT DEFAULT 0, + is_offer INTEGER DEFAULT 0, + is_special INTEGER DEFAULT 0, + visibility INTEGER DEFAULT 1, + created_at DATETIME, + updated_at DATETIME, + FOREIGN KEY (hotel_id) REFERENCES hotels (id) +); +``` + +### 2. Persons Table +```sql +CREATE TABLE persons ( + id INTEGER PRIMARY KEY, + hotel_id INTEGER NOT NULL, + username VARCHAR, + password VARCHAR, + phone_number VARCHAR, + visit_count INTEGER DEFAULT 0, + last_visit DATETIME, + created_at DATETIME, + FOREIGN KEY (hotel_id) REFERENCES hotels (id), + UNIQUE(hotel_id, username), + UNIQUE(hotel_id, phone_number) +); +``` + +### 3. Orders Table +```sql +CREATE TABLE orders ( + id INTEGER PRIMARY KEY, + hotel_id INTEGER NOT NULL, + person_id INTEGER, + table_number INTEGER, + total_amount FLOAT, + status VARCHAR, + unique_id VARCHAR, + created_at DATETIME, + updated_at DATETIME, + FOREIGN KEY (hotel_id) REFERENCES hotels (id), + FOREIGN KEY (person_id) REFERENCES persons (id) +); +``` + +### 4. Order Items Table +```sql +CREATE TABLE order_items ( + id INTEGER PRIMARY KEY, + hotel_id INTEGER NOT NULL, + order_id INTEGER, + dish_id INTEGER, + quantity INTEGER, + price FLOAT, + FOREIGN KEY (hotel_id) REFERENCES hotels (id), + FOREIGN KEY (order_id) REFERENCES orders (id), + FOREIGN KEY (dish_id) REFERENCES dishes (id) +); +``` + +### 5. Tables Table +```sql +CREATE TABLE tables ( + id INTEGER PRIMARY KEY, + hotel_id INTEGER NOT NULL, + table_number INTEGER, + is_occupied BOOLEAN DEFAULT FALSE, + current_order_id INTEGER, + created_at DATETIME, + updated_at DATETIME, + FOREIGN KEY (hotel_id) REFERENCES hotels (id), + FOREIGN KEY (current_order_id) REFERENCES orders (id), + UNIQUE(hotel_id, table_number) +); +``` + +### 6. Settings Table +```sql +CREATE TABLE settings ( + id INTEGER PRIMARY KEY, + hotel_id INTEGER NOT NULL, + hotel_name VARCHAR NOT NULL, + address VARCHAR, + contact_number VARCHAR, + email VARCHAR, + tax_id VARCHAR, + logo_path VARCHAR, + created_at DATETIME, + updated_at DATETIME, + FOREIGN KEY (hotel_id) REFERENCES hotels (id), + UNIQUE(hotel_id) +); +``` + +### 7. Feedback Table +```sql +CREATE TABLE feedback ( + id INTEGER PRIMARY KEY, + hotel_id INTEGER NOT NULL, + order_id INTEGER, + person_id INTEGER, + rating INTEGER, + comment TEXT, + created_at DATETIME, + FOREIGN KEY (hotel_id) REFERENCES hotels (id), + FOREIGN KEY (order_id) REFERENCES orders (id), + FOREIGN KEY (person_id) REFERENCES persons (id) +); +``` + +### 8. Loyalty Program Table +```sql +CREATE TABLE loyalty_tiers ( + id INTEGER PRIMARY KEY, + hotel_id INTEGER NOT NULL, + visit_count INTEGER, + discount_percentage FLOAT, + is_active BOOLEAN DEFAULT TRUE, + created_at DATETIME, + updated_at DATETIME, + FOREIGN KEY (hotel_id) REFERENCES hotels (id) +); +``` + +### 9. Selection Offers Table +```sql +CREATE TABLE selection_offers ( + id INTEGER PRIMARY KEY, + hotel_id INTEGER NOT NULL, + min_amount FLOAT, + discount_amount FLOAT, + is_active BOOLEAN DEFAULT TRUE, + description VARCHAR, + created_at DATETIME, + updated_at DATETIME, + FOREIGN KEY (hotel_id) REFERENCES hotels (id) +); +``` + +## Key Changes Required + +### 1. Database Models (SQLAlchemy) +- Add `hotel_id` column to all models +- Add foreign key relationships to hotels table +- Update unique constraints to include hotel_id + +### 2. Authentication System +- Change from `database_name + password` to `hotel_name + password` +- Update middleware to validate against hotels table +- Modify frontend to use hotel names instead of database names + +### 3. Database Manager +- Remove database switching logic +- Use single connection to Tabble.db +- Add hotel_id context to session management + +### 4. Query Filtering +- Add `filter(Model.hotel_id == current_hotel_id)` to all queries +- Update all CRUD operations to include hotel_id +- Ensure data isolation through query filtering + +### 5. Migration Strategy +- Create migration script to: + 1. Create new Tabble.db with unified schema + 2. Migrate data from existing hotel databases + 3. Populate hotels table from hotels.csv + 4. Add hotel_id to all migrated records + +## Data Isolation Verification +- All queries must include hotel_id filtering +- No cross-hotel data access possible +- Maintain same security level as separate databases