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..1c44a9f --- /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 ./bhv.db:/app/bhv.db \ + -v ./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..ca7534e 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,82 @@ 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 + +![Tests](https://github.com/KathiraveluLab/BHV/workflows/Tests/badge.svg) +![Python](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11-blue) +![License](https://img.shields.io/badge/license-BSD--3--Clause-green) + +> 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! 🐳 diff --git a/bhv.db b/bhv.db new file mode 100644 index 0000000..85ead74 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..e69de29 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..be371d4 --- /dev/null +++ b/bhv/app.py @@ -0,0 +1,620 @@ +""" +BHV - Biomedical Histology Viewer +Main Flask application with user authentication and admin features +""" + +from pathlib import Path +from flask import Flask, render_template, request, redirect, url_for, flash, send_from_directory +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, TextAreaField, FileField, SubmitField +from wtforms.validators import DataRequired, Email, EqualTo, Length, ValidationError +from werkzeug.security import generate_password_hash, check_password_hash +from werkzeug.utils import secure_filename +from datetime import datetime +from functools import wraps +import os +import re +import secrets + +# Initialize Flask app +app = Flask(__name__) + +# Configuration +BASE_DIR = Path(__file__).resolve().parent.parent + +# Production vs Development config +if os.environ.get('FLASK_ENV') == 'production': + SECRET_KEY = os.environ.get('SECRET_KEY', 'fallback-secret-key-change-this') + DATABASE_PATH = os.environ.get('DATABASE_URL', '/opt/render/project/src/bhv.db') +else: + SECRET_KEY = 'dev-secret-key-change-in-production' + DATABASE_PATH = str(BASE_DIR / 'bhv.db') + +app.config['SECRET_KEY'] = SECRET_KEY +app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{DATABASE_PATH}' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024 # 5MB max file size +app.config['UPLOAD_FOLDER'] = str(BASE_DIR / 'static' / 'uploads') + +# Allowed file extensions +ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} + +# Initialize extensions +db = SQLAlchemy(app) +login_manager = LoginManager(app) +login_manager.login_view = 'login' +login_manager.login_message = 'Please log in to access this page.' + +# Create upload folder if it doesn't exist +os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + + +# ==================== MODELS ==================== + +class User(UserMixin, db.Model): + """User model with authentication and admin support""" + __tablename__ = 'users' + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False, index=True) + email = db.Column(db.String(120), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(200), nullable=False) + is_admin = db.Column(db.Boolean, default=False, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + images = db.relationship('Image', backref='owner', lazy='dynamic', cascade='all, delete-orphan') + + def set_password(self, password): + """Hash and set password""" + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + """Verify password""" + return check_password_hash(self.password_hash, password) + + def __repr__(self): + return f'' + + +class Image(db.Model): + """Image model for uploaded files""" + __tablename__ = 'images' + + id = db.Column(db.Integer, primary_key=True) + filename = db.Column(db.String(255), nullable=False) + title = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text) + file_size = db.Column(db.Integer) + uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + + def __repr__(self): + return f'' + + +# ==================== FORMS ==================== + +class RegistrationForm(FlaskForm): + """User registration form""" + username = StringField('Username', validators=[ + DataRequired(), + Length(min=3, max=80, message='Username must be between 3 and 80 characters') + ]) + email = StringField('Email', validators=[ + DataRequired(), + Email(message='Invalid email address') + ]) + password = PasswordField('Password', validators=[ + DataRequired(), + Length(min=6, message='Password must be at least 6 characters') + ]) + confirm_password = PasswordField('Confirm Password', validators=[ + DataRequired(), + EqualTo('password', message='Passwords must match') + ]) + submit = SubmitField('Register') + + def validate_username(self, username): + """Check if username already exists""" + user = User.query.filter_by(username=username.data).first() + if user: + raise ValidationError('Username already taken. Please choose a different one.') + + def validate_email(self, email): + """Check if email already exists""" + user = User.query.filter_by(email=email.data).first() + if user: + raise ValidationError('Email already registered. Please use a different one.') + + +class LoginForm(FlaskForm): + """User login form""" + username = StringField('Username', validators=[DataRequired()]) + password = PasswordField('Password', validators=[DataRequired()]) + submit = SubmitField('Login') + + +class ImageUploadForm(FlaskForm): + """Image upload form""" + title = StringField('Title', validators=[ + DataRequired(), + Length(min=3, max=200, message='Title must be between 3 and 200 characters') + ]) + description = TextAreaField('Description', validators=[ + Length(max=1000, message='Description cannot exceed 1000 characters') + ]) + file = FileField('Image File', validators=[DataRequired()]) + submit = SubmitField('Upload') + + +# ==================== HELPER FUNCTIONS ==================== + +@login_manager.user_loader +def load_user(user_id): + """Load user by ID for Flask-Login""" + return User.query.get(int(user_id)) + + +def admin_required(f): + """Decorator to require admin access""" + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or not current_user.is_admin: + flash('You need admin privileges to access this page.', 'danger') + return redirect(url_for('index')) + return f(*args, **kwargs) + return decorated_function + + +def allowed_file(filename): + """Check if file extension is allowed""" + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + + +def sanitize_filename(filename): + """Sanitize filename to prevent security issues""" + filename = secure_filename(filename) + name, ext = os.path.splitext(filename) + name = re.sub(r'[^\w\s-]', '', name).strip().lower() + name = re.sub(r'[-\s]+', '-', name) + return name + ext + + +def get_unique_filename(filename): + """Generate unique filename to avoid collisions""" + name, ext = os.path.splitext(filename) + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + random_str = secrets.token_hex(4) + return f"{name}_{timestamp}_{random_str}{ext}" + + +# ==================== PUBLIC ROUTES ==================== + +@app.route('/') +def index(): + """Homepage""" + return render_template('index.html') + + +@app.route('/health') +def health(): + """Health check endpoint for monitoring""" + return {'status': 'healthy', 'timestamp': datetime.utcnow().isoformat()} + + +# ==================== AUTHENTICATION ROUTES ==================== + +@app.route('/register', methods=['GET', 'POST']) +def register(): + """User registration""" + if current_user.is_authenticated: + return redirect(url_for('profile')) + + form = RegistrationForm() + if form.validate_on_submit(): + user = User( + username=form.username.data, + email=form.email.data + ) + user.set_password(form.password.data) + + db.session.add(user) + db.session.commit() + + flash(f'Account created successfully! Welcome, {user.username}!', 'success') + login_user(user) + return redirect(url_for('profile')) + + return render_template('register.html', form=form) + + +@app.route('/login', methods=['GET', 'POST']) +def login(): + """User login""" + if current_user.is_authenticated: + return redirect(url_for('profile')) + + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(username=form.username.data).first() + + if user and user.check_password(form.password.data): + login_user(user) + flash(f'Welcome back, {user.username}!', 'success') + + next_page = request.args.get('next') + return redirect(next_page) if next_page else redirect(url_for('profile')) + else: + flash('Invalid username or password. Please try again.', 'danger') + + return render_template('login.html', form=form) + + +@app.route('/logout') +@login_required +def logout(): + """User logout""" + logout_user() + flash('You have been logged out successfully.', 'info') + return redirect(url_for('index')) + + +@app.route('/profile') +@login_required +def profile(): + """User profile page""" + user_images = Image.query.filter_by(user_id=current_user.id).order_by(Image.uploaded_at.desc()).all() + return render_template('profile.html', images=user_images) + + +# ==================== IMAGE ROUTES ==================== + +@app.route('/upload', methods=['GET', 'POST']) +@login_required +def upload(): + """Image upload page""" + form = ImageUploadForm() + + if form.validate_on_submit(): + file = form.file.data + + if not file or file.filename == '': + flash('No file selected', 'danger') + return redirect(request.url) + + if not allowed_file(file.filename): + flash(f'Invalid file type. Allowed types: {", ".join(ALLOWED_EXTENSIONS)}', 'danger') + return redirect(request.url) + + # Sanitize and generate unique filename + original_filename = sanitize_filename(file.filename) + unique_filename = get_unique_filename(original_filename) + filepath = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename) + + # Save file + file.save(filepath) + file_size = os.path.getsize(filepath) + + # Create database entry + image = Image( + filename=unique_filename, + title=form.title.data, + description=form.description.data, + 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', form=form) + + +@app.route('/gallery') +@login_required +def gallery(): + """ + Gallery page with search and filter functionality + Users can search by title/description and sort images + """ + # Get search query from URL parameters + search_query = request.args.get('search', '').strip() + sort_by = request.args.get('sort', 'newest') # newest, oldest, name, size + + # Base query - only current user's images + query = Image.query.filter_by(user_id=current_user.id) + + # Apply search filter if provided + if search_query: + search_filter = f"%{search_query}%" + query = query.filter( + db.or_( + Image.title.ilike(search_filter), + Image.description.ilike(search_filter) + ) + ) + + # Apply sorting + if sort_by == 'newest': + query = query.order_by(Image.uploaded_at.desc()) + elif 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()) + + # Limit to 50 images for performance + images = query.paginate(page=request.args.get('page', 1, type=int), per_page=20, error_out=False) + + # Get total count for display + total_count = Image.query.filter_by(user_id=current_user.id).count() + + 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): + """Serve uploaded files""" + return send_from_directory(app.config['UPLOAD_FOLDER'], filename) + + +# ==================== ADMIN ROUTES ==================== + +@app.route('/admin') +@admin_required +def admin_dashboard(): + """Admin dashboard with statistics and charts""" + from datetime import datetime, timedelta + + # Basic stats + total_users = User.query.count() + total_images = Image.query.count() + + # Calculate total storage + 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 + top_uploaders = db.session.query( + User, db.func.count(Image.id).label('image_count') + ).join(Image).group_by(User.id).order_by(db.desc('image_count')).limit(5).all() + + # Recent images + recent_images = Image.query.order_by(Image.uploaded_at.desc()).limit(6).all() + + # === CHART DATA === + + # 1. Uploads over last 7 days (for line chart) + uploads_dates = [] + uploads_counts = [] + for i in range(6, -1, -1): # Last 7 days + date = datetime.now().date() - timedelta(days=i) + count = Image.query.filter( + db.func.date(Image.uploaded_at) == date + ).count() + uploads_dates.append(date.strftime('%b %d')) + uploads_counts.append(count) + + # 2. Top uploaders data (for bar chart) + top_uploaders_names = [] + top_uploaders_counts = [] + for user, count in top_uploaders[:5]: + top_uploaders_names.append(user.username) + top_uploaders_counts.append(count) + + # 3. Storage distribution by top users (for doughnut chart) + storage_users = [] + storage_sizes = [] + top_storage_users = db.session.query( + User, + db.func.sum(Image.file_size).label('total_size') + ).join(Image).group_by(User.id).order_by(db.desc('total_size')).limit(5).all() + + for user, total_size in top_storage_users: + storage_users.append(user.username) + storage_sizes.append(round(total_size / (1024 * 1024), 2)) # Convert to MB + + # 4. User activity (for pie chart) - users with uploads vs without + users_with_uploads = db.session.query(User.id).join(Image).distinct().count() + users_without_uploads = total_users - users_with_uploads + user_activity = [users_with_uploads, users_without_uploads] + + 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, + # Chart data + uploads_dates=uploads_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') +@admin_required +def admin_users(): + """Admin user management""" + page = request.args.get('page', 1, type=int) + search = request.args.get('search', '', type=str) + + query = User.query + if search: + query = query.filter( + (User.username.contains(search)) | (User.email.contains(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) + + +@app.route('/admin/users/') +@admin_required +def admin_user_detail(user_id): + """Admin user detail page""" + user = User.query.get_or_404(user_id) + images = Image.query.filter_by(user_id=user_id).order_by(Image.uploaded_at.desc()).all() + + # Calculate user's storage + total_storage = sum(img.file_size for img in images) + total_storage_mb = round(total_storage / (1024 * 1024), 2) + + return render_template('admin/user_detail.html', user=user, images=images, total_storage_mb=total_storage_mb) + + +@app.route('/admin/users//delete', methods=['POST']) +@admin_required +def admin_delete_user(user_id): + """Admin delete user""" + 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) + + # Delete user (cascade will delete images) + 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/users//toggle-admin', methods=['POST']) +@admin_required +def admin_toggle_admin(user_id): + """Toggle admin status for user""" + if user_id == current_user.id: + flash('You cannot change your own admin status!', 'danger') + return redirect(url_for('admin_user_detail', user_id=user_id)) + + user = User.query.get_or_404(user_id) + user.is_admin = not user.is_admin + db.session.commit() + + status = 'Admin' if user.is_admin else 'Regular User' + flash(f'{user.username} is now a {status}.', 'success') + return redirect(url_for('admin_user_detail', user_id=user_id)) + + +@app.route('/admin/images') +@admin_required +def admin_images(): + """Admin image management""" + page = request.args.get('page', 1, type=int) + search = request.args.get('search', '', type=str) + + query = Image.query + if search: + query = query.filter( + (Image.title.contains(search)) | (Image.description.contains(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) + + +@app.route('/admin/images//delete', methods=['POST']) +@admin_required +def admin_delete_image(image_id): + """Admin delete image""" + image = Image.query.get_or_404(image_id) + + # Delete file from disk + filepath = os.path.join(app.config['UPLOAD_FOLDER'], image.filename) + if os.path.exists(filepath): + os.remove(filepath) + + # Delete from database + db.session.delete(image) + db.session.commit() + + flash(f'Image "{image.title}" has been deleted.', 'success') + + # Redirect back to referring page + return redirect(request.referrer or url_for('admin_images')) + + +# ==================== ERROR HANDLERS ==================== + +@app.errorhandler(404) +def not_found_error(error): + """Handle 404 errors with custom page""" + return render_template('errors/404.html'), 404 + + +@app.errorhandler(500) +def internal_error(error): + """Handle 500 errors with custom page""" + db.session.rollback() + return render_template('errors/500.html'), 500 + + +@app.errorhandler(403) +def forbidden_error(error): + """Handle 403 Forbidden errors""" + return render_template('errors/403.html'), 403 + + +@app.errorhandler(413) +def request_entity_too_large(error): + """Handle file too large errors""" + flash('File is too large! Maximum size is 5MB.', 'danger') + return redirect(request.referrer or url_for('upload')) + + +# ==================== APPLICATION FACTORY ==================== + +def create_app(): + """Application factory for deployment""" + with app.app_context(): + db.create_all() + return app + + +# ==================== RUN APPLICATION ==================== + +if __name__ == '__main__': + with app.app_context(): + db.create_all() + + # Create admin accounts if they don't exist (for local development) + 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(): + user = User(username=username, email=email, is_admin=True) + user.set_password(password) + db.session.add(user) + print(f'✅ Created admin: {username}') + + db.session.commit() + + app.run(host='0.0.0.0', port=5000, debug=True) \ 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..14e6645 --- /dev/null +++ b/bhv/templates/admin/dashboard.html @@ -0,0 +1,365 @@ +{% extends "base.html" %} +{% block title %}Admin Dashboard - BHV{% endblock %} + +{% block content %} +
+ +
+

⚙️ Admin Dashboard

+

System overview and analytics

+
+ + +
+ +
+
👥
+
+

Total Users

+

{{ total_users }}

+
+
+ + +
+
📸
+
+

Total Images

+

{{ total_images }}

+
+
+ + +
+
💾
+
+

Storage Used

+

{{ total_storage_mb }} MB

+
+
+ + +
+
📊
+
+

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.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..2253ebf --- /dev/null +++ b/bhv/templates/admin/images.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}Manage Images - Admin{% endblock %} +{% block content %} +
+
+

