diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..e4ca154
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,61 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+env/
+venv/
+ENV/
+.venv
+
+# Flask
+instance/
+.webassets-cache
+
+# Database
+*.db
+*.sqlite
+*.sqlite3
+
+# Testing
+.pytest_cache/
+.coverage
+htmlcov/
+.tox/
+
+# IDEs
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Git
+.git/
+.gitignore
+
+# Node
+node_modules/
+npm-debug.log
+
+# Docs
+*.md
+docs/
+
+# CI/CD
+.github/
+
+# Logs
+*.log
+
+# Environment
+.env
+.env.local
+
+# Uploads (will be mounted as volume)
+static/uploads/*
\ No newline at end of file
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..bffce8c
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,32 @@
+name: Tests
+
+on:
+ push:
+ branches: [ main, dev ]
+ pull_request:
+ branches: [ main, dev ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ python-version: ['3.9', '3.10', '3.11']
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install Flask Flask-SQLAlchemy Flask-WTF pytest
+
+ - name: Run tests
+ run: |
+ python -m pytest -v
\ No newline at end of file
diff --git a/DOCKER.md b/DOCKER.md
new file mode 100644
index 0000000..a0dd08a
--- /dev/null
+++ b/DOCKER.md
@@ -0,0 +1,328 @@
+# Docker Deployment Guide for BHV
+
+## Prerequisites
+
+- Docker installed ([Get Docker](https://docs.docker.com/get-docker/))
+- Docker Compose installed (comes with Docker Desktop)
+
+## Quick Start
+
+### Run with Docker Compose (Recommended)
+```bash
+# Clone the repository
+git clone https://github.com/KathiraveluLab/BHV.git
+cd BHV
+
+# Build and run
+docker-compose up -d
+
+# View logs
+docker-compose logs -f
+
+# Stop
+docker-compose down
+```
+
+**Access the application:** http://localhost:5000
+
+### Run with Docker Only
+```bash
+# Build image
+docker build -t bhv-app .
+
+# Run container
+docker run -d \
+ -p 5000:5000 \
+ -v $(pwd)/bhv.db:/app/bhv.db \
+ -v $(pwd)/static/uploads:/app/static/uploads \
+ --name bhv \
+ bhv-app
+
+# View logs
+docker logs -f bhv
+
+# Stop
+docker stop bhv
+docker rm bhv
+```
+
+## Features
+
+### Persistent Data
+
+The Docker setup uses volumes to persist:
+- **Database:** `bhv.db` is mounted from host
+- **Uploads:** `static/uploads/` is mounted from host
+
+This means your data survives container restarts!
+
+### Health Checks
+
+The container includes health checks:
+```bash
+# Check container health
+docker ps
+
+# Should show "healthy" status
+```
+
+### Automatic Restart
+
+The container automatically restarts unless explicitly stopped:
+```yaml
+restart: unless-stopped
+```
+
+## Development with Docker
+
+### Live Reload (Development Mode)
+
+For development with live reload:
+```bash
+# Override the command
+docker-compose run --rm -p 5000:5000 web python bhv/app.py
+```
+
+### Run Commands Inside Container
+```bash
+# Open shell
+docker-compose exec web bash
+
+# Run Python console
+docker-compose exec web python
+
+# Create admin user
+docker-compose exec web python -c "
+from bhv.app import create_app, db, User
+app = create_app()
+with app.app_context():
+ user = User.query.filter_by(username='admin').first()
+ if user:
+ user.is_admin = True
+ db.session.commit()
+ print('Admin created!')
+"
+```
+
+## Troubleshooting
+
+### Port Already in Use
+
+If port 5000 is taken, change it in `docker-compose.yml`:
+```yaml
+ports:
+ - "8080:5000" # Use port 8080 instead
+```
+
+### Permission Issues
+
+On Linux/Mac, you might need to fix permissions:
+```bash
+sudo chown -R $USER:$USER bhv.db static/uploads
+```
+
+### View Logs
+```bash
+# All logs
+docker-compose logs
+
+# Follow logs
+docker-compose logs -f
+
+# Last 100 lines
+docker-compose logs --tail=100
+```
+
+### Rebuild After Code Changes
+```bash
+# Rebuild and restart
+docker-compose up -d --build
+
+# Force rebuild
+docker-compose build --no-cache
+docker-compose up -d
+```
+
+### Clean Everything
+```bash
+# Stop and remove containers, networks, volumes
+docker-compose down -v
+
+# Remove images
+docker rmi bhv-app
+```
+
+## Production Deployment
+
+### Environment Variables
+
+For production, set these in `docker-compose.yml` or `.env` file:
+```yaml
+environment:
+ - SECRET_KEY=your-super-secret-key-here
+ - FLASK_ENV=production
+ - DATABASE_URL=sqlite:///bhv.db
+```
+
+### Using .env File
+
+Create `.env` file:
+```
+SECRET_KEY=your-secret-key
+FLASK_ENV=production
+```
+
+Then reference in `docker-compose.yml`:
+```yaml
+env_file:
+ - .env
+```
+
+### Behind Nginx (Recommended)
+
+For production, run behind Nginx reverse proxy:
+```nginx
+server {
+ listen 80;
+ server_name your-domain.com;
+
+ location / {
+ proxy_pass http://localhost:5000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+}
+```
+
+### HTTPS with Let's Encrypt
+```bash
+# Install certbot
+sudo apt-get install certbot python3-certbot-nginx
+
+# Get certificate
+sudo certbot --nginx -d your-domain.com
+```
+
+## Performance
+
+### Resource Limits
+
+Add resource limits in `docker-compose.yml`:
+```yaml
+services:
+ web:
+ # ... other config
+ deploy:
+ resources:
+ limits:
+ cpus: '1'
+ memory: 512M
+ reservations:
+ cpus: '0.5'
+ memory: 256M
+```
+
+### Multi-Worker Setup
+
+For high traffic, use Gunicorn:
+
+Update `Dockerfile` CMD:
+```dockerfile
+CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "bhv.app:create_app()"]
+```
+
+Add to `requirements.txt`:
+```
+gunicorn==21.2.0
+```
+
+## Monitoring
+
+### Container Stats
+```bash
+# Real-time stats
+docker stats bhv-app
+
+# Memory usage
+docker stats --no-stream bhv-app
+```
+
+### Logs to File
+```bash
+docker-compose logs > bhv.log
+```
+
+## Backup
+
+### Backup Database and Uploads
+```bash
+# Create backup directory
+mkdir -p backups
+
+# Backup database
+docker cp bhv-app:/app/bhv.db backups/bhv_$(date +%Y%m%d).db
+
+# Backup uploads
+docker cp bhv-app:/app/static/uploads backups/uploads_$(date +%Y%m%d)
+```
+
+### Restore from Backup
+```bash
+# Stop container
+docker-compose down
+
+# Restore database
+cp backups/bhv_20240101.db bhv.db
+
+# Restore uploads
+cp -r backups/uploads_20240101/* static/uploads/
+
+# Start container
+docker-compose up -d
+```
+
+## Security Best Practices
+
+1. **Change default SECRET_KEY**
+2. **Use environment variables** for sensitive data
+3. **Run behind reverse proxy** (Nginx)
+4. **Enable HTTPS** with Let's Encrypt
+5. **Regular backups** of database and uploads
+6. **Update base image** regularly
+7. **Scan for vulnerabilities:** `docker scan bhv-app`
+
+## Commands Reference
+```bash
+# Start
+docker-compose up -d
+
+# Stop
+docker-compose down
+
+# View logs
+docker-compose logs -f
+
+# Rebuild
+docker-compose up -d --build
+
+# Shell access
+docker-compose exec web bash
+
+# Python console
+docker-compose exec web python
+
+# Check health
+docker ps
+```
+
+## Support
+
+For issues:
+1. Check logs: `docker-compose logs`
+2. Verify health: `docker ps`
+3. Rebuild: `docker-compose up -d --build`
+4. Open issue on GitHub
+
+## License
+
+BSD-3-Clause (same as project)
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..56b6e88
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,40 @@
+# Use Python 3.11 slim image
+FROM python:3.11-slim
+
+# Set working directory
+WORKDIR /app
+
+# Set environment variables
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+ENV FLASK_APP=bhv/app.py
+ENV FLASK_ENV=production
+
+# Install system dependencies
+RUN apt-get update && apt-get install -y \
+ gcc \
+ && rm -rf /var/lib/apt/lists/*
+
+# Copy requirements first (for better caching)
+COPY requirements.txt .
+
+# Install Python dependencies
+RUN pip install --no-cache-dir --upgrade pip && \
+ pip install --no-cache-dir -r requirements.txt
+
+# Copy application code
+COPY . .
+
+# Create necessary directories
+RUN mkdir -p static/uploads && \
+ chmod -R 755 static/uploads
+
+# Expose port
+EXPOSE 5000
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
+ CMD python -c "import requests; requests.get('http://localhost:5000/health')" || exit 1
+
+# Run the application
+CMD ["python", "bhv/app.py"]
\ No newline at end of file
diff --git a/README.md b/README.md
index ede6666..5bf6c54 100644
--- a/README.md
+++ b/README.md
@@ -17,3 +17,93 @@ The system should avoid unnecessary bloat to enable easy installation in healthc
The front-end should be kept minimal to allow the entire system to be run from a single command (rather than expecting the front-end, backend, and database to be run separately).
The storage of the images could be in a file system with an index to retrieve them easily. The index itself could be in a database to allow easy queries.
+
+
+# BHV - Behavioral Health Vault
+
+
+
+
+
+> A simplified, secure approach to behavioral health documentation
+
+## Features
+
+✅ Secure image upload with multi-layer validation
+✅ CSRF protection and filename sanitization
+✅ SQLite database for easy deployment
+✅ Automated testing with GitHub Actions
+✅ 11 passing unit tests
+
+## Quick Start
+```bash
+# Install dependencies
+pip install -r requirements.txt
+
+# Run application
+python bhv/app.py
+
+# Open browser
+http://localhost:5000
+```
+
+## Running Tests
+```bash
+# Run all tests
+python -m pytest -v
+
+# Should show: 11 passed
+```
+
+## CI/CD
+
+This project uses GitHub Actions for automated testing on every push and pull request.
+
+## Docker Deployment
+
+BHV can be easily deployed using Docker!
+
+### Quick Start
+```bash
+# Clone and run
+git clone https://github.com/KathiraveluLab/BHV.git
+cd BHV
+docker-compose up -d
+```
+
+**Access:** http://localhost:5000
+
+### What's Included
+
+✅ Pre-configured Docker setup
+✅ Persistent database and uploads
+✅ Automatic health checks
+✅ One-command deployment
+✅ Works on Windows, Mac, Linux
+
+### Full Documentation
+
+See [DOCKER.md](DOCKER.md) for complete Docker deployment guide including:
+- Development setup
+- Production deployment
+- Nginx configuration
+- Backup/restore procedures
+- Troubleshooting
+
+### Requirements
+
+- Docker (Get it: https://docs.docker.com/get-docker/)
+- Docker Compose (included with Docker Desktop)
+
+No Python installation needed! 🐳
+"# Force deploy"
+
+## Security Features
+
+- **Automatic Session Timeout**: Sessions expire after 15 minutes of inactivity (HIPAA §164.312(a)(2)(iii) compliance)
+ - User warning 3 minutes before timeout
+ - Automatic logout on expiration
+ - Activity detection (mouse, keyboard, scroll, touch)
+- **Secure Session Cookies**: HTTPOnly, Secure, and SameSite flags enabled
+- **Password Hashing**: Bcrypt password hashing
+- **CSRF Protection**: Flask-WTF CSRF tokens
\ No newline at end of file
diff --git a/admin_images.txt b/admin_images.txt
new file mode 100644
index 0000000..2253ebf
--- /dev/null
+++ b/admin_images.txt
@@ -0,0 +1,64 @@
+{% extends "base.html" %}
+{% block title %}Manage Images - Admin{% endblock %}
+{% block content %}
+
+
+
+
+
+
+
+
+
Total: {{ images.total }} images
+
+
+ {% for image in images.items %}
+
+
 }})
+
+
{{ image.title }}
+
+ by {{ image.owner.username }}
+
+
{{ image.uploaded_at.strftime('%B %d, %Y') }}
+
{{ (image.file_size / 1024)|round(1) }} KB
+
+
+
+
+ {% endfor %}
+
+
+
+ {% if images.pages > 1 %}
+
+ {% if images.has_prev %}
+
← Previous
+ {% endif %}
+
+
Page {{ images.page }} of {{ images.pages }}
+
+ {% if images.has_next %}
+
Next →
+ {% endif %}
+
+ {% endif %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/admin_users.txt b/admin_users.txt
new file mode 100644
index 0000000..8cfb48c
--- /dev/null
+++ b/admin_users.txt
@@ -0,0 +1,76 @@
+{% extends "base.html" %}
+{% block title %}Manage Users - Admin{% endblock %}
+{% block content %}
+
+
+
+
+
+
+
+
+
Total: {{ users.total }} users
+
+
+
+
+ | ID |
+ Username |
+ Email |
+ Images |
+ Joined |
+ Actions |
+
+
+
+ {% for user in users.items %}
+
+ | {{ user.id }} |
+
+ {{ user.username }}
+ {% if user.is_admin %}Admin{% endif %}
+ |
+ {{ user.email }} |
+ {{ user.images.count() }} |
+ {{ user.created_at.strftime('%Y-%m-%d') }} |
+
+ View
+ {% if user.id != current_user.id %}
+
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+
+ {% if users.pages > 1 %}
+
+ {% if users.has_prev %}
+
← Previous
+ {% endif %}
+
+
Page {{ users.page }} of {{ users.pages }}
+
+ {% if users.has_next %}
+
Next →
+ {% endif %}
+
+ {% endif %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/bhv.db b/bhv.db
new file mode 100644
index 0000000..0d8e293
Binary files /dev/null and b/bhv.db differ
diff --git a/bhv/__init__.py b/bhv/__init__.py
new file mode 100644
index 0000000..a118a02
--- /dev/null
+++ b/bhv/__init__.py
@@ -0,0 +1,34 @@
+from datetime import timedelta
+from flask import session, request, redirect, url_for, flash
+import time
+
+# Add to app configuration
+app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(minutes=15)
+app.config['SESSION_COOKIE_SECURE'] = True # HTTPS only
+app.config['SESSION_COOKIE_HTTPONLY'] = True # Prevent XSS
+app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # CSRF protection
+
+# Session timeout check
+@app.before_request
+def check_session_timeout():
+ """
+ Implement automatic session timeout for HIPAA compliance.
+ Sessions expire after 15 minutes of inactivity (§164.312(a)(2)(iii))
+ """
+ if current_user.is_authenticated:
+ session.permanent = True
+
+ # Check last activity
+ last_activity = session.get('last_activity')
+ if last_activity:
+ # Calculate time since last activity
+ elapsed = time.time() - last_activity
+
+ # Timeout after 15 minutes (900 seconds)
+ if elapsed > 900:
+ logout_user()
+ flash('Your session expired due to inactivity. Please log in again.', 'warning')
+ return redirect(url_for('login'))
+
+ # Update last activity timestamp
+ session['last_activity'] = time.time()
\ No newline at end of file
diff --git a/bhv/admin.py b/bhv/admin.py
new file mode 100644
index 0000000..1dd7f44
--- /dev/null
+++ b/bhv/admin.py
@@ -0,0 +1,178 @@
+"""
+Admin Dashboard Routes
+Provides admin functionality for user and image management
+"""
+from flask import Blueprint, render_template, redirect, url_for, flash, request
+from flask_login import login_required, current_user
+from functools import wraps
+from bhv.app import db, User, Image
+from pathlib import Path
+import os
+
+admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
+
+def admin_required(f):
+ """Decorator to require admin access"""
+ @wraps(f)
+ @login_required
+ def decorated_function(*args, **kwargs):
+ if not current_user.is_admin:
+ flash('You need administrator privileges to access this page.', 'error')
+ return redirect(url_for('index'))
+ return f(*args, **kwargs)
+ return decorated_function
+
+@admin_bp.route('/')
+@admin_required
+def dashboard():
+ """Main admin dashboard"""
+ # Get statistics
+ total_users = User.query.count()
+ total_images = Image.query.count()
+
+ # Calculate total storage used
+ total_storage = db.session.query(db.func.sum(Image.file_size)).scalar() or 0
+ total_storage_mb = round(total_storage / (1024 * 1024), 2)
+
+ # Get recent users
+ recent_users = User.query.order_by(User.created_at.desc()).limit(5).all()
+
+ # Get recent images
+ recent_images = Image.query.order_by(Image.uploaded_at.desc()).limit(10).all()
+
+ # Get user with most uploads
+ top_uploaders = db.session.query(
+ User, db.func.count(Image.id).label('upload_count')
+ ).join(Image).group_by(User.id).order_by(db.text('upload_count DESC')).limit(5).all()
+
+ return render_template('admin/dashboard.html',
+ total_users=total_users,
+ total_images=total_images,
+ total_storage_mb=total_storage_mb,
+ recent_users=recent_users,
+ recent_images=recent_images,
+ top_uploaders=top_uploaders)
+
+@admin_bp.route('/users')
+@admin_required
+def users():
+ """View all users"""
+ page = request.args.get('page', 1, type=int)
+ search = request.args.get('search', '', type=str)
+
+ query = User.query
+
+ if search:
+ query = query.filter(
+ db.or_(
+ User.username.like(f'%{search}%'),
+ User.email.like(f'%{search}%')
+ )
+ )
+
+ users = query.order_by(User.created_at.desc()).paginate(page=page, per_page=20, error_out=False)
+
+ return render_template('admin/users.html', users=users, search=search)
+
+@admin_bp.route('/users/')
+@admin_required
+def user_detail(user_id):
+ """View specific user details"""
+ user = User.query.get_or_404(user_id)
+ user_images = Image.query.filter_by(user_id=user_id).order_by(Image.uploaded_at.desc()).all()
+
+ total_storage = db.session.query(db.func.sum(Image.file_size)).filter_by(user_id=user_id).scalar() or 0
+ total_storage_mb = round(total_storage / (1024 * 1024), 2)
+
+ return render_template('admin/user_detail.html',
+ user=user,
+ images=user_images,
+ total_storage_mb=total_storage_mb)
+
+@admin_bp.route('/users//delete', methods=['POST'])
+@admin_required
+def delete_user(user_id):
+ """Delete a user and all their images"""
+ user = User.query.get_or_404(user_id)
+
+ # Prevent deleting yourself
+ if user.id == current_user.id:
+ flash('You cannot delete your own account!', 'error')
+ return redirect(url_for('admin.users'))
+
+ # Delete all user's image files
+ for image in user.images:
+ try:
+ image_path = Path('static/uploads') / image.filename
+ if image_path.exists():
+ os.remove(image_path)
+ except Exception as e:
+ print(f"Error deleting file {image.filename}: {e}")
+
+ username = user.username
+ db.session.delete(user)
+ db.session.commit()
+
+ flash(f'User {username} and all their images have been deleted.', 'success')
+ return redirect(url_for('admin.users'))
+
+@admin_bp.route('/images')
+@admin_required
+def images():
+ """View all images"""
+ page = request.args.get('page', 1, type=int)
+ search = request.args.get('search', '', type=str)
+
+ query = Image.query
+
+ if search:
+ query = query.filter(
+ db.or_(
+ Image.title.like(f'%{search}%'),
+ Image.description.like(f'%{search}%')
+ )
+ )
+
+ images = query.order_by(Image.uploaded_at.desc()).paginate(page=page, per_page=24, error_out=False)
+
+ return render_template('admin/images.html', images=images, search=search)
+
+@admin_bp.route('/images//delete', methods=['POST'])
+@admin_required
+def delete_image(image_id):
+ """Delete an image"""
+ image = Image.query.get_or_404(image_id)
+
+ # Delete file from disk
+ try:
+ image_path = Path('static/uploads') / image.filename
+ if image_path.exists():
+ os.remove(image_path)
+ except Exception as e:
+ flash(f'Error deleting file: {e}', 'error')
+ return redirect(url_for('admin.images'))
+
+ image_title = image.title
+ db.session.delete(image)
+ db.session.commit()
+
+ flash(f'Image "{image_title}" has been deleted.', 'success')
+ return redirect(url_for('admin.images'))
+
+@admin_bp.route('/users//toggle-admin', methods=['POST'])
+@admin_required
+def toggle_admin(user_id):
+ """Toggle admin status for a user"""
+ user = User.query.get_or_404(user_id)
+
+ # Prevent removing your own admin status
+ if user.id == current_user.id:
+ flash('You cannot change your own admin status!', 'error')
+ return redirect(url_for('admin.users'))
+
+ user.is_admin = not user.is_admin
+ db.session.commit()
+
+ status = "granted" if user.is_admin else "revoked"
+ flash(f'Admin privileges {status} for {user.username}.', 'success')
+ return redirect(url_for('admin.user_detail', user_id=user_id))
\ No newline at end of file
diff --git a/bhv/app.py b/bhv/app.py
new file mode 100644
index 0000000..6a08619
--- /dev/null
+++ b/bhv/app.py
@@ -0,0 +1,636 @@
+import os
+from datetime import datetime, timedelta
+from io import BytesIO, StringIO
+import csv
+import json
+import time
+from flask import jsonify
+from flask import Flask, render_template, request, redirect, url_for, flash, send_file
+from flask_sqlalchemy import SQLAlchemy
+from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
+from werkzeug.security import generate_password_hash, check_password_hash
+from werkzeug.utils import secure_filename
+from functools import wraps
+
+# ============================================
+# FLASK APP CONFIGURATION
+# ============================================
+
+app = Flask(__name__)
+app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
+DATABASE_URL = os.environ.get('DATABASE_URL', 'sqlite:///bhv.db')
+if DATABASE_URL.startswith('postgres://'):
+ DATABASE_URL = DATABASE_URL.replace('postgres://', 'postgresql://', 1)
+app.config['SQLALCHEMY_DATABASE_URI'] = DATABASE_URL
+# Fix for Render PostgreSQL URL
+if app.config['SQLALCHEMY_DATABASE_URI'].startswith('postgres://'):
+ app.config['SQLALCHEMY_DATABASE_URI'] = app.config['SQLALCHEMY_DATABASE_URI'].replace('postgres://', 'postgresql://', 1)
+
+app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
+app.config['UPLOAD_FOLDER'] = os.path.join(os.path.dirname(__file__), 'static', 'uploads')
+app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024 # 5MB max file size
+ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
+
+db = SQLAlchemy(app)
+login_manager = LoginManager(app)
+login_manager.login_view = 'login'
+
+# Ensure upload folder exists
+os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
+
+# ============================================
+# DATABASE MODELS
+# ============================================
+
+class User(UserMixin, db.Model):
+ id = db.Column(db.Integer, primary_key=True)
+ username = db.Column(db.String(80), unique=True, nullable=False)
+ email = db.Column(db.String(120), unique=True, nullable=False)
+ password_hash = db.Column(db.String(200), nullable=False)
+ is_admin = db.Column(db.Boolean, default=False)
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
+ images = db.relationship('Image', backref='owner', lazy=True, cascade='all, delete-orphan')
+
+ def set_password(self, password):
+ self.password_hash = generate_password_hash(password)
+
+ def check_password(self, password):
+ return check_password_hash(self.password_hash, password)
+
+class Image(db.Model):
+ id = db.Column(db.Integer, primary_key=True)
+ title = db.Column(db.String(200), nullable=False)
+ description = db.Column(db.Text)
+ filename = db.Column(db.String(200), nullable=False)
+ file_size = db.Column(db.Integer)
+ uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
+ user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
+
+# ============================================
+# HELPER FUNCTIONS
+# ============================================
+
+@login_manager.user_loader
+def load_user(user_id):
+ return User.query.get(int(user_id))
+
+def admin_required(f):
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ if not current_user.is_authenticated or not current_user.is_admin:
+ flash('Access denied. Admin privileges required.', 'danger')
+ return redirect(url_for('index'))
+ return f(*args, **kwargs)
+ return decorated_function
+
+def allowed_file(filename):
+ return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
+
+def get_unique_filename(filename):
+ name, ext = os.path.splitext(filename)
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
+ return f"{secure_filename(name)}_{timestamp}{ext}"
+
+# ============================================
+# EXPORT HELPER FUNCTIONS
+# ============================================
+
+def generate_user_data_csv(user):
+ """Generate CSV export of user's images"""
+ output = StringIO()
+ writer = csv.writer(output)
+
+ # Write header
+ writer.writerow(['Image Title', 'Description', 'Filename', 'File Size (KB)', 'Upload Date'])
+
+ # Write data
+ for image in user.images:
+ writer.writerow([
+ image.title,
+ image.description or 'No description',
+ image.filename,
+ round(image.file_size / 1024, 2),
+ image.uploaded_at.strftime('%Y-%m-%d %H:%M:%S')
+ ])
+
+ # Convert to bytes
+ output.seek(0)
+ bytes_output = BytesIO()
+ bytes_output.write(output.getvalue().encode('utf-8-sig')) # UTF-8 with BOM for Excel
+ bytes_output.seek(0)
+
+ return bytes_output
+
+def generate_user_data_json(user):
+ """Generate JSON export of user's complete data"""
+ data = {
+ 'user': {
+ 'username': user.username,
+ 'email': user.email,
+ 'member_since': user.created_at.strftime('%Y-%m-%d %H:%M:%S'),
+ 'is_admin': user.is_admin,
+ 'total_images': len(user.images),
+ 'total_storage_mb': round(sum(img.file_size for img in user.images) / (1024 * 1024), 2)
+ },
+ 'images': [
+ {
+ 'id': img.id,
+ 'title': img.title,
+ 'description': img.description or 'No description',
+ 'filename': img.filename,
+ 'file_size_kb': round(img.file_size / 1024, 2),
+ 'uploaded_at': img.uploaded_at.strftime('%Y-%m-%d %H:%M:%S')
+ }
+ for img in user.images
+ ],
+ 'export_metadata': {
+ 'export_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
+ 'export_format': 'JSON',
+ 'version': '1.0'
+ }
+ }
+
+ output = BytesIO()
+ output.write(json.dumps(data, indent=2, ensure_ascii=False).encode('utf-8'))
+ output.seek(0)
+
+ return output
+
+def generate_admin_users_csv():
+ """Generate CSV export of all users (admin only)"""
+ output = StringIO()
+ writer = csv.writer(output)
+
+ # Write header
+ writer.writerow(['Username', 'Email', 'Is Admin', 'Total Images', 'Storage (MB)', 'Member Since'])
+
+ # Write data
+ users = User.query.all()
+ for user in users:
+ total_storage = sum(img.file_size for img in user.images) / (1024 * 1024)
+ writer.writerow([
+ user.username,
+ user.email,
+ 'Yes' if user.is_admin else 'No',
+ len(user.images),
+ round(total_storage, 2),
+ user.created_at.strftime('%Y-%m-%d %H:%M:%S')
+ ])
+
+ # Convert to bytes
+ output.seek(0)
+ bytes_output = BytesIO()
+ bytes_output.write(output.getvalue().encode('utf-8-sig'))
+ bytes_output.seek(0)
+
+ return bytes_output
+
+def generate_admin_images_csv():
+ """Generate CSV export of all images (admin only)"""
+ output = StringIO()
+ writer = csv.writer(output)
+
+ # Write header
+ writer.writerow(['Image ID', 'Title', 'Owner', 'Owner Email', 'Filename', 'File Size (KB)', 'Upload Date'])
+
+ # Write data
+ images = Image.query.order_by(Image.uploaded_at.desc()).all()
+ for image in images:
+ writer.writerow([
+ image.id,
+ image.title,
+ image.owner.username,
+ image.owner.email,
+ image.filename,
+ round(image.file_size / 1024, 2),
+ image.uploaded_at.strftime('%Y-%m-%d %H:%M:%S')
+ ])
+
+ # Convert to bytes
+ output.seek(0)
+ bytes_output = BytesIO()
+ bytes_output.write(output.getvalue().encode('utf-8-sig'))
+ bytes_output.seek(0)
+
+ return bytes_output
+
+# ============================================
+# AUTHENTICATION ROUTES
+# ============================================
+
+@app.route('/')
+def index():
+ return render_template('index.html')
+
+@app.route('/register', methods=['GET', 'POST'])
+def register():
+ if current_user.is_authenticated:
+ return redirect(url_for('profile'))
+
+ if request.method == 'POST':
+ username = request.form.get('username')
+ email = request.form.get('email')
+ password = request.form.get('password')
+ confirm_password = request.form.get('confirm_password')
+
+ if not username or not email or not password:
+ flash('All fields are required.', 'danger')
+ return redirect(url_for('register'))
+
+ if password != confirm_password:
+ flash('Passwords do not match.', 'danger')
+ return redirect(url_for('register'))
+
+ if len(password) < 6:
+ flash('Password must be at least 6 characters long.', 'danger')
+ return redirect(url_for('register'))
+
+ if User.query.filter_by(username=username).first():
+ flash('Username already exists.', 'danger')
+ return redirect(url_for('register'))
+
+ if User.query.filter_by(email=email).first():
+ flash('Email already registered.', 'danger')
+ return redirect(url_for('register'))
+
+ user = User(username=username, email=email)
+ user.set_password(password)
+ db.session.add(user)
+ db.session.commit()
+
+ flash('Registration successful! Please login.', 'success')
+ return redirect(url_for('login'))
+
+ return render_template('register.html')
+
+@app.route('/login', methods=['GET', 'POST'])
+def login():
+ if current_user.is_authenticated:
+ return redirect(url_for('profile'))
+
+ if request.method == 'POST':
+ username = request.form.get('username')
+ password = request.form.get('password')
+
+ if not username or not password:
+ flash('Please enter both username and password.', 'danger')
+ return redirect(url_for('login'))
+
+ user = User.query.filter_by(username=username).first()
+
+ if user and user.check_password(password):
+ login_user(user)
+ next_page = request.args.get('next')
+ return redirect(next_page if next_page else url_for('profile'))
+ else:
+ flash('Invalid username or password.', 'danger')
+ return redirect(url_for('login'))
+
+ return render_template('login.html')
+
+@app.route('/logout')
+@login_required
+def logout():
+ logout_user()
+ flash('You have been logged out.', 'info')
+ return redirect(url_for('index'))
+
+@app.route('/ping')
+@login_required
+def ping():
+ """Keep session alive endpoint for session timeout feature"""
+ return jsonify({'status': 'ok', 'timestamp': time.time()})
+
+# ============================================
+# USER ROUTES
+# ============================================
+
+@app.route('/profile')
+@login_required
+def profile():
+ images = Image.query.filter_by(user_id=current_user.id).order_by(Image.uploaded_at.desc()).all()
+ return render_template('profile.html', images=images)
+
+@app.route('/upload', methods=['GET', 'POST'])
+@login_required
+def upload():
+ if request.method == 'POST':
+ if 'file' not in request.files:
+ flash('No file selected.', 'danger')
+ return redirect(url_for('upload'))
+
+ file = request.files['file']
+ title = request.form.get('title')
+ description = request.form.get('description')
+
+ if file.filename == '':
+ flash('No file selected.', 'danger')
+ return redirect(url_for('upload'))
+
+ if not allowed_file(file.filename):
+ flash('Invalid file type. Only PNG, JPG, JPEG, and GIF are allowed.', 'danger')
+ return redirect(url_for('upload'))
+
+ if not title:
+ flash('Please provide a title for your image.', 'danger')
+ return redirect(url_for('upload'))
+
+ filename = get_unique_filename(file.filename)
+ filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
+ file.save(filepath)
+
+ file_size = os.path.getsize(filepath)
+
+ image = Image(
+ title=title,
+ description=description,
+ filename=filename,
+ file_size=file_size,
+ user_id=current_user.id
+ )
+ db.session.add(image)
+ db.session.commit()
+
+ flash('Image uploaded successfully!', 'success')
+ return redirect(url_for('gallery'))
+
+ return render_template('upload.html')
+
+@app.route('/gallery')
+@login_required
+def gallery():
+ search_query = request.args.get('search', '')
+ sort_by = request.args.get('sort', 'newest')
+
+ query = Image.query.filter_by(user_id=current_user.id)
+ total_count = query.count()
+
+ if search_query:
+ query = query.filter(
+ db.or_(
+ Image.title.ilike(f'%{search_query}%'),
+ Image.description.ilike(f'%{search_query}%')
+ )
+ )
+
+ if sort_by == 'oldest':
+ query = query.order_by(Image.uploaded_at.asc())
+ elif sort_by == 'name':
+ query = query.order_by(Image.title.asc())
+ elif sort_by == 'size':
+ query = query.order_by(Image.file_size.desc())
+ else: # newest
+ query = query.order_by(Image.uploaded_at.desc())
+
+ images = query.all()
+
+ return render_template('gallery.html', images=images, search_query=search_query,
+ sort_by=sort_by, total_count=total_count)
+
+@app.route('/uploads/')
+def serve_upload(filename):
+ from flask import send_from_directory
+ return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
+
+# ============================================
+# EXPORT ROUTES
+# ============================================
+
+@app.route('/export/my-data/csv')
+@login_required
+def export_my_data_csv():
+ """Export user's data as CSV"""
+ try:
+ output = generate_user_data_csv(current_user)
+ filename = f'bhv_my_data_{current_user.username}_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
+
+ return send_file(
+ output,
+ mimetype='text/csv',
+ as_attachment=True,
+ download_name=filename
+ )
+ except Exception as e:
+ flash(f'Error exporting data: {str(e)}', 'danger')
+ return redirect(url_for('profile'))
+
+@app.route('/export/my-data/json')
+@login_required
+def export_my_data_json():
+ """Export user's data as JSON"""
+ try:
+ output = generate_user_data_json(current_user)
+ filename = f'bhv_my_data_{current_user.username}_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
+
+ return send_file(
+ output,
+ mimetype='application/json',
+ as_attachment=True,
+ download_name=filename
+ )
+ except Exception as e:
+ flash(f'Error exporting data: {str(e)}', 'danger')
+ return redirect(url_for('profile'))
+
+@app.route('/admin/export/users')
+@login_required
+@admin_required
+def admin_export_users():
+ """Export all users as CSV (admin only)"""
+ try:
+ output = generate_admin_users_csv()
+ filename = f'bhv_all_users_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
+
+ return send_file(
+ output,
+ mimetype='text/csv',
+ as_attachment=True,
+ download_name=filename
+ )
+ except Exception as e:
+ flash(f'Error exporting users: {str(e)}', 'danger')
+ return redirect(url_for('admin_dashboard'))
+
+@app.route('/admin/export/images')
+@login_required
+@admin_required
+def admin_export_images():
+ """Export all images as CSV (admin only)"""
+ try:
+ output = generate_admin_images_csv()
+ filename = f'bhv_all_images_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
+
+ return send_file(
+ output,
+ mimetype='text/csv',
+ as_attachment=True,
+ download_name=filename
+ )
+ except Exception as e:
+ flash(f'Error exporting images: {str(e)}', 'danger')
+ return redirect(url_for('admin_dashboard'))
+
+# ============================================
+# ADMIN ROUTES
+# ============================================
+
+@app.route('/admin')
+@login_required
+@admin_required
+def admin_dashboard():
+ total_users = User.query.count()
+ total_images = Image.query.count()
+ total_storage = db.session.query(db.func.sum(Image.file_size)).scalar() or 0
+ total_storage_mb = round(total_storage / (1024 * 1024), 2)
+
+ # Top uploaders
+ from sqlalchemy import func
+ top_uploaders = db.session.query(User, func.count(Image.id).label('count'))\
+ .join(Image).group_by(User.id).order_by(func.count(Image.id).desc()).limit(5).all()
+
+ # Recent images
+ recent_images = Image.query.order_by(Image.uploaded_at.desc()).limit(6).all()
+
+ # Uploads over last 7 days
+ dates = [(datetime.now() - timedelta(days=i)).strftime('%b %d') for i in range(6, -1, -1)]
+ uploads_counts = []
+ for i in range(6, -1, -1):
+ date = datetime.now() - timedelta(days=i)
+ count = Image.query.filter(
+ db.func.date(Image.uploaded_at) == date.date()
+ ).count()
+ uploads_counts.append(count)
+
+ # Chart data
+ top_uploaders_names = [u.username for u, _ in top_uploaders]
+ top_uploaders_counts = [count for _, count in top_uploaders]
+
+ # Storage distribution (top 5 users)
+ storage_users = []
+ storage_sizes = []
+ for user, _ in top_uploaders[:5]:
+ storage = sum(img.file_size for img in user.images) / (1024 * 1024)
+ storage_users.append(user.username)
+ storage_sizes.append(round(storage, 2))
+
+ # User activity
+ active_users = User.query.join(Image).distinct().count()
+ inactive_users = total_users - active_users
+ user_activity = [active_users, inactive_users]
+
+ return render_template('admin/dashboard.html',
+ total_users=total_users,
+ total_images=total_images,
+ total_storage_mb=total_storage_mb,
+ top_uploaders=top_uploaders,
+ recent_images=recent_images,
+ uploads_dates=dates,
+ uploads_counts=uploads_counts,
+ top_uploaders_names=top_uploaders_names,
+ top_uploaders_counts=top_uploaders_counts,
+ storage_users=storage_users,
+ storage_sizes=storage_sizes,
+ user_activity=user_activity)
+
+@app.route('/admin/users')
+@login_required
+@admin_required
+def admin_users():
+ users = User.query.all()
+ user_stats = []
+ for user in users:
+ image_count = len(user.images)
+ storage_mb = round(sum(img.file_size for img in user.images) / (1024 * 1024), 2)
+ user_stats.append({
+ 'user': user,
+ 'image_count': image_count,
+ 'storage_mb': storage_mb
+ })
+ return render_template('admin/users.html', user_stats=user_stats)
+
+@app.route('/admin/users//toggle-admin', methods=['POST'])
+@login_required
+@admin_required
+def admin_toggle_admin(user_id):
+ if user_id == current_user.id:
+ flash('You cannot modify your own admin status.', 'danger')
+ return redirect(url_for('admin_users'))
+
+ user = User.query.get_or_404(user_id)
+ user.is_admin = not user.is_admin
+ db.session.commit()
+
+ status = 'granted' if user.is_admin else 'revoked'
+ flash(f'Admin privileges {status} for {user.username}.', 'success')
+ return redirect(url_for('admin_users'))
+
+@app.route('/admin/users//delete', methods=['POST'])
+@login_required
+@admin_required
+def admin_delete_user(user_id):
+ if user_id == current_user.id:
+ flash('You cannot delete your own account.', 'danger')
+ return redirect(url_for('admin_users'))
+
+ user = User.query.get_or_404(user_id)
+
+ # Delete user's image files
+ for image in user.images:
+ filepath = os.path.join(app.config['UPLOAD_FOLDER'], image.filename)
+ if os.path.exists(filepath):
+ os.remove(filepath)
+
+ db.session.delete(user)
+ db.session.commit()
+
+ flash(f'User {user.username} and all their images have been deleted.', 'success')
+ return redirect(url_for('admin_users'))
+
+@app.route('/admin/images')
+@login_required
+@admin_required
+def admin_images():
+ images = Image.query.order_by(Image.uploaded_at.desc()).all()
+ return render_template('admin/images.html', images=images)
+
+@app.route('/admin/images//delete', methods=['POST'])
+@login_required
+@admin_required
+def admin_delete_image(image_id):
+ image = Image.query.get_or_404(image_id)
+
+ filepath = os.path.join(app.config['UPLOAD_FOLDER'], image.filename)
+ if os.path.exists(filepath):
+ os.remove(filepath)
+
+ db.session.delete(image)
+ db.session.commit()
+
+ flash('Image deleted successfully.', 'success')
+ return redirect(url_for('admin_images'))
+
+# ============================================
+# ERROR HANDLERS
+# ============================================
+
+@app.errorhandler(403)
+def forbidden(e):
+ return render_template('errors/403.html'), 403
+
+@app.errorhandler(404)
+def not_found(e):
+ return render_template('errors/404.html'), 404
+
+@app.errorhandler(500)
+def internal_error(e):
+ db.session.rollback()
+ return render_template('errors/500.html'), 500
+
+# ============================================
+# MAIN
+# ============================================
+
+if __name__ == '__main__':
+ with app.app_context():
+ db.create_all()
+ app.run(host='0.0.0.0', port=5000)
\ No newline at end of file
diff --git a/bhv/templates/admin/dashboard.html b/bhv/templates/admin/dashboard.html
new file mode 100644
index 0000000..5d69acb
--- /dev/null
+++ b/bhv/templates/admin/dashboard.html
@@ -0,0 +1,380 @@
+{% extends "base.html" %}
+{% block title %}Admin Dashboard - BHV{% endblock %}
+
+{% block content %}
+
+
+
+
Admin Dashboard
+
System overview and analytics
+
+
+
+
+
+
+
USERS
+
+
Total Users
+
{{ total_users }}
+
+
+
+
+
+
IMGS
+
+
Total Images
+
{{ total_images }}
+
+
+
+
+
+
STOR
+
+
Storage Used
+
{{ total_storage_mb }} MB
+
+
+
+
+
+
AVG
+
+
Avg Images/User
+
+ {% if total_users > 0 %}
+ {{ (total_images / total_users)|round(1) }}
+ {% else %}
+ 0
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
Uploads Over Last 7 Days
+
+
+
+
+
+
Top 5 Uploaders
+
+
+
+
+
+
Storage Distribution
+
+
+
+
+
+
User Activity Status
+
+
+
+
+
+
+
+
+
+
Top Uploaders
+ {% if top_uploaders %}
+
+ {% for user, count in top_uploaders %}
+
+
+
+ {{ loop.index }}
+
+
+
{{ user.username }}
+
{{ user.email }}
+
+
+
+
{{ count }}
+
uploads
+
+
+ {% endfor %}
+
+ {% else %}
+
No uploads yet
+ {% endif %}
+
+
+
+
+
Recent Uploads
+ {% if recent_images %}
+
+ {% for image in recent_images %}
+
+

+
+
{{ image.title }}
+
{{ image.owner.username }}
+
+
+ {% endfor %}
+
+ {% else %}
+
No recent uploads
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/bhv/templates/admin/images.html b/bhv/templates/admin/images.html
new file mode 100644
index 0000000..8d11558
--- /dev/null
+++ b/bhv/templates/admin/images.html
@@ -0,0 +1,55 @@
+{% extends "base.html" %}
+{% block title %}Manage Images - Admin{% endblock %}
+{% block content %}
+
+
+
+
+
+
Total: {{ images|length }} images
+
+
+
+
+ {% if images %}
+
+ {% for image in images %}
+
+

+
+
{{ image.title }}
+
+ by {{ image.owner.username }}
+
+
{{ image.uploaded_at.strftime('%B %d, %Y') }}
+
{{ (image.file_size / 1024)|round(1) }} KB
+
+
+
+
+ {% endfor %}
+
+ {% else %}
+
+
IMAGES
+
No Images Yet
+
Images will appear here once users start uploading.
+
+ {% endif %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/bhv/templates/admin/user_detail.html b/bhv/templates/admin/user_detail.html
new file mode 100644
index 0000000..990bf0e
--- /dev/null
+++ b/bhv/templates/admin/user_detail.html
@@ -0,0 +1,90 @@
+{% extends "base.html" %}
+{% block title %}{{ user.username }} - User Details{% endblock %}
+{% block content %}
+
+
+
+
+
+
+
+
Username
+
{{ user.username }}
+
+
+
+
Email
+
{{ user.email }}
+
+
+
+
Member Since
+
{{ user.created_at.strftime('%B %d, %Y') }}
+
+
+
+
Total Images
+
{{ images|length }}
+
+
+
+
Storage Used
+
{{ total_storage_mb }} MB
+
+
+
+
Admin Status
+
+ {% if user.is_admin %}
+ Admin
+ {% else %}
+ Regular User
+ {% endif %}
+
+
+
+
+
+
+ {% if user.id != current_user.id %}
+
+
+
+ {% endif %}
+
+
+
+
+
User's Uploads
+
+ {% if images %}
+
+ {% for image in images %}
+
+
 }})
+
+
{{ image.title }}
+
{{ image.uploaded_at.strftime('%B %d, %Y') }}
+
+
+
+ {% endfor %}
+
+ {% else %}
+
+
This user hasn't uploaded any images yet.
+
+ {% endif %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/bhv/templates/admin/users.html b/bhv/templates/admin/users.html
new file mode 100644
index 0000000..44e586b
--- /dev/null
+++ b/bhv/templates/admin/users.html
@@ -0,0 +1,87 @@
+{% extends "base.html" %}
+{% block title %}Manage Users - Admin{% endblock %}
+{% block content %}
+
+
+
+
+
+
Total: {{ user_stats|length }} users
+
+
+
+
+ {% if user_stats %}
+
+
+
+ | ID |
+ Username |
+ Email |
+ Images |
+ Storage |
+ Joined |
+ Actions |
+
+
+
+ {% for stat in user_stats %}
+
+ | {{ stat.user.id }} |
+
+ {{ stat.user.username }}
+ {% if stat.user.is_admin %}
+ ADMIN
+ {% endif %}
+ |
+ {{ stat.user.email }} |
+
+
+ {{ stat.image_count }}
+
+ |
+ {{ stat.storage_mb }} MB |
+ {{ stat.user.created_at.strftime('%Y-%m-%d') }} |
+
+
+ {% if stat.user.id != current_user.id %}
+
+
+ {% else %}
+ You (Current Admin)
+ {% endif %}
+
+ |
+
+ {% endfor %}
+
+
+ {% else %}
+
+
USERS
+
No Users Yet
+
Users will appear here once they register.
+
+ {% endif %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/bhv/templates/base.html b/bhv/templates/base.html
new file mode 100644
index 0000000..8722110
--- /dev/null
+++ b/bhv/templates/base.html
@@ -0,0 +1,195 @@
+
+
+
+
+
+ {% block title %}BHV - Biomedical Histology Vault{% endblock %}
+
+
+
+
+
+
+
+ {% with messages = get_flashed_messages(with_categories=true) %}
+ {% if messages %}
+
+ {% for category, message in messages %}
+
{{ message }}
+ {% endfor %}
+
+ {% endif %}
+ {% endwith %}
+
+
+
+ {% block content %}{% endblock %}
+
+ {% if current_user.is_authenticated %}
+
+{% endif %}
+
+
\ No newline at end of file
diff --git a/bhv/templates/errors/403.html b/bhv/templates/errors/403.html
new file mode 100644
index 0000000..959acdb
--- /dev/null
+++ b/bhv/templates/errors/403.html
@@ -0,0 +1,19 @@
+{% extends "base.html" %}
+{% block title %}Access Forbidden - BHV{% endblock %}
+{% block content %}
+
+
+
403
+
Access Forbidden
+
+ You don't have permission to access this resource.
+
+
+ Go to Homepage
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/bhv/templates/errors/404.html b/bhv/templates/errors/404.html
new file mode 100644
index 0000000..3fb9a02
--- /dev/null
+++ b/bhv/templates/errors/404.html
@@ -0,0 +1,19 @@
+{% extends "base.html" %}
+{% block title %}Page Not Found - BHV{% endblock %}
+{% block content %}
+
+
+
404
+
Page Not Found
+
+ The page you are looking for doesn't exist or has been moved.
+
+
+ Go to Homepage
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/bhv/templates/errors/500.html b/bhv/templates/errors/500.html
new file mode 100644
index 0000000..abee4ec
--- /dev/null
+++ b/bhv/templates/errors/500.html
@@ -0,0 +1,19 @@
+{% extends "base.html" %}
+{% block title %}Server Error - BHV{% endblock %}
+{% block content %}
+
+
+
500
+
Internal Server Error
+
+ Something went wrong on our end. We're working to fix it!
+
+
+ Go to Homepage
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/bhv/templates/errors/base_error.html b/bhv/templates/errors/base_error.html
new file mode 100644
index 0000000..bbef996
--- /dev/null
+++ b/bhv/templates/errors/base_error.html
@@ -0,0 +1,150 @@
+
+
+
+
+
+ {% block title %}Error{% endblock %} - BHV
+
+
+
+
+
{% block icon %}❌{% endblock %}
+
{% block code %}ERROR{% endblock %}
+
{% block error_title %}Something Went Wrong{% endblock %}
+
{% block message %}We encountered an error while processing your request.{% endblock %}
+
+
+
+ {% block details %}{% endblock %}
+
+
+
\ No newline at end of file
diff --git a/bhv/templates/gallery.html b/bhv/templates/gallery.html
new file mode 100644
index 0000000..d97df1b
--- /dev/null
+++ b/bhv/templates/gallery.html
@@ -0,0 +1,170 @@
+{% extends "base.html" %}
+{% block title %}My Gallery - BHV{% endblock %}
+{% block content %}
+
+
+
+
+
My Gallery
+
+ {% if search_query %}
+ Found {{ images|length }} of {{ total_count }} images
+ {% else %}
+ Your uploaded images ({{ images|length }} total)
+ {% endif %}
+
+
+
+ Upload New Image
+
+
+
+
+
+
+ {% if search_query %}
+
+
+
Searching for:
+
"{{ search_query }}"
+
X
+
+
+ {% endif %}
+
+
+ {% if images %}
+
+ {% for image in images %}
+
+
+
+

+
+
+ {{ (image.file_size / 1024)|round(1) }} KB
+
+
+
+
+
+ {% if search_query and search_query.lower() in image.title.lower() %}
+ {{ image.title|replace(search_query, '' + search_query + '')|safe }}
+ {% else %}
+ {{ image.title }}
+ {% endif %}
+
+
+ {% if image.description %}
+
+ {% if search_query and search_query.lower() in image.description.lower() %}
+ {% set desc_preview = image.description[:100] %}
+ {{ desc_preview|replace(search_query, '' + search_query + '')|safe }}
+ {% if image.description|length > 100 %}...{% endif %}
+ {% else %}
+ {{ image.description[:100] }}{% if image.description|length > 100 %}...{% endif %}
+ {% endif %}
+
+ {% else %}
+
No description
+ {% endif %}
+
+
+ {{ image.uploaded_at.strftime('%B %d, %Y') }}
+
+
+
+ View Full Size
+
+
+
+ {% endfor %}
+
+ {% else %}
+
+
+ {% if search_query %}
+
SEARCH
+
No images found
+
+ No images match your search for "{{ search_query }}"
+
+
+ View All Images
+
+ {% else %}
+
GALLERY
+
No Images Yet
+
Start building your gallery by uploading your first image!
+
+ Upload Your First Image
+
+ {% endif %}
+
+ {% endif %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/bhv/templates/index.html b/bhv/templates/index.html
new file mode 100644
index 0000000..5cbcd2c
--- /dev/null
+++ b/bhv/templates/index.html
@@ -0,0 +1,105 @@
+{% extends "base.html" %}
+{% block title %}Home - BHV{% endblock %}
+{% block content %}
+
+
+
+
+
+
+
+
+ Welcome to BHV
+
+
+ A secure platform for storing and sharing your behavioral health journey through images and narratives.
+
+
+ {% if current_user.is_authenticated %}
+
+ {% else %}
+
+ {% endif %}
+
+
+
+
+
+ BHV - A simplified approach to behavioral health documentation
+
+
+
+
+
+
Secure Storage
+
Your data is encrypted and stored securely with user authentication and privacy controls.
+
+
+
+
+
Easy Upload
+
Upload images with titles and descriptions in just a few clicks with drag-and-drop support.
+
+
+
+
+
Search & Filter
+
Find images quickly with powerful search and filter capabilities by title, date, and more.
+
+
+
+
+
Admin Analytics
+
Administrators can monitor system usage with interactive charts and detailed analytics.
+
+
+
+ {% if not current_user.is_authenticated %}
+
+
+ Ready to get started?
+
+
+ Please login
+ or register
+ to start using BHV.
+
+
+ {% endif %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/bhv/templates/login.html b/bhv/templates/login.html
new file mode 100644
index 0000000..382757c
--- /dev/null
+++ b/bhv/templates/login.html
@@ -0,0 +1,39 @@
+{% extends "base.html" %}
+{% block title %}Login - BHV{% endblock %}
+{% block content %}
+
+
Login to BHV
+
+
+
+
+ Don't have an account?
+ Register here
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/bhv/templates/profile.html b/bhv/templates/profile.html
new file mode 100644
index 0000000..773e2c4
--- /dev/null
+++ b/bhv/templates/profile.html
@@ -0,0 +1,186 @@
+{% extends "base.html" %}
+{% block title %}My Profile - BHV{% endblock %}
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
{{ current_user.username }}
+
{{ current_user.email }}
+
+
+
Member Since
+
{{ current_user.created_at.strftime('%B %Y') }}
+
+
+
Total Uploads
+
{{ images|length }}
+
+ {% if current_user.is_admin %}
+
+ ADMIN
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Total Images
+
{{ images|length }}
+
+
+
+
+
+
+
+
+
+
+
Storage Used
+
+ {% set total_size = images|sum(attribute='file_size')|default(0) %}
+ {{ (total_size / (1024 * 1024))|round(2) }} MB
+
+
+
+
+
+
+
+
+
+
+
+
Last Upload
+
+ {% if images %}
+ {{ images[0].uploaded_at.strftime('%b %d') }}
+ {% else %}
+ Never
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
Recent Uploads
+
+ {% if images %}
+
+ {% for image in images[:6] %}
+
+

+
+
{{ image.title }}
+
{{ image.uploaded_at.strftime('%B %d, %Y') }}
+
+
+ {% endfor %}
+
+
+ {% if images|length > 6 %}
+
+ {% endif %}
+
+ {% else %}
+
+ {% endif %}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/bhv/templates/register.html b/bhv/templates/register.html
new file mode 100644
index 0000000..8106078
--- /dev/null
+++ b/bhv/templates/register.html
@@ -0,0 +1,59 @@
+{% extends "base.html" %}
+{% block title %}Register - BHV{% endblock %}
+{% block content %}
+
+
Create Account
+
+
+
+
+ Already have an account?
+ Login here
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/bhv/templates/upload.html b/bhv/templates/upload.html
new file mode 100644
index 0000000..bdce39a
--- /dev/null
+++ b/bhv/templates/upload.html
@@ -0,0 +1,144 @@
+{% extends "base.html" %}
+{% block title %}Upload Image - BHV{% endblock %}
+{% block content %}
+
+
+
+
+
+
Upload New Image
+
Share your behavioral health journey
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/bhv/utils/__init__.py b/bhv/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/build.sh b/build.sh
new file mode 100644
index 0000000..a138998
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+# Exit on error
+set -o errexit
+
+echo "=========================================="
+echo "Starting BHV Build Process"
+echo "=========================================="
+
+echo ""
+echo "[1/4] Upgrading pip..."
+pip install --upgrade pip
+
+echo ""
+echo "[2/4] Installing Python dependencies..."
+pip install -r requirements.txt
+
+echo ""
+echo "[3/4] Creating uploads directory..."
+mkdir -p bhv/static/uploads
+echo "✓ Uploads directory ready"
+
+echo ""
+echo "[4/4] Initializing database..."
+python init_db.py
+
+echo ""
+echo "=========================================="
+echo "Build Process Complete!"
+echo "=========================================="
\ No newline at end of file
diff --git a/config.py b/config.py
new file mode 100644
index 0000000..9940726
--- /dev/null
+++ b/config.py
@@ -0,0 +1,28 @@
+import os
+from pathlib import Path
+
+BASE_DIR = Path(__file__).parent
+
+class Config:
+ SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
+ SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or f'sqlite:///{BASE_DIR / "bhv.db"}'
+ SQLALCHEMY_TRACK_MODIFICATIONS = False
+ UPLOAD_FOLDER = BASE_DIR / 'static' / 'uploads'
+ MAX_CONTENT_LENGTH = 5 * 1024 * 1024
+ ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
+ SESSION_COOKIE_SECURE = True
+ SESSION_COOKIE_HTTPONLY = True
+ SESSION_COOKIE_SAMESITE = 'Lax'
+
+class DevelopmentConfig(Config):
+ DEBUG = True
+ SESSION_COOKIE_SECURE = False
+
+class ProductionConfig(Config):
+ DEBUG = False
+
+config = {
+ 'development': DevelopmentConfig,
+ 'production': ProductionConfig,
+ 'default': DevelopmentConfig
+}
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..4a62ec2
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,26 @@
+version: '3.8'
+
+services:
+ web:
+ build: .
+ container_name: bhv-app
+ ports:
+ - "5000:5000"
+ volumes:
+ - ./bhv.db:/app/bhv.db
+ - ./static/uploads:/app/static/uploads
+ environment:
+ - FLASK_APP=bhv/app.py
+ - FLASK_ENV=production
+ - SECRET_KEY=your-secret-key-change-in-production
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
+ interval: 30s
+ timeout: 3s
+ retries: 3
+ start_period: 10s
+
+volumes:
+ bhv_db:
+ bhv_uploads:
\ No newline at end of file
diff --git a/docs/DEPLOYMENT_TROUBLESHOOTING.md b/docs/DEPLOYMENT_TROUBLESHOOTING.md
new file mode 100644
index 0000000..0ae858f
--- /dev/null
+++ b/docs/DEPLOYMENT_TROUBLESHOOTING.md
@@ -0,0 +1,828 @@
+# BHV Deployment Troubleshooting Guide
+
+This guide helps you solve common issues when deploying BHV to production environments like Render.com, Heroku, or your own VPS.
+
+## 📋 Table of Contents
+1. [PostgreSQL Database Issues](#postgresql-database-issues)
+2. [Environment Variables](#environment-variables)
+3. [Static Files & Assets](#static-files--assets)
+4. [Database Migration Errors](#database-migration-errors)
+5. [Port & Binding Issues](#port--binding-issues)
+6. [Memory & Performance](#memory--performance)
+7. [SSL/HTTPS Configuration](#sslhttps-configuration)
+8. [Common Error Messages](#common-error-messages)
+9. [Deployment Checklist](#deployment-checklist)
+10. [Getting Help](#getting-help)
+
+---
+
+## PostgreSQL Database Issues
+
+### Issue: "No module named 'psycopg2'"
+
+**Symptom:** Application crashes on startup with import error
+
+**Error Message:**
+```
+ModuleNotFoundError: No module named 'psycopg2'
+```
+
+**Solution:**
+```bash
+pip install psycopg2-binary
+```
+
+Add to `requirements.txt`:
+```
+psycopg2-binary==2.9.9
+```
+
+---
+
+### Issue: Invalid PostgreSQL URL Format ⭐ COMMON
+
+**Symptom:** `OperationalError: could not connect to server`
+
+**Problem:**
+Render.com and Heroku provide database URLs starting with `postgres://`, but SQLAlchemy 1.4+ requires `postgresql://` (note the "ql" at the end).
+
+**Error Message:**
+```
+sqlalchemy.exc.NoSuchModuleError: Can't load plugin: sqlalchemy.dialects:postgres
+```
+
+**Solution 1 (Recommended - Code Fix):**
+
+Add this to your `app.py` or config file:
+```python
+import os
+
+# Get database URL from environment
+database_url = os.environ.get('DATABASE_URL')
+
+# Fix postgres:// to postgresql://
+if database_url and database_url.startswith('postgres://'):
+ database_url = database_url.replace('postgres://', 'postgresql://', 1)
+
+# Use the fixed URL
+app.config['SQLALCHEMY_DATABASE_URI'] = database_url
+```
+
+**Solution 2 (Environment Variable):**
+
+In your Render.com or Heroku dashboard, manually edit the `DATABASE_URL`:
+
+Change:
+```
+postgres://user:password@host:5432/dbname
+```
+
+To:
+```
+postgresql://user:password@host:5432/dbname
+```
+
+---
+
+### Issue: Database Connection Pool Exhausted
+
+**Symptom:** `QueuePool limit of size X overflow Y reached`
+
+**Error Message:**
+```
+sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached
+```
+
+**Solution:**
+
+Configure connection pooling in your app:
+```python
+# Add to app configuration
+app.config['SQLALCHEMY_POOL_SIZE'] = 10
+app.config['SQLALCHEMY_POOL_RECYCLE'] = 3600
+app.config['SQLALCHEMY_MAX_OVERFLOW'] = 20
+app.config['SQLALCHEMY_POOL_TIMEOUT'] = 30
+```
+
+---
+
+### Issue: SSL Certificate Required
+
+**Symptom:** `sslmode=require` error when connecting to database
+
+**Solution:**
+
+Add SSL mode to database URL:
+```python
+# For Render.com PostgreSQL
+database_url = os.environ.get('DATABASE_URL')
+if database_url:
+ database_url += '?sslmode=require'
+```
+
+---
+
+## Environment Variables
+
+### Required Environment Variables
+
+Create a `.env` file for local development:
+```bash
+# Database
+DATABASE_URL=postgresql://localhost/bhv_dev
+
+# Flask Configuration
+FLASK_APP=app.py
+FLASK_ENV=development
+SECRET_KEY=your-secret-key-here-change-this
+
+# Upload Configuration
+UPLOAD_FOLDER=uploads
+MAX_CONTENT_LENGTH=16777216
+
+# Security (Production)
+SESSION_COOKIE_SECURE=True
+SESSION_COOKIE_HTTPONLY=True
+```
+
+### Generating a Secret Key
+
+**Never use a simple string in production!** Generate a secure secret key:
+```bash
+python -c "import secrets; print(secrets.token_hex(32))"
+```
+
+Copy the output and use it as your `SECRET_KEY`.
+
+---
+
+### Render.com Environment Variables Setup
+
+In your Render.com dashboard:
+
+1. Go to your web service
+2. Click "Environment" tab
+3. Add these variables:
+
+| Key | Value | Notes |
+|-----|-------|-------|
+| `DATABASE_URL` | (auto-provided) | Don't change this |
+| `SECRET_KEY` | (generated above) | Use `secrets.token_hex(32)` |
+| `FLASK_ENV` | `production` | Important! |
+| `PYTHONUNBUFFERED` | `1` | For better logs |
+
+---
+
+### Heroku Environment Variables Setup
+```bash
+# Set environment variables
+heroku config:set FLASK_ENV=production
+heroku config:set SECRET_KEY=your-generated-secret-key
+heroku config:set PYTHONUNBUFFERED=1
+
+# View all config vars
+heroku config
+```
+
+---
+
+## Static Files & Assets
+
+### Issue: CSS/JavaScript Not Loading
+
+**Symptom:** Website displays but has no styling or JavaScript functionality
+
+**Check in Browser:**
+1. Right-click on page → Inspect
+2. Go to Console tab
+3. Look for 404 errors on static files
+
+**Solution 1 (Flask Configuration):**
+
+Verify static folder is configured correctly:
+```python
+from flask import Flask
+
+app = Flask(__name__,
+ static_folder='static',
+ static_url_path='/static')
+```
+
+**Solution 2 (Template Path Check):**
+
+In your HTML templates, make sure you're using:
+```html
+
+
+
+
+
+
+```
+
+**Solution 3 (WhiteNoise for Production):**
+
+For production deployments, use WhiteNoise to serve static files:
+```bash
+pip install whitenoise
+```
+
+Add to `requirements.txt`:
+```
+whitenoise==6.6.0
+```
+
+Update `app.py`:
+```python
+from flask import Flask
+from whitenoise import WhiteNoise
+
+app = Flask(__name__)
+
+# Add WhiteNoise middleware
+app.wsgi_app = WhiteNoise(
+ app.wsgi_app,
+ root='static/',
+ prefix='static/'
+)
+```
+
+---
+
+### Issue: Uploaded Images Not Displaying
+
+**Symptom:** Images upload successfully but don't display
+
+**Solution:**
+
+Make sure your upload folder is properly configured:
+```python
+import os
+
+# Configure upload folder
+UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), 'uploads')
+app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
+
+# Create folder if it doesn't exist
+os.makedirs(UPLOAD_FOLDER, exist_ok=True)
+```
+
+Add route to serve uploaded files:
+```python
+from flask import send_from_directory
+
+@app.route('/uploads/')
+def uploaded_file(filename):
+ return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
+```
+
+---
+
+## Database Migration Errors
+
+### Issue: "Table already exists"
+
+**Symptom:** Migration fails with duplicate table error
+
+**Error Message:**
+```
+sqlalchemy.exc.ProgrammingError: (psycopg2.errors.DuplicateTable) relation "users" already exists
+```
+
+**Solution (Development Only - Will Delete Data!):**
+```bash
+# Option 1: Reset migrations
+flask db downgrade
+flask db upgrade
+
+# Option 2: Drop and recreate (Python shell)
+python
+>>> from app import db
+>>> db.drop_all()
+>>> db.create_all()
+>>> exit()
+```
+
+⚠️ **WARNING:** This deletes ALL data! Only use in development!
+
+---
+
+### Issue: "Target database is not up to date"
+
+**Error Message:**
+```
+Target database is not up to date.
+```
+
+**Solution:**
+```bash
+# Check current migration status
+flask db current
+
+# Apply all pending migrations
+flask db upgrade
+
+# If stuck, stamp the database to current version
+flask db stamp head
+```
+
+---
+
+### Issue: Migration Files Out of Sync
+
+**Symptom:** Migrations work locally but fail in production
+
+**Solution:**
+```bash
+# Generate a new migration comparing actual database to models
+flask db migrate -m "Sync database with models"
+
+# Review the generated migration file
+# Delete unwanted changes
+
+# Apply the migration
+flask db upgrade
+```
+
+---
+
+## Port & Binding Issues
+
+### Issue: "Address already in use"
+
+**Symptom:** Can't start Flask app because port is occupied
+
+**Error Message:**
+```
+OSError: [Errno 48] Address already in use
+```
+
+**Solution (Linux/Mac):**
+```bash
+# Find process using port 5000
+lsof -ti:5000
+
+# Kill the process
+lsof -ti:5000 | xargs kill -9
+
+# Or use a different port
+flask run --port 5001
+```
+
+**Solution (Windows):**
+```bash
+# Find process using port 5000
+netstat -ano | findstr :5000
+
+# Kill the process (replace PID with actual process ID)
+taskkill /PID /F
+
+# Or use a different port
+flask run --port 5001
+```
+
+---
+
+### Issue: App Not Accessible from Other Devices
+
+**Symptom:** Can access app on localhost but not from other devices on network
+
+**Solution:**
+
+Bind to `0.0.0.0` instead of `127.0.0.1`:
+```python
+if __name__ == '__main__':
+ # WRONG - Only accessible locally
+ # app.run(host='127.0.0.1', port=5000)
+
+ # CORRECT - Accessible on network
+ app.run(host='0.0.0.0', port=5000)
+```
+
+For Render/Heroku, they handle this automatically.
+
+---
+
+## Memory & Performance
+
+### Issue: "Memory limit exceeded" on Render
+
+**Symptom:** App crashes randomly with memory error
+
+**Error Message:**
+```
+Error R14 (Memory quota exceeded)
+```
+
+**Solutions:**
+
+**1. Reduce Image Upload Size:**
+```python
+# Limit upload size to 10MB
+app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024 # 10MB
+```
+
+**2. Compress Images on Upload:**
+```python
+from PIL import Image
+import io
+
+def compress_image(image_data, max_size=(1920, 1080), quality=85):
+ img = Image.open(io.BytesIO(image_data))
+ img.thumbnail(max_size, Image.Resampling.LANCZOS)
+
+ output = io.BytesIO()
+ img.save(output, format=img.format, quality=quality, optimize=True)
+ output.seek(0)
+
+ return output.read()
+```
+
+**3. Implement Connection Pooling:**
+
+(See PostgreSQL Connection Pool section above)
+
+**4. Clear Flask Sessions:**
+```python
+from datetime import timedelta
+
+app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=1)
+```
+
+**5. Upgrade Render Plan:**
+
+If using free tier, consider upgrading to a paid plan for more memory.
+
+---
+
+### Issue: Slow Application Response
+
+**Solutions:**
+
+**1. Add Database Indexes:**
+```python
+# In your models
+class Image(db.Model):
+ id = db.Column(db.Integer, primary_key=True)
+ user_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True) # ← Add index
+ created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True) # ← Add index
+```
+
+**2. Use Lazy Loading:**
+```python
+# Lazy load relationships
+class User(db.Model):
+ id = db.Column(db.Integer, primary_key=True)
+ images = db.relationship('Image', backref='user', lazy='dynamic') # ← lazy='dynamic'
+```
+
+**3. Limit Query Results:**
+```python
+# Don't load all images at once
+images = Image.query.filter_by(user_id=user_id).limit(50).all()
+```
+
+---
+
+## SSL/HTTPS Configuration
+
+### Issue: Mixed Content Warnings
+
+**Symptom:** Browser shows "Not Secure" or blocks resources
+
+**Solution:**
+
+Force HTTPS in production:
+```bash
+pip install flask-talisman
+```
+
+Add to `requirements.txt`:
+```
+flask-talisman==1.1.0
+```
+
+Update `app.py`:
+```python
+from flask_talisman import Talisman
+
+# Only in production
+if os.environ.get('FLASK_ENV') == 'production':
+ Talisman(app, content_security_policy=None)
+```
+
+---
+
+### Issue: Redirect Loop with HTTPS
+
+**Solution:**
+```python
+from flask_talisman import Talisman
+
+Talisman(app,
+ content_security_policy=None,
+ force_https=True,
+ force_https_permanent=False) # ← Set to False
+```
+
+---
+
+## Common Error Messages
+
+### "ImportError: cannot import name 'X' from 'Y'"
+
+**Cause:** Missing dependency or circular import
+
+**Solution:**
+```bash
+# Reinstall dependencies
+pip install -r requirements.txt
+
+# Check for circular imports in your code
+```
+
+---
+
+### "TemplateNotFound: template.html"
+
+**Cause:** Template file not in correct location
+
+**Solution:**
+
+Make sure templates are in `templates/` folder:
+```
+BHV/
+├── app.py
+├── templates/
+│ ├── base.html
+│ ├── index.html
+│ └── login.html
+└── static/
+```
+
+---
+
+### "werkzeug.routing.BuildError"
+
+**Cause:** Trying to use `url_for()` with non-existent route
+
+**Solution:**
+
+Check your route names:
+```python
+# Define route
+@app.route('/dashboard')
+def dashboard():
+ return render_template('dashboard.html')
+
+# Use correct route name
+redirect(url_for('dashboard')) # ← Must match function name
+```
+
+---
+
+## Deployment Checklist
+
+Before deploying to production, verify:
+
+### Security:
+- [ ] `DEBUG = False` in production
+- [ ] Strong `SECRET_KEY` generated (not 'dev' or 'test')
+- [ ] HTTPS enabled (SSL certificate)
+- [ ] Database password is secure
+- [ ] `.env` file is in `.gitignore` (never commit secrets!)
+
+### Configuration:
+- [ ] All environment variables set correctly
+- [ ] Database URL format is correct (`postgresql://` not `postgres://`)
+- [ ] `requirements.txt` is up to date
+- [ ] Static files are configured
+- [ ] Upload folder has correct permissions
+
+### Database:
+- [ ] Migrations are applied (`flask db upgrade`)
+- [ ] Connection pooling is configured
+- [ ] Backups are set up (if using managed database)
+
+### Performance:
+- [ ] Database indexes added
+- [ ] Image size limits enforced
+- [ ] Static files are served efficiently (WhiteNoise)
+
+### Monitoring:
+- [ ] Logging is enabled
+- [ ] Error tracking is set up (optional: Sentry)
+- [ ] Health check endpoint exists
+
+---
+
+## Getting Help
+
+If you encounter issues not covered here:
+
+### 1. Check Application Logs
+
+**Render.com:**
+1. Go to your dashboard
+2. Click on your service
+3. Go to "Logs" tab
+4. Look for error messages
+
+**Heroku:**
+```bash
+heroku logs --tail
+```
+
+**Local:**
+```bash
+# View logs in terminal where Flask is running
+# Or check logs/app.log if file logging is configured
+```
+
+---
+
+### 2. Common Debug Commands
+
+**Test Database Connection:**
+```bash
+python -c "from app import db; db.create_all(); print('Database connected!')"
+```
+
+**Check Environment Variables:**
+```bash
+python -c "import os; print(os.environ.get('DATABASE_URL'))"
+```
+
+**Verify Python Version:**
+```bash
+python --version
+```
+
+**Check Installed Packages:**
+```bash
+pip list
+```
+
+---
+
+### 3. Enable Debug Mode (Development Only!)
+```python
+# In app.py - DEVELOPMENT ONLY!
+app.config['DEBUG'] = True
+app.config['PROPAGATE_EXCEPTIONS'] = True
+```
+
+⚠️ **Never enable DEBUG in production!** It exposes sensitive information.
+
+---
+
+### 4. Ask for Help
+
+- **GitHub Issues:** https://github.com/KathiraveluLab/BHV/issues
+- **Discussions:** https://github.com/KathiraveluLab/BHV/discussions
+- **Tag:** @yadavchiragg for deployment help
+
+When asking for help, include:
+1. What you're trying to do
+2. What error you're getting (exact error message)
+3. What you've already tried
+4. Your platform (Render, Heroku, local, etc.)
+
+---
+
+## Debugging Tips
+
+### Enable Detailed Error Pages (Development)
+```python
+from flask import Flask
+from werkzeug.debug import DebuggedApplication
+
+app = Flask(__name__)
+
+if app.debug:
+ app.wsgi_app = DebuggedApplication(app.wsgi_app, evalex=True)
+```
+
+---
+
+### Test Database Connection Separately
+```python
+from sqlalchemy import create_engine
+
+try:
+ engine = create_engine('your-database-url-here')
+ connection = engine.connect()
+ print("✅ Database connected successfully!")
+ connection.close()
+except Exception as e:
+ print(f"❌ Database connection failed: {e}")
+```
+
+---
+
+### Check File Permissions
+```bash
+# Linux/Mac - Make sure upload folder is writable
+chmod 755 uploads/
+
+# Check current permissions
+ls -la uploads/
+```
+
+---
+
+## Platform-Specific Notes
+
+### Render.com
+
+**Pros:**
+- Free tier available
+- Automatic HTTPS
+- Easy PostgreSQL setup
+- Auto-deploy from GitHub
+
+**Gotchas:**
+- Free tier spins down after 15 minutes of inactivity (30-second cold start)
+- Need to fix `postgres://` → `postgresql://` URL
+- 512MB memory on free tier
+
+---
+
+### Heroku
+
+**Pros:**
+- Popular platform with lots of documentation
+- Many add-ons available
+- Easy to scale
+
+**Gotchas:**
+- No free tier anymore (requires paid plan)
+- Need Heroku CLI for deployment
+- Ephemeral filesystem (uploaded files disappear on restart)
+
+---
+
+### VPS (DigitalOcean, AWS, etc.)
+
+**Pros:**
+- Full control
+- Persistent storage
+- Can run background tasks
+
+**Gotchas:**
+- Requires more setup (nginx, gunicorn)
+- Need to manage security updates
+- Need to configure SSL manually
+
+---
+
+## Performance Benchmarks
+
+Based on deploying BHV to Render.com:
+
+| Metric | Without Optimization | With Optimization |
+|--------|---------------------|-------------------|
+| Gallery Load (50 images) | ~8 seconds | ~2 seconds |
+| Database Queries | 45+ per page | 10-12 per page |
+| Memory Usage | 250MB | 100MB |
+| Image Upload | 3 seconds | 1 second |
+
+Optimizations applied:
+- Database connection pooling
+- Static file serving with WhiteNoise
+- Image compression on upload
+- Database indexes on foreign keys
+- Query optimization (lazy loading)
+
+---
+
+## Credits
+
+This guide was compiled from real deployment experiences by BHV contributors, particularly from deploying to Render.com at https://bhv-q4tp.onrender.com
+
+### Contributors:
+- **Chirag Yadav** ([@yadavchiragg](https://github.com/yadavchiragg)) - Initial guide and Render.com deployment
+
+If you solved a deployment issue not listed here, please contribute by opening a PR!
+
+---
+
+## Additional Resources
+
+- **Flask Documentation:** https://flask.palletsprojects.com/
+- **SQLAlchemy Documentation:** https://docs.sqlalchemy.org/
+- **Render Documentation:** https://render.com/docs
+- **PostgreSQL Documentation:** https://www.postgresql.org/docs/
+- **BHV Repository:** https://github.com/KathiraveluLab/BHV
+
+---
+
+**Last Updated:** January 2026
+**Maintained by:** BHV Community
+**Live Demo:** https://bhv-q4tp.onrender.com
+
+---
+
+## License
+
+This documentation is part of the BHV project and follows the same license.
\ No newline at end of file
diff --git a/init_db.py b/init_db.py
new file mode 100644
index 0000000..aa8b49d
--- /dev/null
+++ b/init_db.py
@@ -0,0 +1,19 @@
+from bhv.app import app, db, User
+
+with app.app_context():
+ db.create_all()
+
+ admins = [
+ ('yadavchiragg', 'yadav@bhv.com', 'Demo2024!'),
+ ('pradeeban', 'pradeeban@bhv.com', 'BHV2024!'),
+ ('mdxabu', 'mdxabu@bhv.com', 'BHV2024!')
+ ]
+
+ for username, email, password in admins:
+ if not User.query.filter_by(username=username).first():
+ admin = User(username=username, email=email, is_admin=True)
+ admin.set_password(password)
+ db.session.add(admin)
+
+ db.session.commit()
+ print("Database initialized successfully!")
\ No newline at end of file
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..13bc1da
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,6 @@
+[pytest]
+testpaths = tests
+python_files = test_*.py
+python_classes = Test*
+python_functions = test_*
+addopts = -v --tb=short
\ No newline at end of file
diff --git a/render.yaml b/render.yaml
new file mode 100644
index 0000000..481bedf
--- /dev/null
+++ b/render.yaml
@@ -0,0 +1,16 @@
+services:
+ - type: web
+ name: bhv-demo
+ env: python
+ region: oregon
+ plan: free
+ branch: main
+ buildCommand: "./build.sh"
+ startCommand: "gunicorn bhv.app:app"
+ envVars:
+ - key: PYTHON_VERSION
+ value: 3.11.0
+ - key: SECRET_KEY
+ generateValue: true
+ - key: FLASK_ENV
+ value: production
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..f193263
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,9 @@
+Flask==3.0.0
+Flask-SQLAlchemy==3.1.1
+Flask-WTF==1.2.1
+Flask-Login==0.6.3
+Werkzeug==3.0.1
+email-validator==2.1.0
+pytest==7.4.3
+gunicorn==21.2.0
+requests==2.31.0
\ No newline at end of file
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_comprehensive.py b/tests/test_comprehensive.py
new file mode 100644
index 0000000..55adf62
--- /dev/null
+++ b/tests/test_comprehensive.py
@@ -0,0 +1,378 @@
+"""
+Comprehensive test suite for BHV application
+Tests authentication, uploads, admin features, and security
+"""
+
+import sys
+import os
+from pathlib import Path
+import tempfile
+import pytest
+from io import BytesIO
+
+# Add parent directory to path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from bhv.app import app, db, User, Image
+
+
+@pytest.fixture
+def client():
+ """Create test client with temporary database"""
+ app.config['TESTING'] = True
+ app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
+ app.config['WTF_CSRF_ENABLED'] = False
+ app.config['UPLOAD_FOLDER'] = tempfile.mkdtemp()
+
+ with app.test_client() as client:
+ with app.app_context():
+ db.create_all()
+ yield client
+ db.drop_all()
+
+
+@pytest.fixture
+def regular_user(client):
+ """Create a regular test user"""
+ with app.app_context():
+ user = User(username='testuser', email='test@example.com', is_admin=False)
+ user.set_password('password123')
+ db.session.add(user)
+ db.session.commit()
+ return user
+
+
+@pytest.fixture
+def admin_user(client):
+ """Create an admin test user"""
+ with app.app_context():
+ user = User(username='admin', email='admin@example.com', is_admin=True)
+ user.set_password('admin123')
+ db.session.add(user)
+ db.session.commit()
+ return user
+
+
+# ==================== AUTHENTICATION TESTS ====================
+
+def test_homepage_accessible(client):
+ """Test that homepage is accessible without login"""
+ response = client.get('/')
+ assert response.status_code == 200
+
+
+def test_register_page_accessible(client):
+ """Test that registration page loads"""
+ response = client.get('/register')
+ assert response.status_code == 200
+
+
+def test_login_page_accessible(client):
+ """Test that login page loads"""
+ response = client.get('/login')
+ assert response.status_code == 200
+
+
+def test_user_registration_success(client):
+ """Test successful user registration"""
+ response = client.post('/register', data={
+ 'username': 'newuser',
+ 'email': 'newuser@example.com',
+ 'password': 'password123',
+ 'confirm_password': 'password123'
+ }, follow_redirects=True)
+
+ assert response.status_code == 200
+
+ with app.app_context():
+ user = User.query.filter_by(username='newuser').first()
+ assert user is not None
+ assert user.email == 'newuser@example.com'
+
+
+def test_user_registration_duplicate_username(client, regular_user):
+ """Test that duplicate usernames are rejected"""
+ response = client.post('/register', data={
+ 'username': 'testuser', # Already exists
+ 'email': 'different@example.com',
+ 'password': 'password123',
+ 'confirm_password': 'password123'
+ })
+
+ assert b'Username already taken' in response.data or response.status_code != 200
+
+
+def test_user_registration_duplicate_email(client, regular_user):
+ """Test that duplicate emails are rejected"""
+ response = client.post('/register', data={
+ 'username': 'differentuser',
+ 'email': 'test@example.com', # Already exists
+ 'password': 'password123',
+ 'confirm_password': 'password123'
+ })
+
+ assert b'Email already registered' in response.data or response.status_code != 200
+
+
+def test_user_registration_password_mismatch(client):
+ """Test that mismatched passwords are rejected"""
+ response = client.post('/register', data={
+ 'username': 'newuser',
+ 'email': 'newuser@example.com',
+ 'password': 'password123',
+ 'confirm_password': 'different123'
+ })
+
+ assert b'Passwords must match' in response.data or response.status_code != 200
+
+
+def test_user_login_success(client, regular_user):
+ """Test successful user login"""
+ response = client.post('/login', data={
+ 'username': 'testuser',
+ 'password': 'password123'
+ }, follow_redirects=True)
+
+ assert response.status_code == 200
+
+
+def test_user_login_wrong_password(client, regular_user):
+ """Test login with incorrect password"""
+ response = client.post('/login', data={
+ 'username': 'testuser',
+ 'password': 'wrongpassword'
+ })
+
+ assert b'Invalid username or password' in response.data
+
+
+def test_user_login_nonexistent_user(client):
+ """Test login with non-existent username"""
+ response = client.post('/login', data={
+ 'username': 'nonexistent',
+ 'password': 'password123'
+ })
+
+ assert b'Invalid username or password' in response.data
+
+
+def test_user_logout(client, regular_user):
+ """Test user logout"""
+ # Login first
+ client.post('/login', data={
+ 'username': 'testuser',
+ 'password': 'password123'
+ })
+
+ # Then logout
+ response = client.get('/logout', follow_redirects=True)
+ assert response.status_code == 200
+
+
+# ==================== PROTECTED ROUTES TESTS ====================
+
+def test_gallery_requires_login(client):
+ """Test that gallery requires authentication"""
+ response = client.get('/gallery')
+ assert response.status_code == 302 # Redirect to login
+
+
+def test_upload_requires_login(client):
+ """Test that upload page requires authentication"""
+ response = client.get('/upload')
+ assert response.status_code == 302 # Redirect to login
+
+
+def test_profile_requires_login(client):
+ """Test that profile page requires authentication"""
+ response = client.get('/profile')
+ assert response.status_code == 302 # Redirect to login
+
+
+def test_gallery_accessible_when_logged_in(client, regular_user):
+ """Test that gallery is accessible after login"""
+ client.post('/login', data={
+ 'username': 'testuser',
+ 'password': 'password123'
+ })
+
+ response = client.get('/gallery')
+ assert response.status_code == 200
+
+
+def test_profile_accessible_when_logged_in(client, regular_user):
+ """Test that profile is accessible after login"""
+ client.post('/login', data={
+ 'username': 'testuser',
+ 'password': 'password123'
+ })
+
+ response = client.get('/profile')
+ assert response.status_code == 200
+
+
+# ==================== ADMIN ACCESS TESTS ====================
+
+def test_admin_dashboard_requires_admin(client, regular_user):
+ """Test that non-admin users cannot access admin dashboard"""
+ client.post('/login', data={
+ 'username': 'testuser',
+ 'password': 'password123'
+ })
+
+ response = client.get('/admin')
+ assert response.status_code == 302 # Redirect
+
+
+def test_admin_dashboard_accessible_for_admin(client, admin_user):
+ """Test that admin users can access admin dashboard"""
+ client.post('/login', data={
+ 'username': 'admin',
+ 'password': 'admin123'
+ })
+
+ response = client.get('/admin')
+ assert response.status_code == 200
+
+
+def test_admin_users_page_requires_admin(client, regular_user):
+ """Test that admin users page requires admin privileges"""
+ client.post('/login', data={
+ 'username': 'testuser',
+ 'password': 'password123'
+ })
+
+ response = client.get('/admin/users')
+ assert response.status_code == 302 # Redirect
+
+
+def test_admin_images_page_requires_admin(client, regular_user):
+ """Test that admin images page requires admin privileges"""
+ client.post('/login', data={
+ 'username': 'testuser',
+ 'password': 'password123'
+ })
+
+ response = client.get('/admin/images')
+ assert response.status_code == 302 # Redirect
+
+
+# ==================== IMAGE UPLOAD TESTS ====================
+
+def test_upload_page_shows_form(client, regular_user):
+ """Test that upload page displays the form"""
+ client.post('/login', data={
+ 'username': 'testuser',
+ 'password': 'password123'
+ })
+
+ response = client.get('/upload')
+ assert response.status_code == 200
+ assert b'Upload' in response.data
+
+
+def test_upload_image_success(client, regular_user):
+ """Test successful image upload"""
+ client.post('/login', data={
+ 'username': 'testuser',
+ 'password': 'password123'
+ })
+
+ # Create a fake image file
+ data = {
+ 'title': 'Test Image',
+ 'description': 'Test Description',
+ 'file': (BytesIO(b'fake image data'), 'test.jpg')
+ }
+
+ response = client.post('/upload', data=data, content_type='multipart/form-data', follow_redirects=True)
+
+ # Should redirect to gallery or profile
+ assert response.status_code == 200
+
+
+def test_upload_without_file(client, regular_user):
+ """Test upload without selecting a file"""
+ client.post('/login', data={
+ 'username': 'testuser',
+ 'password': 'password123'
+ })
+
+ data = {
+ 'title': 'Test Image',
+ 'description': 'Test Description'
+ }
+
+ response = client.post('/upload', data=data, content_type='multipart/form-data')
+
+ # Should show error or stay on upload page
+ assert response.status_code in [200, 302]
+
+
+# ==================== USER MODEL TESTS ====================
+
+def test_user_password_hashing():
+ """Test that passwords are properly hashed"""
+ with app.app_context():
+ user = User(username='testuser', email='test@example.com')
+ user.set_password('password123')
+
+ assert user.password_hash != 'password123'
+ assert user.check_password('password123') == True
+ assert user.check_password('wrongpassword') == False
+
+
+def test_user_admin_status():
+ """Test user admin status"""
+ with app.app_context():
+ user = User(username='admin', email='admin@example.com', is_admin=True)
+ assert user.is_admin == True
+
+ regular = User(username='regular', email='regular@example.com', is_admin=False)
+ assert regular.is_admin == False
+
+
+# ==================== HEALTH CHECK TEST ====================
+
+def test_health_endpoint(client):
+ """Test health check endpoint"""
+ response = client.get('/health')
+ assert response.status_code == 200
+ # Health endpoint might return 'ok' or 'healthy'
+ json_data = response.get_json()
+ assert json_data is not None
+ assert 'status' in json_data
+
+
+# ==================== SECURITY TESTS ====================
+
+def test_xss_in_title(client, regular_user):
+ """Test that XSS in title is handled"""
+ client.post('/login', data={
+ 'username': 'testuser',
+ 'password': 'password123'
+ })
+
+ data = {
+ 'title': '',
+ 'description': 'Test',
+ 'file': (BytesIO(b'fake image'), 'test.jpg')
+ }
+
+ response = client.post('/upload', data=data, content_type='multipart/form-data')
+
+ # Should either reject or sanitize
+ assert response.status_code in [200, 302, 400]
+
+
+def test_sql_injection_in_username(client):
+ """Test SQL injection attempt in username"""
+ response = client.post('/register', data={
+ 'username': "admin' OR '1'='1",
+ 'email': 'test@example.com',
+ 'password': 'password123',
+ 'confirm_password': 'password123'
+ })
+
+ # Should handle gracefully
+ assert response.status_code in [200, 302, 400]
\ No newline at end of file
diff --git a/tests/test_models.py b/tests/test_models.py
new file mode 100644
index 0000000..fe5c920
--- /dev/null
+++ b/tests/test_models.py
@@ -0,0 +1,23 @@
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+def test_imports_work():
+ from bhv.app import User, Image, db
+ assert User is not None
+ assert Image is not None
+ assert db is not None
+
+def test_user_password_hashing():
+ from bhv.app import User
+ user = User(username='test', email='test@test.com')
+ user.set_password('mypassword')
+ assert user.password_hash != 'mypassword'
+ assert len(user.password_hash) > 20
+
+def test_user_password_check():
+ from bhv.app import User
+ user = User(username='test', email='test@test.com')
+ user.set_password('correct')
+ assert user.check_password('correct') == True
+ assert user.check_password('wrong') == False
\ No newline at end of file
diff --git a/tests/test_session_timeout.py b/tests/test_session_timeout.py
new file mode 100644
index 0000000..4a78c46
--- /dev/null
+++ b/tests/test_session_timeout.py
@@ -0,0 +1,44 @@
+"""Tests for session timeout functionality"""
+import time
+import pytest
+
+
+def test_session_timeout_configuration(client):
+ """Test that session timeout is configured correctly"""
+ from datetime import timedelta
+ timeout = client.application.config.get('PERMANENT_SESSION_LIFETIME')
+ assert timeout == timedelta(minutes=15)
+
+
+def test_ping_endpoint_requires_login(client):
+ """Test that ping endpoint requires authentication"""
+ response = client.get('/ping')
+ # Should redirect to login (302) or return 401
+ assert response.status_code in [302, 401]
+
+
+def test_ping_endpoint_works_when_logged_in(client, auth):
+ """Test that ping endpoint returns success when authenticated"""
+ # Login first
+ auth.login()
+
+ # Then test ping
+ response = client.get('/ping')
+ assert response.status_code == 200
+ assert b'status' in response.data
+ assert b'ok' in response.data
+
+
+def test_secure_cookie_configuration(client):
+ """Test that session cookies have security flags enabled"""
+ app = client.application
+ assert app.config.get('SESSION_COOKIE_HTTPONLY') is True
+ assert app.config.get('SESSION_COOKIE_SECURE') is True
+ assert app.config.get('SESSION_COOKIE_SAMESITE') == 'Lax'
+
+
+def test_session_permanent_lifetime(client):
+ """Test session lifetime is 15 minutes"""
+ from datetime import timedelta
+ lifetime = client.application.config.get('PERMANENT_SESSION_LIFETIME')
+ assert lifetime.total_seconds() == 900 # 15 minutes
\ No newline at end of file
diff --git a/tests/test_validators.py b/tests/test_validators.py
new file mode 100644
index 0000000..349c375
--- /dev/null
+++ b/tests/test_validators.py
@@ -0,0 +1,100 @@
+"""
+Test suite for BHV validators and helper functions
+Tests file validation, sanitization, and security
+"""
+
+import sys
+from pathlib import Path
+
+# Add parent directory to path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from bhv.app import allowed_file, sanitize_filename, get_unique_filename
+
+
+def test_allowed_file_valid_extensions():
+ """Test that valid file extensions are accepted"""
+ assert allowed_file('image.jpg') == True
+ assert allowed_file('photo.jpeg') == True
+ assert allowed_file('picture.png') == True
+ assert allowed_file('animation.gif') == True
+ assert allowed_file('IMAGE.JPG') == True # Case insensitive
+
+
+def test_allowed_file_invalid_extensions():
+ """Test that invalid file extensions are rejected"""
+ assert allowed_file('virus.exe') == False
+ assert allowed_file('script.php') == False
+ assert allowed_file('hack.sh') == False
+ assert allowed_file('document.pdf') == False
+ assert allowed_file('code.py') == False
+
+
+def test_allowed_file_no_extension():
+ """Test files without extensions are rejected"""
+ assert allowed_file('noextension') == False
+ assert allowed_file('file') == False
+
+
+def test_allowed_file_edge_cases():
+ """Test edge cases for file validation"""
+ assert allowed_file('') == False
+ assert allowed_file('file..jpg') == True
+
+
+def test_sanitize_filename_basic():
+ """Test basic filename sanitization"""
+ result = sanitize_filename('test.jpg')
+ assert result == 'test.jpg'
+
+ # Sanitize converts to lowercase and replaces spaces
+ result = sanitize_filename('My Photo.png')
+ assert 'photo' in result.lower()
+ assert '.png' in result
+
+
+def test_sanitize_filename_security():
+ """Test that path traversal attempts are sanitized"""
+ result = sanitize_filename('../../../etc/passwd')
+ assert '../' not in result
+
+ result = sanitize_filename('..\\..\\windows\\system32')
+ assert '\\' not in result
+
+
+def test_sanitize_filename_special_chars():
+ """Test removal of special characters"""
+ result = sanitize_filename('file@#$%.jpg')
+ # Special chars should be removed or replaced
+ assert '.jpg' in result
+
+
+def test_get_unique_filename_uniqueness():
+ """Test that generated filenames are unique"""
+ name1 = get_unique_filename('test.jpg')
+ name2 = get_unique_filename('test.jpg')
+
+ assert name1 != name2
+ assert name1.endswith('.jpg')
+ assert name2.endswith('.jpg')
+
+
+def test_get_unique_filename_format():
+ """Test that unique filenames have correct format"""
+ result = get_unique_filename('photo.png')
+
+ # Should contain timestamp and random string
+ assert '_' in result
+ assert '.png' in result
+
+
+def test_get_unique_filename_preserves_extension():
+ """Test that file extensions are preserved"""
+ result = get_unique_filename('image.jpg')
+ assert result.endswith('.jpg')
+
+ result = get_unique_filename('picture.png')
+ assert result.endswith('.png')
+
+ result = get_unique_filename('animation.gif')
+ assert result.endswith('.gif')
\ No newline at end of file