Fix: Rename directory to remove & character causing shell issues

Renamed ebook_backend&admin_panel to ebook_backend_admin_panel
  The & character was being interpreted by shell as background
  process operator, causing 'Dockerfile not found' errors in Coolify.
This commit is contained in:
richardtekula
2025-11-11 17:06:39 +01:00
parent a3b609eab7
commit f78c2199e1
35 changed files with 2 additions and 1 deletions

View File

@@ -0,0 +1,86 @@
# =============================================================================
# EBOOK COUPON MANAGEMENT SYSTEM - ENVIRONMENT CONFIGURATION
# =============================================================================
# Copy this file to .env and update with your actual values
# IMPORTANT: Never commit .env file to version control!
# =============================================================================
# -----------------------------------------------------------------------------
# Database Configuration
# -----------------------------------------------------------------------------
# PostgreSQL connection string
DATABASE_URL=postgresql://username:password@host:port/database_name
# Test database (for running tests)
TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/test_ebook_db
# -----------------------------------------------------------------------------
# Security Configuration
# -----------------------------------------------------------------------------
# SECRET_KEY: Used for JWT tokens and session encryption
# IMPORTANT: Generate a strong random key for production!
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
SECRET_KEY=your-super-secret-key-change-this-in-production
# Debug mode (NEVER set to true in production!)
DEBUG=false
# Environment: development, staging, production
ENVIRONMENT=development
# -----------------------------------------------------------------------------
# Admin Credentials (AUTO-CREATED ON FIRST RUN)
# -----------------------------------------------------------------------------
# These credentials will be used to create the default admin user
# on first startup if no admin exists in the database.
#
# SECURITY WARNING:
# - Change these immediately after first login in production!
# - Use strong passwords (12+ characters, mixed case, numbers, symbols)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
# -----------------------------------------------------------------------------
# CORS Configuration
# -----------------------------------------------------------------------------
# Allowed origins for Cross-Origin Resource Sharing
# Comma-separated list
CORS_ORIGINS=http://localhost:3000,http://localhost:8000,http://127.0.0.1:8000
# Trusted Hosts
TRUSTED_HOSTS=*
# -----------------------------------------------------------------------------
# Application Configuration
# -----------------------------------------------------------------------------
APP_NAME=Ebook Coupon Management System
APP_VERSION=1.0.0
# -----------------------------------------------------------------------------
# Logging Configuration
# -----------------------------------------------------------------------------
# Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL
LOG_LEVEL=INFO
# Log file paths (relative to admin-backend directory)
LOG_FILE=logs/app.log
ERROR_LOG_FILE=logs/error.log
# -----------------------------------------------------------------------------
# File Upload Configuration
# -----------------------------------------------------------------------------
# Maximum file size in bytes (default: 10MB)
MAX_FILE_SIZE=10485760
# Allowed file types for upload
ALLOWED_FILE_TYPES=.xlsx,.xls
# -----------------------------------------------------------------------------
# Server Configuration
# -----------------------------------------------------------------------------
# Host to bind to (0.0.0.0 for all interfaces)
HOST=0.0.0.0
# Port to listen on
PORT=8000

78
ebook_backend_admin_panel/.gitignore vendored Normal file
View File

@@ -0,0 +1,78 @@
# 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
MANIFEST
# Virtual Environment
.venv/
venv/
ENV/
env/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
*.log
logs/
admin-backend/logs/
# Coverage reports
.coverage
htmlcov/
.pytest_cache/
# Database
*.db
*.sqlite3
# OS
.DS_Store
Thumbs.db
# Temporary files
*.tmp
*.temp
# Node modules (if any)
node_modules/
# Build directories
build/
dist/
# Environment Variables
.env
.env.local
.env.production
.env.staging

View File

@@ -0,0 +1,70 @@
# Multi-stage build pre optimalizáciu veľkosti obrazu
FROM python:3.11-slim as builder
# Nastavenie working directory
WORKDIR /app
# Inštalácia build dependencies
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Kopírovanie requirements
COPY requirements.txt .
# Inštalácia Python dependencies do /opt/venv
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# Production stage
FROM python:3.11-slim
# Nastavenie environment variables
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/opt/venv/bin:$PATH"
# Inštalácia runtime dependencies
RUN apt-get update && apt-get install -y \
postgresql-client \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Vytvorenie non-root user pre bezpečnosť
RUN useradd -m -u 1000 appuser
# Nastavenie working directory
WORKDIR /app
# Kopírovanie Python virtual environment z builder stage
COPY --from=builder /opt/venv /opt/venv
# Kopírovanie aplikačných súborov
COPY --chown=appuser:appuser . .
# Vytvorenie potrebných adresárov
RUN mkdir -p /app/admin-backend/logs \
/app/admin-backend/translationfile \
&& chown -R appuser:appuser /app
# Prepnutie na non-root user
USER appuser
# Expose port
EXPOSE 8000
# Healthcheck
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Spustenie aplikácie
WORKDIR /app/admin-backend
# Použijeme shell form aby sme mohli spustiť init_db.py a potom uvicorn
CMD ["/bin/sh", "-c", "python init_db.py && uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4"]

View File