📸 Manage Images

+ ← Back to Dashboard +
+ + +
+
+ + + {% if search %} + Clear + {% endif %} +
+
+ + +
+

Total: {{ images.total }} images

+ +
+ {% for image in images.items %} +
+ {{ image.title }} +
+

{{ image.title }}

+

+ by {{ image.owner.username }} +

+

{{ image.uploaded_at.strftime('%B %d, %Y') }}

+

{{ (image.file_size / 1024)|round(1) }} KB

+ +
+ View User +
+ +
+
+
+
+ {% 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/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 %} +
+
+

User Details

+ ← Back to Users +
+ + +
+
+
+

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.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..8cfb48c --- /dev/null +++ b/bhv/templates/admin/users.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% block title %}Manage Users - Admin{% endblock %} +{% block content %} +
+
+

👥 Manage Users

+ ← Back to Dashboard +
+ + +
+
+ + + {% if search %} + Clear + {% endif %} +
+
+ + +
+

Total: {{ users.total }} users

+ + + + + + + + + + + + + + {% for user in users.items %} + + + + + + + + + {% endfor %} + +
IDUsernameEmailImagesJoinedActions
{{ 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 %} +
+ + + {% 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/templates/base.html b/bhv/templates/base.html new file mode 100644 index 0000000..5abbf94 --- /dev/null +++ b/bhv/templates/base.html @@ -0,0 +1,70 @@ + + + + + + {% block title %}BHV - Behavioral Health Vault{% endblock %} + + + +
+

🐝 BHV - Behavioral Health Vault

+ +
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ +
+

BHV - A simplified approach to behavioral health documentation

+

+ {% if current_user.is_authenticated %} + Logged in as {{ current_user.username }} + {% else %} + Please login or register + {% 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..33e7ecc --- /dev/null +++ b/bhv/templates/errors/403.html @@ -0,0 +1,34 @@ +{% extends "errors/base_error.html" %} + +{% block title %}Access Denied{% endblock %} + +{% block icon %}🚫{% endblock %} + +{% block code %}403{% endblock %} + +{% block error_title %}Access Denied{% endblock %} + +{% block message %} +You don't have permission to access this page. This area is restricted to authorized users only. +{% endblock %} + +{% block actions %} +🏠 Go Home +{% if not current_user.is_authenticated %} +🔐 Login +{% else %} +👤 My Profile +{% endif %} +{% endblock %} + +{% block details %} +
+ 💡 Why am I seeing this?
+ • You're not logged in
+ • You don't have admin privileges
+ • This page requires special permissions

+ {% if not current_user.is_authenticated %} + 💡 Try logging in to access more features! + {% endif %} +
+{% 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..eccd441 --- /dev/null +++ b/bhv/templates/errors/404.html @@ -0,0 +1,30 @@ +{% extends "errors/base_error.html" %} + +{% block title %}Page Not Found{% endblock %} + +{% block icon %}🔍{% endblock %} + +{% block code %}404{% endblock %} + +{% block error_title %}Page Not Found{% endblock %} + +{% block message %} +The page you're looking for doesn't exist or has been moved. Don't worry, it happens to the best of us! +{% endblock %} + +{% block actions %} +🏠 Go Home + + {% if current_user.is_authenticated %}📸 My Gallery{% else %}🔐 Login{% endif %} + +{% endblock %} + +{% block details %} +
+ 💡 What you can do:
+ • Check the URL for typos
+ • Go back to the previous page
+ • Visit our homepage
+ • Use the search function (coming soon!) +
+{% 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..e3c05d9 --- /dev/null +++ b/bhv/templates/errors/500.html @@ -0,0 +1,32 @@ +{% extends "errors/base_error.html" %} + +{% block title %}Server Error{% endblock %} + +{% block icon %}⚠️{% endblock %} + +{% block code %}500{% endblock %} + +{% block error_title %}Internal Server Error{% endblock %} + +{% block message %} +Oops! Something went wrong on our end. Our team has been notified and we're working to fix it. Please try again in a few moments. +{% endblock %} + +{% block actions %} +🏠 Go Home +🔄 Try Again +{% endblock %} + +{% block details %} +
+ 💡 What happened?
+ The server encountered an unexpected error and couldn't complete your request. This could be due to:
+ • Temporary server overload
+ • Database connection issues
+ • A bug in our code (we're on it!)

+ What you can do:
+ • Wait a few moments and try again
+ • Clear your browser cache
+ • Contact support if the problem persists +
+{% 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 actions %} + 🏠 Go Home + ← Go Back + {% 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..32a79df --- /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 %} + + {% endif %} +
+
+ + +
+ +
+ + + + + {% if search_query or sort_by != 'newest' %} + + + ✕ Clear + + {% endif %} +
+
+ + + {% if search_query %} +
+
+ 🔍 Searching for: + "{{ search_query }}" + +
+
+ {% endif %} + + + {% if images %} +
+ {% for image in images %} +
+ +
+ {{ image.title }} + +
+ {{ (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 %} +
🔍
+

No images found

+

+ No images match your search for "{{ search_query }}" +

+ + View All Images + + {% else %} +
📷
+

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..4ef7062 --- /dev/null +++ b/bhv/templates/index.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% 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 %} + Upload Your Story + My Profile + {% else %} + Get Started + Login + {% endif %} + View Gallery +
+
+{% 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..4138094 --- /dev/null +++ b/bhv/templates/login.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}Login - BHV{% endblock %} +{% block content %} +
+

Welcome Back! 👋

+ +
+ {{ form.hidden_tag() }} + +
+ {{ form.username.label(style="display: block; margin-bottom: 5px; font-weight: bold; color: #555;") }} + {{ form.username(style="width: 100%; padding: 12px; border: 2px solid #ddd; border-radius: 5px; font-size: 1rem;", placeholder="Enter your username") }} + {% if form.username.errors %} + {% for error in form.username.errors %} +

{{ error }}

+ {% endfor %} + {% endif %} +
+ +
+ {{ form.password.label(style="display: block; margin-bottom: 5px; font-weight: bold; color: #555;") }} + {{ form.password(style="width: 100%; padding: 12px; border: 2px solid #ddd; border-radius: 5px; font-size: 1rem;", placeholder="Enter your password") }} + {% if form.password.errors %} + {% for error in form.password.errors %} +

{{ error }}

+ {% endfor %} + {% endif %} +
+ +
+ {{ form.submit(style="width: 100%; padding: 15px; background: #3498db; color: white; border: none; border-radius: 5px; font-size: 1.1rem; font-weight: bold; cursor: pointer; transition: background 0.3s;") }} +
+
+ +
+

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..40bfded --- /dev/null +++ b/bhv/templates/profile.html @@ -0,0 +1,136 @@ +{% 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 %} +

+
+
+
+
+ + +
+ + + Upload New Image + + + 📸 View My Gallery + + {% if current_user.is_admin %} + + ⚙️ Admin Dashboard + + {% endif %} +
+ + +
+

Recent Uploads

+ + {% if images %} +
+ {% for image in images[:6] %} +
+ {{ image.title }} +
+

{{ image.title }}

+

{{ image.uploaded_at.strftime('%B %d, %Y') }}

+
+
+ {% endfor %} +
+ + {% if images|length > 6 %} + + {% endif %} + + {% else %} +
+
📷
+

No Images Yet

+

Start your collection by uploading your first image!

+ + Upload Your First Image + +
+ {% 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..5c1139d --- /dev/null +++ b/bhv/templates/register.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% block title %}Register - BHV{% endblock %} +{% block content %} +
+

Create Your Account

+ +
+ {{ form.hidden_tag() }} + +
+ {{ form.username.label(style="display: block; margin-bottom: 5px; font-weight: bold; color: #555;") }} + {{ form.username(style="width: 100%; padding: 12px; border: 2px solid #ddd; border-radius: 5px; font-size: 1rem;", placeholder="Choose a username") }} + {% if form.username.errors %} + {% for error in form.username.errors %} +

{{ error }}

+ {% endfor %} + {% endif %} +
+ +
+ {{ form.email.label(style="display: block; margin-bottom: 5px; font-weight: bold; color: #555;") }} + {{ form.email(style="width: 100%; padding: 12px; border: 2px solid #ddd; border-radius: 5px; font-size: 1rem;", placeholder="your.email@example.com", type="email") }} + {% if form.email.errors %} + {% for error in form.email.errors %} +

{{ error }}

+ {% endfor %} + {% endif %} +
+ +
+ {{ form.password.label(style="display: block; margin-bottom: 5px; font-weight: bold; color: #555;") }} + {{ form.password(style="width: 100%; padding: 12px; border: 2px solid #ddd; border-radius: 5px; font-size: 1rem;", placeholder="At least 6 characters") }} + {% if form.password.errors %} + {% for error in form.password.errors %} +

{{ error }}

+ {% endfor %} + {% endif %} + Minimum 6 characters +
+ +
+ {{ form.confirm_password.label(style="display: block; margin-bottom: 5px; font-weight: bold; color: #555;") }} + {{ form.confirm_password(style="width: 100%; padding: 12px; border: 2px solid #ddd; border-radius: 5px; font-size: 1rem;", placeholder="Repeat your password") }} + {% if form.confirm_password.errors %} + {% for error in form.confirm_password.errors %} +

{{ error }}

+ {% endfor %} + {% endif %} +
+ +
+ {{ form.submit(style="width: 100%; padding: 15px; background: #2ecc71; color: white; border: none; border-radius: 5px; font-size: 1.1rem; font-weight: bold; cursor: pointer; transition: background 0.3s;") }} +
+
+ +
+

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..a072fc1 --- /dev/null +++ b/bhv/templates/upload.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} +{% block title %}Upload Image - BHV{% endblock %} +{% block content %} +
+
+

📤 Upload Image

+

Share your histology images with the community

+
+ +
+
+ {{ form.hidden_tag() }} + + +
+ {{ form.title.label(style="display: block; margin-bottom: 8px; font-weight: bold; color: #2c3e50;") }} + {{ form.title(class="form-control", style="width: 100%; padding: 12px; border: 2px solid #ddd; border-radius: 5px; font-size: 1rem;", placeholder="Enter image title") }} + {% if form.title.errors %} +
+ {% for error in form.title.errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} +
+ + +
+ {{ form.description.label(style="display: block; margin-bottom: 8px; font-weight: bold; color: #2c3e50;") }} + {{ form.description(class="form-control", style="width: 100%; padding: 12px; border: 2px solid #ddd; border-radius: 5px; font-size: 1rem; min-height: 120px; font-family: inherit;", placeholder="Describe your image (optional)", rows="5") }} + {% if form.description.errors %} +
+ {% for error in form.description.errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} +
+ + +
+ {{ form.file.label(style="display: block; margin-bottom: 8px; font-weight: bold; color: #2c3e50;") }} +
+ {{ form.file(style="display: block; margin: 0 auto;", accept="image/png,image/jpeg,image/jpg,image/gif") }} +

Allowed formats: PNG, JPG, JPEG, GIF

+

Max file size: 5MB

+
+ {% if form.file.errors %} +
+ {% for error in form.file.errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} +
+ + +
+ {{ form.submit(style="width: 100%; padding: 15px; background: #3498db; color: white; border: none; border-radius: 8px; font-size: 1.1rem; font-weight: bold; cursor: pointer;") }} +
+
+ + +
+

📋 Upload Guidelines

+
    +
  • Images should be clear and properly focused
  • +
  • Provide descriptive titles for easy identification
  • +
  • Add detailed descriptions when possible
  • +
  • Ensure images meet file size requirements
  • +
  • Only upload images you have rights to share
  • +
+
+
+ + + +
+ + +{% 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..8aff9d0 --- /dev/null +++ b/build.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -o errexit + +pip install --upgrade pip +pip install -r requirements.txt +mkdir -p static/uploads +python init_db.py \ 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/init_db.py b/init_db.py new file mode 100644 index 0000000..237c0b5 --- /dev/null +++ b/init_db.py @@ -0,0 +1,22 @@ +from bhv.app import create_app, db, User + +app = create_app() + +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(): + user = User(username=username, email=email, is_admin=True) + user.set_password(password) + db.session.add(user) + print(f'Created: {username}') + + db.session.commit() + print('Done!') \ 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..472518c --- /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:create_app()' --bind 0.0.0.0:$PORT" + 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/static/uploads/1344763_20260105_204101_115ca85a.jpeg b/static/uploads/1344763_20260105_204101_115ca85a.jpeg new file mode 100644 index 0000000..1dcf271 Binary files /dev/null and b/static/uploads/1344763_20260105_204101_115ca85a.jpeg differ diff --git a/static/uploads/7c3a485f48024cebb6ef400d713c1a86.jpg b/static/uploads/7c3a485f48024cebb6ef400d713c1a86.jpg new file mode 100644 index 0000000..4440f46 Binary files /dev/null and b/static/uploads/7c3a485f48024cebb6ef400d713c1a86.jpg differ diff --git a/static/uploads/c7b1fd1cc4d647299277556fec962c7a.png b/static/uploads/c7b1fd1cc4d647299277556fec962c7a.png new file mode 100644 index 0000000..0677c57 Binary files /dev/null and b/static/uploads/c7b1fd1cc4d647299277556fec962c7a.png differ 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_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