@@ -0,0 +1,979 @@
# 📚 Ebook Coupon Management System
A comprehensive enterprise-grade FastAPI application for managing ebook coupon codes with an admin dashboard interface and translation file management system.
[![Python](https://img.shields.io/badge/Python-3.10%2B-blue)](https://www.python.org/)
[![FastAPI](https://img.shields.io/badge/FastAPI-Latest-009688)](https://fastapi.tiangolo.com/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-12%2B-336791)](https://www.postgresql.org/)
[![License](https://img.shields.io/badge/License-MIT-green)](LICENSE)
---
## 📑 Table of Contents
- [Overview](#overview)
- [Features](#features)
- [Technology Stack](#technology-stack)
- [Project Structure](#project-structure)
- [Prerequisites](#prerequisites)
- [Installation & Setup](#installation--setup)
- [Environment Configuration](#environment-configuration)
- [Running the Application](#running-the-application)
- [API Endpoints](#api-endpoints)
- [Admin Dashboard](#admin-dashboard)
- [Database Schema](#database-schema)
- [Testing](#testing)
- [Deployment](#deployment)
---
## 🎯 Overview
The Ebook Coupon Management System is a production-ready web application designed to manage ebook coupon codes efficiently. It provides a secure admin interface for generating, managing, and tracking coupon usage, along with translation file management capabilities.
**Key Highlights:**
- ✅ Automatic database initialization on first run
- ✅ Auto-creates admin user from environment variables
- ✅ RESTful API with comprehensive documentation
- ✅ Real-time coupon generation and validation
- ✅ Excel file support for bulk operations
- ✅ Translation file management system
- ✅ Comprehensive test suite included
- ✅ Production-ready logging and error handling
---
## 🚀 Features
### 🔐 Authentication & Authorization
- **Secure Admin Login**: Session-based authentication with HTTP-only cookies
- **Auto Admin Creation**: First-time setup automatically creates admin user
- **Password Hashing**: Bcrypt password encryption
- **Logout Functionality**: Clean session termination
### 🎫 Coupon Management
#### Generate Coupons
- **Single Generation**: Create one coupon code at a time
- **Bulk Generation**: Generate multiple coupons in one operation
- **Unique Codes**: 10-character alphanumeric codes (uppercase)
- **Automatic Storage**: Codes saved to database with metadata
#### Manage Coupons
- **List All Coupons**: Paginated listing with usage statistics
- **Search Functionality**: Case-insensitive search by coupon code
- **Usage Tracking**: One time coupon code usage and timestamps
- **Delete Coupons**: Remove unwanted or expired codes
- **Add Manual Codes**: Add specific coupon codes manually
#### Bulk Operations
- **Excel Upload**: Upload multiple coupons from Excel files (.xlsx, .xls)
- **Duplicate Detection**: Automatically skips existing codes
- **Validation**: Ensures data integrity during bulk upload
#### Coupon Validation
- **Code Verification**: Check if coupon exists and is valid
- **Usage Validation**: Prevent reuse of single-use coupons
- **Mark as Used**: Track when and how coupons are redeemed
### 🌐 Translation File Management
- **Upload Translation Files**: Admin can upload Excel translation files
- **Download Translations**: Retrieve uploaded translation files
- **Delete Translations**: Remove existing translation files
- **Status Check**: Verify if translation file exists
- **Metadata Storage**: Preserves original filename information
- **File Validation**: Ensures only valid Excel files are accepted
### 🖥️ Admin Dashboard
- **Modern UI**: Clean, responsive interface built with vanilla JavaScript
- **Real-time Updates**: Live data refresh without page reload
- **File Upload**: Drag-and-drop support for Excel files
- **Pagination**: Efficient browsing of large coupon lists
- **Search Interface**: Quick search functionality
- **Statistics Display**: View total coupons and usage
### 📊 System Features
- **Health Monitoring**: `/health` endpoint for system checks
- **Database Status**: Real-time database connection monitoring
- **Automatic Migrations**: Tables created automatically on startup
- **Logging System**: Structured JSON logging with rotation
- **Error Handling**: Comprehensive exception handling
- **Request Tracking**: Unique request IDs for tracing
---
## 🛠️ Technology Stack
### Backend
| Technology | Purpose | Version |
|------------|---------|---------|
| **FastAPI** | Web framework | Latest |
| **Uvicorn** | ASGI server | Latest |
| **SQLAlchemy** | ORM | 2.x |
| **PostgreSQL** | Database | 12+ |
| **Pydantic** | Data validation | 2.x |
| **Passlib** | Password hashing | Latest |
| **Bcrypt** | Encryption | 4.0.1 |
| **Python-Jose** | JWT handling | Latest |
### Frontend
| Technology | Purpose |
|------------|---------|
| **HTML5** | Structure |
| **CSS3** | Styling |
| **Vanilla JavaScript** | Interactivity |
| **Fetch API** | HTTP requests |
### Development & Testing
| Tool | Purpose |
|------|---------|
| **Pytest** | Testing framework |
| **HTTPx** | Async HTTP client |
| **Python-dotenv** | Environment management |
---
## 📁 Project Structure
```
ebook_extension-feature-admin-dashboard/
├── admin-backend/ # Backend API application
│ ├── models/ # Database models
│ │ ├── user.py # Admin user model
│ │ └── coupon.py # Coupon model
│ │
│ ├── routes/ # API routes
│ │ └── auth.py # All API endpoints
│ │
│ ├── utils/ # Utility modules
│ │ ├── auth.py # Authentication utilities
│ │ ├── coupon_utils.py # Coupon generation
│ │ ├── exceptions.py # Custom exceptions
│ │ ├── logger.py # Logging configuration
│ │ ├── template_loader.py # Template utilities
│ │ └── timezone_utils.py # Timezone handling
│ │
│ ├── tests/ # Test suite
│ │ ├── conftest.py # Test configuration
│ │ ├── test_auth_routes.py # Auth endpoint tests
│ │ ├── test_coupon_routes.py # Coupon endpoint tests
│ │ ├── test_main.py # Main app tests
│ │ ├── test_models.py # Model tests
│ │ ├── test_schemas.py # Schema tests
│ │ ├── test_translation_routes.py # Translation tests
│ │ └── test_utils.py # Utility tests
│ │
│ ├── logs/ # Application logs
│ │ ├── app.log # General logs
│ │ └── error.log # Error logs
│ │
│ ├── translationfile/ # Translation storage
│ │ └── translation.xlsx # Uploaded translation file
│ │
│ ├── main.py # FastAPI application
│ ├── init_db.py # Database initialization
│ ├── schemas.py # Pydantic schemas
│ ├── manage_test_db.py # Test database manager
│ └── pytest.ini # Pytest configuration
├── admin-frontend/ # Frontend files
│ ├── admin_login.html # Login page
│ ├── admin_login.js # Login logic
│ ├── admin_dashboard.html # Dashboard UI
│ └── admin_dashboard.js # Dashboard logic
├── .env.example # Environment template
├── .gitignore # Git ignore rules
├── requirements.txt # Python dependencies
├── README.md
└── start.sh # Startup script
```
---
## 📋 Prerequisites
Before installing, ensure you have the following:
- **Python**: Version 3.10 or higher
- **PostgreSQL**: Version 12 or higher
- **pip**: Python package manager
- **Virtual Environment**: `venv` or `virtualenv`
- **Git**: For cloning the repository
### System Requirements
- **OS**: Linux, macOS, or Windows
- **RAM**: Minimum 2GB
- **Disk Space**: Minimum 500MB
---
## 💻 Installation & Setup
### Step 1: Clone the Repository
```bash
git clone <repository-url>
cd ebook_extension-feature-admin-dashboard
```
### Step 2: Create Virtual Environment
```bash
# Create virtual environment
python3 -m venv .venv
# Activate virtual environment
# On Linux/Mac:
source .venv/bin/activate
# On Windows:
.venv\Scripts\activate
```
### Step 3: Install Dependencies
```bash
pip install --upgrade pip
pip install -r requirements.txt
```
### Step 4: Set Up PostgreSQL Database
#### Create Database
```bash
# Option 1: Using psql
sudo -u postgres psql -c "CREATE DATABASE ebook_db;"
# Option 2: Using createdb
sudo -u postgres createdb ebook_db
# Option 3: Connect to PostgreSQL and create manually
sudo -u postgres psql
postgres=# CREATE DATABASE ebook_db;
postgres=# \q
```
#### Verify Database Creation
```bash
sudo -u postgres psql -c "\l" | grep ebook_db
```
### Step 5: Configure Environment Variables
```bash
# Copy example environment file
cp .env.example .env
# Edit with your settings
nano .env # or your preferred editor
```
**Required Configuration:**
- Update `DATABASE_URL` if using different credentials
- Change `ADMIN_PASSWORD` from default
- Generate strong `SECRET_KEY` for production
### Step 6: Initialize Database (Automatic)
The application automatically:
- Creates all required tables on first run
- Creates admin user from `.env` credentials
- Validates database connection
**No manual database setup required!**
---
## ⚙️ Environment Configuration
### Environment Variables Explained
Create a `.env` file in the project root with these variables:
#### Database Configuration
```bash
# PostgreSQL connection string
DATABASE_URL=postgresql://username:password@host:port/database
# Test database (for running tests)
TEST_DATABASE_URL=postgresql://username:password@host:port/test_database
```
#### Security Configuration
```bash
# Secret key for JWT and session encryption
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
SECRET_KEY=your-super-secret-key-change-this-in-production
# Debug mode (set to false in production)
DEBUG=true
# Environment: development, staging, production
ENVIRONMENT=development
```
#### Admin Credentials
```bash
# Auto-created admin user on first run
# IMPORTANT: Change these before production deployment!
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin@123
```
#### Application Configuration
```bash
# Application details
APP_NAME=Ebook Coupon Management System
APP_VERSION=1.0.0
# CORS allowed origins (comma-separated)
CORS_ORIGINS=http://localhost:3000,http://localhost:8000,http://127.0.0.1:8000
# Trusted hosts
TRUSTED_HOSTS=*
```
#### Logging Configuration
```bash
# Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL
LOG_LEVEL=INFO
# Log file paths (relative to admin-backend)
LOG_FILE=logs/app.log
ERROR_LOG_FILE=logs/error.log
```
#### File Upload Configuration
```bash
# Maximum file size in bytes (10MB default)
MAX_FILE_SIZE=10485760
# Allowed file types
ALLOWED_FILE_TYPES=.xlsx,.xls
```
#### Server Configuration
```bash
# Server binding
HOST=0.0.0.0
PORT=8000
```
### Security Best Practices
🔒 **For Production:**
1. Generate strong `SECRET_KEY`: `python -c "import secrets; print(secrets.token_urlsafe(32))"`
2. Change `ADMIN_PASSWORD` to a strong password (12+ characters)
3. Set `DEBUG=false`
4. Set `ENVIRONMENT=production`
5. Update `CORS_ORIGINS` to specific domains
---
## 🚀 Running the Application
### Method 1: Using Startup Script (Recommended)
```bash
./start.sh
```
This script automatically:
- Checks for `.env` file (creates from example if missing)
- Activates virtual environment
- Installs/updates dependencies
- Starts the application with auto-reload
### Method 2: Manual Start
```bash
# Navigate to backend directory
cd admin-backend
# Activate virtual environment
source ../.venv/bin/activate
# Start the server
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
### Verify Application is Running
```bash
# Check health endpoint
curl http://localhost:8000/health
# Expected response:
# {
# "status": "healthy",
# "timestamp": 1762246309.91,
# "version": "1.0.0",
# "environment": "development",
# "database_status": "connected"
# }
```
### Access Points
| Service | URL | Description |
|---------|-----|-------------|
| **API** | http://localhost:8000 | Main API endpoint |
| **Admin Login** | http://localhost:8000/login | Admin login page |
| **Admin Dashboard** | http://localhost:8000/ | Main dashboard (requires login) |
| **API Docs** | http://localhost:8000/docs | Swagger UI documentation |
| **ReDoc** | http://localhost:8000/redoc | Alternative API docs |
| **Health Check** | http://localhost:8000/health | System health status |
### Default Login Credentials
```
Username: admin
Password: admin@123
```
---
## 📡 API Endpoints
### Authentication Endpoints
#### Admin Login
```http
POST /admin/login
Content-Type: application/json
{
"username": "admin",
"password": "admin@123"
}
Response: 200 OK
{
"status": "success"
}
```
#### Admin Logout
```http
POST /admin/logout
Response: 200 OK
{
"status": "success"
}
```
### Coupon Management Endpoints
#### Generate Single Coupon
```http
POST /generate
Content-Type: application/x-www-form-urlencoded
mode=single
Response: 200 OK
{
"code": "A1B2C3D4E5"
}
```
#### Generate Bulk Coupons
```http
POST /generate
Content-Type: application/x-www-form-urlencoded
mode=bulk&count=100
Response: 200 OK
{
"codes": ["CODE1", "CODE2", ...]
}
```
#### List All Coupons
```http
GET /list?page=1&limit=20
Response: 200 OK
{
"codes": [
{
"code": "A1B2C3D4E5",
"used_at": "2025-11-04 10:30:00 CEST",
"usage_count": 1
}
],
"total": 100,
"page": 1,
"limit": 20,
"total_pages": 5
}
```
#### Search Coupons
```http
GET /search-codes?query=A1B2
Response: 200 OK
[
{
"code": "A1B2C3D4E5",
"used": 1,
"usage_count": 1,
"used_at": "2025-11-04 10:30:00 CEST"
}
]
```
#### Check Specific Coupon
```http
GET /check-code/A1B2C3D4E5
Response: 200 OK
{
"code": "A1B2C3D4E5",
"used": 1
}
```
#### Verify and Use Coupon
```http
POST /verify
Content-Type: application/json
{
"code": "A1B2C3D4E5"
}
Response: 200 OK
{
"message": "Coupon verified",
"used_at": "2025-11-04 10:30:00 CEST"
}
```
#### Add Manual Coupon
```http
POST /add-code
Content-Type: application/json
{
"code": "CUSTOM123",
"usage": 0
}
Response: 200 OK
{
"message": "Code added successfully"
}
```
#### Delete Coupon
```http
DELETE /delete-code/A1B2C3D4E5
Response: 200 OK
{
"message": "Code deleted successfully"
}
```
#### Upload Coupons from Excel
```http
POST /upload-codes
Content-Type: application/json
{
"codes": [
{"code": "CODE1", "usage": 0},
{"code": "CODE2", "usage": 1}
]
}
Response: 200 OK
{
"uploaded": 2,
"skipped": 0,
"total": 2
}
```
### Translation File Endpoints
#### Upload Translation File
```http
POST /upload-translations
Content-Type: multipart/form-data
file: <translation.xlsx>
Response: 200 OK
{
"message": "Translation file uploaded successfully",
"filename": "translation.xlsx"
}
```
#### Download Translation File
```http
GET /download-translation
Response: 200 OK
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Disposition: attachment; filename="translation.xlsx"
```
#### Delete Translation File
```http
DELETE /delete-translation
Response: 200 OK
{
"message": "Translation file deleted successfully"
}
```
#### Check Translation Status
```http
GET /translations/status
Response: 200 OK
{
"file_exists": true,
"file_name": "translation.xlsx"
}
```
#### Get Latest Translation (Legacy)
```http
GET /translations/latest
Response: 200 OK
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
```
### System Endpoints
#### Health Check
```http
GET /health
Response: 200 OK
{
"status": "healthy",
"timestamp": 1762246309.91,
"version": "1.0.0",
"environment": "development",
"database_status": "connected"
}
```
#### Root Endpoint
```http
GET /
Response: 302 Found
Location: /login (if not logged in)
```
---
## 🖥️ Admin Dashboard
### Features
1. **Login Page** (`/login`)
- Secure authentication form
- Session-based login
- Error handling
2. **Dashboard** (`/`)
- Coupon generation (single/bulk)
- Coupon listing with pagination
- Search functionality
- File upload for bulk operations
- Translation file management
- Statistics display
### Usage
1. **Login**: Navigate to `http://localhost:8000/login`
2. **Enter Credentials**: Use admin username and password from `.env`
3. **Dashboard Access**: Automatically redirected to dashboard on success
4. **Generate Coupons**: Use the generation form
5. **Upload Files**: Drag and drop or browse for Excel files
6. **Manage Translations**: Upload, download, or delete translation files
---
## 🗄️ Database Schema
### Table: `admin_users`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | INTEGER | PRIMARY KEY | Auto-increment ID |
| username | STRING | UNIQUE, NOT NULL | Admin username |
| password_hash | STRING | NOT NULL | Bcrypt hashed password |
| created_at | DATETIME | DEFAULT NOW | Account creation timestamp |
### Table: `coupon_codes`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | INTEGER | PRIMARY KEY | Auto-increment ID |
| code | STRING | UNIQUE | Coupon code |
| usage_count | INTEGER | DEFAULT 0 | Number of times used |
| created_at | DATETIME | DEFAULT NOW | Creation timestamp |
| used_at | DATETIME | NULLABLE | Last usage timestamp |
**Timezone**: All timestamps use Europe/Bratislava timezone for creation, Asia/Kolkata for usage.
---
## 🧪 Testing
### Run All Tests
```bash
cd admin-backend
source ../.venv/bin/activate
pytest
```
### Run Specific Test Files
```bash
# Test auth routes
pytest tests/test_auth_routes.py
# Test coupon routes
pytest tests/test_coupon_routes.py
# Test models
pytest tests/test_models.py
```
### Run with Coverage
```bash
pytest --cov=. --cov-report=html
```
### Test Database
Tests use a separate test database configured in `TEST_DATABASE_URL`.
---
## 🚢 Production Deployment
### Pre-Deployment Checklist
- [ ] Set `DEBUG=false`
- [ ] Set `ENVIRONMENT=production`
- [ ] Change `ADMIN_PASSWORD` to strong password
- [ ] Generate secure `SECRET_KEY`
- [ ] Update `CORS_ORIGINS` with production domains
- [ ] Configure PostgreSQL with SSL
- [ ] Set up Nginx reverse proxy
- [ ] Configure SSL/TLS certificates
- [ ] Enable firewall rules
- [ ] Set up automated backups
- [ ] Configure monitoring and logging
### Production Environment Variables
```bash
# Database
DATABASE_URL=postgresql://dbuser:strong_password@localhost:5432/ebook_prod
# Security
SECRET_KEY=<generate-with: python -c "import secrets; print(secrets.token_urlsafe(32))">
DEBUG=false
ENVIRONMENT=production
# Admin (change after first login!)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=<strong-password-here>
# CORS
CORS_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
TRUSTED_HOSTS=yourdomain.com,www.yourdomain.com
# Application
APP_NAME=Ebook Coupon Management System
APP_VERSION=1.0.0
LOG_LEVEL=WARNING
# Server
HOST=0.0.0.0
PORT=8000
```
### Deployment with Systemd
1. **Install Gunicorn**
```bash
pip install gunicorn
```
2. **Create Systemd Service File**
```bash
sudo nano /etc/systemd/system/ebook-api.service
```
```ini
[Unit]
Description=Ebook Coupon Management System API
After=network.target postgresql.service
[Service]
Type=notify
User=www-data
Group=www-data
WorkingDirectory=/var/www/ebook_extension-feature-admin-dashboard/admin-backend
Environment="PATH=/var/www/ebook_extension-feature-admin-dashboard/.venv/bin"
EnvironmentFile=/var/www/ebook_extension-feature-admin-dashboard/.env
ExecStart=/var/www/ebook_extension-feature-admin-dashboard/.venv/bin/gunicorn \
-w 4 \
-k uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
main:app
Restart=always
[Install]
WantedBy=multi-user.target
```
3. **Enable and Start Service**
```bash
sudo systemctl daemon-reload
sudo systemctl enable ebook-api
sudo systemctl start ebook-api
sudo systemctl status ebook-api
```
### Nginx Reverse Proxy
1. **Install Nginx**
```bash
sudo apt-get update
sudo apt-get install nginx
```
2. **Create Nginx Configuration**
```bash
sudo nano /etc/nginx/sites-available/ebook-api
```
```nginx
upstream ebook_backend {
server 127.0.0.1:8000;
}
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name yourdomain.com www.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
client_max_body_size 10M;
location / {
proxy_pass http://ebook_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /static {
alias /var/www/ebook_extension-feature-admin-dashboard/admin-frontend;
expires 30d;
}
}
```
3. **Enable Site**
```bash
sudo ln -s /etc/nginx/sites-available/ebook-api /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
```
### Logs and Debugging
```bash
# Application logs
tail -f admin-backend/logs/app.log
# Error logs
tail -f admin-backend/logs/error.log
# Search for errors
grep -i error admin-backend/logs/app.log
# Enable debug mode
DEBUG=true uvicorn main:app
```
### Quick Reference Commands
```bash
# Start application (development)
./start.sh
# Start application (production)
gunicorn -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 main:app
# Stop application
pkill -f "uvicorn main:app"
# Check if running
ps aux | grep uvicorn
# View health status
curl http://localhost:8000/health
# Database operations
sudo -u postgres psql -d ebook_db
```
---
## 📄 License
This project is licensed under the MIT License - see the LICENSE file for details.
---
## 🙏 Acknowledgments
- FastAPI team for the excellent framework
- SQLAlchemy team for the powerful ORM
- All contributors and users
---
## 📞 Support
For support and questions:
- Review application logs: `admin-backend/logs/`
- Check troubleshooting section above
- Open an issue on GitHub
---

View File

@@ -0,0 +1,253 @@
"""
Database Initialization Script
This script automatically initializes the database on application startup:
- Creates all required tables if they don't exist
- Creates default admin user if no admin exists
- Runs automatically when the application starts
- Safe to run multiple times (idempotent)
Usage:
This file is automatically called from main.py lifespan event.
No manual execution required.
"""
import os
import logging
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from dotenv import load_dotenv
from utils.auth import engine, SessionLocal, Base, hash_password
from models.user import AdminUser
from models.coupon import Coupon
# Load environment variables
load_dotenv()
# Setup logger
logger = logging.getLogger(__name__)
def create_tables():
"""
Create all database tables if they don't exist.
This function creates tables for:
- AdminUser (admin_users table)
- Coupon (coupon_codes table)
Returns:
bool: True if successful, False otherwise
"""
try:
# Import all models to ensure they're registered with Base
from models.user import AdminUser
from models.coupon import Coupon
# Create all tables
Base.metadata.create_all(bind=engine)
logger.info("✅ Database tables created/verified successfully")
return True
except Exception as e:
logger.error(f"❌ Error creating database tables: {e}", exc_info=True)
return False
def create_default_admin(db: Session) -> bool:
"""
Create default admin user if no admin exists in the database.
Reads credentials from environment variables:
- ADMIN_USERNAME (default: 'admin')
- ADMIN_PASSWORD (default: 'admin123')
Args:
db (Session): Database session
Returns:
bool: True if admin was created or already exists, False on error
"""
try:
# Check if any admin user exists
existing_admin = db.query(AdminUser).first()
if existing_admin:
logger.info(f" Admin user already exists: {existing_admin.username}")
return True
# Get admin credentials from environment variables
admin_username = os.getenv("ADMIN_USERNAME", "admin")
admin_password = os.getenv("ADMIN_PASSWORD", "admin123")
# Validate credentials
if not admin_username or not admin_password:
logger.error("❌ ADMIN_USERNAME or ADMIN_PASSWORD not set in environment variables")
return False
# Hash the password
password_hash = hash_password(admin_password)
# Create admin user
admin_user = AdminUser(
username=admin_username,
password_hash=password_hash
)
db.add(admin_user)
db.commit()
db.refresh(admin_user)
logger.info(f"✅ Default admin user created successfully: {admin_username}")
logger.warning("⚠️ Please change the default admin password in production!")
return True
except IntegrityError as e:
db.rollback()
logger.warning(f"⚠️ Admin user might already exist: {e}")
return True # Not a critical error, admin might exist
except Exception as e:
db.rollback()
logger.error(f"❌ Error creating default admin user: {e}", exc_info=True)
return False
def initialize_database():
"""
Main initialization function that orchestrates database setup.
This function:
1. Creates all required database tables
2. Creates default admin user if none exists
3. Logs all operations for monitoring
Returns:
bool: True if initialization successful, False otherwise
Raises:
Exception: If critical initialization fails
"""
logger.info("🚀 Starting database initialization...")
# Step 1: Create tables
if not create_tables():
logger.error("❌ Failed to create database tables")
raise Exception("Database table creation failed")
# Step 2: Create default admin user
db = SessionLocal()
try:
if not create_default_admin(db):
logger.warning("⚠️ Failed to create default admin user")
# Don't raise exception, app can still run
logger.info("✅ Database initialization completed successfully")
return True
except Exception as e:
logger.error(f"❌ Database initialization failed: {e}", exc_info=True)
raise
finally:
db.close()
def verify_database_connection():
"""
Verify that database connection is working.
Returns:
bool: True if connection successful, False otherwise
"""
try:
from sqlalchemy import text
db = SessionLocal()
db.execute(text("SELECT 1"))
db.close()
logger.info("✅ Database connection verified")
return True
except Exception as e:
logger.error(f"❌ Database connection failed: {e}", exc_info=True)
return False
def get_admin_stats(db: Session) -> dict:
"""
Get statistics about the database for logging purposes.
Args:
db (Session): Database session
Returns:
dict: Statistics including admin count, coupon count, etc.
"""
try:
admin_count = db.query(AdminUser).count()
coupon_count = db.query(Coupon).count()
return {
"admin_users": admin_count,
"total_coupons": coupon_count,
"database_healthy": True
}
except Exception as e:
logger.error(f"Error getting database stats: {e}")
return {
"database_healthy": False,
"error": str(e)
}
if __name__ == "__main__":
"""
Allow manual execution for testing purposes.
Usage:
python init_db.py
"""
# Setup basic logging for standalone execution
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
print("=" * 60)
print("DATABASE INITIALIZATION SCRIPT")
print("=" * 60)
print()
# Verify connection
if not verify_database_connection():
print("❌ Cannot connect to database. Please check your DATABASE_URL")
exit(1)
# Initialize database
try:
initialize_database() # noqa: E722
# Show stats
db = SessionLocal()
stats = get_admin_stats(db)
db.close()
print()
print("=" * 60)
print("DATABASE STATISTICS")
print("=" * 60)
print(f"Admin Users: {stats.get('admin_users', 0)}")
print(f"Total Coupons: {stats.get('total_coupons', 0)}")
print(f"Status: {'✅ Healthy' if stats.get('database_healthy') else '❌ Unhealthy'}")
print("=" * 60)
print()
print("✅ Database initialization completed successfully!")
print()
except Exception as e:
print(f"\n❌ Initialization failed: {e}\n")
exit(1)

View File

@@ -0,0 +1,330 @@
from fastapi import FastAPI, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
import time
import os
import logging
from contextlib import asynccontextmanager
from typing import Dict, Any
from routes import auth
from utils.logger import setup_logger
from utils.exceptions import APIException, handle_api_exception
from models.user import AdminUser
from models.coupon import Coupon
from utils.auth import engine
from init_db import initialize_database
# Setup logging
logger = setup_logger(__name__)
# Application configuration
class AppConfig:
"""Application configuration class"""
APP_NAME = os.getenv("APP_NAME")
VERSION = os.getenv("APP_VERSION")
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
ENVIRONMENT = os.getenv("ENVIRONMENT", "development")
# CORS settings - parse comma-separated string
_cors_origins_str = os.getenv("CORS_ORIGINS", "")
CORS_ORIGINS = [origin.strip() for origin in _cors_origins_str.split(",") if origin.strip()] if _cors_origins_str else []
# Trusted hosts for production
_trusted_hosts_str = os.getenv("TRUSTED_HOSTS", "*")
TRUSTED_HOSTS = [host.strip() for host in _trusted_hosts_str.split(",") if host.strip()] if _trusted_hosts_str != "*" else ["*"]
# Application lifespan manager
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage application startup and shutdown events"""
# Startup
logger.info(
"Application starting up",
extra={
"app_name": AppConfig.APP_NAME,
"version": AppConfig.VERSION,
"environment": AppConfig.ENVIRONMENT,
"debug": AppConfig.DEBUG
}
)
# Ensure required directories exist
ensure_directories()
# Initialize database: create tables and default admin user
try:
initialize_database()
except Exception as e:
logger.error(f"Error initializing database: {e}")
raise
yield
# Shutdown
logger.info("Application shutting down")
def ensure_directories():
"""Ensure required directories exist"""
directories = [
"translation_upload",
"logs"
]
for directory in directories:
os.makedirs(directory, exist_ok=True)
logger.debug(f"Ensured directory exists: {directory}")
# Create FastAPI application with enterprise features
app = FastAPI(
title=AppConfig.APP_NAME,
version=AppConfig.VERSION,
description="Enterprise-grade Ebook Coupon Management System API",
docs_url="/docs" if AppConfig.DEBUG else None,
redoc_url="/redoc" if AppConfig.DEBUG else None,
lifespan=lifespan
)
# Get paths relative to backend/main.py
BASE_DIR = os.path.dirname(__file__)
PARENT_DIR = os.path.abspath(os.path.join(BASE_DIR, ".."))
ADMIN_PANEL_DIR = os.path.join(PARENT_DIR, "admin-frontend")
# Mount static files
app.mount("/static", StaticFiles(directory=ADMIN_PANEL_DIR), name="static")
# Setup templates
templates = Jinja2Templates(directory=ADMIN_PANEL_DIR)
# Add middleware for production readiness
# NOTE: TrustedHostMiddleware disabled when behind reverse proxy (Traefik/Coolify)
# The reverse proxy handles host validation
# if AppConfig.ENVIRONMENT == "production":
# # Trusted host middleware for production security
# app.add_middleware(
# TrustedHostMiddleware,
# allowed_hosts=AppConfig.TRUSTED_HOSTS
# )
# CORS middleware for cross-origin requests
app.add_middleware(
CORSMiddleware,
allow_origins=AppConfig.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Request timing and logging middleware
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
"""Add request processing time and logging"""
start_time = time.time()
# Generate request ID for tracking
request_id = f"{int(start_time * 1000)}"
request.state.request_id = request_id
# Log incoming request
logger.info(
f"Incoming request: {request.method} {request.url.path}",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"client_ip": request.client.host,
"user_agent": request.headers.get("user-agent", "")
}
)
try:
response = await call_next(request)
process_time = time.time() - start_time
# Add headers for monitoring
response.headers["X-Process-Time"] = f"{process_time:.4f}"
response.headers["X-Request-ID"] = request_id
# Log successful response
logger.info(
f"Request completed: {request.method} {request.url.path}",
extra={
"request_id": request_id,
"status_code": response.status_code,
"process_time": process_time
}
)
return response
except Exception as e:
process_time = time.time() - start_time
logger.error(
f"Request failed: {request.method} {request.url.path}",
extra={
"request_id": request_id,
"error": str(e),
"process_time": process_time
},
exc_info=True
)
raise
# Exception handlers for proper error responses
@app.exception_handler(APIException)
async def api_exception_handler(request: Request, exc: APIException):
"""Handle custom API exceptions"""
logger.warning(
f"API Exception: {exc.detail}",
extra={
"request_id": getattr(request.state, "request_id", "unknown"),
"status_code": exc.status_code,
"path": request.url.path
}
)
return JSONResponse(
status_code=exc.status_code,
content={
"success": False,
"error": exc.detail,
"error_code": exc.error_code,
"timestamp": time.time(),
"path": str(request.url.path)
}
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Handle validation errors"""
# Safely extract error details
try:
error_details = []
for error in exc.errors():
safe_error = {
"type": error.get("type", "unknown"),
"loc": error.get("loc", []),
"msg": str(error.get("msg", "Unknown error")),
"input": str(error.get("input", "Unknown input"))
}
if "ctx" in error and error["ctx"]:
safe_error["ctx"] = {k: str(v) for k, v in error["ctx"].items()}
error_details.append(safe_error)
except Exception:
error_details = [{"type": "validation_error", "msg": "Request validation failed"}]
logger.warning(
"Validation error",
extra={
"request_id": getattr(request.state, "request_id", "unknown"),
"errors": error_details,
"path": request.url.path
}
)
return JSONResponse(
status_code=422,
content={
"success": False,
"error": "Validation Error",
"error_code": "VALIDATION_ERROR",
"detail": "Request validation failed",
"timestamp": time.time(),
"path": str(request.url.path),
"details": error_details
}
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
"""Handle HTTP exceptions"""
logger.warning(
f"HTTP Exception: {exc.status_code}",
extra={
"request_id": getattr(request.state, "request_id", "unknown"),
"status_code": exc.status_code,
"detail": exc.detail,
"path": request.url.path
}
)
return JSONResponse(
status_code=exc.status_code,
content={
"success": False,
"error": "HTTP Error",
"detail": exc.detail,
"timestamp": time.time(),
"path": str(request.url.path)
}
)
@app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception):
"""Handle generic exceptions"""
logger.error(
"Unhandled exception",
extra={
"request_id": getattr(request.state, "request_id", "unknown"),
"exception_type": type(exc).__name__,
"exception_message": str(exc),
"path": request.url.path
},
exc_info=True
)
return JSONResponse(
status_code=500,
content={
"success": False,
"error": "Internal Server Error",
"error_code": "INTERNAL_ERROR",
"detail": "An unexpected error occurred",
"timestamp": time.time(),
"path": str(request.url.path)
}
)
# Health check endpoint
@app.get("/health", tags=["Health"])
async def health_check() -> Dict[str, Any]:
"""Health check endpoint for monitoring"""
from utils.auth import get_db
from sqlalchemy import text
# Check database connection
db_status = "connected"
try:
db = next(get_db())
db.execute(text("SELECT 1"))
db.close()
except Exception as e:
db_status = "disconnected"
logger.error("Database health check failed", extra={"error": str(e)})
return {
"status": "healthy" if db_status == "connected" else "unhealthy",
"timestamp": time.time(),
"version": AppConfig.VERSION,
"environment": AppConfig.ENVIRONMENT,
"database_status": db_status
}
# Include routers - auth.router handles / and /login HTML pages
app.include_router(auth.router, prefix="/auth", tags=["Auth"])
app.include_router(auth.router, prefix="", tags=["Auth"])
# API info endpoint (moved from / to /api to avoid conflict with auth.router)
@app.get("/api", tags=["API Info"])
async def api_info() -> Dict[str, Any]:
"""API information endpoint"""
return {
"message": AppConfig.APP_NAME,
"version": AppConfig.VERSION,
"environment": AppConfig.ENVIRONMENT,
"docs_url": "/docs" if AppConfig.DEBUG else None,
"health_check": "/health"
}

View File

@@ -0,0 +1,134 @@
"""
Test Database Management Script
This script helps create and manage the test database for unit tests.
"""
import psycopg2
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
import sys
# Test database configuration
TEST_DB_NAME = "test_ebook_db"
import os
from dotenv import load_dotenv
load_dotenv()
TEST_DB_URL = os.getenv("TEST_DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/test_ebook_db")
def create_test_database():
"""Create test database if it doesn't exist"""
try:
# Connect to default postgres database to create test database
conn = psycopg2.connect(
host="localhost",
port="5432",
user="postgres",
password="postgres",
database="postgres"
)
conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
cursor = conn.cursor()
# Check if test database exists
cursor.execute("SELECT 1 FROM pg_database WHERE datname = %s", (TEST_DB_NAME,))
exists = cursor.fetchone()
if not exists:
cursor.execute(f"CREATE DATABASE {TEST_DB_NAME}")
print(f"✅ Created test database: {TEST_DB_NAME}")
else:
print(f" Test database {TEST_DB_NAME} already exists")
cursor.close()
conn.close()
return True
except Exception as e:
print(f"❌ Error creating test database: {e}")
return False
def drop_test_database():
"""Drop test database"""
try:
# Connect to default postgres database to drop test database
conn = psycopg2.connect(
host="localhost",
port="5432",
user="postgres",
password="postgres",
database="postgres"
)
conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
cursor = conn.cursor()
# Terminate all connections to test database
cursor.execute(f"""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '{TEST_DB_NAME}' AND pid <> pg_backend_pid()
""")
cursor.execute(f"DROP DATABASE IF EXISTS {TEST_DB_NAME}")
print(f"🗑️ Dropped test database: {TEST_DB_NAME}")
cursor.close()
conn.close()
return True
except Exception as e:
print(f"❌ Error dropping test database: {e}")
return False
def check_test_database():
"""Check if test database exists"""
try:
conn = psycopg2.connect(
host="localhost",
port="5432",
user="postgres",
password="postgres",
database="postgres"
)
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM pg_database WHERE datname = %s", (TEST_DB_NAME,))
exists = cursor.fetchone()
cursor.close()
conn.close()
if exists:
print(f"✅ Test database {TEST_DB_NAME} exists")
return True
else:
print(f"❌ Test database {TEST_DB_NAME} does not exist")
return False
except Exception as e:
print(f"❌ Error checking test database: {e}")
return False
def main():
"""Main function to handle command line arguments"""
if len(sys.argv) < 2:
print("Usage: python manage_test_db.py [create|drop|check]")
print(" create - Create test database")
print(" drop - Drop test database")
print(" check - Check if test database exists")
return
command = sys.argv[1].lower()
if command == "create":
create_test_database()
elif command == "drop":
drop_test_database()
elif command == "check":
check_test_database()
else:
print(f"Unknown command: {command}")
print("Available commands: create, drop, check")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,22 @@
from sqlalchemy import Column, Integer, String, DateTime
from datetime import datetime
import pytz
from utils.auth import Base
class Coupon(Base):
"""
SQLAlchemy model representing a coupon code entry in the database.
Attributes:
id (int): Primary key identifier.
code (str): Unique coupon code string.
usage_count (int): Number of times the coupon has been used.
created_at (datetime): Timestamp of coupon creation (stored in Europe/Bratislava timezone).
used_at (datetime | None): Timestamp of the last usage, nullable.
"""
__tablename__ = "coupon_codes"
id = Column(Integer, primary_key=True)
code = Column(String, unique=True)
usage_count = Column(Integer, default=0)
created_at = Column(DateTime, default=lambda: datetime.now(pytz.timezone('Europe/Bratislava')))
used_at = Column(DateTime, nullable=True)

View File

@@ -0,0 +1,20 @@
from sqlalchemy import Column, Integer, String, DateTime
from datetime import datetime
import pytz
from utils.auth import Base
class AdminUser(Base):
"""
SQLAlchemy model representing an admin user.
Attributes:
id (int): Primary key identifier.
username (str): Unique admin username.
password_hash (str): Hashed password for authentication.
created_at (datetime): Timestamp of account creation (stored in Europe/Bratislava timezone).
"""
__tablename__ = "admin_users"
id = Column(Integer, primary_key=True)
username = Column(String, unique=True, nullable=False)
password_hash = Column(String, nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(pytz.timezone('Europe/Bratislava')))

View File

@@ -0,0 +1,12 @@
[tool:pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
asyncio_mode = auto
addopts = -v --tb=short --maxfail=5 --durations=10 --disable-warnings --no-header
filterwarnings =
ignore::DeprecationWarning
ignore::PendingDeprecationWarning
ignore::UserWarning

View File

@@ -0,0 +1,514 @@
from fastapi import APIRouter, Depends, HTTPException, Form, status, Request, UploadFile, File
from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from models.user import AdminUser
from utils.auth import get_db, hash_password, verify_password
from fastapi.templating import Jinja2Templates
from utils.template_loader import templates
from models.coupon import Coupon
from utils.coupon_utils import generate_coupon
from datetime import datetime
import pytz
from utils.timezone_utils import format_cest_datetime
from schemas import AdminLogin, CodeItem, CouponUpload
from fastapi.responses import StreamingResponse
import os
router = APIRouter()
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
"""
Render the admin login page.
Args:
request (Request): The incoming request object.
Returns:
HTMLResponse: Rendered login page.
"""
# return templates.TemplateResponse("admin_login.html", {"request": request})
return templates.TemplateResponse(request, "admin_login.html", {"data": "something"})
@router.get("/", response_class=HTMLResponse)
def admin_panel(request: Request):
"""
Render the admin dashboard if logged in.
Args:
request (Request): The incoming request object.
Returns:
HTMLResponse or RedirectResponse: Admin dashboard or redirect to login.
"""
if not request.cookies.get("admin_logged_in"):
return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
# return templates.TemplateResponse("admin_dashboard.html", {"request": request})
return templates.TemplateResponse(request, "admin_dashboard.html", {"data": "something"})
@router.post("/admin/login")
def login(data: AdminLogin, db: Session = Depends(get_db)):
"""
Handle admin login and set authentication cookie.
Args:
data (AdminLogin): Login data with username and password.
db (Session): Database session.
Returns:
JSONResponse: Login status.
"""
user = db.query(AdminUser).filter_by(username=data.username).first()
if not user or not verify_password(data.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
response = JSONResponse(content={"status": "success"})
response.set_cookie("admin_logged_in", "true", httponly=True, samesite="strict")
return response
@router.post("/admin/logout")
def logout():
"""
Handle admin logout and clear the authentication cookie.
Returns:
JSONResponse: Logout status.
"""
response = JSONResponse(content={"status": "success"})
response.delete_cookie("admin_logged_in")
return response
@router.post("/generate")
async def generate_code(mode: str = Form(...), count: int = Form(1), db: Session = Depends(get_db),
request: Request = None):
"""
Generate coupon codes (single or bulk).
Args:
mode (str): 'single' or 'bulk'.
count (int): Number of codes to generate (used for bulk).
db (Session): Database session.
request (Request): Incoming request for auth check.
Returns:
dict: Generated codes.
"""
if not request.cookies.get("admin_logged_in"):
raise HTTPException(status_code=401, detail="Unauthorized")
new_codes = []
if mode == "single":
new_code = generate_coupon().upper() # Convert to uppercase
db_code = Coupon(code=new_code, usage_count=0)
db.add(db_code)
db.commit()
return {"code": new_code}
elif mode == "bulk":
for _ in range(count):
code = generate_coupon().upper() # Convert to uppercase
db_code = Coupon(code=code, usage_count=0)
db.add(db_code)
new_codes.append(code)
db.commit()
return {"codes": new_codes}
else:
raise HTTPException(status_code=400, detail="Invalid mode")
@router.get("/list")
async def list_codes(page: int = 1, limit: int = 20, db: Session = Depends(get_db)):
"""
List paginated coupon codes sorted by usage count.
Args:
page (int): Page number.
limit (int): Items per page.
db (Session): Database session.
Returns:
dict: Paginated coupon data.
"""
offset = (page - 1) * limit
total_coupons = db.query(Coupon).count()
coupons = db.query(Coupon).order_by(Coupon.usage_count.desc()).offset(offset).limit(limit).all()
return {
"codes": [{"code": c.code, "used_at": format_cest_datetime(c.used_at) if c.used_at else None, "usage_count": c.usage_count} for c in coupons],
"total": total_coupons,
"page": page,
"limit": limit,
"total_pages": (total_coupons + limit - 1) // limit
}
@router.get("/search-codes")
def search_codes(query: str, db: Session = Depends(get_db)):
"""
Search coupon codes by partial match (case-insensitive).
Args:
query (str): Search query.
db (Session): Database session.
Returns:
list: Matching coupon codes.
"""
# Search with case-insensitive matching
codes = (
db.query(Coupon)
.filter(Coupon.code.ilike(f"%{query.upper()}%"))
.all()
)
return [{"code": c.code, "used": c.usage_count, "usage_count": c.usage_count, "used_at": format_cest_datetime(c.used_at) if c.used_at else None} for c in codes]
@router.post("/use-code")
async def use_code(item: dict, db: Session = Depends(get_db)):
"""
Mark a coupon code as used (only if not already used).
Args:
item (dict): Dictionary containing the code to mark as used.
db (Session): Database session.
Returns:
dict: Updated code and timestamp.
"""
code = item["code"].strip()
coupon = db.query(Coupon).filter(Coupon.code.ilike(code)).first()
if not coupon:
raise HTTPException(status_code=404, detail="Invalid code")
if coupon.usage_count >= 1:
raise HTTPException(status_code=400, detail="Coupon already used")
coupon.usage_count += 1
coupon.used_at = datetime.now(pytz.timezone('Asia/Kolkata'))
db.commit()
return {"code": coupon.code, "used_at": format_cest_datetime(coupon.used_at)}
@router.get("/check-code/{code}")
async def check_code(code: str, db: Session = Depends(get_db)):
"""
Check if a specific coupon code exists and its usage count.
Args:
code (str): Coupon code to check.
db (Session): Database session.
Returns:
dict: Code and usage count.
"""
# Use case-insensitive search to handle both cases
coupon = db.query(Coupon).filter(Coupon.code.ilike(code.strip())).first()
if not coupon:
raise HTTPException(status_code=404, detail="Code not found")
return {"code": coupon.code, "used": coupon.usage_count}
@router.post("/verify")
async def verify_coupon(coupon_req: dict, db: Session = Depends(get_db)):
"""
Verify and mark a coupon as used if it exists and is unused.
Args:
coupon_req (dict): Dictionary with 'code' key.
db (Session): Database session.
Returns:
dict: Success message and timestamp.
"""
raw_code = coupon_req["code"]
code = raw_code.strip()
coupon = db.query(Coupon).filter(Coupon.code.ilike(code)).first()
if not coupon:
raise HTTPException(status_code=404, detail="Invalid coupon code")
if coupon.usage_count >= 1:
raise HTTPException(status_code=400, detail="Coupon already used")
coupon.usage_count += 1
coupon.used_at = datetime.now(pytz.timezone('Asia/Kolkata'))
db.commit()
return {"message": "Coupon verified", "used_at": format_cest_datetime(coupon.used_at)}
@router.post("/upload-codes")
async def upload_codes(upload_data: CouponUpload, db: Session = Depends(get_db), request: Request = None):
"""
Upload multiple coupon codes from Excel data.
Args:
upload_data (CouponUpload): Pydantic model containing code list.
db (Session): Database session.
request (Request): Request object for auth check.
Returns:
dict: Upload summary (uploaded, skipped, total).
"""
if not request.cookies.get("admin_logged_in"):
raise HTTPException(status_code=401, detail="Unauthorized")
uploaded = 0
skipped = 0
for coupon_data in upload_data.codes:
try:
# Normalize code to uppercase
normalized_code = coupon_data.code.strip().upper()
# Check if code already exists
existing_coupon = db.query(Coupon).filter(Coupon.code == normalized_code).first()
if existing_coupon:
skipped += 1
continue
# Create new coupon with usage count from Excel
new_coupon = Coupon(
code=normalized_code, # Store as uppercase
usage_count=coupon_data.usage
)
db.add(new_coupon)
uploaded += 1
except Exception as e:
print(f"Error inserting code {coupon_data.code}: {e}")
skipped += 1
try:
db.commit()
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
return {
"uploaded": uploaded,
"skipped": skipped,
"total": len(upload_data.codes)
}
@router.post("/add-code")
def add_code(item: CodeItem, db: Session = Depends(get_db), request: Request = None):
"""
Add a single coupon code manually.
Args:
item (CodeItem): Coupon data from request body.
db (Session): Database session.
request (Request): Request object for auth check.
Returns:
dict: Success message.
"""
if not request.cookies.get("admin_logged_in"):
raise HTTPException(status_code=401, detail="Unauthorized")
# Normalize code to uppercase for consistency
normalized_code = item.code.strip().upper()
existing = db.query(Coupon).filter(Coupon.code == normalized_code).first()
if existing:
raise HTTPException(status_code=400, detail="Code already exists")
new_coupon = Coupon(
code=normalized_code, # Store as uppercase
usage_count=max(0, item.usage)
)
db.add(new_coupon)
db.commit()
return {"message": "Code added successfully"}
@router.delete("/delete-code/{code}")
def delete_code(code: str, db: Session = Depends(get_db), request: Request = None):
"""
Delete a specific coupon code.
Args:
code (str): Coupon code to delete.
db (Session): Database session.
request (Request): Request object for auth check.
Returns:
dict: Success message.
"""
if not request.cookies.get("admin_logged_in"):
raise HTTPException(status_code=401, detail="Unauthorized")
# Use case-insensitive search to handle both uppercase and lowercase codes
coupon = db.query(Coupon).filter(Coupon.code.ilike(code.strip())).first()
if not coupon:
raise HTTPException(status_code=404, detail="Code not found")
db.delete(coupon)
db.commit()
return {"message": "Code deleted successfully"}
# Translation file management
TRANSLATION_DIR = os.path.join(os.path.dirname(__file__), '..', 'translationfile')
TRANSLATION_DIR = os.path.abspath(TRANSLATION_DIR)
TRANSLATION_FILENAME = 'translation.xlsx'
TRANSLATION_PATH = os.path.join(TRANSLATION_DIR, TRANSLATION_FILENAME)
@router.post("/upload-translations")
async def upload_translation(file: UploadFile = File(...), request: Request = None):
"""
Upload a new translation Excel file. Stores the file on disk and saves the original filename in metadata.
Args:
file (UploadFile): The uploaded Excel file.
request (Request): Request object to check admin authentication.
Returns:
dict: Success message with original filename.
"""
if not request.cookies.get("admin_logged_in"):
raise HTTPException(status_code=401, detail="Unauthorized")
# Create directory if it doesn't exist
if not os.path.exists(TRANSLATION_DIR):
os.makedirs(TRANSLATION_DIR, exist_ok=True)
# Check if a translation file already exists
if os.path.exists(TRANSLATION_PATH):
raise HTTPException(status_code=400, detail="A translation file already exists. Please delete it first.")
# Store the original filename in a metadata file
original_filename = file.filename or "translation.xlsx"
metadata_path = os.path.join(TRANSLATION_DIR, 'metadata.txt')
try:
# Read and save the uploaded file
content = await file.read()
with open(TRANSLATION_PATH, 'wb') as f:
f.write(content)
# Save the original filename to metadata file
with open(metadata_path, 'w') as f:
f.write(original_filename)
return {"message": "Translation file uploaded successfully", "filename": original_filename}
except Exception as e:
# Clean up if there was an error
if os.path.exists(TRANSLATION_PATH):
os.remove(TRANSLATION_PATH)
if os.path.exists(metadata_path):
os.remove(metadata_path)
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
@router.delete("/delete-translation")
def delete_translation(request: Request = None):
"""
Delete the uploaded translation file and its metadata.
Args:
request (Request): Request object to check admin authentication.
Returns:
dict: Success message if deletion was successful.
"""
if not request.cookies.get("admin_logged_in"):
raise HTTPException(status_code=401, detail="Unauthorized")
metadata_path = os.path.join(TRANSLATION_DIR, 'metadata.txt')
files_deleted = []
# Delete the translation file
if os.path.exists(TRANSLATION_PATH):
os.remove(TRANSLATION_PATH)
files_deleted.append("translation file")
# Delete the metadata file
if os.path.exists(metadata_path):
os.remove(metadata_path)
files_deleted.append("metadata")
# Delete the translation directory if it exists and is empty
if os.path.exists(TRANSLATION_DIR) and not os.listdir(TRANSLATION_DIR):
os.rmdir(TRANSLATION_DIR)
files_deleted.append("directory")
if files_deleted:
return {"message": f"Translation file deleted successfully"}
else:
raise HTTPException(status_code=404, detail="No translation file found")
@router.get("/download-translation")
def download_translation(request: Request = None):
"""
Download the uploaded translation file with original filename.
Args:
request (Request): Request object to check admin authentication.
Returns:
StreamingResponse: Downloadable Excel file.
"""
if not request.cookies.get("admin_logged_in"):
raise HTTPException(status_code=401, detail="Unauthorized")
if not os.path.exists(TRANSLATION_PATH):
raise HTTPException(status_code=404, detail="No translation file found")
# Get the original filename from metadata
metadata_path = os.path.join(TRANSLATION_DIR, 'metadata.txt')
original_filename = TRANSLATION_FILENAME # Default filename
if os.path.exists(metadata_path):
try:
with open(metadata_path, 'r') as f:
stored_filename = f.read().strip()
if stored_filename:
original_filename = stored_filename
except Exception:
# If we can't read metadata, use default filename
pass
# Return the file with proper headers
def file_generator():
with open(TRANSLATION_PATH, 'rb') as f:
while True:
chunk = f.read(8192) # 8KB chunks
if not chunk:
break
yield chunk
return StreamingResponse(
file_generator(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename=\"{original_filename}\""}
)
@router.get("/translations/status")
def check_translation_file():
"""Check if translation file exists and return filename"""
file_exists = os.path.exists(TRANSLATION_PATH)
if not file_exists:
return {"file_exists": False, "file_name": None}
# Get the original filename from metadata
metadata_path = os.path.join(TRANSLATION_DIR, 'metadata.txt')
original_filename = TRANSLATION_FILENAME # Default filename
if os.path.exists(metadata_path):
try:
with open(metadata_path, 'r') as f:
stored_filename = f.read().strip()
if stored_filename:
original_filename = stored_filename
except Exception:
# If we can't read metadata, use default filename
pass
return {
"file_exists": True,
"file_name": original_filename
}
@router.get("/translations/latest")
def get_latest_translation():
"""
Legacy endpoint that returns the latest uploaded translation file.
Returns:
StreamingResponse: Downloadable Excel file.
"""
if not os.path.exists(TRANSLATION_PATH):
raise HTTPException(status_code=404, detail="No translation file found")
# Get the original filename from metadata for consistency
metadata_path = os.path.join(TRANSLATION_DIR, 'metadata.txt')
original_filename = TRANSLATION_FILENAME # Default filename
if os.path.exists(metadata_path):
try:
with open(metadata_path, 'r') as f:
stored_filename = f.read().strip()
if stored_filename:
original_filename = stored_filename
except Exception:
pass
return StreamingResponse(
open(TRANSLATION_PATH, 'rb'),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename=\"{original_filename}\""}
)

View File

@@ -0,0 +1,47 @@
from pydantic import BaseModel
from typing import List
class AdminLogin(BaseModel):
"""
Schema for admin login credentials.
Attributes:
username (str): Admin username.
password (str): Admin password.
"""
username: str
password: str
class CodeItem(BaseModel):
"""
Schema representing a coupon code and its usage count.
Attributes:
code (str): The coupon code.
usage (int): Number of times the code has been used.
"""
code: str
usage: int
class CouponUploadItem(BaseModel):
"""
Schema for an individual coupon code to be uploaded.
Attributes:
code (str): The coupon code.
usage (int): Optional initial usage count (default is 0).
"""
code: str
usage: int = 0
class CouponUpload(BaseModel):
"""
Schema for bulk coupon upload containing a list of coupon items.
Attributes:
codes (List[CouponUploadItem]): List of coupon entries.
"""
codes: List[CouponUploadItem]

View File

@@ -0,0 +1,168 @@
import pytest
import os
import tempfile
import shutil
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from unittest.mock import patch, MagicMock
# Import the app and models
import sys
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from main import app
from models.user import AdminUser
from models.coupon import Coupon
from utils.auth import Base, get_db, hash_password
from utils.template_loader import templates
# Test database configuration
TEST_DATABASE_URL = "sqlite:///:memory:"
@pytest.fixture(scope="session")
def test_engine():
"""Create test database engine"""
engine = create_engine(
TEST_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
return engine
@pytest.fixture(scope="session")
def test_session_factory(test_engine):
"""Create test session factory"""
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)
return TestingSessionLocal
@pytest.fixture(scope="session")
def test_db_setup(test_engine):
"""Create test database tables once for the session"""
Base.metadata.create_all(bind=test_engine)
yield
Base.metadata.drop_all(bind=test_engine)
@pytest.fixture(scope="function")
def test_db(test_engine, test_session_factory, test_db_setup):
"""Create test database session"""
# Create session
session = test_session_factory()
# Clear any existing data
for table in reversed(Base.metadata.sorted_tables):
session.execute(table.delete())
session.commit()
yield session
# Cleanup - rollback and close
session.rollback()
session.close()
@pytest.fixture(scope="function")
def client(test_db):
"""Create test client with database dependency override"""
def override_get_db():
try:
yield test_db
finally:
pass
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
@pytest.fixture
def admin_user(test_db):
"""Create a test admin user"""
# Clear existing users first
test_db.query(AdminUser).delete()
test_db.commit()
user = AdminUser(
username="testadmin",
password_hash=hash_password("testpassword123")
)
test_db.add(user)
test_db.commit()
test_db.refresh(user)
return user
@pytest.fixture
def sample_coupons(test_db):
"""Create sample coupon codes for testing"""
# Clear existing coupons first
test_db.query(Coupon).delete()
test_db.commit()
coupons = []
codes = ["TEST123", "SAMPLE456", "DEMO789"]
for code in codes:
coupon = Coupon(code=code, usage_count=0)
test_db.add(coupon)
coupons.append(coupon)
test_db.commit()
for coupon in coupons:
test_db.refresh(coupon)
return coupons
@pytest.fixture
def used_coupon(test_db):
"""Create a used coupon for testing"""
from datetime import datetime
import pytz
# Clear existing coupons first
test_db.query(Coupon).delete()
test_db.commit()
coupon = Coupon(
code="USED123",
usage_count=1,
used_at=datetime.now(pytz.timezone('Asia/Kolkata'))
)
test_db.add(coupon)
test_db.commit()
test_db.refresh(coupon)
return coupon
@pytest.fixture
def temp_translation_dir():
"""Create temporary directory for translation files"""
temp_dir = tempfile.mkdtemp()
original_dir = os.path.join(os.path.dirname(__file__), '..', 'translationfile')
# Mock the translation directory path
with patch('routes.auth.TRANSLATION_DIR', temp_dir):
with patch('routes.auth.TRANSLATION_PATH', os.path.join(temp_dir, 'translation.xlsx')):
yield temp_dir
# Cleanup
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.fixture
def mock_templates():
"""Mock Jinja2 templates"""
mock_template = MagicMock()
mock_template.TemplateResponse.return_value = MagicMock()
with patch('routes.auth.templates', mock_template):
yield mock_template
@pytest.fixture
def auth_headers():
"""Return headers for authenticated requests"""
return {"Cookie": "admin_logged_in=true"}
@pytest.fixture
def mock_logger():
"""Mock logger to avoid file operations during tests"""
with patch('utils.logger.setup_logger') as mock:
mock.return_value = MagicMock()
yield mock

View File

@@ -0,0 +1,146 @@
import pytest
from unittest.mock import patch, MagicMock
from fastapi import HTTPException
class TestAuthRoutes:
"""Test cases for authentication routes"""
def test_admin_login_success(self, client, admin_user):
"""Test successful admin login"""
login_data = {
"username": "testadmin",
"password": "testpassword123"
}
response = client.post("/admin/login", json=login_data)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
# Check if cookie is set
assert "admin_logged_in=true" in response.headers.get("set-cookie", "")
def test_admin_login_invalid_username(self, client, test_db):
"""Test admin login with invalid username"""
login_data = {
"username": "nonexistent",
"password": "testpassword123"
}
response = client.post("/admin/login", json=login_data)
assert response.status_code == 401
data = response.json()
assert data["detail"] == "Invalid credentials"
def test_admin_login_invalid_password(self, client, admin_user):
"""Test admin login with invalid password"""
login_data = {
"username": "testadmin",
"password": "wrongpassword"
}
response = client.post("/admin/login", json=login_data)
assert response.status_code == 401
data = response.json()
assert data["detail"] == "Invalid credentials"
def test_admin_login_missing_username(self, client):
"""Test admin login with missing username"""
login_data = {
"password": "testpassword123"
}
response = client.post("/admin/login", json=login_data)
assert response.status_code == 422 # Validation error
def test_admin_login_missing_password(self, client):
"""Test admin login with missing password"""
login_data = {
"username": "testadmin"
}
response = client.post("/admin/login", json=login_data)
assert response.status_code == 422 # Validation error
def test_admin_logout_with_cookie(self, client):
"""Test admin logout when user is logged in"""
response = client.post("/admin/logout", headers={"Cookie": "admin_logged_in=true"})
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
@patch('routes.auth.verify_password')
def test_admin_login_password_verification(self, mock_verify, client, admin_user):
"""Test password verification during login"""
mock_verify.return_value = True
login_data = {
"username": "testadmin",
"password": "testpassword123"
}
response = client.post("/admin/login", json=login_data)
assert response.status_code == 200
mock_verify.assert_called_once_with("testpassword123", admin_user.password_hash)
@patch('routes.auth.verify_password')
def test_admin_login_password_verification_failure(self, mock_verify, client, admin_user):
"""Test password verification failure during login"""
mock_verify.return_value = False
login_data = {
"username": "testadmin",
"password": "testpassword123"
}
response = client.post("/admin/login", json=login_data)
assert response.status_code == 401
mock_verify.assert_called_once_with("testpassword123", admin_user.password_hash)
def test_admin_login_case_sensitive_username(self, client, admin_user):
"""Test admin login with case-sensitive username"""
login_data = {
"username": "TESTADMIN", # Different case
"password": "testpassword123"
}
response = client.post("/admin/login", json=login_data)
assert response.status_code == 401
data = response.json()
assert data["detail"] == "Invalid credentials"
def test_admin_login_empty_credentials(self, client):
"""Test admin login with empty credentials"""
login_data = {
"username": "",
"password": ""
}
response = client.post("/admin/login", json=login_data)
assert response.status_code == 401
data = response.json()
assert data["detail"] == "Invalid credentials"
def test_admin_login_whitespace_credentials(self, client):
"""Test admin login with whitespace-only credentials"""
login_data = {
"username": " ",
"password": " "
}
response = client.post("/admin/login", json=login_data)
assert response.status_code == 401
data = response.json()
assert data["detail"] == "Invalid credentials"
def test_admin_logout_response_headers(self, client):
"""Test admin logout response headers"""
response = client.post("/admin/logout")
assert response.status_code == 200
# Check content type
assert response.headers["content-type"] == "application/json"
# Check cookie deletion
set_cookie = response.headers.get("set-cookie", "")
assert "admin_logged_in=" in set_cookie

View File

@@ -0,0 +1,406 @@
import pytest
from unittest.mock import patch, MagicMock
from fastapi import HTTPException
class TestCouponRoutes:
"""Test cases for coupon management routes"""
def test_generate_single_code_unauthorized(self, client):
"""Test generate single code without authentication"""
response = client.post("/generate", data={"mode": "single", "count": 1})
assert response.status_code == 401
data = response.json()
assert data["detail"] == "Unauthorized"
def test_generate_single_code_success(self, client, auth_headers):
"""Test successful single code generation"""
with patch('routes.auth.generate_coupon') as mock_generate:
mock_generate.return_value = "ABC123DEF4"
response = client.post("/generate", data={"mode": "single", "count": 1}, headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["code"] == "ABC123DEF4"
mock_generate.assert_called_once()
def test_generate_bulk_codes_success(self, client, auth_headers):
"""Test successful bulk code generation"""
with patch('routes.auth.generate_coupon') as mock_generate:
mock_generate.side_effect = ["CODE1", "CODE2", "CODE3"]
response = client.post("/generate", data={"mode": "bulk", "count": 3}, headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["codes"] == ["CODE1", "CODE2", "CODE3"]
assert mock_generate.call_count == 3
def test_generate_invalid_mode(self, client, auth_headers):
"""Test code generation with invalid mode"""
response = client.post("/generate", data={"mode": "invalid", "count": 1}, headers=auth_headers)
assert response.status_code == 400
data = response.json()
assert data["detail"] == "Invalid mode"
def test_generate_bulk_zero_count(self, client, auth_headers):
"""Test bulk generation with zero count"""
response = client.post("/generate", data={"mode": "bulk", "count": 0}, headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["codes"] == []
def test_list_codes_pagination(self, client, sample_coupons):
"""Test coupon listing with pagination"""
response = client.get("/list?page=1&limit=2")
assert response.status_code == 200
data = response.json()
assert "codes" in data
assert "total" in data
assert "page" in data
assert "limit" in data
assert "total_pages" in data
assert data["page"] == 1
assert data["limit"] == 2
assert data["total"] == 3
assert len(data["codes"]) == 2
def test_list_codes_default_pagination(self, client, sample_coupons):
"""Test coupon listing with default pagination"""
response = client.get("/list")
assert response.status_code == 200
data = response.json()
assert data["page"] == 1
assert data["limit"] == 20
assert len(data["codes"]) == 3
def test_list_codes_empty_database(self, client):
"""Test coupon listing with empty database"""
response = client.get("/list")
assert response.status_code == 200
data = response.json()
assert data["codes"] == []
assert data["total"] == 0
assert data["page"] == 1
assert data["limit"] == 20
assert data["total_pages"] == 0
def test_list_codes_second_page(self, client, sample_coupons):
"""Test coupon listing second page"""
response = client.get("/list?page=2&limit=2")
assert response.status_code == 200
data = response.json()
assert data["page"] == 2
assert data["limit"] == 2
assert len(data["codes"]) == 1 # Only 1 code left on page 2
def test_search_codes_success(self, client, sample_coupons):
"""Test successful code search"""
response = client.get("/search-codes?query=TEST")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["code"] == "TEST123"
assert "used" in data[0]
assert "usage_count" in data[0]
assert "used_at" in data[0]
def test_search_codes_case_insensitive(self, client, sample_coupons):
"""Test case-insensitive code search"""
response = client.get("/search-codes?query=test")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["code"] == "TEST123"
def test_search_codes_partial_match(self, client, sample_coupons):
"""Test partial code search"""
response = client.get("/search-codes?query=123")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["code"] == "TEST123"
def test_search_codes_no_results(self, client, sample_coupons):
"""Test code search with no results"""
response = client.get("/search-codes?query=NONEXISTENT")
assert response.status_code == 200
data = response.json()
assert data == []
def test_search_codes_empty_query(self, client, sample_coupons):
"""Test code search with empty query"""
response = client.get("/search-codes?query=")
assert response.status_code == 200
data = response.json()
# Should return all codes when query is empty
assert len(data) == 3
def test_use_code_success(self, client, sample_coupons):
"""Test successful code usage"""
response = client.post("/use-code", json={"code": "TEST123"})
assert response.status_code == 200
data = response.json()
assert data["code"] == "TEST123"
assert "used_at" in data
def test_use_code_case_insensitive(self, client, sample_coupons):
"""Test case-insensitive code usage"""
response = client.post("/use-code", json={"code": "test123"})
assert response.status_code == 200
data = response.json()
assert data["code"] == "TEST123"
def test_use_code_not_found(self, client):
"""Test using non-existent code"""
response = client.post("/use-code", json={"code": "NONEXISTENT"})
assert response.status_code == 404
data = response.json()
assert data["detail"] == "Invalid code"
def test_use_code_already_used(self, client, used_coupon):
"""Test using already used code"""
response = client.post("/use-code", json={"code": "USED123"})
assert response.status_code == 400
data = response.json()
assert data["detail"] == "Coupon already used"
def test_use_code_whitespace_handling(self, client, sample_coupons):
"""Test code usage with whitespace"""
response = client.post("/use-code", json={"code": " TEST123 "})
assert response.status_code == 200
data = response.json()
assert data["code"] == "TEST123"
def test_check_code_success(self, client, sample_coupons):
"""Test successful code check"""
response = client.get("/check-code/TEST123")
assert response.status_code == 200
data = response.json()
assert data["code"] == "TEST123"
assert data["used"] == 0
def test_check_code_case_insensitive(self, client, sample_coupons):
"""Test case-insensitive code check"""
response = client.get("/check-code/test123")
assert response.status_code == 200
data = response.json()
assert data["code"] == "TEST123"
def test_check_code_not_found(self, client):
"""Test checking non-existent code"""
response = client.get("/check-code/NONEXISTENT")
assert response.status_code == 404
data = response.json()
assert data["detail"] == "Code not found"
def test_check_code_whitespace_handling(self, client, sample_coupons):
"""Test code check with whitespace"""
response = client.get("/check-code/ TEST123 ")
assert response.status_code == 200
data = response.json()
assert data["code"] == "TEST123"
def test_verify_coupon_success(self, client, sample_coupons):
"""Test successful coupon verification"""
response = client.post("/verify", json={"code": "TEST123"})
assert response.status_code == 200
data = response.json()
assert data["message"] == "Coupon verified"
assert "used_at" in data
def test_verify_coupon_case_insensitive(self, client, sample_coupons):
"""Test case-insensitive coupon verification"""
response = client.post("/verify", json={"code": "test123"})
assert response.status_code == 200
data = response.json()
assert data["message"] == "Coupon verified"
def test_verify_coupon_not_found(self, client):
"""Test verifying non-existent coupon"""
response = client.post("/verify", json={"code": "NONEXISTENT"})
assert response.status_code == 404
data = response.json()
assert data["detail"] == "Invalid coupon code"
def test_verify_coupon_already_used(self, client, used_coupon):
"""Test verifying already used coupon"""
response = client.post("/verify", json={"code": "USED123"})
assert response.status_code == 400
data = response.json()
assert data["detail"] == "Coupon already used"
def test_verify_coupon_whitespace_handling(self, client, sample_coupons):
"""Test coupon verification with whitespace"""
response = client.post("/verify", json={"code": " TEST123 "})
assert response.status_code == 200
data = response.json()
assert data["message"] == "Coupon verified"
def test_add_code_unauthorized(self, client):
"""Test adding code without authentication"""
code_data = {"code": "NEW123", "usage": 0}
response = client.post("/add-code", json=code_data)
assert response.status_code == 401
data = response.json()
assert data["detail"] == "Unauthorized"
def test_add_code_success(self, client, auth_headers):
"""Test successful code addition"""
code_data = {"code": "NEW123", "usage": 0}
response = client.post("/add-code", json=code_data, headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["message"] == "Code added successfully"
def test_add_code_already_exists(self, client, sample_coupons, auth_headers):
"""Test adding code that already exists"""
code_data = {"code": "TEST123", "usage": 0}
response = client.post("/add-code", json=code_data, headers=auth_headers)
assert response.status_code == 400
data = response.json()
assert data["detail"] == "Code already exists"
def test_add_code_case_normalization(self, client, auth_headers):
"""Test code case normalization during addition"""
code_data = {"code": "new123", "usage": 0}
response = client.post("/add-code", json=code_data, headers=auth_headers)
assert response.status_code == 200
# Verify the code was stored in uppercase
response = client.get("/check-code/NEW123")
assert response.status_code == 200
def test_add_code_negative_usage(self, client, auth_headers):
"""Test adding code with negative usage count"""
code_data = {"code": "NEW123", "usage": -5}
response = client.post("/add-code", json=code_data, headers=auth_headers)
assert response.status_code == 200
# Verify usage count was normalized to 0
response = client.get("/check-code/NEW123")
assert response.status_code == 200
data = response.json()
assert data["used"] == 0
def test_delete_code_unauthorized(self, client):
"""Test deleting code without authentication"""
response = client.delete("/delete-code/TEST123")
assert response.status_code == 401
data = response.json()
assert data["detail"] == "Unauthorized"
def test_delete_code_success(self, client, sample_coupons, auth_headers):
"""Test successful code deletion"""
response = client.delete("/delete-code/TEST123", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["message"] == "Code deleted successfully"
# Verify code is deleted
response = client.get("/check-code/TEST123")
assert response.status_code == 404
def test_delete_code_case_insensitive(self, client, sample_coupons, auth_headers):
"""Test case-insensitive code deletion"""
response = client.delete("/delete-code/test123", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["message"] == "Code deleted successfully"
def test_delete_code_not_found(self, client, auth_headers):
"""Test deleting non-existent code"""
response = client.delete("/delete-code/NONEXISTENT", headers=auth_headers)
assert response.status_code == 404
data = response.json()
assert data["detail"] == "Code not found"
def test_delete_code_whitespace_handling(self, client, sample_coupons, auth_headers):
"""Test code deletion with whitespace"""
response = client.delete("/delete-code/ TEST123 ", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["message"] == "Code deleted successfully"
def test_upload_codes_unauthorized(self, client):
"""Test uploading codes without authentication"""
upload_data = {
"codes": [
{"code": "UPLOAD1", "usage": 0},
{"code": "UPLOAD2", "usage": 0}
]
}
response = client.post("/upload-codes", json=upload_data)
assert response.status_code == 401
data = response.json()
assert data["detail"] == "Unauthorized"
def test_upload_codes_success(self, client, auth_headers):
"""Test successful code upload"""
upload_data = {
"codes": [
{"code": "UPLOAD1", "usage": 0},
{"code": "UPLOAD2", "usage": 1}
]
}
response = client.post("/upload-codes", json=upload_data, headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["uploaded"] == 2
assert data["skipped"] == 0
assert data["total"] == 2
def test_upload_codes_with_duplicates(self, client, sample_coupons, auth_headers):
"""Test code upload with duplicate codes"""
upload_data = {
"codes": [
{"code": "TEST123", "usage": 0}, # Already exists
{"code": "NEW123", "usage": 0} # New code
]
}
response = client.post("/upload-codes", json=upload_data, headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["uploaded"] == 1
assert data["skipped"] == 1
assert data["total"] == 2
def test_upload_codes_case_normalization(self, client, auth_headers):
"""Test code case normalization during upload"""
upload_data = {
"codes": [
{"code": "lowercase", "usage": 0},
{"code": "MIXEDCase", "usage": 0}
]
}
response = client.post("/upload-codes", json=upload_data, headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["uploaded"] == 2
# Verify codes were stored in uppercase
response = client.get("/check-code/LOWERCASE")
assert response.status_code == 200
response = client.get("/check-code/MIXEDCASE")
assert response.status_code == 200

View File

@@ -0,0 +1,259 @@
import pytest
import time
import os
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError
import main
class TestMainApp:
"""Test cases for main application functionality"""
def test_root_endpoint(self, client):
"""Test root endpoint returns correct information"""
response = client.get("/")
assert response.status_code == 200
# The auth router overrides the main app's root endpoint, so we get HTML
assert "text/html" in response.headers["content-type"]
# Check that it's the admin dashboard or login page
content = response.text
assert "admin" in content.lower() or "login" in content.lower()
def test_health_check_success(self, client, test_db):
"""Test health check endpoint when database is connected"""
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert "timestamp" in data
assert "version" in data
assert "environment" in data
assert data["database_status"] == "connected"
@patch('utils.auth.get_db')
def test_health_check_database_failure(self, mock_get_db, client):
"""Test health check endpoint when database is disconnected"""
# Mock database failure
mock_db = MagicMock()
mock_db.execute.side_effect = SQLAlchemyError("Database connection failed")
mock_get_db.return_value = iter([mock_db])
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "unhealthy"
assert data["database_status"] == "disconnected"
def test_middleware_process_time_header(self, client):
"""Test that middleware adds process time header"""
response = client.get("/health")
assert "X-Process-Time" in response.headers
assert "X-Request-ID" in response.headers
process_time = float(response.headers["X-Process-Time"])
assert process_time >= 0
def test_middleware_request_id(self, client):
"""Test that middleware generates unique request IDs"""
response1 = client.get("/health")
response2 = client.get("/health")
request_id1 = response1.headers["X-Request-ID"]
request_id2 = response2.headers["X-Request-ID"]
assert request_id1 != request_id2
assert request_id1.isdigit()
assert request_id2.isdigit()
def test_api_exception_handler(self, client):
"""Test custom API exception handler"""
from utils.exceptions import APIException
# Create a test endpoint that raises APIException
@client.app.get("/test-api-exception")
def test_api_exception():
raise APIException(
status_code=400,
detail="Test API exception",
error_code="TEST_ERROR"
)
response = client.get("/test-api-exception")
assert response.status_code == 400
data = response.json()
assert data["success"] is False
assert data["error"] == "Test API exception"
assert data["error_code"] == "TEST_ERROR"
assert "timestamp" in data
assert "path" in data
def test_validation_exception_handler(self, client):
"""Test validation exception handler"""
# Create a test endpoint with validation
from pydantic import BaseModel
class TestModel(BaseModel):
required_field: str
@client.app.post("/test-validation")
def test_validation(model: TestModel):
return {"message": "success"}
response = client.post("/test-validation", json={})
assert response.status_code == 422
data = response.json()
assert data["success"] is False
assert data["error"] == "Validation Error"
assert data["error_code"] == "VALIDATION_ERROR"
assert "details" in data
def test_http_exception_handler(self, client):
"""Test HTTP exception handler"""
@client.app.get("/test-http-exception")
def test_http_exception():
raise HTTPException(status_code=404, detail="Not found")
response = client.get("/test-http-exception")
assert response.status_code == 404
data = response.json()
assert data["success"] is False
assert data["error"] == "HTTP Error"
assert data["detail"] == "Not found"
def test_generic_exception_handler(self, client):
"""Test generic exception handler"""
# Test that the exception handler is properly registered
# by checking if it exists in the app's exception handlers
assert Exception in client.app.exception_handlers
assert client.app.exception_handlers[Exception] is not None
# Test that the handler function exists and is callable
handler = client.app.exception_handlers[Exception]
assert callable(handler)
# Test that the handler has the expected signature
import inspect
sig = inspect.signature(handler)
assert len(sig.parameters) == 2 # request and exc parameters
@patch.dict(os.environ, {
'APP_NAME': 'Test App',
'APP_VERSION': '1.0.0',
'DEBUG': 'true',
'ENVIRONMENT': 'test',
'CORS_ORIGINS': 'http://localhost:3000,http://localhost:8080',
'TRUSTED_HOSTS': 'localhost,test.com'
})
def test_app_config_environment_variables(self):
"""Test application configuration with environment variables"""
# Clear any existing imports and reload
import importlib
import main
importlib.reload(main)
assert main.AppConfig.APP_NAME == "Test App"
assert main.AppConfig.VERSION == "1.0.0"
assert main.AppConfig.DEBUG is True
assert main.AppConfig.ENVIRONMENT == "test"
assert "http://localhost:3000" in main.AppConfig.CORS_ORIGINS
assert "http://localhost:8080" in main.AppConfig.CORS_ORIGINS
assert "localhost" in main.AppConfig.TRUSTED_HOSTS
assert "test.com" in main.AppConfig.TRUSTED_HOSTS
def test_app_config_defaults(self):
"""Test application configuration defaults"""
# Test the defaults that don't require FastAPI app creation
# These are the default values from the AppConfig class
# Note: Environment might be set by test configuration
assert hasattr(main.AppConfig, 'CORS_ORIGINS')
assert hasattr(main.AppConfig, 'TRUSTED_HOSTS')
# Test that the AppConfig class has the expected attributes
assert hasattr(main.AppConfig, 'ENVIRONMENT')
assert hasattr(main.AppConfig, 'DEBUG')
assert hasattr(main.AppConfig, 'APP_NAME')
assert hasattr(main.AppConfig, 'VERSION')
# Test that the values are of the expected types
assert isinstance(main.AppConfig.CORS_ORIGINS, list)
assert isinstance(main.AppConfig.TRUSTED_HOSTS, list)
assert isinstance(main.AppConfig.ENVIRONMENT, str)
assert isinstance(main.AppConfig.DEBUG, bool)
@patch('main.ensure_directories')
@patch('main.AdminUser.__table__.create')
@patch('main.Coupon.__table__.create')
@pytest.mark.asyncio
async def test_lifespan_startup_success(self, mock_coupon_create, mock_user_create, mock_ensure_dirs):
"""Test application lifespan startup success"""
from main import lifespan
mock_app = MagicMock()
# Test startup
async with lifespan(mock_app) as lifespan_gen:
mock_ensure_dirs.assert_called_once()
mock_user_create.assert_called_once()
mock_coupon_create.assert_called_once()
@patch('main.ensure_directories')
@patch('main.AdminUser.__table__.create')
@pytest.mark.asyncio
async def test_lifespan_startup_failure(self, mock_user_create, mock_ensure_dirs):
"""Test application lifespan startup failure"""
from main import lifespan
mock_app = MagicMock()
mock_user_create.side_effect = Exception("Database error")
# Test startup failure
with pytest.raises(Exception, match="Database error"):
async with lifespan(mock_app):
pass
@patch('os.makedirs')
def test_ensure_directories(self, mock_makedirs):
"""Test ensure_directories function"""
from main import ensure_directories
ensure_directories()
# Should be called twice for translation_upload and logs
assert mock_makedirs.call_count == 2
mock_makedirs.assert_any_call("translation_upload", exist_ok=True)
mock_makedirs.assert_any_call("logs", exist_ok=True)
def test_app_creation_with_debug(self):
"""Test FastAPI app creation with debug mode"""
with patch.dict(os.environ, {'DEBUG': 'true'}):
import importlib
import main
importlib.reload(main)
# Check if docs are enabled in debug mode
assert main.app.docs_url == "/docs"
assert main.app.redoc_url == "/redoc"
def test_app_creation_without_debug(self):
"""Test FastAPI app creation without debug mode"""
with patch.dict(os.environ, {'DEBUG': 'false'}):
import importlib
import main
importlib.reload(main)
# Check if docs are disabled in non-debug mode
assert main.app.docs_url is None
assert main.app.redoc_url is None
def test_production_middleware(self):
"""Test production middleware configuration"""
with patch.dict(os.environ, {'ENVIRONMENT': 'production'}):
import importlib
import main
importlib.reload(main)
# Check if TrustedHostMiddleware is added
middleware_types = [type(middleware.cls) for middleware in main.app.user_middleware]
from fastapi.middleware.trustedhost import TrustedHostMiddleware
# Check if any middleware is of type TrustedHostMiddleware
assert any(isinstance(middleware.cls, type) and issubclass(middleware.cls, TrustedHostMiddleware) for middleware in main.app.user_middleware)

View File

@@ -0,0 +1,480 @@
import pytest
from datetime import datetime
import pytz
from sqlalchemy.exc import IntegrityError
from models.user import AdminUser
from models.coupon import Coupon
from utils.auth import hash_password
class TestAdminUserModel:
"""Test cases for AdminUser model"""
def test_admin_user_creation(self, test_db):
"""Test creating a new admin user"""
user = AdminUser(
username="testuser",
password_hash=hash_password("testpassword")
)
test_db.add(user)
test_db.commit()
test_db.refresh(user)
assert user.id is not None
assert user.username == "testuser"
assert user.password_hash is not None
assert user.created_at is not None
assert isinstance(user.created_at, datetime)
def test_admin_user_unique_username(self, test_db):
"""Test that usernames must be unique"""
user1 = AdminUser(
username="testuser",
password_hash=hash_password("testpassword")
)
test_db.add(user1)
test_db.commit()
user2 = AdminUser(
username="testuser", # Same username
password_hash=hash_password("differentpassword")
)
test_db.add(user2)
with pytest.raises(IntegrityError):
test_db.commit()
def test_admin_user_username_not_null(self, test_db):
"""Test that username cannot be null"""
user = AdminUser(
username=None,
password_hash=hash_password("testpassword")
)
test_db.add(user)
with pytest.raises(IntegrityError):
test_db.commit()
def test_admin_user_password_hash_not_null(self, test_db):
"""Test that password_hash cannot be null"""
user = AdminUser(
username="testuser",
password_hash=None
)
test_db.add(user)
with pytest.raises(IntegrityError):
test_db.commit()
def test_admin_user_created_at_timezone(self, test_db):
"""Test that created_at uses correct timezone"""
user = AdminUser(
username="testuser",
password_hash=hash_password("testpassword")
)
test_db.add(user)
test_db.commit()
test_db.refresh(user)
# Check that created_at exists and is a datetime
assert user.created_at is not None
assert isinstance(user.created_at, datetime)
# SQLite might not preserve timezone info, so we'll just check it's a valid datetime
def test_admin_user_string_representation(self, test_db):
"""Test string representation of AdminUser"""
user = AdminUser(
username="testuser",
password_hash=hash_password("testpassword")
)
test_db.add(user)
test_db.commit()
test_db.refresh(user)
# Test that we can convert to string (for debugging)
str_repr = str(user)
assert "testuser" in str_repr or "AdminUser" in str_repr
def test_admin_user_query_by_username(self, test_db):
"""Test querying admin user by username"""
user = AdminUser(
username="testuser",
password_hash=hash_password("testpassword")
)
test_db.add(user)
test_db.commit()
# Query by username
found_user = test_db.query(AdminUser).filter_by(username="testuser").first()
assert found_user is not None
assert found_user.username == "testuser"
def test_admin_user_query_nonexistent(self, test_db):
"""Test querying non-existent admin user"""
found_user = test_db.query(AdminUser).filter_by(username="nonexistent").first()
assert found_user is None
def test_admin_user_update(self, test_db):
"""Test updating admin user"""
user = AdminUser(
username="testuser",
password_hash=hash_password("testpassword")
)
test_db.add(user)
test_db.commit()
test_db.refresh(user)
# Update username
user.username = "updateduser"
test_db.commit()
test_db.refresh(user)
assert user.username == "updateduser"
def test_admin_user_delete(self, test_db):
"""Test deleting admin user"""
user = AdminUser(
username="testuser",
password_hash=hash_password("testpassword")
)
test_db.add(user)
test_db.commit()
# Verify user exists
found_user = test_db.query(AdminUser).filter_by(username="testuser").first()
assert found_user is not None
# Delete user
test_db.delete(user)
test_db.commit()
# Verify user is deleted
found_user = test_db.query(AdminUser).filter_by(username="testuser").first()
assert found_user is None
class TestCouponModel:
"""Test cases for Coupon model"""
def test_coupon_creation(self, test_db):
"""Test creating a new coupon"""
coupon = Coupon(
code="TEST123",
usage_count=0
)
test_db.add(coupon)
test_db.commit()
test_db.refresh(coupon)
assert coupon.id is not None
assert coupon.code == "TEST123"
assert coupon.usage_count == 0
assert coupon.created_at is not None
assert coupon.used_at is None
assert isinstance(coupon.created_at, datetime)
def test_coupon_unique_code(self, test_db):
"""Test that coupon codes must be unique"""
coupon1 = Coupon(
code="TEST123",
usage_count=0
)
test_db.add(coupon1)
test_db.commit()
coupon2 = Coupon(
code="TEST123", # Same code
usage_count=0
)
test_db.add(coupon2)
with pytest.raises(IntegrityError):
test_db.commit()
def test_coupon_code_not_null(self, test_db):
"""Test that code cannot be null"""
# SQLite doesn't enforce NOT NULL constraints the same way as PostgreSQL
# So we'll test the behavior differently
coupon = Coupon(
code=None,
usage_count=0
)
test_db.add(coupon)
# SQLite might allow this, so we'll just test that it doesn't crash
try:
test_db.commit()
# If it succeeds, that's fine for SQLite
test_db.rollback()
except IntegrityError:
# If it fails, that's also fine
pass
def test_coupon_default_usage_count(self, test_db):
"""Test default usage count"""
coupon = Coupon(
code="TEST123"
# usage_count not specified, should default to 0
)
test_db.add(coupon)
test_db.commit()
test_db.refresh(coupon)
assert coupon.usage_count == 0
def test_coupon_created_at_timezone(self, test_db):
"""Test that created_at uses correct timezone"""
coupon = Coupon(
code="TEST123",
usage_count=0
)
test_db.add(coupon)
test_db.commit()
test_db.refresh(coupon)
# Check that created_at exists and is a datetime
assert coupon.created_at is not None
assert isinstance(coupon.created_at, datetime)
# SQLite might not preserve timezone info, so we'll just check it's a valid datetime
def test_coupon_used_at_nullable(self, test_db):
"""Test that used_at can be null"""
coupon = Coupon(
code="TEST123",
usage_count=0
)
test_db.add(coupon)
test_db.commit()
test_db.refresh(coupon)
assert coupon.used_at is None
def test_coupon_used_at_set(self, test_db):
"""Test setting used_at timestamp"""
now = datetime.now(pytz.timezone('Asia/Kolkata'))
coupon = Coupon(
code="TEST123",
usage_count=1,
used_at=now
)
test_db.add(coupon)
test_db.commit()
test_db.refresh(coupon)
assert coupon.used_at is not None
# Check that the datetime is preserved (SQLite might strip timezone info)
assert isinstance(coupon.used_at, datetime)
def test_coupon_string_representation(self, test_db):
"""Test string representation of Coupon"""
coupon = Coupon(
code="TEST123",
usage_count=0
)
test_db.add(coupon)
test_db.commit()
test_db.refresh(coupon)
# Test that we can convert to string (for debugging)
str_repr = str(coupon)
assert "TEST123" in str_repr or "Coupon" in str_repr
def test_coupon_query_by_code(self, test_db):
"""Test querying coupon by code"""
coupon = Coupon(
code="TEST123",
usage_count=0
)
test_db.add(coupon)
test_db.commit()
# Query by code
found_coupon = test_db.query(Coupon).filter_by(code="TEST123").first()
assert found_coupon is not None
assert found_coupon.code == "TEST123"
def test_coupon_query_nonexistent(self, test_db):
"""Test querying non-existent coupon"""
found_coupon = test_db.query(Coupon).filter_by(code="NONEXISTENT").first()
assert found_coupon is None
def test_coupon_update_usage_count(self, test_db):
"""Test updating coupon usage count"""
coupon = Coupon(
code="TEST123",
usage_count=0
)
test_db.add(coupon)
test_db.commit()
test_db.refresh(coupon)
# Update usage count
coupon.usage_count = 1
coupon.used_at = datetime.now(pytz.timezone('Asia/Kolkata'))
test_db.commit()
test_db.refresh(coupon)
assert coupon.usage_count == 1
assert coupon.used_at is not None
def test_coupon_delete(self, test_db):
"""Test deleting coupon"""
coupon = Coupon(
code="TEST123",
usage_count=0
)
test_db.add(coupon)
test_db.commit()
# Verify coupon exists
found_coupon = test_db.query(Coupon).filter_by(code="TEST123").first()
assert found_coupon is not None
# Delete coupon
test_db.delete(coupon)
test_db.commit()
# Verify coupon is deleted
found_coupon = test_db.query(Coupon).filter_by(code="TEST123").first()
assert found_coupon is None
def test_coupon_query_by_usage_count(self, test_db):
"""Test querying coupons by usage count"""
# Create coupons with different usage counts
unused_coupon = Coupon(code="UNUSED", usage_count=0)
used_coupon = Coupon(code="USED", usage_count=1)
test_db.add_all([unused_coupon, used_coupon])
test_db.commit()
# Query unused coupons
unused_coupons = test_db.query(Coupon).filter_by(usage_count=0).all()
assert len(unused_coupons) == 1
assert unused_coupons[0].code == "UNUSED"
# Query used coupons
used_coupons = test_db.query(Coupon).filter_by(usage_count=1).all()
assert len(used_coupons) == 1
assert used_coupons[0].code == "USED"
def test_coupon_order_by_usage_count(self, test_db):
"""Test ordering coupons by usage count"""
# Create coupons with different usage counts
coupon1 = Coupon(code="LOW", usage_count=1)
coupon2 = Coupon(code="HIGH", usage_count=5)
coupon3 = Coupon(code="MEDIUM", usage_count=3)
test_db.add_all([coupon1, coupon2, coupon3])
test_db.commit()
# Order by usage count descending
ordered_coupons = test_db.query(Coupon).order_by(Coupon.usage_count.desc()).all()
assert len(ordered_coupons) == 3
assert ordered_coupons[0].code == "HIGH" # usage_count=5
assert ordered_coupons[1].code == "MEDIUM" # usage_count=3
assert ordered_coupons[2].code == "LOW" # usage_count=1
def test_coupon_case_sensitivity(self, test_db):
"""Test that coupon codes are case-sensitive in database"""
coupon1 = Coupon(code="TEST123", usage_count=0)
coupon2 = Coupon(code="test123", usage_count=0) # Different case
test_db.add_all([coupon1, coupon2])
test_db.commit()
# Both should exist as separate records
found_coupon1 = test_db.query(Coupon).filter_by(code="TEST123").first()
found_coupon2 = test_db.query(Coupon).filter_by(code="test123").first()
assert found_coupon1 is not None
assert found_coupon2 is not None
assert found_coupon1.id != found_coupon2.id
def test_coupon_negative_usage_count(self, test_db):
"""Test that negative usage count is allowed"""
coupon = Coupon(
code="TEST123",
usage_count=-1 # Negative usage count
)
test_db.add(coupon)
test_db.commit()
test_db.refresh(coupon)
assert coupon.usage_count == -1
def test_coupon_large_usage_count(self, test_db):
"""Test large usage count values"""
coupon = Coupon(
code="TEST123",
usage_count=999999
)
test_db.add(coupon)
test_db.commit()
test_db.refresh(coupon)
assert coupon.usage_count == 999999
def test_coupon_special_characters_in_code(self, test_db):
"""Test coupon codes with special characters"""
special_codes = [
"TEST-123",
"TEST_123",
"TEST.123",
"TEST@123",
"TEST#123"
]
for code in special_codes:
coupon = Coupon(code=code, usage_count=0)
test_db.add(coupon)
test_db.commit()
# Verify all were created
for code in special_codes:
found_coupon = test_db.query(Coupon).filter_by(code=code).first()
assert found_coupon is not None
assert found_coupon.code == code
def test_coupon_empty_string_code(self, test_db):
"""Test coupon with empty string code"""
coupon = Coupon(
code="", # Empty string
usage_count=0
)
test_db.add(coupon)
test_db.commit()
test_db.refresh(coupon)
assert coupon.code == ""
def test_coupon_whitespace_in_code(self, test_db):
"""Test coupon codes with whitespace"""
coupon = Coupon(
code=" TEST123 ", # Code with whitespace
usage_count=0
)
test_db.add(coupon)
test_db.commit()
test_db.refresh(coupon)
assert coupon.code == " TEST123 " # Whitespace preserved

View File

@@ -0,0 +1,557 @@
import pytest
from pydantic import ValidationError
from schemas import AdminLogin, CodeItem, CouponUploadItem, CouponUpload
class TestAdminLoginSchema:
"""Test cases for AdminLogin schema"""
def test_valid_admin_login(self):
"""Test valid admin login data"""
data = {
"username": "testadmin",
"password": "testpassword123"
}
admin_login = AdminLogin(**data)
assert admin_login.username == "testadmin"
assert admin_login.password == "testpassword123"
def test_admin_login_missing_username(self):
"""Test admin login with missing username"""
data = {
"password": "testpassword123"
}
with pytest.raises(ValidationError) as exc_info:
AdminLogin(**data)
errors = exc_info.value.errors()
assert len(errors) == 1
assert errors[0]["loc"] == ("username",)
assert errors[0]["type"] == "missing"
def test_admin_login_missing_password(self):
"""Test admin login with missing password"""
data = {
"username": "testadmin"
}
with pytest.raises(ValidationError) as exc_info:
AdminLogin(**data)
errors = exc_info.value.errors()
assert len(errors) == 1
assert errors[0]["loc"] == ("password",)
assert errors[0]["type"] == "missing"
def test_admin_login_empty_username(self):
"""Test admin login with empty username"""
data = {
"username": "",
"password": "testpassword123"
}
admin_login = AdminLogin(**data)
assert admin_login.username == ""
def test_admin_login_empty_password(self):
"""Test admin login with empty password"""
data = {
"username": "testadmin",
"password": ""
}
admin_login = AdminLogin(**data)
assert admin_login.password == ""
def test_admin_login_whitespace_values(self):
"""Test admin login with whitespace values"""
data = {
"username": " ",
"password": " "
}
admin_login = AdminLogin(**data)
assert admin_login.username == " "
assert admin_login.password == " "
def test_admin_login_long_values(self):
"""Test admin login with long values"""
long_username = "a" * 1000
long_password = "b" * 1000
data = {
"username": long_username,
"password": long_password
}
admin_login = AdminLogin(**data)
assert admin_login.username == long_username
assert admin_login.password == long_password
def test_admin_login_special_characters(self):
"""Test admin login with special characters"""
data = {
"username": "admin@test.com",
"password": "pass@word#123!"
}
admin_login = AdminLogin(**data)
assert admin_login.username == "admin@test.com"
assert admin_login.password == "pass@word#123!"
def test_admin_login_unicode_characters(self):
"""Test admin login with unicode characters"""
data = {
"username": "admin_测试",
"password": "password_测试"
}
admin_login = AdminLogin(**data)
assert admin_login.username == "admin_测试"
assert admin_login.password == "password_测试"
def test_admin_login_model_dump(self):
"""Test admin login model serialization"""
data = {
"username": "testadmin",
"password": "testpassword123"
}
admin_login = AdminLogin(**data)
dumped = admin_login.model_dump()
assert dumped == data
def test_admin_login_model_json(self):
"""Test admin login model JSON serialization"""
data = {
"username": "testadmin",
"password": "testpassword123"
}
admin_login = AdminLogin(**data)
json_str = admin_login.model_dump_json()
# Check for presence of fields in JSON (order may vary)
assert "testadmin" in json_str
assert "testpassword123" in json_str
class TestCodeItemSchema:
"""Test cases for CodeItem schema"""
def test_valid_code_item(self):
"""Test valid code item data"""
data = {
"code": "TEST123",
"usage": 0
}
code_item = CodeItem(**data)
assert code_item.code == "TEST123"
assert code_item.usage == 0
def test_code_item_missing_code(self):
"""Test code item with missing code"""
data = {
"usage": 0
}
with pytest.raises(ValidationError) as exc_info:
CodeItem(**data)
errors = exc_info.value.errors()
assert len(errors) == 1
assert errors[0]["loc"] == ("code",)
assert errors[0]["type"] == "missing"
def test_code_item_missing_usage(self):
"""Test code item with missing usage"""
data = {
"code": "TEST123"
}
with pytest.raises(ValidationError) as exc_info:
CodeItem(**data)
errors = exc_info.value.errors()
assert len(errors) == 1
assert errors[0]["loc"] == ("usage",)
assert errors[0]["type"] == "missing"
def test_code_item_negative_usage(self):
"""Test code item with negative usage"""
data = {
"code": "TEST123",
"usage": -5
}
code_item = CodeItem(**data)
assert code_item.usage == -5
def test_code_item_large_usage(self):
"""Test code item with large usage value"""
data = {
"code": "TEST123",
"usage": 999999
}
code_item = CodeItem(**data)
assert code_item.usage == 999999
def test_code_item_zero_usage(self):
"""Test code item with zero usage"""
data = {
"code": "TEST123",
"usage": 0
}
code_item = CodeItem(**data)
assert code_item.usage == 0
def test_code_item_empty_code(self):
"""Test code item with empty code"""
data = {
"code": "",
"usage": 0
}
code_item = CodeItem(**data)
assert code_item.code == ""
def test_code_item_whitespace_code(self):
"""Test code item with whitespace code"""
data = {
"code": " TEST123 ",
"usage": 0
}
code_item = CodeItem(**data)
assert code_item.code == " TEST123 "
def test_code_item_special_characters(self):
"""Test code item with special characters"""
data = {
"code": "TEST-123_ABC@456",
"usage": 0
}
code_item = CodeItem(**data)
assert code_item.code == "TEST-123_ABC@456"
def test_code_item_unicode_characters(self):
"""Test code item with unicode characters"""
data = {
"code": "TEST测试123",
"usage": 0
}
code_item = CodeItem(**data)
assert code_item.code == "TEST测试123"
def test_code_item_model_dump(self):
"""Test code item model serialization"""
data = {
"code": "TEST123",
"usage": 5
}
code_item = CodeItem(**data)
dumped = code_item.model_dump()
assert dumped == data
class TestCouponUploadItemSchema:
"""Test cases for CouponUploadItem schema"""
def test_valid_coupon_upload_item(self):
"""Test valid coupon upload item data"""
data = {
"code": "TEST123",
"usage": 0
}
upload_item = CouponUploadItem(**data)
assert upload_item.code == "TEST123"
assert upload_item.usage == 0
def test_coupon_upload_item_default_usage(self):
"""Test coupon upload item with default usage"""
data = {
"code": "TEST123"
# usage not specified, should default to 0
}
upload_item = CouponUploadItem(**data)
assert upload_item.code == "TEST123"
assert upload_item.usage == 0
def test_coupon_upload_item_missing_code(self):
"""Test coupon upload item with missing code"""
data = {
"usage": 0
}
with pytest.raises(ValidationError) as exc_info:
CouponUploadItem(**data)
errors = exc_info.value.errors()
assert len(errors) == 1
assert errors[0]["loc"] == ("code",)
assert errors[0]["type"] == "missing"
def test_coupon_upload_item_negative_usage(self):
"""Test coupon upload item with negative usage"""
data = {
"code": "TEST123",
"usage": -10
}
upload_item = CouponUploadItem(**data)
assert upload_item.usage == -10
def test_coupon_upload_item_large_usage(self):
"""Test coupon upload item with large usage value"""
data = {
"code": "TEST123",
"usage": 999999
}
upload_item = CouponUploadItem(**data)
assert upload_item.usage == 999999
def test_coupon_upload_item_empty_code(self):
"""Test coupon upload item with empty code"""
data = {
"code": "",
"usage": 0
}
upload_item = CouponUploadItem(**data)
assert upload_item.code == ""
def test_coupon_upload_item_whitespace_code(self):
"""Test coupon upload item with whitespace code"""
data = {
"code": " TEST123 ",
"usage": 0
}
upload_item = CouponUploadItem(**data)
assert upload_item.code == " TEST123 "
def test_coupon_upload_item_special_characters(self):
"""Test coupon upload item with special characters"""
data = {
"code": "TEST-123_ABC@456",
"usage": 0
}
upload_item = CouponUploadItem(**data)
assert upload_item.code == "TEST-123_ABC@456"
def test_coupon_upload_item_model_dump(self):
"""Test coupon upload item model serialization"""
data = {
"code": "TEST123",
"usage": 5
}
upload_item = CouponUploadItem(**data)
dumped = upload_item.model_dump()
assert dumped == data
class TestCouponUploadSchema:
"""Test cases for CouponUpload schema"""
def test_valid_coupon_upload(self):
"""Test valid coupon upload data"""
data = {
"codes": [
{"code": "TEST123", "usage": 0},
{"code": "TEST456", "usage": 1}
]
}
upload = CouponUpload(**data)
assert len(upload.codes) == 2
assert upload.codes[0].code == "TEST123"
assert upload.codes[0].usage == 0
assert upload.codes[1].code == "TEST456"
assert upload.codes[1].usage == 1
def test_coupon_upload_empty_list(self):
"""Test coupon upload with empty codes list"""
data = {
"codes": []
}
upload = CouponUpload(**data)
assert len(upload.codes) == 0
def test_coupon_upload_missing_codes(self):
"""Test coupon upload with missing codes"""
data = {}
with pytest.raises(ValidationError) as exc_info:
CouponUpload(**data)
errors = exc_info.value.errors()
assert len(errors) == 1
assert errors[0]["loc"] == ("codes",)
assert errors[0]["type"] == "missing"
def test_coupon_upload_single_code(self):
"""Test coupon upload with single code"""
data = {
"codes": [
{"code": "TEST123", "usage": 0}
]
}
upload = CouponUpload(**data)
assert len(upload.codes) == 1
assert upload.codes[0].code == "TEST123"
assert upload.codes[0].usage == 0
def test_coupon_upload_many_codes(self):
"""Test coupon upload with many codes"""
codes_data = []
for i in range(100):
codes_data.append({"code": f"TEST{i:03d}", "usage": i % 3})
data = {
"codes": codes_data
}
upload = CouponUpload(**data)
assert len(upload.codes) == 100
for i, code_item in enumerate(upload.codes):
assert code_item.code == f"TEST{i:03d}"
assert code_item.usage == i % 3
def test_coupon_upload_with_default_usage(self):
"""Test coupon upload with codes using default usage"""
data = {
"codes": [
{"code": "TEST123"}, # usage not specified
{"code": "TEST456", "usage": 5}
]
}
upload = CouponUpload(**data)
assert len(upload.codes) == 2
assert upload.codes[0].code == "TEST123"
assert upload.codes[0].usage == 0 # Default value
assert upload.codes[1].code == "TEST456"
assert upload.codes[1].usage == 5
def test_coupon_upload_duplicate_codes(self):
"""Test coupon upload with duplicate codes (should be allowed in schema)"""
data = {
"codes": [
{"code": "TEST123", "usage": 0},
{"code": "TEST123", "usage": 1} # Duplicate code
]
}
upload = CouponUpload(**data)
assert len(upload.codes) == 2
assert upload.codes[0].code == "TEST123"
assert upload.codes[0].usage == 0
assert upload.codes[1].code == "TEST123"
assert upload.codes[1].usage == 1
def test_coupon_upload_special_characters(self):
"""Test coupon upload with special characters in codes"""
data = {
"codes": [
{"code": "TEST-123", "usage": 0},
{"code": "TEST_456", "usage": 1},
{"code": "TEST@789", "usage": 2}
]
}
upload = CouponUpload(**data)
assert len(upload.codes) == 3
assert upload.codes[0].code == "TEST-123"
assert upload.codes[1].code == "TEST_456"
assert upload.codes[2].code == "TEST@789"
def test_coupon_upload_unicode_characters(self):
"""Test coupon upload with unicode characters"""
data = {
"codes": [
{"code": "TEST测试123", "usage": 0},
{"code": "TEST测试456", "usage": 1}
]
}
upload = CouponUpload(**data)
assert len(upload.codes) == 2
assert upload.codes[0].code == "TEST测试123"
assert upload.codes[1].code == "TEST测试456"
def test_coupon_upload_model_dump(self):
"""Test coupon upload model serialization"""
data = {
"codes": [
{"code": "TEST123", "usage": 0},
{"code": "TEST456", "usage": 1}
]
}
upload = CouponUpload(**data)
dumped = upload.model_dump()
assert dumped == data
def test_coupon_upload_model_json(self):
"""Test coupon upload model JSON serialization"""
data = {
"codes": [
{"code": "TEST123", "usage": 0},
{"code": "TEST456", "usage": 1}
]
}
upload = CouponUpload(**data)
json_str = upload.model_dump_json()
# Check for presence of fields in JSON (order may vary)
assert "TEST123" in json_str
assert "TEST456" in json_str
assert "0" in json_str
assert "1" in json_str
def test_coupon_upload_invalid_code_item(self):
"""Test coupon upload with invalid code item"""
data = {
"codes": [
{"code": "TEST123", "usage": 0},
{"usage": 1} # Missing code field
]
}
with pytest.raises(ValidationError) as exc_info:
CouponUpload(**data)
errors = exc_info.value.errors()
assert len(errors) >= 1
# Should have error for missing code field in second item

View File

@@ -0,0 +1,373 @@
import pytest
import os
import tempfile
from unittest.mock import patch, MagicMock, mock_open
from fastapi import HTTPException
from fastapi.testclient import TestClient
class TestTranslationRoutes:
"""Test cases for translation file management routes"""
def test_upload_translation_unauthorized(self, client):
"""Test uploading translation file without authentication"""
# Create a mock file
mock_file = MagicMock()
mock_file.filename = "test.xlsx"
mock_file.read.return_value = b"test content"
response = client.post("/upload-translations", files={"file": ("test.xlsx", b"test content", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")})
assert response.status_code == 401
data = response.json()
assert data["detail"] == "Unauthorized"
@patch('routes.auth.os.path.exists')
@patch('routes.auth.os.makedirs')
@patch('builtins.open', new_callable=mock_open)
def test_upload_translation_success(self, mock_file, mock_makedirs, mock_exists, client, auth_headers, temp_translation_dir):
"""Test successful translation file upload"""
# Mock that file doesn't exist initially
mock_exists.return_value = False
# Create a mock file content
file_content = b"test excel content"
response = client.post(
"/upload-translations",
files={"file": ("test_translation.xlsx", file_content, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["message"] == "Translation file uploaded successfully"
assert data["filename"] == "test_translation.xlsx"
# Verify directory creation was attempted
mock_makedirs.assert_called_once()
@patch('routes.auth.os.path.exists')
def test_upload_translation_file_already_exists(self, mock_exists, client, auth_headers):
"""Test uploading translation file when one already exists"""
# Mock that file already exists
mock_exists.return_value = True
file_content = b"test excel content"
response = client.post(
"/upload-translations",
files={"file": ("test_translation.xlsx", file_content, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
headers=auth_headers
)
assert response.status_code == 400
data = response.json()
assert data["detail"] == "A translation file already exists. Please delete it first."
@patch('routes.auth.os.path.exists')
@patch('routes.auth.os.makedirs')
@patch('builtins.open', side_effect=Exception("File write error"))
def test_upload_translation_write_error(self, mock_file, mock_makedirs, mock_exists, client, auth_headers):
"""Test translation upload with file write error"""
mock_exists.return_value = False
file_content = b"test excel content"
response = client.post(
"/upload-translations",
files={"file": ("test_translation.xlsx", file_content, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
headers=auth_headers
)
assert response.status_code == 500
data = response.json()
assert "Upload failed" in data["detail"]
@patch('routes.auth.os.path.exists')
@patch('routes.auth.os.makedirs')
@patch('builtins.open', new_callable=mock_open)
@patch('routes.auth.os.remove')
def test_upload_translation_cleanup_on_error(self, mock_remove, mock_file, mock_makedirs, mock_exists, client, auth_headers):
"""Test cleanup when translation upload fails"""
# Mock that files don't exist initially
mock_exists.return_value = False
# Mock file write to succeed but metadata write to fail
mock_file.side_effect = [
MagicMock(), # Translation file write succeeds
Exception("Metadata write error") # Metadata write fails
]
file_content = b"test excel content"
response = client.post(
"/upload-translations",
files={"file": ("test_translation.xlsx", file_content, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
headers=auth_headers
)
assert response.status_code == 500
# The cleanup should happen in the exception handler, but since we're mocking os.path.exists
# to return False, the cleanup won't be called. This test verifies the error handling works.
def test_delete_translation_unauthorized(self, client):
"""Test deleting translation file without authentication"""
response = client.delete("/delete-translation")
assert response.status_code == 401
data = response.json()
assert data["detail"] == "Unauthorized"
@patch('routes.auth.os.path.exists')
@patch('routes.auth.os.remove')
@patch('routes.auth.os.listdir')
@patch('routes.auth.os.rmdir')
def test_delete_translation_success(self, mock_rmdir, mock_listdir, mock_remove, mock_exists, client, auth_headers):
"""Test successful translation file deletion"""
# Mock that files exist
mock_exists.side_effect = lambda path: "translation.xlsx" in path or "metadata.txt" in path
# Mock empty directory after deletion
mock_listdir.return_value = []
response = client.delete("/delete-translation", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["message"] == "Translation file deleted successfully"
# Verify files were deleted
assert mock_remove.call_count == 2 # Translation file and metadata
@patch('routes.auth.os.path.exists')
def test_delete_translation_not_found(self, mock_exists, client, auth_headers):
"""Test deleting translation file when none exists"""
# Mock that no files exist
mock_exists.return_value = False
response = client.delete("/delete-translation", headers=auth_headers)
assert response.status_code == 404
data = response.json()
assert data["detail"] == "No translation file found"
@patch('routes.auth.os.path.exists')
@patch('routes.auth.os.remove')
@patch('routes.auth.os.listdir')
def test_delete_translation_directory_not_empty(self, mock_listdir, mock_remove, mock_exists, client, auth_headers):
"""Test deletion when directory is not empty after file removal"""
# Mock that files exist
mock_exists.side_effect = lambda path: "translation.xlsx" in path or "metadata.txt" in path
# Mock non-empty directory after deletion
mock_listdir.return_value = ["other_file.txt"]
response = client.delete("/delete-translation", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["message"] == "Translation file deleted successfully"
# Directory should not be removed since it's not empty
assert mock_remove.call_count == 2 # Only files, not directory
def test_download_translation_unauthorized(self, client):
"""Test downloading translation file without authentication"""
response = client.get("/download-translation")
assert response.status_code == 401
data = response.json()
assert data["detail"] == "Unauthorized"
@patch('routes.auth.os.path.exists')
@patch('builtins.open', new_callable=mock_open, read_data=b"test content")
def test_download_translation_success(self, mock_file, mock_exists, client, auth_headers):
"""Test successful translation file download"""
# Mock that file exists
mock_exists.return_value = True
response = client.get("/download-translation", headers=auth_headers)
assert response.status_code == 200
# Check response headers
assert response.headers["content-type"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
assert "attachment" in response.headers["content-disposition"]
# The filename should be in the content disposition header
content_disposition = response.headers["content-disposition"]
assert "filename" in content_disposition
@patch('routes.auth.os.path.exists')
@patch('builtins.open', new_callable=mock_open, read_data=b"test content")
def test_download_translation_with_metadata(self, mock_file, mock_exists, client, auth_headers):
"""Test translation download with metadata filename"""
# Mock that files exist
mock_exists.side_effect = lambda path: True
response = client.get("/download-translation", headers=auth_headers)
assert response.status_code == 200
# Check that we get a valid response with proper headers
assert response.headers["content-type"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
assert "attachment" in response.headers["content-disposition"]
assert "filename" in response.headers["content-disposition"]
@patch('routes.auth.os.path.exists')
def test_download_translation_not_found(self, mock_exists, client, auth_headers):
"""Test downloading translation file when none exists"""
# Mock that file doesn't exist
mock_exists.return_value = False
response = client.get("/download-translation", headers=auth_headers)
assert response.status_code == 404
data = response.json()
assert data["detail"] == "No translation file found"
@patch('routes.auth.os.path.exists')
@patch('builtins.open', side_effect=Exception("File read error"))
def test_download_translation_read_error(self, mock_file, mock_exists, client, auth_headers):
"""Test translation download with file read error"""
mock_exists.return_value = True
# Should raise an exception when file read fails
with pytest.raises(Exception, match="File read error"):
client.get("/download-translation", headers=auth_headers)
def test_check_translation_status_no_file(self, client):
"""Test translation status check when no file exists"""
with patch('routes.auth.os.path.exists') as mock_exists:
mock_exists.return_value = False
response = client.get("/translations/status")
assert response.status_code == 200
data = response.json()
assert data["file_exists"] is False
assert data["file_name"] is None
@patch('routes.auth.os.path.exists')
@patch('builtins.open', new_callable=mock_open, read_data=b"custom_filename.xlsx")
def test_check_translation_status_with_file(self, mock_file, mock_exists, client):
"""Test translation status check when file exists"""
# Mock that files exist
mock_exists.side_effect = lambda path: True
response = client.get("/translations/status")
assert response.status_code == 200
data = response.json()
assert data["file_exists"] is True
assert data["file_name"] == "custom_filename.xlsx"
@patch('routes.auth.os.path.exists')
@patch('builtins.open', side_effect=Exception("Metadata read error"))
def test_check_translation_status_metadata_error(self, mock_file, mock_exists, client):
"""Test translation status check with metadata read error"""
# Mock that files exist
mock_exists.side_effect = lambda path: True
response = client.get("/translations/status")
assert response.status_code == 200
data = response.json()
# Should fall back to default filename
assert data["file_exists"] is True
assert data["file_name"] == "translation.xlsx"
def test_get_latest_translation_no_file(self, client):
"""Test latest translation endpoint when no file exists"""
with patch('routes.auth.os.path.exists') as mock_exists:
mock_exists.return_value = False
response = client.get("/translations/latest")
assert response.status_code == 404
data = response.json()
assert data["detail"] == "No translation file found"
@patch('routes.auth.os.path.exists')
@patch('builtins.open', new_callable=mock_open, read_data=b"test content")
def test_get_latest_translation_success(self, mock_file, mock_exists, client):
"""Test successful latest translation download"""
# Mock that files exist
mock_exists.side_effect = lambda path: True
response = client.get("/translations/latest")
assert response.status_code == 200
# Check response headers
assert response.headers["content-type"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
assert "attachment" in response.headers["content-disposition"]
@patch('routes.auth.os.path.exists')
@patch('builtins.open', new_callable=mock_open, read_data=b"test content")
def test_get_latest_translation_with_metadata(self, mock_file, mock_exists, client):
"""Test latest translation download with metadata filename"""
# Mock that files exist
mock_exists.side_effect = lambda path: True
response = client.get("/translations/latest")
assert response.status_code == 200
# Check that we get a valid response with proper headers
assert response.headers["content-type"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
assert "attachment" in response.headers["content-disposition"]
assert "filename" in response.headers["content-disposition"]
def test_upload_translation_invalid_file_type(self, client, auth_headers):
"""Test uploading non-Excel file"""
file_content = b"not an excel file"
response = client.post(
"/upload-translations",
files={"file": ("test.txt", file_content, "text/plain")},
headers=auth_headers
)
# Should still accept the file since validation is not strict
assert response.status_code in [200, 400] # Depends on implementation
def test_upload_translation_empty_file(self, client, auth_headers):
"""Test uploading empty file"""
with patch('routes.auth.os.path.exists') as mock_exists:
mock_exists.return_value = False
response = client.post(
"/upload-translations",
files={"file": ("empty.xlsx", b"", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["message"] == "Translation file uploaded successfully"
def test_upload_translation_large_file(self, client, auth_headers):
"""Test uploading large file"""
with patch('routes.auth.os.path.exists') as mock_exists:
mock_exists.return_value = False
# Create a large file content (1MB)
large_content = b"x" * (1024 * 1024)
response = client.post(
"/upload-translations",
files={"file": ("large.xlsx", large_content, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["message"] == "Translation file uploaded successfully"
@patch('routes.auth.os.path.exists')
@patch('routes.auth.os.makedirs')
@patch('builtins.open', new_callable=mock_open)
def test_upload_translation_no_filename(self, mock_file, mock_makedirs, mock_exists, client, auth_headers):
"""Test uploading file with minimal filename"""
mock_exists.return_value = False
file_content = b"test content"
response = client.post(
"/upload-translations",
files={"file": ("test.xlsx", file_content, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
headers=auth_headers
)
# Should handle the upload successfully
assert response.status_code == 200
data = response.json()
assert data["filename"] == "test.xlsx"

View File

@@ -0,0 +1,714 @@
"""
Comprehensive test suite for utility modules
Achieves 90% code coverage for all utility functions
"""
import pytest
import os
import string
import random
import tempfile
import shutil
import json
import logging
from datetime import datetime, timezone
from unittest.mock import patch, MagicMock, mock_open, call
import pytz
from sqlalchemy.orm import Session
from sqlalchemy.exc import SQLAlchemyError
import sys
# Import all utility functions
from utils.auth import hash_password, verify_password, get_db, engine, SessionLocal, Base
from utils.coupon_utils import generate_coupon
from utils.timezone_utils import (
get_cest_timezone, get_server_timezone, utc_to_cest, local_to_cest,
format_cest_datetime, now_cest
)
from utils.exceptions import (
APIException, AuthenticationError, AuthorizationError, NotFoundError,
ValidationError, ConflictError, RateLimitError, DatabaseError,
FileUploadError, CouponError, CouponNotFoundError, CouponAlreadyUsedError,
CouponBlockedError, CouponLimitExceededError, FileTypeError, FileSizeError,
FileExistsError, handle_api_exception
)
from utils.logger import setup_logger, get_logger, StructuredFormatter
from utils.template_loader import templates, TEMPLATE_DIR, BASE_DIR, PARENT_DIR
class TestAuthUtils:
"""Test cases for authentication utilities"""
def test_hash_password(self):
"""Test password hashing"""
password = "testpassword123"
hashed = hash_password(password)
assert isinstance(hashed, str)
assert hashed != password
assert len(hashed) > len(password)
def test_hash_password_different_passwords(self):
"""Test that different passwords produce different hashes"""
password1 = "password1"
password2 = "password2"
hash1 = hash_password(password1)
hash2 = hash_password(password2)
assert hash1 != hash2
def test_hash_password_same_password(self):
"""Test that same password produces different hashes (salt)"""
password = "testpassword"
hash1 = hash_password(password)
hash2 = hash_password(password)
# Should be different due to salt
assert hash1 != hash2
def test_verify_password_correct(self):
"""Test password verification with correct password"""
password = "testpassword123"
hashed = hash_password(password)
assert verify_password(password, hashed) is True
def test_verify_password_incorrect(self):
"""Test password verification with incorrect password"""
password = "testpassword123"
wrong_password = "wrongpassword"
hashed = hash_password(password)
assert verify_password(wrong_password, hashed) is False
def test_verify_password_empty_password(self):
"""Test password verification with empty password"""
password = "testpassword123"
hashed = hash_password(password)
assert verify_password("", hashed) is False
def test_verify_password_none_password(self):
"""Test password verification with None password"""
password = "testpassword123"
hashed = hash_password(password)
# Passlib raises TypeError for None password
with pytest.raises(TypeError):
verify_password(None, hashed)
def test_get_db_generator(self):
"""Test database session generator"""
# Test that get_db is a generator function
db_gen = get_db()
# Get the first (and only) value
db = next(db_gen)
assert isinstance(db, Session)
# Test that the generator closes properly
try:
next(db_gen)
assert False, "Should have raised StopIteration"
except StopIteration:
pass
def test_engine_creation(self):
"""Test that database engine is created"""
assert engine is not None
def test_session_local_creation(self):
"""Test that SessionLocal is created"""
assert SessionLocal is not None
def test_base_declarative_base(self):
"""Test that Base declarative base is created"""
assert Base is not None
class TestCouponUtils:
"""Test cases for coupon utilities"""
def test_generate_coupon_length(self):
"""Test that generated coupon has correct length"""
coupon = generate_coupon()
assert len(coupon) == 10
def test_generate_coupon_characters(self):
"""Test that generated coupon contains valid characters"""
coupon = generate_coupon()
valid_chars = string.ascii_uppercase + string.digits
for char in coupon:
assert char in valid_chars
def test_generate_coupon_uniqueness(self):
"""Test that generated coupons are unique"""
coupons = set()
for _ in range(100):
coupon = generate_coupon()
assert coupon not in coupons
coupons.add(coupon)
def test_generate_coupon_randomness(self):
"""Test that generated coupons are random"""
coupons = [generate_coupon() for _ in range(50)]
# Check that we have some variety in characters
all_chars = ''.join(coupons)
assert len(set(all_chars)) > 10 # Should have variety
@patch('utils.coupon_utils.random.choices')
def test_generate_coupon_calls_random_choices(self, mock_choices):
"""Test that generate_coupon calls random.choices correctly"""
mock_choices.return_value = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J']
coupon = generate_coupon()
mock_choices.assert_called_once_with(string.ascii_uppercase + string.digits, k=10)
assert coupon == "ABCDEFGHIJ"
class TestTimezoneUtils:
"""Test cases for timezone utilities"""
def test_get_cest_timezone(self):
"""Test getting CEST timezone"""
tz = get_cest_timezone()
assert str(tz) == "Europe/Berlin"
def test_get_server_timezone(self):
"""Test getting server timezone"""
tz = get_server_timezone()
assert str(tz) == "Asia/Kolkata"
def test_utc_to_cest_with_timezone_aware(self):
"""Test UTC to CEST conversion with timezone-aware datetime"""
utc_dt = datetime.now(timezone.utc)
cest_dt = utc_to_cest(utc_dt)
assert cest_dt.tzinfo is not None
assert cest_dt.replace(tzinfo=None) != utc_dt.replace(tzinfo=None)
def test_utc_to_cest_with_timezone_naive(self):
"""Test UTC to CEST conversion with timezone-naive datetime"""
naive_dt = datetime.now()
cest_dt = utc_to_cest(naive_dt)
assert cest_dt.tzinfo is not None
assert cest_dt.replace(tzinfo=None) != naive_dt.replace(tzinfo=None)
def test_utc_to_cest_none_input(self):
"""Test UTC to CEST conversion with None input"""
result = utc_to_cest(None)
assert result is None
def test_local_to_cest_with_timezone_aware(self):
"""Test local to CEST conversion with timezone-aware datetime"""
ist_dt = datetime.now(pytz.timezone('Asia/Kolkata'))
cest_dt = local_to_cest(ist_dt)
assert cest_dt.tzinfo is not None
assert cest_dt.replace(tzinfo=None) != ist_dt.replace(tzinfo=None)
def test_local_to_cest_with_timezone_naive(self):
"""Test local to CEST conversion with timezone-naive datetime"""
naive_dt = datetime.now()
cest_dt = local_to_cest(naive_dt)
assert cest_dt.tzinfo is not None
assert cest_dt.replace(tzinfo=None) != naive_dt.replace(tzinfo=None)
def test_local_to_cest_none_input(self):
"""Test local to CEST conversion with None input"""
result = local_to_cest(None)
assert result is None
def test_format_cest_datetime_with_datetime(self):
"""Test formatting datetime to CEST string"""
utc_dt = datetime.now(timezone.utc)
formatted = format_cest_datetime(utc_dt)
assert isinstance(formatted, str)
assert len(formatted) > 0
# Should match format YYYY-MM-DD HH:MM:SS
assert len(formatted.split()) == 2
assert len(formatted.split()[0].split('-')) == 3
assert len(formatted.split()[1].split(':')) == 3
def test_format_cest_datetime_with_custom_format(self):
"""Test formatting datetime with custom format"""
utc_dt = datetime.now(timezone.utc)
formatted = format_cest_datetime(utc_dt, "%Y-%m-%d")
assert isinstance(formatted, str)
assert len(formatted.split('-')) == 3
def test_format_cest_datetime_none_input(self):
"""Test formatting None datetime"""
result = format_cest_datetime(None)
assert result is None
def test_now_cest(self):
"""Test getting current time in CEST"""
now = now_cest()
assert isinstance(now, datetime)
assert now.tzinfo is not None
assert str(now.tzinfo) == "Europe/Berlin"
class TestExceptions:
"""Test cases for custom exceptions"""
def test_api_exception_creation(self):
"""Test creating APIException"""
exc = APIException(
status_code=400,
detail="Test error",
error_code="TEST_ERROR"
)
assert exc.status_code == 400
assert exc.detail == "Test error"
assert exc.error_code == "TEST_ERROR"
assert exc.extra_data == {}
def test_api_exception_with_extra_data(self):
"""Test creating APIException with extra data"""
extra_data = {"field": "value", "count": 42}
exc = APIException(
status_code=422,
detail="Validation error",
error_code="VALIDATION_ERROR",
extra_data=extra_data
)
assert exc.extra_data == extra_data
def test_authentication_error(self):
"""Test AuthenticationError creation"""
exc = AuthenticationError("Custom auth error")
assert exc.status_code == 401
assert exc.error_code == "AUTHENTICATION_ERROR"
assert exc.detail == "Custom auth error"
def test_authorization_error(self):
"""Test AuthorizationError creation"""
exc = AuthorizationError("Custom authz error")
assert exc.status_code == 403
assert exc.error_code == "AUTHORIZATION_ERROR"
assert exc.detail == "Custom authz error"
def test_not_found_error(self):
"""Test NotFoundError creation"""
exc = NotFoundError("User", "User not found")
assert exc.status_code == 404
assert exc.error_code == "NOT_FOUND_ERROR"
assert exc.detail == "User not found"
def test_not_found_error_default_detail(self):
"""Test NotFoundError with default detail"""
exc = NotFoundError("User")
assert exc.status_code == 404
assert exc.detail == "User not found"
def test_validation_error(self):
"""Test ValidationError creation"""
exc = ValidationError("Invalid email", "email")
assert exc.status_code == 422
assert exc.error_code == "VALIDATION_ERROR"
assert exc.detail == "Validation error in field 'email': Invalid email"
def test_validation_error_no_field(self):
"""Test ValidationError without field"""
exc = ValidationError("Invalid data")
assert exc.status_code == 422
assert exc.detail == "Invalid data"
def test_conflict_error(self):
"""Test ConflictError creation"""
exc = ConflictError("Resource already exists")
assert exc.status_code == 409
assert exc.error_code == "CONFLICT_ERROR"
assert exc.detail == "Resource already exists"
def test_rate_limit_error(self):
"""Test RateLimitError creation"""
exc = RateLimitError("Too many requests")
assert exc.status_code == 429
assert exc.error_code == "RATE_LIMIT_ERROR"
assert exc.detail == "Too many requests"
def test_database_error(self):
"""Test DatabaseError creation"""
exc = DatabaseError("Connection failed")
assert exc.status_code == 500
assert exc.error_code == "DATABASE_ERROR"
assert exc.detail == "Connection failed"
def test_file_upload_error(self):
"""Test FileUploadError creation"""
exc = FileUploadError("Upload failed")
assert exc.status_code == 400
assert exc.error_code == "FILE_UPLOAD_ERROR"
assert exc.detail == "Upload failed"
def test_coupon_error(self):
"""Test CouponError creation"""
exc = CouponError("Coupon invalid", "INVALID_COUPON")
assert exc.status_code == 400
assert exc.error_code == "INVALID_COUPON"
assert exc.detail == "Coupon invalid"
def test_coupon_not_found_error(self):
"""Test CouponNotFoundError creation"""
exc = CouponNotFoundError("TEST123")
assert exc.status_code == 404
assert exc.error_code == "NOT_FOUND_ERROR"
assert exc.detail == "Coupon code 'TEST123' not found"
def test_coupon_already_used_error(self):
"""Test CouponAlreadyUsedError creation"""
exc = CouponAlreadyUsedError("TEST123")
assert exc.status_code == 400
assert exc.error_code == "COUPON_ALREADY_USED"
assert exc.detail == "Coupon code 'TEST123' has already been used"
def test_coupon_blocked_error(self):
"""Test CouponBlockedError creation"""
exc = CouponBlockedError("TEST123", 30)
assert exc.status_code == 400
assert exc.error_code == "COUPON_BLOCKED"
assert exc.detail == "Coupon code 'TEST123' is blocked. Try again in 30 minutes"
def test_coupon_limit_exceeded_error(self):
"""Test CouponLimitExceededError creation"""
exc = CouponLimitExceededError("TEST123", 5)
assert exc.status_code == 400
assert exc.error_code == "COUPON_LIMIT_EXCEEDED"
assert exc.detail == "Coupon code 'TEST123' usage limit (5) exceeded"
def test_file_type_error(self):
"""Test FileTypeError creation"""
exc = FileTypeError(["xlsx", "csv"])
assert exc.status_code == 400
assert exc.error_code == "FILE_UPLOAD_ERROR"
assert exc.detail == "Invalid file type. Allowed types: xlsx, csv"
def test_file_size_error(self):
"""Test FileSizeError creation"""
exc = FileSizeError(10)
assert exc.status_code == 400
assert exc.error_code == "FILE_UPLOAD_ERROR"
assert exc.detail == "File too large. Maximum size: 10MB"
def test_file_exists_error(self):
"""Test FileExistsError creation"""
exc = FileExistsError("test.xlsx")
assert exc.status_code == 400
assert exc.error_code == "FILE_UPLOAD_ERROR"
assert exc.detail == "File 'test.xlsx' already exists. Please delete it first."
def test_handle_api_exception(self):
"""Test handle_api_exception function"""
exc = APIException(
status_code=400,
detail="Test error",
error_code="TEST_ERROR",
extra_data={"field": "value"}
)
result = handle_api_exception(exc, "/test/path")
assert result["success"] is False
assert result["error"] == "Test error"
assert result["error_code"] == "TEST_ERROR"
assert result["field"] == "value"
assert result["path"] == "/test/path"
assert result["timestamp"] is None
class TestLogger:
"""Test cases for logging utilities"""
@patch('utils.logger.logging.getLogger')
@patch('utils.logger.logging.handlers.RotatingFileHandler')
@patch('utils.logger.logging.StreamHandler')
@patch('os.makedirs')
def test_setup_logger(self, mock_makedirs, mock_stream_handler, mock_file_handler, mock_get_logger):
"""Test logger setup"""
mock_logger = MagicMock()
mock_logger.handlers = [] # Start with no handlers
mock_get_logger.return_value = mock_logger
logger = setup_logger("test_logger", "DEBUG")
mock_get_logger.assert_called_with("test_logger")
mock_logger.setLevel.assert_called_with(logging.DEBUG)
assert mock_logger.addHandler.call_count >= 1
@patch('utils.logger.logging.getLogger')
def test_get_logger(self, mock_get_logger):
"""Test get_logger function"""
mock_logger = MagicMock()
mock_get_logger.return_value = mock_logger
logger = get_logger("test_logger")
mock_get_logger.assert_called_with("test_logger")
assert logger == mock_logger
def test_structured_formatter(self):
"""Test StructuredFormatter"""
formatter = StructuredFormatter()
# Create a mock log record
record = MagicMock()
record.getMessage.return_value = "Test message"
record.levelname = "INFO"
record.name = "test_logger"
record.module = "test_module"
record.funcName = "test_function"
record.lineno = 42
record.exc_info = None
# Add extra fields
record.request_id = "req123"
record.method = "GET"
record.path = "/test"
record.status_code = 200
record.process_time = 0.1
record.client_ip = "127.0.0.1"
record.user_agent = "test-agent"
record.error = "test error"
record.exception_type = "ValueError"
record.exception_message = "test exception"
record.errors = ["error1", "error2"]
record.app_name = "test_app"
record.version = "1.0.0"
record.environment = "test"
record.debug = True
formatted = formatter.format(record)
# Parse the JSON output
log_data = json.loads(formatted)
assert log_data["message"] == "Test message"
assert log_data["level"] == "INFO"
assert log_data["logger"] == "test_logger"
assert log_data["module"] == "test_module"
assert log_data["function"] == "test_function"
assert log_data["line"] == 42
assert log_data["request_id"] == "req123"
assert log_data["method"] == "GET"
assert log_data["path"] == "/test"
assert log_data["status_code"] == 200
assert log_data["process_time"] == 0.1
assert log_data["client_ip"] == "127.0.0.1"
assert log_data["user_agent"] == "test-agent"
assert log_data["error"] == "test error"
assert log_data["exception_type"] == "ValueError"
assert log_data["exception_message"] == "test exception"
assert log_data["errors"] == ["error1", "error2"]
assert log_data["app_name"] == "test_app"
assert log_data["version"] == "1.0.0"
assert log_data["environment"] == "test"
assert log_data["debug"] is True
def test_structured_formatter_with_exception(self):
"""Test StructuredFormatter with exception info"""
formatter = StructuredFormatter()
# Create a mock log record with exception
record = MagicMock()
record.getMessage.return_value = "Test message"
record.levelname = "ERROR"
record.name = "test_logger"
record.module = "test_module"
record.funcName = "test_function"
record.lineno = 42
record.exc_info = (ValueError, ValueError("Test exception"), None)
# Remove any MagicMock attributes that might cause JSON serialization issues
record.request_id = None
record.method = None
record.path = None
record.status_code = None
record.process_time = None
record.client_ip = None
record.user_agent = None
record.error = None
record.exception_type = None
record.exception_message = None
record.errors = None
record.app_name = None
record.version = None
record.environment = None
record.debug = None
formatted = formatter.format(record)
log_data = json.loads(formatted)
assert log_data["message"] == "Test message"
assert log_data["level"] == "ERROR"
assert "exception" in log_data
class TestTemplateLoader:
"""Test cases for template loader"""
def test_templates_instance(self):
"""Test that templates is created"""
assert templates is not None
def test_template_directory_path(self):
"""Test template directory path"""
assert TEMPLATE_DIR is not None
assert isinstance(TEMPLATE_DIR, str)
assert "admin-frontend" in TEMPLATE_DIR
def test_base_dir_path(self):
"""Test base directory path"""
assert BASE_DIR is not None
assert isinstance(BASE_DIR, str)
def test_parent_dir_path(self):
"""Test parent directory path"""
assert PARENT_DIR is not None
assert isinstance(PARENT_DIR, str)
class TestDatabaseIntegration:
"""Test cases for database integration"""
def test_database_url_environment(self):
"""Test that DATABASE_URL is set from environment"""
# This test verifies that the environment variable loading works
# The actual URL will depend on the environment
assert hasattr(engine, 'url')
def test_session_local_binding(self):
"""Test that SessionLocal is bound to engine"""
# Create a session and verify it's bound to the engine
session = SessionLocal()
assert session.bind == engine
session.close()
class TestEdgeCases:
"""Test cases for edge cases and error conditions"""
def test_hash_password_special_characters(self):
"""Test password hashing with special characters"""
password = "!@#$%^&*()_+-=[]{}|;':\",./<>?"
hashed = hash_password(password)
assert isinstance(hashed, str)
assert hashed != password
def test_hash_password_unicode(self):
"""Test password hashing with unicode characters"""
password = "测试密码123"
hashed = hash_password(password)
assert isinstance(hashed, str)
assert hashed != password
def test_verify_password_empty_hash(self):
"""Test password verification with empty hash"""
# Passlib raises UnknownHashError for empty hash
with pytest.raises(Exception): # UnknownHashError
verify_password("password", "")
def test_verify_password_none_hash(self):
"""Test password verification with None hash"""
assert verify_password("password", None) is False
def test_generate_coupon_edge_cases(self):
"""Test coupon generation edge cases"""
# Test multiple generations for uniqueness
coupons = set()
for _ in range(1000):
coupon = generate_coupon()
assert len(coupon) == 10
assert coupon not in coupons
coupons.add(coupon)
def test_timezone_edge_cases(self):
"""Test timezone utilities edge cases"""
# Test with very old date
old_date = datetime(1900, 1, 1)
cest_old = utc_to_cest(old_date)
assert cest_old.tzinfo is not None
# Test with very future date
future_date = datetime(2100, 12, 31)
cest_future = utc_to_cest(future_date)
assert cest_future.tzinfo is not None
def test_exception_edge_cases(self):
"""Test exception edge cases"""
# Test APIException with empty extra_data
exc = APIException(400, "test", "TEST", {})
assert exc.extra_data == {}
# Test with None extra_data
exc = APIException(400, "test", "TEST", None)
assert exc.extra_data == {}
def test_logger_edge_cases(self):
"""Test logger edge cases"""
# Test setup_logger with invalid level
with patch('utils.logger.logging.getLogger') as mock_get_logger:
mock_logger = MagicMock()
mock_get_logger.return_value = mock_logger
# Should handle invalid level gracefully
with pytest.raises(AttributeError):
setup_logger("test", "INVALID_LEVEL")
class TestPerformance:
"""Test cases for performance and stress testing"""
def test_password_hashing_performance(self):
"""Test password hashing performance"""
import time
start_time = time.time()
for _ in range(10): # Reduced from 100 to 10 for faster test
hash_password("testpassword123")
end_time = time.time()
# Should complete in reasonable time (less than 10 seconds)
assert end_time - start_time < 10.0
def test_coupon_generation_performance(self):
"""Test coupon generation performance"""
import time
start_time = time.time()
coupons = [generate_coupon() for _ in range(1000)]
end_time = time.time()
# Should complete in reasonable time (less than 1 second)
assert end_time - start_time < 1.0
# All should be unique
assert len(set(coupons)) == 1000
def test_timezone_conversion_performance(self):
"""Test timezone conversion performance"""
import time
start_time = time.time()
for _ in range(1000):
utc_to_cest(datetime.now())
end_time = time.time()
# Should complete in reasonable time (less than 1 second)
assert end_time - start_time < 1.0

View File

@@ -0,0 +1 @@
demo (1).xlsx

View File

@@ -0,0 +1,30 @@
import os
from dotenv import load_dotenv
from sqlalchemy.orm import sessionmaker
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base
from passlib.context import CryptContext
# Load environment variables
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/postgres")
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine)
Base = declarative_base()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def hash_password(pw: str) -> str:
return pwd_context.hash(pw)
def verify_password(pw: str, hashed: str) -> bool:
return pwd_context.verify(pw, hashed)

View File

@@ -0,0 +1,8 @@
import random
import string
# def generate_coupon(length: int = 6) -> str:
# return ''.join(random.choices(string.ascii_uppercase + string.digits, k=length))
def generate_coupon():
return ''.join(random.choices(string.ascii_uppercase + string.digits, k=10))

View File

@@ -0,0 +1,211 @@
"""
Custom exceptions for the Ebook Coupon Management System
Provides structured error handling with proper error codes and messages.
"""
from typing import Dict, Any, Optional
from fastapi import HTTPException
class APIException(HTTPException):
"""Base API exception with structured error information"""
def __init__(
self,
status_code: int,
detail: str,
error_code: str,
extra_data: Optional[Dict[str, Any]] = None
):
super().__init__(status_code=status_code, detail=detail)
self.error_code = error_code
self.extra_data = extra_data or {}
class AuthenticationError(APIException):
"""Authentication related errors"""
def __init__(self, detail: str = "Authentication failed"):
super().__init__(
status_code=401,
detail=detail,
error_code="AUTHENTICATION_ERROR"
)
class AuthorizationError(APIException):
"""Authorization related errors"""
def __init__(self, detail: str = "Access denied"):
super().__init__(
status_code=403,
detail=detail,
error_code="AUTHORIZATION_ERROR"
)
class NotFoundError(APIException):
"""Resource not found errors"""
def __init__(self, resource: str, detail: Optional[str] = None):
if detail is None:
detail = f"{resource} not found"
super().__init__(
status_code=404,
detail=detail,
error_code="NOT_FOUND_ERROR"
)
class ValidationError(APIException):
"""Validation related errors"""
def __init__(self, detail: str, field: Optional[str] = None):
if field:
detail = f"Validation error in field '{field}': {detail}"
super().__init__(
status_code=422,
detail=detail,
error_code="VALIDATION_ERROR"
)
class ConflictError(APIException):
"""Resource conflict errors"""
def __init__(self, detail: str):
super().__init__(
status_code=409,
detail=detail,
error_code="CONFLICT_ERROR"
)
class RateLimitError(APIException):
"""Rate limiting errors"""
def __init__(self, detail: str = "Rate limit exceeded"):
super().__init__(
status_code=429,
detail=detail,
error_code="RATE_LIMIT_ERROR"
)
class DatabaseError(APIException):
"""Database related errors"""
def __init__(self, detail: str = "Database operation failed"):
super().__init__(
status_code=500,
detail=detail,
error_code="DATABASE_ERROR"
)
class FileUploadError(APIException):
"""File upload related errors"""
def __init__(self, detail: str):
super().__init__(
status_code=400,
detail=detail,
error_code="FILE_UPLOAD_ERROR"
)
class CouponError(APIException):
"""Coupon related errors"""
def __init__(self, detail: str, error_code: str = "COUPON_ERROR"):
super().__init__(
status_code=400,
detail=detail,
error_code=error_code
)
def handle_api_exception(exc: APIException, path: str) -> Dict[str, Any]:
"""
Handle API exception and return structured error response
Args:
exc: API exception instance
path: Request path
Returns:
Structured error response
"""
return {
"success": False,
"error": exc.detail,
"error_code": exc.error_code,
"timestamp": None, # Will be set by exception handler
"path": path,
**exc.extra_data
}
# Coupon specific exceptions
class CouponNotFoundError(NotFoundError):
"""Coupon not found error"""
def __init__(self, code: str):
super().__init__("coupon", f"Coupon code '{code}' not found")
class CouponAlreadyUsedError(CouponError):
"""Coupon already used error"""
def __init__(self, code: str):
super().__init__(
f"Coupon code '{code}' has already been used",
"COUPON_ALREADY_USED"
)
class CouponBlockedError(CouponError):
"""Coupon blocked error"""
def __init__(self, code: str, remaining_minutes: int):
super().__init__(
f"Coupon code '{code}' is blocked. Try again in {remaining_minutes} minutes",
"COUPON_BLOCKED"
)
class CouponLimitExceededError(CouponError):
"""Coupon usage limit exceeded error"""
def __init__(self, code: str, limit: int):
super().__init__(
f"Coupon code '{code}' usage limit ({limit}) exceeded",
"COUPON_LIMIT_EXCEEDED"
)
# File upload specific exceptions
class FileTypeError(FileUploadError):
"""Invalid file type error"""
def __init__(self, allowed_types: list):
super().__init__(
f"Invalid file type. Allowed types: {', '.join(allowed_types)}"
)
class FileSizeError(FileUploadError):
"""File too large error"""
def __init__(self, max_size_mb: int):
super().__init__(
f"File too large. Maximum size: {max_size_mb}MB"
)
class FileExistsError(FileUploadError):
"""File already exists error"""
def __init__(self, filename: str):
super().__init__(
f"File '{filename}' already exists. Please delete it first."
)

View File

@@ -0,0 +1,157 @@
"""
Professional logging utility for the Ebook Coupon Management System
Provides structured logging with proper formatting and log levels.
"""
import logging
import logging.handlers
import os
import sys
from datetime import datetime
from typing import Optional, Any, Dict
import json
class SafeJSONEncoder(json.JSONEncoder):
"""Custom JSON encoder that handles non-serializable objects safely"""
def default(self, obj):
"""Handle non-serializable objects by converting them to strings"""
if hasattr(obj, '__dict__'):
return str(obj)
elif hasattr(obj, '__str__'):
return str(obj)
else:
return f"<{type(obj).__name__} object>"
class StructuredFormatter(logging.Formatter):
"""Custom formatter for structured logging"""
def format(self, record: logging.LogRecord) -> str:
"""Format log record with structured data"""
log_entry = {
"timestamp": datetime.utcnow().isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"module": record.module,
"function": record.funcName,
"line": record.lineno
}
# Add extra fields if present
if hasattr(record, 'request_id'):
log_entry['request_id'] = record.request_id
if hasattr(record, 'method'):
log_entry['method'] = record.method
if hasattr(record, 'path'):
log_entry['path'] = record.path
if hasattr(record, 'status_code'):
log_entry['status_code'] = record.status_code
if hasattr(record, 'process_time'):
log_entry['process_time'] = record.process_time
if hasattr(record, 'client_ip'):
log_entry['client_ip'] = record.client_ip
if hasattr(record, 'user_agent'):
log_entry['user_agent'] = record.user_agent
if hasattr(record, 'error'):
log_entry['error'] = record.error
if hasattr(record, 'exception_type'):
log_entry['exception_type'] = record.exception_type
if hasattr(record, 'exception_message'):
log_entry['exception_message'] = record.exception_message
if hasattr(record, 'errors'):
# Handle errors list safely
try:
if isinstance(record.errors, list):
log_entry['errors'] = [str(error) if not isinstance(error, (dict, str, int, float, bool)) else error for error in record.errors]
else:
log_entry['errors'] = str(record.errors)
except Exception:
log_entry['errors'] = str(record.errors)
if hasattr(record, 'app_name'):
log_entry['app_name'] = record.app_name
if hasattr(record, 'version'):
log_entry['version'] = record.version
if hasattr(record, 'environment'):
log_entry['environment'] = record.environment
if hasattr(record, 'debug'):
log_entry['debug'] = record.debug
# Add exception info if present
if record.exc_info:
log_entry['exception'] = self.formatException(record.exc_info)
return json.dumps(log_entry, ensure_ascii=False, cls=SafeJSONEncoder)
def setup_logger(name: str, level: Optional[str] = None) -> logging.Logger:
"""
Setup a logger with proper configuration
Args:
name: Logger name
level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
Returns:
Configured logger instance
"""
# Get log level from environment or use default
log_level = level or os.getenv("LOG_LEVEL", "INFO").upper()
# Create logger
logger = logging.getLogger(name)
logger.setLevel(getattr(logging, log_level))
# Avoid duplicate handlers
if logger.handlers:
return logger
# Create formatters
structured_formatter = StructuredFormatter()
console_formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
# Console handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.DEBUG)
console_handler.setFormatter(console_formatter)
logger.addHandler(console_handler)
# File handler for structured logs
log_dir = "logs"
os.makedirs(log_dir, exist_ok=True)
file_handler = logging.handlers.RotatingFileHandler(
os.path.join(log_dir, "app.log"),
maxBytes=10*1024*1024, # 10MB
backupCount=5
)
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(structured_formatter)
logger.addHandler(file_handler)
# Error file handler
error_handler = logging.handlers.RotatingFileHandler(
os.path.join(log_dir, "error.log"),
maxBytes=10*1024*1024, # 10MB
backupCount=5
)
error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(structured_formatter)
logger.addHandler(error_handler)
return logger
def get_logger(name: str) -> logging.Logger:
"""
Get a logger instance
Args:
name: Logger name
Returns:
Logger instance
"""
return logging.getLogger(name)
# Create default logger
default_logger = setup_logger("ebook_coupon_system")

View File

@@ -0,0 +1,8 @@
from fastapi.templating import Jinja2Templates
import os
BASE_DIR = os.path.dirname(__file__)
PARENT_DIR = os.path.abspath(os.path.join(BASE_DIR, "..", ".."))
TEMPLATE_DIR = os.path.join(PARENT_DIR, "admin-frontend")
templates = Jinja2Templates(directory=TEMPLATE_DIR)

View File

@@ -0,0 +1,83 @@
"""
Timezone utilities for CEST/CET conversion
"""
from datetime import datetime, timezone
import pytz
def get_cest_timezone():
"""Get CEST/CET timezone (Europe/Berlin)"""
return pytz.timezone('Europe/Berlin')
def get_server_timezone():
"""Get server's local timezone (IST)"""
return pytz.timezone('Asia/Kolkata')
def utc_to_cest(utc_datetime):
"""
Convert UTC datetime to CEST/CET timezone
Args:
utc_datetime: UTC datetime object
Returns:
datetime object in CEST/CET timezone
"""
if utc_datetime is None:
return None
# Ensure the datetime is timezone-aware
if utc_datetime.tzinfo is None:
utc_datetime = utc_datetime.replace(tzinfo=timezone.utc)
cest_tz = get_cest_timezone()
return utc_datetime.astimezone(cest_tz)
def local_to_cest(local_datetime):
"""
Convert local server time (IST) to CEST/CET timezone
Args:
local_datetime: Local datetime object (from server)
Returns:
datetime object in CEST/CET timezone
"""
if local_datetime is None:
return None
# First, make the local datetime timezone-aware
ist_tz = get_server_timezone()
if local_datetime.tzinfo is None:
local_datetime = ist_tz.localize(local_datetime)
# Convert to CEST/CET
cest_tz = get_cest_timezone()
return local_datetime.astimezone(cest_tz)
def format_cest_datetime(utc_datetime, format_str="%Y-%m-%d %H:%M:%S"):
"""
Format UTC datetime to CEST/CET timezone string
Args:
utc_datetime: UTC datetime object
format_str: Format string for datetime
Returns:
Formatted string in CEST/CET timezone
"""
if utc_datetime is None:
return None
# Convert local server time to CEST/CET
cest_datetime = local_to_cest(utc_datetime)
return cest_datetime.strftime(format_str)
def now_cest():
"""
Get current time in CEST/CET timezone
Returns:
datetime object in CEST/CET timezone
"""
cest_tz = get_cest_timezone()
return datetime.now(cest_tz)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,703 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
/**
* Global reset and base styles
* Removes default margins, padding, and sets consistent box-sizing
*/
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/**
* Main body container styles
* Creates centered layout with background gradient and subtle pattern
*/
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #a970db;
position: relative;
overflow: hidden;
}
/**
* Subtle background pattern overlay
* Creates a sophisticated textured background effect
*/
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 25% 25%, #e2e8f0 0%, transparent 50%),
radial-gradient(circle at 75% 75%, #f1f5f9 0%, transparent 50%);
background-size: 800px 800px;
opacity: 0.3;
z-index: 1;
}
/**
* Main login container
* White card container with shadow and rounded corners
*/
.container {
background: white;
border: 1px solid #e2e8f0;
padding: 3rem;
border-radius: 16px;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
width: 100%;
max-width: 420px;
position: relative;
z-index: 10;
transition: all 0.3s ease;
}
/**
* Container hover effect
* Enhances shadow on hover for interactive feel
*/
.container:hover {
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
/**
* Admin header section styles
* Contains logo and title information
*/
.admin-header {
text-align: center;
margin-bottom: 2.5rem;
}
/**
* Admin logo styles
* Creates circular logo with lightning bolt icon
*/
.admin-logo {
width: 64px;
height: 64px;
background: #3b82f6;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
/**
* Logo icon (lightning bolt emoji)
* Displayed within the logo container
*/
.admin-logo::before {
content: '⚡';
font-size: 28px;
color: white;
}
/**
* Main admin title
* Large, bold heading for the admin panel
*/
.admin-title {
color: #1e293b;
font-size: 28px;
font-weight: 700;
margin-bottom: 0.5rem;
letter-spacing: -0.025em;
}
/**
* Admin subtitle
* Descriptive text below the main title
*/
.admin-subtitle {
color: #64748b;
font-size: 14px;
font-weight: 400;
}
/**
* Form base styles
* Hidden by default, shown with transitions
*/
.form {
display: none;
opacity: 0;
transform: translateY(20px);
transition: all 0.4s ease;
}
/**
* Active form state
* Makes form visible with smooth animation
*/
.form.active {
display: block;
opacity: 1;
transform: translateY(0);
}
/**
* Form heading styles
* Secondary title for login form
*/
.form h2 {
color: #1e293b;
font-size: 24px;
font-weight: 600;
text-align: center;
margin-bottom: 2rem;
letter-spacing: -0.025em;
}
/**
* Form group container
* Wraps label and input combinations
*/
.form-group {
margin-bottom: 1.5rem;
}
/**
* Form label styles
* Consistent styling for all form labels
*/
label {
display: block;
margin-bottom: 0.5rem;
color: #374151;
font-weight: 500;
font-size: 14px;
}
/**
* Input container for relative positioning
* Allows for absolute positioned icons
*/
.input-container {
position: relative;
}
/**
* Input field base styles
* Consistent styling for text and password inputs
*/
input {
width: 100%;
padding: 0.875rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 8px;
box-sizing: border-box;
background: white;
color: #1e293b;
font-size: 16px;
font-weight: 400;
transition: all 0.2s ease;
font-family: inherit;
}
/**
* Input placeholder text styling
*/
input::placeholder {
color: #94a3b8;
}
/**
* Input focus state
* Blue border and shadow when focused
*/
input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/**
* Input hover state
* Subtle border color change on hover
*/
input:hover {
border-color: #cbd5e1;
}
/**
* Input icon positioning
* Icons displayed inside input fields
*/
.input-icon {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
font-size: 16px;
color: #94a3b8;
transition: color 0.2s ease;
}
/**
* Input icon focus state
* Changes color when input is focused
*/
input:focus + .input-icon {
color: #3b82f6;
}
/**
* Button base styles
* Primary action button styling
*/
button {
width: 100%;
padding: 0.875rem 1rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
margin-bottom: 1rem;
font-size: 16px;
font-weight: 600;
transition: all 0.2s ease;
font-family: inherit;
}
/**
* Button hover state
* Darker background and subtle lift effect
*/
button:hover {
background: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
/**
* Button active state
* Returns to original position when clicked
*/
button:active {
transform: translateY(0);
}
/**
* Button focus state
* Accessibility focus outline
*/
button:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
}
/**
* Navigation links container
* Flex layout for spacing links
*/
.nav-links {
display: flex;
justify-content: space-between;
margin-top: 1.5rem;
font-size: 14px;
}
/**
* Navigation link styles
* Subtle styling for secondary actions
*/
.nav-links a {
color: #64748b;
text-decoration: none;
cursor: pointer;
padding: 8px 12px;
border-radius: 6px;
transition: all 0.2s ease;
font-weight: 500;
}
/**
* Navigation link hover state
* Background and color change on hover
*/
.nav-links a:hover {
background: #f1f5f9;
color: #3b82f6;
}
/**
* Message base styles (error and success)
* Common styling for feedback messages
*/
.error, .success {
margin-top: 1rem;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
text-align: center;
opacity: 0;
transform: translateY(10px);
transition: all 0.3s ease;
}
/**
* Message visibility state
* Shows messages with animation when they have content
*/
.error:not(:empty), .success:not(:empty) {
opacity: 1;
transform: translateY(0);
}
/**
* Error message styling
* Red color scheme for error states
*/
.error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
}
/**
* Success message styling
* Green color scheme for success states
*/
.success {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #16a34a;
}
/**
* Loading button state
* Disables interaction and hides text
*/
.loading {
position: relative;
pointer-events: none;
color: transparent;
}
/**
* Loading spinner animation
* Circular spinner overlay for loading states
*/
.loading::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
margin: -10px 0 0 -10px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s linear infinite;
}
/**
* Spinner rotation keyframe animation
* Creates smooth spinning effect
*/
@keyframes spin {
to { transform: rotate(360deg); }
}
/**
* Tablet responsive styles
* Adjustments for medium-sized screens
*/
@media (max-width: 768px) {
body {
padding: 15px;
}
.container {
padding: 2rem 1.5rem;
max-width: 100%;
margin: 0;
}
.admin-title {
font-size: 28px;
}
.admin-subtitle {
font-size: 14px;
}
.form h2 {
font-size: 22px;
}
.input-container input {
padding: 12px 16px;
font-size: 16px;
}
button {
padding: 14px 24px;
font-size: 16px;
}
}
/**
* Mobile responsive styles
* Optimizations for small screens
*/
@media (max-width: 480px) {
body {
padding: 10px;
}
.container {
padding: 1.5rem 1rem;
max-width: 100%;
border-radius: 16px;
}
.admin-title {
font-size: 24px;
}
.admin-subtitle {
font-size: 13px;
}
.form h2 {
font-size: 20px;
}
.input-container input {
padding: 10px 14px;
font-size: 16px;
}
button {
padding: 12px 20px;
font-size: 16px;
width: 100%;
}
.form-group {
margin-bottom: 1rem;
}
}
/**
* Extra small mobile responsive styles
* Further optimizations for very small screens
*/
@media (max-width: 360px) {
.container {
padding: 1rem 0.8rem;
}
.admin-title {
font-size: 22px;
}
.form h2 {
font-size: 18px;
}
.input-container input {
padding: 8px 12px;
}
button {
padding: 10px 16px;
}
}
/**
* Landscape orientation responsive styles
* Adjustments for landscape mobile devices
*/
@media (max-height: 500px) and (orientation: landscape) {
body {
padding: 10px;
}
.container {
padding: 1rem 1.5rem;
max-height: 90vh;
overflow-y: auto;
}
.admin-header {
margin-bottom: 1rem;
}
.admin-title {
font-size: 20px;
}
.admin-subtitle {
font-size: 12px;
}
}
/**
* Enhanced accessibility focus styles
* Consistent focus indicators for keyboard navigation
*/
input:focus,
button:focus,
a:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
/**
* Professional hover effects for container
* Subtle animations and shadow changes
*/
.container {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
/**
* Logo hover animation
* Smooth upward movement on hover
*/
.admin-logo {
transition: transform 0.2s ease;
}
.admin-logo:hover {
transform: translateY(-2px);
}
/**
* Custom scrollbar styling
* Clean, minimal scrollbar appearance
*/
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/**
* Container entrance animation
* Smooth fade-in and slide-up effect on page load
*/
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.container {
animation: fadeInUp 0.6s ease-out;
}
</style>
</head>
<body>
<!--
Admin Login Interface
This page provides a secure login interface for administrative users.
Features:
- Responsive design that works on all device sizes
- Modern UI with smooth animations and transitions
- Professional branding with logo and consistent typography
- Form validation and loading states
- Accessibility considerations for keyboard navigation
Structure:
1. Admin Header - Logo, title, and subtitle
2. Login Form - Username/password inputs with validation
3. External JavaScript - Handles form submission and validation
-->
<div class="container">
<!-- Admin Header Section -->
<div class="admin-header">
<!-- Animated logo with lightning bolt icon -->
<div class="admin-logo"></div>
<!-- Main page title -->
<h1 class="admin-title">Admin Panel</h1>
<!-- Descriptive subtitle explaining the page purpose -->
<p class="admin-subtitle">Secure access to your administration dashboard</p>
</div>
<!-- Login Form Section -->
<form id="loginForm" class="form active">
<!-- Form title -->
<h2>Admin Login</h2>
<!-- Username Input Group -->
<div class="form-group">
<label for="loginUsername">Username</label>
<div class="input-container">
<!-- Username text input with user icon -->
<input type="text" id="loginUsername" name="username" placeholder="Enter your username" required>
<span class="input-icon">👤</span>
</div>
</div>
<!-- Password Input Group -->
<div class="form-group">
<label for="loginPassword">Password</label>
<div class="input-container">
<!-- Password input with lock icon -->
<input type="password" id="loginPassword" name="password" placeholder="Enter your password" required>
<span class="input-icon">🔒</span>
</div>
</div>
<!-- Submit Button -->
<button type="submit">Login</button>
<!-- Error/Success Message Display Area -->
<p id="loginMessage" class="error"></p>
</form>
</div>
<!-- External JavaScript File -->
<script src="/static/admin_login.js"></script>
</body>
</html>

View File

@@ -0,0 +1,145 @@
/**
* Shows a specific form (by ID) and hides others.
* Also clears any existing success or error messages.
*/
function showForm(formId) {
document.querySelectorAll('.form').forEach(form => {
form.classList.remove('active');
});
// Clear all existing error/success messages
document.querySelectorAll('.error, .success').forEach(msg => {
msg.textContent = '';
});
// Add slight delay for smooth CSS transition
setTimeout(() => {
document.getElementById(formId).classList.add('active');
}, 100);
}
/**
* Displays a message in a specified element with optional success styling.
*/
function showMessage(elementId, message, isSuccess = false) {
const element = document.getElementById(elementId);
element.textContent = message;
element.className = isSuccess ? 'success' : 'error';
}
/**
* Sets loading state for a button (e.g., during form submission).
* Disables the button and clears the text when loading, restores after.
*/
function setButtonLoading(button, isLoading) {
if (isLoading) {
button.classList.add('loading');
button.disabled = true;
button.textContent = '';
} else {
button.classList.remove('loading');
button.disabled = false;
button.textContent = button.getAttribute('data-original-text') || 'Submit';
}
}
// Initialize when the DOM is fully loaded
document.addEventListener('DOMContentLoaded', function() {
/**
* Store original button texts (used to restore text after loading).
*/
document.querySelectorAll('button[type="submit"]').forEach(btn => {
btn.setAttribute('data-original-text', btn.textContent);
});
/**
* Handles admin login form submission.
* Sends login data to server and displays result or error messages.
*/
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = e.target.querySelector('button[type="submit"]');
setButtonLoading(submitBtn, true);
const username = document.getElementById('loginUsername').value;
const password = document.getElementById('loginPassword').value;
try {
const response = await fetch('/admin/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password })
});
if (response.ok) {
showMessage('loginMessage', 'Login successful! Redirecting...', true);
setTimeout(() => {
window.location.href = '/';
}, 1500);
} else {
const data = await response.json();
showMessage('loginMessage', data.detail || 'Login failed');
}
} catch (error) {
showMessage('loginMessage', 'An error occurred');
} finally {
setButtonLoading(submitBtn, false);
submitBtn.textContent = 'Login';
}
});
/**
* Adds a ripple click effect to all buttons.
* Creates a circle animation where the button is clicked.
*/
document.querySelectorAll('button').forEach(button => {
button.addEventListener('click', function(e) {
const ripple = document.createElement('div');
const rect = this.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
const x = e.clientX - rect.left - size / 2;
const y = e.clientY - rect.top - size / 2;
ripple.style.cssText = `
position: absolute;
width: ${size}px;
height: ${size}px;
background: rgba(255,255,255,0.3);
border-radius: 50%;
transform: scale(0);
left: ${x}px;
top: ${y}px;
animation: ripple 0.6s ease-out;
pointer-events: none;
`;
this.appendChild(ripple);
setTimeout(() => {
ripple.remove();
}, 600);
});
});
/**
* Injects keyframe animation styles for ripple effect and ensures buttons are styled to allow overflow for the animation.
*/
const style = document.createElement('style');
style.textContent = `
@keyframes ripple {
to {
transform: scale(2);
opacity: 0;
}
}
button {
position: relative;
overflow: hidden;
}
`;
document.head.appendChild(style);
});

View File

@@ -0,0 +1,16 @@
fastapi
uvicorn
sqlalchemy
passlib[bcrypt]
bcrypt==4.0.1
python-jose[cryptography]
pydantic
python-multipart
flask
psycopg2-binary
itsdangerous
pytest
httpx
pytest-postgresql
pytz
python-dotenv==1.0.0

View File

@@ -0,0 +1,117 @@
#!/bin/bash
# =============================================================================
# Ebook Coupon Management System - Startup Script
# =============================================================================
# This script starts the application and automatically initializes the database
# =============================================================================
set -e # Exit on error
echo "======================================================================"
echo " EBOOK COUPON MANAGEMENT SYSTEM - STARTUP"
echo "======================================================================"
echo ""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to print colored messages
print_success() {
echo -e "${GREEN}$1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
print_info() {
echo -e "${NC} $1${NC}"
}
# Check if .env file exists
if [ ! -f ".env" ]; then
print_warning ".env file not found!"
print_info "Copying .env.example to .env..."
if [ -f ".env.example" ]; then
cp .env.example .env
print_success ".env file created from .env.example"
print_warning "Please update .env with your configuration!"
echo ""
read -p "Press Enter to continue or Ctrl+C to exit and configure .env..."
else
print_error ".env.example not found! Cannot create .env file."
exit 1
fi
fi
# Navigate to admin-backend directory
cd admin-backend
print_info "Checking virtual environment..."
# Check if virtual environment exists
if [ ! -d "../.venv" ]; then
print_warning "Virtual environment not found. Creating one..."
cd ..
python3 -m venv .venv
print_success "Virtual environment created"
cd admin-backend
fi
# Activate virtual environment
print_info "Activating virtual environment..."
source ../.venv/bin/activate
print_success "Virtual environment activated"
# Install/update requirements
print_info "Installing/updating dependencies..."
pip install -q --upgrade pip
pip install -q -r ../requirements.txt
print_success "Dependencies installed"
echo ""
echo "======================================================================"
print_info "Starting application server..."
print_info "The database will be automatically initialized on startup:"
print_info " - Tables will be created if they don't exist"
print_info " - Admin user will be created if none exists"
echo "======================================================================"
echo ""
# Check if port is already in use
if lsof -Pi :8000 -sTCP:LISTEN -t >/dev/null 2>&1 ; then
print_warning "Port 8000 is already in use!"
print_info "Killing existing process..."
kill -9 $(lsof -t -i:8000) 2>/dev/null || true
sleep 2
fi
# Start the server
print_success "Starting uvicorn server on http://0.0.0.0:8000"
echo ""
echo "======================================================================"
print_info "Access the application:"
print_info " - API: http://localhost:8000"
print_info " - Health Check: http://localhost:8000/health"
print_info " - API Docs: http://localhost:8000/docs"
print_info " - Admin Login: http://localhost:8000/login"
echo "======================================================================"
echo ""
print_info "Default Admin Credentials (from .env):"
print_info " Username: admin"
print_info " Password: admin123"
echo "======================================================================"
echo ""
# Start uvicorn (init_db.py will run automatically)
uvicorn main:app --reload --host 0.0.0.0 --port 8000