23 Commits

Author SHA1 Message Date
ryan
e20e5eac01 Merge pull request 'asdf' (#5) from add-claude-migration-guide into main
Reviewed-on: #5
2025-08-15 07:49:15 -04:00
Ryan Chen
e9803eff37 asdf 2025-08-15 07:49:05 -04:00
ryan
41123f046e Merge pull request 'docs: add Claude development guidelines for database migrations' (#4) from add-claude-migration-guide into main
Reviewed-on: #4
2025-08-08 00:13:28 -04:00
ryan
013c398296 Merge pull request 'feat: add comprehensive database migration system' (#3) from add-migrations-system into main
Reviewed-on: #3
2025-08-08 00:13:21 -04:00
Ryan Chen
6c4acf438d docs: add Claude development guidelines for database migrations
- Create CLAUDE.md with comprehensive migration guidelines
- Emphasize MUST create migrations for all schema changes
- Provide complete migration template and workflow
- Include naming conventions and best practices
- Document current database schema reference
- Add integration examples for dev/Docker/production

This ensures future AI assistants and developers follow
proper migration practices to prevent schema inconsistencies.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-08 00:07:23 -04:00
Ryan Chen
bef2577e13 feat: add comprehensive database migration system
- Create migrations/ directory with proper structure
- Add migration runner (migrations/migrate.py) with tracking
- Implement 001_add_description_column.py migration
- Add comprehensive README with usage and best practices
- Support for ordered execution and rollback capabilities
- Integration ready for Docker and CI/CD workflows

The migration system provides:
- Ordered execution by filename (001_, 002_, etc.)
- Applied migration tracking in database
- Idempotent operations (safe to re-run)
- Standalone and batch execution modes
- Comprehensive error handling and logging

Restored description column that was missing after merge.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-08 00:05:37 -04:00
ryan
ed896a2bdf Merge pull request 'feat: Update Docker setup for refactored Flask architecture' (#2) from docker-refactor-updates into main
Reviewed-on: #2
2025-08-08 00:00:03 -04:00
ryan
7deeb681c0 Merge branch 'main' into docker-refactor-updates 2025-08-07 23:59:57 -04:00
Ryan Chen
8814dd8994 perf: reduce Gunicorn workers to 1 for stability
- Change from 4 workers to 1 worker in both Dockerfile and docker-compose.yml
- Helps reduce resource usage and connection reset issues
- Better for single-user or low-traffic deployment scenarios

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-07 23:59:00 -04:00
Ryan Chen
02247340d4 fix: resolve image display issues after refactor
- Fix upload folder path to be relative to Flask app static folder
- Update app initialization to use proper static folder structure
- Change default development port to 5001 to avoid AirPlay conflicts
- Images now display correctly in both public and admin views

The refactored app now properly serves uploaded images from the
correct static file location.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-07 23:56:14 -04:00
ryan
f3c14d649c Merge pull request 'Updating Dockerfile and docker-compose following' (#1) from docker-refactor-updates into main
Reviewed-on: #1
2025-08-07 23:41:21 -04:00
Ryan Chen
b71187009b feat: update Docker setup for refactored app structure
- Update Dockerfile to copy new app/ directory structure
- Fix docker-compose.yml volume paths for app/static/uploads
- Add environment variable support for new config system
- Update migration service to use migrate_session_changes.py
- Add .dockerignore for optimized builds
- Remove obsolete version field from docker-compose.yml
- Create comprehensive README_DOCKER.md documentation

The Docker setup now fully supports the refactored Flask application
with modular structure, authentication, and like system.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-07 23:40:12 -04:00
Ryan Chen
77cea8cbc5 feat: complete codebase refactor with Flask best practices
- Implement application factory pattern with blueprints
- Add modular structure (models, routes, utils, templates)
- Create configuration management system
- Add comprehensive error handling and logging
- Implement cookie-based like system with database migration
- Add proper authentication decorators and session management
- Update .gitignore to exclude database files and OS artifacts

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-07 18:29:15 -04:00
Ryan Chen
918fc91604 yurt 2025-08-07 18:18:36 -04:00
Ryan Chen
8df728530e docs: update docker compose commands to force rebuild 2025-06-13 17:11:56 -04:00
Ryan Chen
5081785dae feat: add description field and migration support 2025-06-13 17:10:00 -04:00
Ryan Chen
1d30809507 feat: add Docker migration service 2025-06-13 17:08:34 -04:00
Ryan Chen
7c62dc8d08 feat: add description field for pet pictures 2025-06-13 17:07:22 -04:00
Ryan Chen
dd9cc42271 chore: update port to 54321 2025-06-13 17:01:00 -04:00
Ryan Chen
59e905162c feat: switch to Gunicorn for production 2025-06-13 16:58:30 -04:00
Ryan Chen
febb1b67f6 feat: add docker-compose configuration 2025-06-13 16:56:08 -04:00
Ryan Chen
57b1bb3ddd fix: specify package structure in pyproject.toml 2025-06-13 16:54:45 -04:00
Ryan Chen
d76640e546 fix: copy README.md in Dockerfile 2025-06-13 16:54:14 -04:00
41 changed files with 2099 additions and 285 deletions

View File

@@ -1,46 +1,44 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
venv/
# Database files (will be mounted as volume)
*.db
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Git
.git
.gitignore
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
venv/
ENV/
# IDE
.idea/
# IDE files
.vscode/
.idea/
*.swp
*.swo
# Database
*.db
# Logs
logs/
*.log
# Uploads
static/uploads/*
!static/uploads/.gitkeep
# Development files
.env
.env.local
# Docker
Dockerfile
.dockerignore
# Cache directories
.cache/

13
.gitignore vendored
View File

@@ -8,3 +8,16 @@ wheels/
# Virtual environments
.venv
# Database files
*.db
pet_pictures.db
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

201
CLAUDE.md Normal file
View File

@@ -0,0 +1,201 @@
# Claude Development Guidelines
## Database Migrations
**⚠️ IMPORTANT: When making changes to database models or schema, you MUST create migration files.**
### When to Create Migrations
**ALWAYS create a migration when:**
- Adding new columns to existing tables
- Creating new tables
- Modifying column types or constraints
- Adding or removing indexes
- Changing default values
- Adding foreign keys or relationships
**Examples that require migrations:**
```python
# Adding a new field to PetPicture model
class PetPicture:
# ... existing fields ...
tags = models.TextField() # ❌ Requires migration!
```
```python
# Changing database schema in init_db()
def init_db():
db.execute("""
CREATE TABLE users ( -- ❌ New table requires migration!
id INTEGER PRIMARY KEY,
username TEXT NOT NULL
)
""")
```
### How to Create Migrations
1. **Navigate to migrations directory:**
```bash
cd migrations/
```
2. **Create new migration file:**
```bash
# Use next sequential number (002, 003, etc.)
touch XXX_descriptive_name.py
```
3. **Use the migration template:**
```python
#!/usr/bin/env python3
"""
Migration XXX: Brief description of changes
Date: YYYY-MM-DD
Description: Detailed explanation of what this migration does
"""
import sqlite3
import sys
from datetime import datetime
def check_column_exists(cursor, table_name, column_name):
"""Check if a column exists in a table"""
cursor.execute(f"PRAGMA table_info({table_name})")
columns = [column[1] for column in cursor.fetchall()]
return column_name in columns
def migrate_up(cursor):
"""Apply the migration"""
# Check if already applied
if check_column_exists(cursor, 'pet_pictures', 'new_column'):
print("Migration already applied, skipping...")
return False
# Apply the migration
print("Adding new_column to pet_pictures table...")
cursor.execute("ALTER TABLE pet_pictures ADD COLUMN new_column TEXT")
return True
def migrate_down(cursor):
"""Rollback the migration (optional)"""
print("Rollback not implemented for SQLite column drops")
return False
def main():
"""Run migration standalone"""
# Standard migration execution code
pass
if __name__ == "__main__":
sys.exit(main())
```
4. **Test the migration:**
```bash
# Test individual migration
python migrations/XXX_descriptive_name.py
# Or run all pending migrations
python migrations/migrate.py
```
5. **Commit the migration file:**
```bash
git add migrations/XXX_descriptive_name.py
git commit -m "feat: add migration for [description]"
```
### Migration Naming Convention
- **Sequential numbering**: `001_`, `002_`, `003_`, etc.
- **Descriptive names**: Explain what the migration does
- **Examples**:
- `002_add_user_table.py`
- `003_add_tags_column_to_pictures.py`
- `004_create_comments_table.py`
### DO NOT Modify Database Schema Without Migrations
**❌ NEVER do this:**
```python
# Directly modifying init_db() or model schema
def init_db():
db.execute("""
CREATE TABLE pet_pictures (
id INTEGER PRIMARY KEY,
filename TEXT NOT NULL,
new_field TEXT -- ❌ Added without migration!
)
""")
```
**✅ ALWAYS do this:**
1. Create migration file first
2. Run migration to update schema
3. Then update model code if needed
### Model Changes Workflow
1. **Identify needed schema change**
2. **Create migration file**
3. **Test migration locally**
4. **Update model/application code** (if needed)
5. **Test full application**
6. **Commit migration + code changes together**
### Migration Best Practices
- **One logical change per migration**
- **Make migrations idempotent** (safe to run multiple times)
- **Test on development data first**
- **Include rollback logic when possible**
- **Document breaking changes clearly**
- **Never edit applied migrations**
### Integration Points
**Development:**
```bash
# Apply migrations before starting app
python migrations/migrate.py && python main.py
```
**Docker:**
```bash
# Migrations run automatically via docker-compose
docker compose --profile migrate up migrate
```
**Production Deployment:**
```bash
# Run migrations as part of deployment
python migrations/migrate.py
systemctl restart pet-picture-queue
```
---
## Database Schema Reference
**Current Schema (after migrations):**
### `pet_pictures` table:
- `id` - INTEGER PRIMARY KEY AUTOINCREMENT
- `filename` - TEXT NOT NULL
- `subscriber_name` - TEXT NOT NULL
- `description` - TEXT NULL (added via migration 001)
- `uploaded_at` - TIMESTAMP NOT NULL
- `posted` - BOOLEAN DEFAULT 0
- `likes` - INTEGER DEFAULT 0
### `migrations` table (auto-created):
- `id` - INTEGER PRIMARY KEY AUTOINCREMENT
- `migration_name` - TEXT NOT NULL UNIQUE
- `applied_at` - TIMESTAMP NOT NULL
---
**Remember: Schema changes without migrations will cause deployment issues and data inconsistencies!**
Always follow the migration workflow for database changes.

View File

@@ -15,10 +15,13 @@ RUN pip install uv
# Copy project files
COPY pyproject.toml .
COPY main.py .
COPY templates/ templates/
COPY app/ app/
COPY migrate_session_changes.py .
COPY README_MIGRATION.md .
COPY README.md .
# Create uploads directory
RUN mkdir -p static/uploads
# Create uploads directory in the correct location
RUN mkdir -p app/static/uploads
# Create and activate virtual environment, then install dependencies
RUN uv venv && \
@@ -29,9 +32,10 @@ RUN uv venv && \
ENV FLASK_APP=main.py
ENV FLASK_ENV=production
ENV PATH="/app/.venv/bin:$PATH"
ENV GUNICORN_CMD_ARGS="--workers=1 --bind=0.0.0.0:5000 --timeout=120"
# Expose port
EXPOSE 5000
# Run the application
CMD ["python", "main.py"]
# Run the application with Gunicorn
CMD ["gunicorn", "main:app"]

View File

@@ -10,6 +10,7 @@ A web application to manage pet pictures from subscribers. Built with Flask, SQL
- Modern, responsive UI using Tailwind CSS
- Click to view full-size images
- Download original images
- Add descriptions to pet pictures
## Setup
@@ -29,8 +30,46 @@ python main.py
The application will be available at `http://localhost:5000`
### Database Migration
#### Local Migration
If you have an existing database and want to add the description field:
```bash
python migrate.py
```
#### Docker Migration
To run the migration in Docker:
```bash
docker compose --profile migrate up --build migrate
```
This will safely add the description column to your existing database without affecting existing data.
### Docker Deployment
#### Using Docker Compose (Recommended)
1. Start the application:
```bash
docker compose up --build -d
```
2. Stop the application:
```bash
docker compose down
```
The application will be available at `http://localhost:54321`
#### Using Docker Directly
1. Build the Docker image:
```bash
@@ -41,28 +80,30 @@ docker build -t pet-picture-queue .
```bash
docker run -d \
-p 5000:5000 \
-p 54321:5000 \
-v $(pwd)/static/uploads:/app/static/uploads \
-v $(pwd)/pet_pictures.db:/app/pet_pictures.db \
--name pet-picture-queue \
pet-picture-queue
```
The application will be available at `http://localhost:5000`
The application will be available at `http://localhost:54321`
## Usage
1. Visit the homepage to see all uploaded pet pictures
2. Click "Upload New" to add a new pet picture
3. Fill in the subscriber name and select a picture
4. View uploaded pictures on the homepage
5. Click on any picture to view it in full size
6. Use the "Download Original" button to download the original file
7. Mark pictures as posted using the "Mark as Posted" button
4. Add an optional description for the pet picture
5. View uploaded pictures on the homepage
6. Click on any picture to view it in full size
7. Use the "Download Original" button to download the original file
8. Mark pictures as posted using the "Mark as Posted" button
## File Structure
- `main.py` - Main Flask application
- `migrate.py` - Database migration script
- `templates/` - Jinja2 templates
- `base.html` - Base template with common layout
- `index.html` - Homepage with picture grid
@@ -71,3 +112,4 @@ The application will be available at `http://localhost:5000`
- `pet_pictures.db` - SQLite database (created automatically)
- `Dockerfile` - Docker configuration
- `.dockerignore` - Docker build exclusions
- `docker-compose.yml` - Docker Compose configuration

160
README_DOCKER.md Normal file
View File

@@ -0,0 +1,160 @@
# Docker Setup for Pets of Powerwashing
## Updated for Refactored Architecture
The Docker setup has been updated to work with the refactored Flask application structure.
## Quick Start
### 1. Build and Run
```bash
# Build and start the application
docker compose up --build
# Or run in detached mode
docker compose up --build -d
```
### 2. Run Database Migration
```bash
# Run the migration (first time setup or after schema changes)
docker compose --profile migrate up migrate
```
### 3. Access the Application
- **Web Interface**: http://localhost:54321
- **Admin Login**: username: `admin`, password: `password123`
## File Structure Changes
The refactored application now uses this structure:
```
app/
├── static/uploads/ # Upload directory (mounted as volume)
├── templates/ # HTML templates
├── models/ # Database models
├── routes/ # Route handlers (blueprints)
├── utils/ # Utilities and helpers
└── ...
```
## Docker Configuration
### Environment Variables
You can customize the application through environment variables:
```yaml
environment:
# Flask settings
- FLASK_ENV=production
# Application settings
- UPLOAD_FOLDER=app/static/uploads
- DATABASE_PATH=pet_pictures.db
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=password123
# Optional: Override secret key for production
- SECRET_KEY=your-secret-key-here
```
### Volume Mounts
- `./app/static/uploads:/app/app/static/uploads` - Persistent file uploads
- `./pet_pictures.db:/app/pet_pictures.db` - Persistent database
## Services
### Web Service (`web`)
- **Port**: 54321 (maps to internal port 5000)
- **Process**: Gunicorn with 4 workers
- **Health Check**: HTTP check every 30 seconds
- **Restart Policy**: unless-stopped
### Migration Service (`migrate`)
- **Purpose**: Database schema updates
- **Usage**: `docker compose --profile migrate up migrate`
- **Script**: Uses `migrate_session_changes.py`
## Commands
### Development
```bash
# Build only
docker compose build
# View logs
docker compose logs -f
# Stop services
docker compose down
# Remove everything including volumes
docker compose down -v
```
### Production
```bash
# Start in production mode
FLASK_ENV=production docker compose up -d
# Update and restart
docker compose pull
docker compose up --build -d
```
## Troubleshooting
### Common Issues
**1. Permission Issues with Uploads**
```bash
# Fix upload directory permissions
sudo chown -R 1000:1000 ./app/static/uploads
```
**2. Database Migration Fails**
```bash
# Run migration manually
docker compose exec web python migrate_session_changes.py
```
**3. Build Fails**
```bash
# Clean build
docker compose build --no-cache
```
### Health Check
The application includes a health check that:
- Tests HTTP connectivity on port 5000
- Retries 3 times with 5-second delays
- Runs every 30 seconds
- Allows 40 seconds for startup
## Security Notes
**For Production:**
1. Change default admin credentials via environment variables
2. Set a secure `SECRET_KEY`
3. Use HTTPS reverse proxy (nginx/traefik)
4. Limit file upload sizes
5. Regular database backups
## File Locations in Container
- **Application**: `/app/`
- **Database**: `/app/pet_pictures.db`
- **Uploads**: `/app/app/static/uploads/`
- **Logs**: `/app/logs/` (if logging enabled)
## Changes from Previous Version
**Updated paths** for refactored app structure
**New migration script** (`migrate_session_changes.py`)
**Added environment variables** for configuration
**Improved .dockerignore** for smaller builds
**Removed obsolete version** from docker-compose.yml
**Better volume mapping** for uploads directory

111
README_MIGRATION.md Normal file
View File

@@ -0,0 +1,111 @@
# Database Migration Guide
## Session Changes Migration
During this development session, the following database changes were made:
### Changes Applied
1. **Added `likes` column** - INTEGER DEFAULT 0 (for the cookie-based like system)
2. **Ensured `description` column exists** - TEXT (for pet picture descriptions)
3. **Verified `posted` column** - BOOLEAN DEFAULT 0 (for admin posting control)
### Migration Script
Run the migration script to update your database:
```bash
# Make sure you're in the project directory
cd /path/to/pet-picture-queue
# Activate virtual environment (if using one)
source .venv/bin/activate
# Run the migration
python migrate_session_changes.py
```
### What the Migration Does
The `migrate_session_changes.py` script:
-**Idempotent**: Safe to run multiple times
-**Backwards Compatible**: Won't break existing data
-**Verification**: Checks that migration completed successfully
-**Error Handling**: Proper error messages and rollback protection
-**Database Detection**: Automatically finds your database file
### Migration Output
You should see output like:
```
============================================================
Pet Picture Database Migration Script
Session Changes: Adding likes system and ensuring schema consistency
============================================================
Using database: pet_pictures.db
Starting database migration...
• Description column already exists
✓ Added likes column and initialized existing records
• Posted column already exists
✓ Cleaned up NULL values
Final table schema:
id INTEGER NOT NULL
filename TEXT NOT NULL
subscriber_name TEXT NOT NULL
description TEXT NULL
uploaded_at TIMESTAMP NOT NULL
posted BOOLEAN NULL DEFAULT 0
likes INTEGER NULL DEFAULT 0
Migration completed successfully!
Applied 1 migrations
Database contains X pet pictures
Migration completed at: 2025-01-XX...
============================================================
Verifying migration...
✅ Verification successful: All required columns present
🎉 Migration completed successfully!
============================================================
```
### Troubleshooting
**If migration fails:**
1. Check that the database file exists and is accessible
2. Ensure you have write permissions to the database file
3. Make sure no other process is using the database
4. Check the error message for specific issues
**If you need to start fresh:**
```bash
# Backup existing database (optional)
cp pet_pictures.db pet_pictures.db.backup
# Remove database and let app recreate it
rm pet_pictures.db
# Run the app - it will create a new database with correct schema
python main.py
```
### Schema After Migration
The final `pet_pictures` table schema:
| Column | Type | Constraints | Default | Description |
|--------|------|-------------|---------|-------------|
| id | INTEGER | PRIMARY KEY, AUTOINCREMENT | | Unique picture ID |
| filename | TEXT | NOT NULL | | Stored filename |
| subscriber_name | TEXT | NOT NULL | | Name of submitter |
| description | TEXT | | | Optional picture description |
| uploaded_at | TIMESTAMP | NOT NULL | | Upload timestamp |
| posted | BOOLEAN | | 0 | Admin approval status |
| likes | INTEGER | | 0 | Number of likes received |
### Files Created
- `migrate_session_changes.py` - Main migration script
- `README_MIGRATION.md` - This documentation
- `add_likes_column.py` - Individual likes column migration (deprecated, use main script)

48
app/__init__.py Normal file
View File

@@ -0,0 +1,48 @@
"""
Pets of Powerwashing - A Flask application for managing pet picture submissions
"""
from flask import Flask
import os
def create_app():
"""Application factory pattern for Flask app creation"""
app = Flask(__name__)
# Import configuration
from app.config import Config
app.config.from_object(Config)
# Ensure upload directory exists
upload_path = os.path.join(app.static_folder, 'uploads')
os.makedirs(upload_path, exist_ok=True)
# Update config to use absolute path for file operations
app.config['UPLOAD_FOLDER'] = upload_path
# Setup logging
from app.utils.logging_config import setup_logging
setup_logging(app)
# Register error handlers
from app.utils.error_handlers import register_error_handlers
register_error_handlers(app)
# Initialize database
from app.models.database import init_db, close_db
with app.app_context():
init_db()
# Register database close function
app.teardown_appcontext(close_db)
# Register blueprints
from app.routes.main import main_bp
from app.routes.auth import auth_bp
from app.routes.pictures import pictures_bp
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(pictures_bp)
return app

40
app/config.py Normal file
View File

@@ -0,0 +1,40 @@
"""
Configuration settings for the application
"""
import os
class Config:
"""Base configuration class"""
# Flask settings
SECRET_KEY = os.environ.get('SECRET_KEY') or os.urandom(24)
# Upload settings
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER') or 'static/uploads'
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
# Authentication settings
ADMIN_USERNAME = os.environ.get('ADMIN_USERNAME') or 'admin'
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD') or 'password123'
# Database settings
DATABASE_PATH = os.environ.get('DATABASE_PATH') or 'pet_pictures.db'
class DevelopmentConfig(Config):
"""Development configuration"""
DEBUG = True
class ProductionConfig(Config):
"""Production configuration"""
DEBUG = False
class TestingConfig(Config):
"""Testing configuration"""
TESTING = True
DATABASE_PATH = ':memory:'

1
app/models/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Models package

94
app/models/database.py Normal file
View File

@@ -0,0 +1,94 @@
"""
Database operations and models for pet pictures
"""
import sqlite3
from flask import current_app, g
def get_db():
"""Get database connection"""
if 'db' not in g:
g.db = sqlite3.connect("pet_pictures.db")
g.db.row_factory = sqlite3.Row
return g.db
def close_db(e=None):
"""Close database connection"""
db = g.pop('db', None)
if db is not None:
db.close()
def init_db():
"""Initialize database with required tables"""
db = get_db()
db.execute(
"""
CREATE TABLE IF NOT EXISTS pet_pictures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
subscriber_name TEXT NOT NULL,
description TEXT,
uploaded_at TIMESTAMP NOT NULL,
posted BOOLEAN DEFAULT 0,
likes INTEGER DEFAULT 0
)
"""
)
db.commit()
class PetPicture:
"""Model for pet picture operations"""
@staticmethod
def get_all():
"""Get all pet pictures"""
db = get_db()
return db.execute(
"SELECT * FROM pet_pictures ORDER BY uploaded_at DESC"
).fetchall()
@staticmethod
def get_posted():
"""Get only posted pet pictures"""
db = get_db()
return db.execute(
"SELECT * FROM pet_pictures WHERE posted = 1 ORDER BY uploaded_at DESC"
).fetchall()
@staticmethod
def create(filename, subscriber_name, description, uploaded_at):
"""Create new pet picture record"""
db = get_db()
db.execute(
"INSERT INTO pet_pictures (filename, subscriber_name, description, uploaded_at) VALUES (?, ?, ?, ?)",
(filename, subscriber_name, description, uploaded_at)
)
db.commit()
@staticmethod
def mark_as_posted(picture_id):
"""Mark picture as posted"""
db = get_db()
db.execute("UPDATE pet_pictures SET posted = 1 WHERE id = ?", (picture_id,))
db.commit()
@staticmethod
def update_likes(picture_id, increment=True):
"""Update like count for a picture"""
db = get_db()
if increment:
db.execute("UPDATE pet_pictures SET likes = likes + 1 WHERE id = ?", (picture_id,))
else:
db.execute("UPDATE pet_pictures SET likes = likes - 1 WHERE id = ?", (picture_id,))
db.commit()
@staticmethod
def get_like_count(picture_id):
"""Get current like count for a picture"""
db = get_db()
result = db.execute("SELECT likes FROM pet_pictures WHERE id = ?", (picture_id,)).fetchone()
return result['likes'] if result else 0

1
app/routes/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Routes package

33
app/routes/auth.py Normal file
View File

@@ -0,0 +1,33 @@
"""
Authentication routes
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash
from app.utils.auth import check_credentials, login_user, logout_user
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
"""Login page and authentication"""
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if check_credentials(username, password):
login_user()
flash('Successfully logged in!')
return redirect(url_for('main.index'))
else:
flash('Invalid username or password.')
return render_template('login.html')
@auth_bp.route('/logout')
def logout():
"""Logout user and redirect to main page"""
logout_user()
flash('You have been logged out.')
return redirect(url_for('main.index'))

30
app/routes/main.py Normal file
View File

@@ -0,0 +1,30 @@
"""
Main application routes
"""
from flask import Blueprint, render_template, request
from app.models.database import PetPicture
from app.utils.auth import is_authenticated
from app.utils.helpers import get_liked_pictures_from_cookie, add_liked_status_to_pictures
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
def index():
"""Main gallery page - shows all pictures for admins, posted only for public"""
# Get liked pictures from cookie
liked_list = get_liked_pictures_from_cookie(request)
# Get pictures based on authentication status
if is_authenticated():
pictures = PetPicture.get_all()
public_view = False
else:
pictures = PetPicture.get_posted()
public_view = True
# Add liked status to pictures
pictures_with_likes = add_liked_status_to_pictures(pictures, liked_list)
return render_template("index.html", pictures=pictures_with_likes, public_view=public_view)

81
app/routes/pictures.py Normal file
View File

@@ -0,0 +1,81 @@
"""
Picture management routes
"""
import os
from datetime import datetime
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_from_directory, make_response, jsonify, current_app
from werkzeug.utils import secure_filename
from app.models.database import PetPicture
from app.utils.auth import login_required
from app.utils.helpers import allowed_file, handle_like_action
pictures_bp = Blueprint('pictures', __name__)
@pictures_bp.route('/upload', methods=['GET', 'POST'])
@login_required
def upload():
"""Upload new pet picture"""
if request.method == 'POST':
if 'picture' not in request.files:
flash('No file selected')
return redirect(request.url)
file = request.files['picture']
subscriber_name = request.form.get('subscriber_name')
description = request.form.get('description', '').strip()
if file.filename == '':
flash('No file selected')
return redirect(request.url)
if not subscriber_name:
flash('Subscriber name is required')
return redirect(request.url)
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_')
filename = timestamp + filename
# Ensure upload directory exists
upload_path = current_app.config['UPLOAD_FOLDER']
os.makedirs(upload_path, exist_ok=True)
file.save(os.path.join(upload_path, filename))
# Save to database
PetPicture.create(filename, subscriber_name, description, datetime.now())
flash('Picture uploaded successfully!')
return redirect(url_for('main.index'))
flash('Invalid file type')
return redirect(request.url)
return render_template('upload.html')
@pictures_bp.route('/mark_posted/<int:picture_id>', methods=['POST'])
@login_required
def mark_posted(picture_id):
"""Mark picture as posted"""
PetPicture.mark_as_posted(picture_id)
flash('Picture marked as posted!')
return redirect(url_for('main.index'))
@pictures_bp.route('/like/<int:picture_id>', methods=['POST'])
def like_picture(picture_id):
"""Handle like/unlike actions"""
return handle_like_action(picture_id, request)
@pictures_bp.route('/download/<filename>')
def download_file(filename):
"""Download original picture file"""
return send_from_directory(
current_app.config['UPLOAD_FOLDER'], filename, as_attachment=True
)

View File

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

134
app/templates/base.html Normal file
View File

@@ -0,0 +1,134 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Pets of Powerwashing{% endblock %}</title>
<link
href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
rel="stylesheet"
/>
<style>
.modal {
display: none;
}
.modal.active {
display: flex;
}
</style>
</head>
<body class="bg-gray-100 min-h-screen">
<nav class="bg-white">
<div class="max-w-6xl mx-auto px-4">
<div class="flex justify-between items-center py-4">
<div class="text-xl font-semibold text-gray-800">
<a href="{{ url_for('main.index') }}">Pets of Powerwashing</a>
</div>
<div class="space-x-4">
<a
href="{{ url_for('main.index') }}"
class="text-gray-600 hover:text-gray-800"
>Gallery</a
>
{% if session.logged_in %}
<a
href="{{ url_for('pictures.upload') }}"
class="text-gray-600 hover:text-gray-800"
>Upload New</a
>
<a
href="{{ url_for('auth.logout') }}"
class="text-gray-600 hover:text-gray-800"
>Logout</a
>
{% else %}
<a
href="{{ url_for('auth.login') }}"
class="text-gray-600 hover:text-gray-800"
>Login</a
>
{% endif %}
</div>
</div>
</div>
</nav>
<main class="max-w-6xl mx-auto px-4 py-8">
{% with messages = get_flashed_messages() %} {% if messages %} {% for
message in messages %}
<div
class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 mb-4"
role="alert"
>
{{ message }}
</div>
{% endfor %} {% endif %} {% endwith %} {% block content %}{% endblock %}
</main>
<!-- Modal for fullscreen image view -->
<div id="imageModal" class="modal fixed inset-0 bg-black bg-opacity-75 items-center justify-center z-50" onclick="closeModal()">
<div class="relative max-w-full max-h-full p-4" onclick="event.stopPropagation()">
<button onclick="closeModal()" class="absolute top-4 right-4 text-white text-4xl hover:text-gray-300 z-10">&times;</button>
<img id="modalImage" src="" alt="" class="max-w-full max-h-full object-contain">
<div id="modalInfo" class="absolute bottom-4 left-4 bg-black bg-opacity-50 text-white p-3 rounded">
<h3 id="modalTitle" class="text-lg font-semibold"></h3>
<p id="modalDescription" class="text-sm"></p>
<p id="modalDate" class="text-xs opacity-75"></p>
</div>
</div>
</div>
<script>
function openModal(imageSrc, title, description, date) {
document.getElementById('modalImage').src = imageSrc;
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalDescription').textContent = description;
document.getElementById('modalDate').textContent = date;
document.getElementById('imageModal').classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeModal() {
document.getElementById('imageModal').classList.remove('active');
document.body.style.overflow = 'auto';
}
// Close modal with Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeModal();
}
});
// Like functionality
function likePicture(pictureId) {
fetch('/like/' + pictureId, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
const likeBtn = document.getElementById('like-btn-' + pictureId);
const likeCount = document.getElementById('like-count-' + pictureId);
// Update like count
likeCount.textContent = data.like_count;
// Update button appearance and heart
if (data.liked) {
likeBtn.className = 'flex-1 flex items-center justify-center px-4 py-2 rounded transition-colors bg-red-500 text-white hover:bg-red-600';
likeBtn.querySelector('span').textContent = '❤️';
} else {
likeBtn.className = 'flex-1 flex items-center justify-center px-4 py-2 rounded transition-colors bg-gray-200 text-gray-700 hover:bg-gray-300';
likeBtn.querySelector('span').textContent = '🤍';
}
})
.catch(error => {
console.error('Error:', error);
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}Page Not Found - Pets of Powerwashing{% endblock %}
{% block content %}
<div class="text-center py-12">
<h1 class="text-6xl font-bold text-gray-400 mb-4">404</h1>
<h2 class="text-2xl font-semibold text-gray-800 mb-4">Page Not Found</h2>
<p class="text-gray-600 mb-6">Sorry, the page you're looking for doesn't exist.</p>
<a href="{{ url_for('main.index') }}" class="bg-blue-500 text-white px-6 py-3 rounded hover:bg-blue-600 transition-colors">
Back to Gallery
</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}File Too Large - Pets of Powerwashing{% endblock %}
{% block content %}
<div class="text-center py-12">
<h1 class="text-6xl font-bold text-gray-400 mb-4">413</h1>
<h2 class="text-2xl font-semibold text-gray-800 mb-4">File Too Large</h2>
<p class="text-gray-600 mb-6">The file you're trying to upload is too large. Please choose a smaller file.</p>
<a href="{{ url_for('pictures.upload') }}" class="bg-blue-500 text-white px-6 py-3 rounded hover:bg-blue-600 transition-colors">
Try Again
</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}Server Error - Pets of Powerwashing{% endblock %}
{% block content %}
<div class="text-center py-12">
<h1 class="text-6xl font-bold text-gray-400 mb-4">500</h1>
<h2 class="text-2xl font-semibold text-gray-800 mb-4">Internal Server Error</h2>
<p class="text-gray-600 mb-6">Something went wrong on our end. Please try again later.</p>
<a href="{{ url_for('main.index') }}" class="bg-blue-500 text-white px-6 py-3 rounded hover:bg-blue-600 transition-colors">
Back to Gallery
</a>
</div>
{% endblock %}

92
app/templates/index.html Normal file
View File

@@ -0,0 +1,92 @@
{% extends "base.html" %} {% block title %}Pet Pictures - Pets of Powerwashing{%
endblock %} {% block content %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for picture in pictures %}
<div
class="bg-white rounded-lg shadow-md overflow-hidden {% if picture.posted and not public_view %}opacity-50{% endif %}"
>
<div class="relative group">
<a
href="javascript:void(0)"
onclick="openModal('{{ url_for('static', filename='uploads/' + picture.filename) }}', 'From: {{ picture.subscriber_name }}', '{{ picture.description or '' }}', 'Uploaded: {{ picture.uploaded_at }}')"
class="block transform transition-transform hover:scale-105"
>
<img
src="{{ url_for('static', filename='uploads/' + picture.filename) }}"
alt="Pet picture from {{ picture.subscriber_name }}"
class="w-full h-64 object-cover transition-transform duration-300"
/>
<div
class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-opacity duration-300 flex items-center justify-center"
>
<span
class="text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300"
>
Click to view fullscreen
</span>
</div>
</a>
</div>
<div class="p-4">
<h3 class="text-lg font-semibold text-gray-800">
From: {{ picture.subscriber_name }}
</h3>
{% if picture.description %}
<p class="mt-2 text-gray-600">{{ picture.description }}</p>
{% endif %}
<p class="text-sm text-gray-600">Uploaded: {{ picture.uploaded_at }}</p>
<div class="mt-4 space-y-2">
<div class="flex space-x-2 mb-2">
<button
onclick="likePicture({{ picture.id }})"
id="like-btn-{{ picture.id }}"
class="flex-1 flex items-center justify-center px-4 py-2 rounded transition-colors {% if picture.user_liked %}bg-red-500 text-white hover:bg-red-600{% else %}bg-gray-200 text-gray-700 hover:bg-gray-300{% endif %}"
>
<span class="mr-1">{% if picture.user_liked %}❤️{% else %}🤍{% endif %}</span>
<span id="like-count-{{ picture.id }}">{{ picture.likes or 0 }}</span>
</button>
</div>
<a
href="{{ url_for('pictures.download_file', filename=picture.filename) }}"
class="block w-full bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors text-center"
>
Download Original
</a>
{% if not public_view %}
{% if not picture.posted %}
<form
action="{{ url_for('pictures.mark_posted', picture_id=picture.id) }}"
method="POST"
>
<button
type="submit"
class="w-full bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 transition-colors"
>
Mark as Posted
</button>
</form>
{% else %}
<div class="text-center text-green-600 font-semibold">✓ Posted</div>
{% endif %}
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="col-span-full text-center py-12">
{% if public_view %}
<p class="text-gray-600 text-lg">No published pet pictures yet.</p>
<p class="text-gray-500 text-sm mt-2">Pictures will appear here once they are marked as posted by an admin.</p>
{% else %}
<p class="text-gray-600 text-lg">No pet pictures uploaded yet.</p>
<a
href="{{ url_for('pictures.upload') }}"
class="mt-4 inline-block bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600 transition-colors"
>
Upload Your First Picture
</a>
{% endif %}
</div>
{% endfor %}
</div>
{% endblock %}

50
app/templates/login.html Normal file
View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Login - Pets of Powerwashing{% endblock %}
{% block content %}
<div class="max-w-md mx-auto bg-white rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold text-gray-800 mb-6 text-center">Admin Login</h2>
<form method="POST" class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input
type="text"
id="username"
name="username"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter username"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
id="password"
name="password"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter password"
/>
</div>
<button
type="submit"
class="w-full bg-blue-500 text-white py-2 px-4 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Login
</button>
</form>
<div class="mt-6 text-center">
<a href="{{ url_for('main.index') }}" class="text-blue-500 hover:text-blue-600 text-sm">
Back to Gallery
</a>
</div>
</div>
{% endblock %}

View File

@@ -1,11 +1,11 @@
{% extends "base.html" %} {% block title %}Upload Pet Picture - Pet Picture
Queue{% endblock %} {% block content %}
{% extends "base.html" %} {% block title %}Upload Pet Picture - Pets of
Powerwashing{% endblock %} {% block content %}
<div class="max-w-2xl mx-auto">
<div class="bg-white rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold text-gray-800 mb-6">Upload Pet Picture</h2>
<form
action="{{ url_for('upload') }}"
action="{{ url_for('pictures.upload') }}"
method="POST"
enctype="multipart/form-data"
class="space-y-6"
@@ -25,6 +25,19 @@ Queue{% endblock %} {% block content %}
/>
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700"
>Description (Optional)</label
>
<textarea
name="description"
id="description"
rows="3"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="Add a description for the pet picture..."
></textarea>
</div>
<div>
<label for="picture" class="block text-sm font-medium text-gray-700"
>Pet Picture</label

1
app/utils/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Utils package

38
app/utils/auth.py Normal file
View File

@@ -0,0 +1,38 @@
"""
Authentication utilities and decorators
"""
from functools import wraps
from flask import session, flash, redirect, url_for, current_app
def login_required(f):
"""Decorator to require login for route access"""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'logged_in' not in session:
flash('Please log in to access this page.')
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated_function
def check_credentials(username, password):
"""Check if provided credentials are valid"""
return (username == current_app.config['ADMIN_USERNAME'] and
password == current_app.config['ADMIN_PASSWORD'])
def login_user():
"""Log in the user by setting session"""
session['logged_in'] = True
def logout_user():
"""Log out the user by clearing session"""
session.pop('logged_in', None)
def is_authenticated():
"""Check if current user is authenticated"""
return 'logged_in' in session

View File

@@ -0,0 +1,24 @@
"""
Error handlers for the application
"""
from flask import render_template, current_app
def register_error_handlers(app):
"""Register error handlers with the app"""
@app.errorhandler(404)
def not_found_error(error):
current_app.logger.error(f'404 error: {error}')
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def internal_error(error):
current_app.logger.error(f'500 error: {error}')
return render_template('errors/500.html'), 500
@app.errorhandler(413)
def too_large(error):
current_app.logger.error(f'413 error - File too large: {error}')
return render_template('errors/413.html'), 413

60
app/utils/helpers.py Normal file
View File

@@ -0,0 +1,60 @@
"""
Utility helper functions
"""
from flask import current_app, make_response, jsonify
from app.models.database import PetPicture
def allowed_file(filename):
"""Check if file extension is allowed"""
return ('.' in filename and
filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS'])
def get_liked_pictures_from_cookie(request):
"""Extract liked picture IDs from cookie"""
liked_pictures = request.cookies.get('liked_pictures', '')
return liked_pictures.split(',') if liked_pictures else []
def add_liked_status_to_pictures(pictures, liked_list):
"""Add user_liked status to picture records"""
pictures_with_likes = []
for picture in pictures:
picture_dict = dict(picture)
picture_dict['user_liked'] = str(picture['id']) in liked_list
pictures_with_likes.append(picture_dict)
return pictures_with_likes
def handle_like_action(picture_id, request):
"""Handle like/unlike action and return JSON response"""
# Get existing likes from cookie
liked_list = get_liked_pictures_from_cookie(request)
picture_id_str = str(picture_id)
if picture_id_str in liked_list:
# Unlike: remove from cookie and decrement count
liked_list.remove(picture_id_str)
PetPicture.update_likes(picture_id, increment=False)
liked = False
else:
# Like: add to cookie and increment count
liked_list.append(picture_id_str)
PetPicture.update_likes(picture_id, increment=True)
liked = True
# Get updated like count
like_count = PetPicture.get_like_count(picture_id)
# Create response with updated cookie
response = make_response(jsonify({
'liked': liked,
'like_count': like_count
}))
# Update cookie (expires in 1 year)
response.set_cookie('liked_pictures', ','.join(liked_list), max_age=365*24*60*60)
return response

View File

@@ -0,0 +1,32 @@
"""
Logging configuration
"""
import logging
import os
from logging.handlers import RotatingFileHandler
def setup_logging(app):
"""Setup application logging"""
if not app.debug and not app.testing:
# Production logging setup
if not os.path.exists('logs'):
os.mkdir('logs')
file_handler = RotatingFileHandler(
'logs/pets_powerwashing.log',
maxBytes=10240,
backupCount=10
)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
))
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
app.logger.info('Pets of Powerwashing startup')
else:
# Development logging
app.logger.setLevel(logging.DEBUG)

44
docker-compose.yml Normal file
View File

@@ -0,0 +1,44 @@
services:
web:
build: .
ports:
- "54321:5000"
volumes:
- ./app/static/uploads:/app/app/static/uploads
- ./pet_pictures.db:/app/pet_pictures.db
environment:
- FLASK_APP=main.py
- FLASK_ENV=production
- GUNICORN_CMD_ARGS=--workers=1 --bind=0.0.0.0:5000 --timeout=120 --keep-alive=5 --worker-class=sync --worker-connections=1000 --max-requests=1000 --max-requests-jitter=50
# Application configuration
- UPLOAD_FOLDER=app/static/uploads
- DATABASE_PATH=pet_pictures.db
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=password123
restart: unless-stopped
healthcheck:
test:
[
"CMD",
"curl",
"-f",
"http://localhost:5000/",
"--connect-timeout",
"5",
"--retry",
"3",
"--retry-delay",
"5",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
migrate:
build: .
volumes:
- ./pet_pictures.db:/app/pet_pictures.db
command: python migrate_session_changes.py
profiles:
- migrate

View File

@@ -0,0 +1,13 @@
2025-08-07 18:12:42,230 INFO: Pets of Powerwashing startup [in /Users/ryanchen/Programs/pet-picture-queue/app/utils/logging_config.py:29]
2025-08-07 23:46:18,045 INFO: Pets of Powerwashing startup [in /Users/ryanchen/Programs/pet-picture-queue/app/utils/logging_config.py:29]
2025-08-07 23:46:25,378 INFO: Pets of Powerwashing startup [in /Users/ryanchen/Programs/pet-picture-queue/app/utils/logging_config.py:29]
2025-08-07 23:46:48,136 INFO: Pets of Powerwashing startup [in /Users/ryanchen/Programs/pet-picture-queue/app/utils/logging_config.py:29]
2025-08-07 23:46:56,323 INFO: Pets of Powerwashing startup [in /Users/ryanchen/Programs/pet-picture-queue/app/utils/logging_config.py:29]
2025-08-07 23:47:05,316 INFO: Pets of Powerwashing startup [in /Users/ryanchen/Programs/pet-picture-queue/app/utils/logging_config.py:29]
2025-08-07 23:47:20,820 INFO: Pets of Powerwashing startup [in /Users/ryanchen/Programs/pet-picture-queue/app/utils/logging_config.py:29]
2025-08-07 23:47:44,082 INFO: Pets of Powerwashing startup [in /Users/ryanchen/Programs/pet-picture-queue/app/utils/logging_config.py:29]
2025-08-07 23:47:44,243 INFO: Pets of Powerwashing startup [in /Users/ryanchen/Programs/pet-picture-queue/app/utils/logging_config.py:29]
2025-08-07 23:48:09,264 ERROR: 404 error: 404 Not Found: The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again. [in /Users/ryanchen/Programs/pet-picture-queue/app/utils/error_handlers.py:13]
2025-08-15 07:36:43,456 INFO: Pets of Powerwashing startup [in /Users/ryanchen/Programs/pet-picture-queue/app/utils/logging_config.py:29]
2025-08-15 07:36:47,429 ERROR: 404 error: 404 Not Found: The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again. [in /Users/ryanchen/Programs/pet-picture-queue/app/utils/error_handlers.py:13]
2025-08-15 07:37:31,681 INFO: Pets of Powerwashing startup [in /Users/ryanchen/Programs/pet-picture-queue/app/utils/logging_config.py:29]

131
main.py
View File

@@ -1,120 +1,19 @@
#!/usr/bin/env python3
"""
Pets of Powerwashing - Main application entry point
A Flask application for managing pet picture submissions with authentication,
likes system, and public gallery functionality.
"""
import os
import sqlite3
from datetime import datetime
from flask import (
Flask,
render_template,
request,
redirect,
url_for,
flash,
send_from_directory,
)
from werkzeug.utils import secure_filename
app = Flask(__name__)
app.secret_key = os.urandom(24)
# Configure upload folder
UPLOAD_FOLDER = "static/uploads"
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif"}
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
# Ensure upload directory exists
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
def get_db():
db = sqlite3.connect("pet_pictures.db")
db.row_factory = sqlite3.Row
return db
def init_db():
with get_db() as db:
db.execute(
"""
CREATE TABLE IF NOT EXISTS pet_pictures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
subscriber_name TEXT NOT NULL,
uploaded_at TIMESTAMP NOT NULL,
posted BOOLEAN DEFAULT 0
)
"""
)
db.commit()
def allowed_file(filename):
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
@app.route("/")
def index():
with get_db() as db:
pictures = db.execute(
"SELECT * FROM pet_pictures ORDER BY uploaded_at DESC"
).fetchall()
return render_template("index.html", pictures=pictures)
@app.route("/upload", methods=["GET", "POST"])
def upload():
if request.method == "POST":
if "picture" not in request.files:
flash("No file selected")
return redirect(request.url)
file = request.files["picture"]
subscriber_name = request.form.get("subscriber_name")
if file.filename == "":
flash("No file selected")
return redirect(request.url)
if not subscriber_name:
flash("Subscriber name is required")
return redirect(request.url)
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_")
filename = timestamp + filename
file.save(os.path.join(app.config["UPLOAD_FOLDER"], filename))
with get_db() as db:
db.execute(
"INSERT INTO pet_pictures (filename, subscriber_name, uploaded_at) VALUES (?, ?, ?)",
(filename, subscriber_name, datetime.now()),
)
db.commit()
flash("Picture uploaded successfully!")
return redirect(url_for("index"))
flash("Invalid file type")
return redirect(request.url)
return render_template("upload.html")
@app.route("/mark_posted/<int:picture_id>", methods=["POST"])
def mark_posted(picture_id):
with get_db() as db:
db.execute("UPDATE pet_pictures SET posted = 1 WHERE id = ?", (picture_id,))
db.commit()
flash("Picture marked as posted!")
return redirect(url_for("index"))
@app.route("/download/<filename>")
def download_file(filename):
return send_from_directory(
app.config["UPLOAD_FOLDER"], filename, as_attachment=True
)
from app import create_app
# Create Flask application using factory pattern
app = create_app()
if __name__ == "__main__":
init_db()
app.run(debug=True)
# Development server configuration
debug_mode = os.environ.get('FLASK_ENV') == 'development'
port = int(os.environ.get('PORT', 5001)) # Use port 5001 to avoid AirPlay conflict
app.run(debug=debug_mode, host='0.0.0.0', port=port)

33
migrate.py Normal file
View File

@@ -0,0 +1,33 @@
import sqlite3
import sys
def migrate_database():
try:
# Connect to the database
db = sqlite3.connect("pet_pictures.db")
cursor = db.cursor()
# Check if description column exists
cursor.execute("PRAGMA table_info(pet_pictures)")
columns = [column[1] for column in cursor.fetchall()]
if "description" not in columns:
print("Adding description column to pet_pictures table...")
cursor.execute("ALTER TABLE pet_pictures ADD COLUMN description TEXT")
db.commit()
print("Migration completed successfully!")
else:
print("Description column already exists. No migration needed.")
except sqlite3.Error as e:
print(f"An error occurred: {e}")
sys.exit(1)
finally:
if db:
db.close()
if __name__ == "__main__":
print("Starting database migration...")
migrate_database()

206
migrate_session_changes.py Executable file
View File

@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""
Database migration script for session changes
Handles all database schema changes made during this session:
1. Adding likes column (if not exists)
2. Ensuring description column exists
3. Updating any existing records to have default values
This script is idempotent - can be run multiple times safely.
"""
import sqlite3
import sys
import os
from datetime import datetime
def get_database_path():
"""Get the correct database path"""
if os.path.exists("pet_pictures.db"):
return "pet_pictures.db"
elif os.path.exists("app/pet_pictures.db"):
return "app/pet_pictures.db"
else:
return "pet_pictures.db" # Default path
def check_column_exists(cursor, table_name, column_name):
"""Check if a column exists in a table"""
cursor.execute(f"PRAGMA table_info({table_name})")
columns = [column[1] for column in cursor.fetchall()]
return column_name in columns
def migrate_database():
"""Main migration function"""
db_path = get_database_path()
print(f"Using database: {db_path}")
try:
# Connect to the database
db = sqlite3.connect(db_path)
cursor = db.cursor()
print("Starting database migration...")
# Check if pet_pictures table exists
cursor.execute("""
SELECT name FROM sqlite_master
WHERE type='table' AND name='pet_pictures'
""")
if not cursor.fetchone():
print("pet_pictures table doesn't exist. Creating it...")
cursor.execute("""
CREATE TABLE pet_pictures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
subscriber_name TEXT NOT NULL,
description TEXT,
uploaded_at TIMESTAMP NOT NULL,
posted BOOLEAN DEFAULT 0,
likes INTEGER DEFAULT 0
)
""")
db.commit()
print("✓ Created pet_pictures table with all columns")
return
migrations_applied = 0
# Migration 1: Add description column if it doesn't exist
if not check_column_exists(cursor, 'pet_pictures', 'description'):
print("Adding description column...")
cursor.execute("ALTER TABLE pet_pictures ADD COLUMN description TEXT")
db.commit()
print("✓ Added description column")
migrations_applied += 1
else:
print("• Description column already exists")
# Migration 2: Add likes column if it doesn't exist
if not check_column_exists(cursor, 'pet_pictures', 'likes'):
print("Adding likes column...")
cursor.execute("ALTER TABLE pet_pictures ADD COLUMN likes INTEGER DEFAULT 0")
# Update existing records to have 0 likes
cursor.execute("UPDATE pet_pictures SET likes = 0 WHERE likes IS NULL")
db.commit()
print("✓ Added likes column and initialized existing records")
migrations_applied += 1
else:
print("• Likes column already exists")
# Migration 3: Ensure posted column exists and has correct type
if not check_column_exists(cursor, 'pet_pictures', 'posted'):
print("Adding posted column...")
cursor.execute("ALTER TABLE pet_pictures ADD COLUMN posted BOOLEAN DEFAULT 0")
db.commit()
print("✓ Added posted column")
migrations_applied += 1
else:
print("• Posted column already exists")
# Migration 4: Clean up any NULL values in critical columns
print("Cleaning up NULL values...")
cursor.execute("""
UPDATE pet_pictures
SET likes = 0
WHERE likes IS NULL
""")
cursor.execute("""
UPDATE pet_pictures
SET posted = 0
WHERE posted IS NULL
""")
cursor.execute("""
UPDATE pet_pictures
SET description = ''
WHERE description IS NULL
""")
db.commit()
print("✓ Cleaned up NULL values")
# Verify final schema
cursor.execute("PRAGMA table_info(pet_pictures)")
columns = cursor.fetchall()
print("\nFinal table schema:")
for column in columns:
print(f" {column[1]} {column[2]} {'NOT NULL' if column[3] else 'NULL'} {f'DEFAULT {column[4]}' if column[4] else ''}")
# Get record count
cursor.execute("SELECT COUNT(*) FROM pet_pictures")
record_count = cursor.fetchone()[0]
print(f"\nMigration completed successfully!")
print(f"Applied {migrations_applied} migrations")
print(f"Database contains {record_count} pet pictures")
# Log migration
print(f"Migration completed at: {datetime.now().isoformat()}")
except sqlite3.Error as e:
print(f"❌ Database error occurred: {e}")
sys.exit(1)
except Exception as e:
print(f"❌ An unexpected error occurred: {e}")
sys.exit(1)
finally:
if 'db' in locals():
db.close()
def verify_migration():
"""Verify that the migration was successful"""
db_path = get_database_path()
try:
db = sqlite3.connect(db_path)
cursor = db.cursor()
# Check all required columns exist
required_columns = ['id', 'filename', 'subscriber_name', 'description', 'uploaded_at', 'posted', 'likes']
cursor.execute("PRAGMA table_info(pet_pictures)")
existing_columns = [column[1] for column in cursor.fetchall()]
missing_columns = set(required_columns) - set(existing_columns)
if missing_columns:
print(f"❌ Verification failed: Missing columns: {missing_columns}")
return False
print("✅ Verification successful: All required columns present")
return True
except sqlite3.Error as e:
print(f"❌ Verification failed with database error: {e}")
return False
finally:
if 'db' in locals():
db.close()
if __name__ == "__main__":
print("=" * 60)
print("Pet Picture Database Migration Script")
print("Session Changes: Adding likes system and ensuring schema consistency")
print("=" * 60)
# Run migration
migrate_database()
print("\n" + "=" * 60)
print("Verifying migration...")
# Verify migration
if verify_migration():
print("🎉 Migration completed successfully!")
else:
print("⚠️ Migration verification failed")
sys.exit(1)
print("=" * 60)

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""
Migration 001: Add description column
Date: 2025-08-08
Description: Add description field to pet_pictures table for optional image descriptions
"""
import sqlite3
import sys
from datetime import datetime
def check_column_exists(cursor, table_name, column_name):
"""Check if a column exists in a table"""
cursor.execute(f"PRAGMA table_info({table_name})")
columns = [column[1] for column in cursor.fetchall()]
return column_name in columns
def migrate_up(cursor):
"""Apply the migration"""
if not check_column_exists(cursor, 'pet_pictures', 'description'):
print("Adding description column to pet_pictures table...")
cursor.execute("ALTER TABLE pet_pictures ADD COLUMN description TEXT")
return True
else:
print("Description column already exists, skipping...")
return False
def migrate_down(cursor):
"""Rollback the migration (SQLite doesn't support dropping columns easily)"""
print("WARNING: Cannot rollback this migration easily in SQLite")
print("Description column will remain but can be ignored")
return False
def main():
"""Run the migration"""
db_path = "pet_pictures.db"
try:
db = sqlite3.connect(db_path)
cursor = db.cursor()
print(f"Running migration 001: Add description column")
print(f"Database: {db_path}")
print(f"Timestamp: {datetime.now().isoformat()}")
print("-" * 50)
if migrate_up(cursor):
db.commit()
print("✓ Migration 001 applied successfully")
else:
print("• Migration 001 already applied")
# Verify the migration
cursor.execute("PRAGMA table_info(pet_pictures)")
columns = cursor.fetchall()
print("\nFinal schema:")
for col in columns:
nullable = "NOT NULL" if col[3] else "NULL"
default = f"DEFAULT {col[4]}" if col[4] else ""
print(f" {col[1]} {col[2]} {nullable} {default}")
except sqlite3.Error as e:
print(f"❌ Migration failed: {e}")
return 1
except Exception as e:
print(f"❌ Unexpected error: {e}")
return 1
finally:
if 'db' in locals():
db.close()
return 0
if __name__ == "__main__":
sys.exit(main())

142
migrations/README.md Normal file
View File

@@ -0,0 +1,142 @@
# Database Migrations
This directory contains database migrations for the Pet Picture Queue application.
## Migration System
The migration system provides:
-**Ordered execution** - Migrations run in filename order (001_, 002_, etc.)
-**Tracking** - Applied migrations are tracked in the database
-**Idempotent** - Safe to run multiple times
-**Rollback support** - Individual migrations can implement rollback logic
## Usage
### Run All Pending Migrations
```bash
# From project root
python migrations/migrate.py
# Or make it executable and run directly
chmod +x migrations/migrate.py
./migrations/migrate.py
```
### Run Individual Migration
```bash
python migrations/001_add_description_column.py
```
## Migration Structure
Each migration file should follow this pattern:
```python
#!/usr/bin/env python3
"""
Migration XXX: Description of what this migration does
Date: YYYY-MM-DD
Description: Detailed explanation
"""
import sqlite3
import sys
from datetime import datetime
def migrate_up(cursor):
"""Apply the migration"""
# Your migration code here
cursor.execute("ALTER TABLE ...")
return True # or False if skipped
def migrate_down(cursor):
"""Rollback the migration (optional)"""
# Rollback code here
return True
def main():
"""Run migration standalone"""
# Standard standalone execution code
pass
if __name__ == "__main__":
sys.exit(main())
```
## Existing Migrations
### 001_add_description_column.py
- **Purpose**: Add description field to pet_pictures table
- **Date**: 2025-08-08
- **Changes**: Adds `description TEXT NULL` column
## Migration Naming Convention
- Use 3-digit numbers: `001_`, `002_`, `003_`, etc.
- Descriptive names: `add_description_column`, `create_user_table`
- Full format: `XXX_descriptive_name.py`
## Database Schema Tracking
The migration system creates a `migrations` table to track applied migrations:
```sql
CREATE TABLE migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
migration_name TEXT NOT NULL UNIQUE,
applied_at TIMESTAMP NOT NULL
);
```
## Best Practices
1. **Always backup** before running migrations on production data
2. **Test migrations** on development data first
3. **Make migrations idempotent** - check if changes already exist
4. **Keep migrations small** - one logical change per migration
5. **Never edit** existing migration files after they've been applied
6. **Document breaking changes** in migration comments
## Development Workflow
1. **Create migration**: Copy template and modify
2. **Test locally**: Run migration on development database
3. **Add to version control**: Commit the migration file
4. **Deploy**: Run migrations on staging/production
5. **Verify**: Check database schema and data integrity
## Troubleshooting
**Migration fails:**
```bash
# Check database connection
ls -la pet_pictures.db
# Check migration table
sqlite3 pet_pictures.db "SELECT * FROM migrations;"
# Run specific migration for debugging
python migrations/001_add_description_column.py
```
**Reset migration tracking:**
```sql
-- Careful! This will re-run all migrations
DELETE FROM migrations WHERE migration_name = '001_add_description_column';
```
## Integration
### Docker
The migration runner is integrated with Docker:
```bash
# Run migrations in Docker
docker compose --profile migrate up migrate
```
### CI/CD
Add to deployment scripts:
```bash
# Run migrations before starting app
python migrations/migrate.py && python main.py
```

1
migrations/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Migrations package

174
migrations/migrate.py Executable file
View File

@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""
Migration runner for Pet Picture Queue
Handles database schema migrations in order
"""
import os
import sys
import importlib.util
import sqlite3
from datetime import datetime
def get_database_path():
"""Get the correct database path"""
if os.path.exists("pet_pictures.db"):
return "pet_pictures.db"
elif os.path.exists("app/pet_pictures.db"):
return "app/pet_pictures.db"
else:
return "pet_pictures.db"
def create_migration_table(cursor):
"""Create migrations tracking table"""
cursor.execute("""
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
migration_name TEXT NOT NULL UNIQUE,
applied_at TIMESTAMP NOT NULL
)
""")
def get_applied_migrations(cursor):
"""Get list of applied migrations"""
try:
cursor.execute("SELECT migration_name FROM migrations ORDER BY id")
return [row[0] for row in cursor.fetchall()]
except sqlite3.OperationalError:
# Migrations table doesn't exist yet
return []
def get_migration_files():
"""Get list of migration files in order"""
migrations_dir = os.path.dirname(__file__)
migration_files = []
for file in os.listdir(migrations_dir):
if file.endswith('.py') and file != 'migrate.py' and file != '__init__.py':
migration_files.append(file)
# Sort by filename (assumes numbered naming like 001_xxx.py)
migration_files.sort()
return migration_files
def load_migration_module(migration_file):
"""Load a migration module dynamically"""
migrations_dir = os.path.dirname(__file__)
file_path = os.path.join(migrations_dir, migration_file)
spec = importlib.util.spec_from_file_location("migration", file_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def run_migrations():
"""Run pending migrations"""
db_path = get_database_path()
print(f"Using database: {db_path}")
try:
db = sqlite3.connect(db_path)
cursor = db.cursor()
# Create migrations table
create_migration_table(cursor)
db.commit()
# Get current state
applied_migrations = get_applied_migrations(cursor)
available_migrations = get_migration_files()
print(f"Applied migrations: {len(applied_migrations)}")
print(f"Available migrations: {len(available_migrations)}")
# Find pending migrations
pending_migrations = []
for migration_file in available_migrations:
migration_name = migration_file.replace('.py', '')
if migration_name not in applied_migrations:
pending_migrations.append(migration_file)
if not pending_migrations:
print("✓ No pending migrations")
return 0
print(f"\nRunning {len(pending_migrations)} pending migrations:")
# Run each pending migration
for migration_file in pending_migrations:
migration_name = migration_file.replace('.py', '')
print(f"\n📦 Running migration: {migration_name}")
try:
# Load and run migration
module = load_migration_module(migration_file)
if hasattr(module, 'migrate_up'):
success = module.migrate_up(cursor)
if success is not False: # Allow None or True
# Mark as applied
cursor.execute(
"INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)",
(migration_name, datetime.now())
)
db.commit()
print(f"✓ Migration {migration_name} applied successfully")
else:
print(f"• Migration {migration_name} skipped (already applied)")
else:
print(f"❌ Migration {migration_name} has no migrate_up function")
except Exception as e:
print(f"❌ Migration {migration_name} failed: {e}")
db.rollback()
return 1
print(f"\n🎉 All migrations completed successfully!")
# Show final schema
cursor.execute("PRAGMA table_info(pet_pictures)")
columns = cursor.fetchall()
print(f"\nFinal pet_pictures schema:")
for col in columns:
nullable = "NOT NULL" if col[3] else "NULL"
default = f"DEFAULT {col[4]}" if col[4] else ""
print(f" {col[1]} {col[2]} {nullable} {default}")
return 0
except Exception as e:
print(f"❌ Migration runner failed: {e}")
return 1
finally:
if 'db' in locals():
db.close()
def main():
"""Main entry point"""
print("=" * 60)
print("Pet Picture Queue - Database Migration Runner")
print(f"Started at: {datetime.now().isoformat()}")
print("=" * 60)
result = run_migrations()
print("=" * 60)
if result == 0:
print("✅ Migration process completed successfully!")
else:
print("❌ Migration process failed!")
print("=" * 60)
return result
if __name__ == "__main__":
sys.exit(main())

View File

@@ -10,8 +10,12 @@ dependencies = [
"flask>=3.0.0",
"werkzeug>=3.0.0",
"python-dotenv>=1.0.0",
"gunicorn>=21.2.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["."]

View File

@@ -1,47 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Pet Picture Queue{% endblock %}</title>
<link
href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
rel="stylesheet"
/>
</head>
<body class="bg-gray-100 min-h-screen">
<nav class="bg-white shadow-lg">
<div class="max-w-6xl mx-auto px-4">
<div class="flex justify-between items-center py-4">
<div class="text-xl font-semibold text-gray-800">
<a href="{{ url_for('index') }}">Pet Picture Queue</a>
</div>
<div class="space-x-4">
<a
href="{{ url_for('index') }}"
class="text-gray-600 hover:text-gray-800"
>View Pictures</a
>
<a
href="{{ url_for('upload') }}"
class="text-gray-600 hover:text-gray-800"
>Upload New</a
>
</div>
</div>
</div>
</nav>
<main class="max-w-6xl mx-auto px-4 py-8">
{% with messages = get_flashed_messages() %} {% if messages %} {% for
message in messages %}
<div
class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 mb-4"
role="alert"
>
{{ message }}
</div>
{% endfor %} {% endif %} {% endwith %} {% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -1,72 +0,0 @@
{% extends "base.html" %} {% block title %}Pet Pictures - Pet Picture Queue{%
endblock %} {% block content %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for picture in pictures %}
<div
class="bg-white rounded-lg shadow-md overflow-hidden {% if picture.posted %}opacity-50{% endif %}"
>
<div class="relative group">
<a
href="{{ url_for('static', filename='uploads/' + picture.filename) }}"
target="_blank"
class="block"
>
<img
src="{{ url_for('static', filename='uploads/' + picture.filename) }}"
alt="Pet picture from {{ picture.subscriber_name }}"
class="w-full h-64 object-cover transition-transform duration-300 group-hover:scale-105"
/>
<div
class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-opacity duration-300 flex items-center justify-center"
>
<span
class="text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300"
>
Click to view
</span>
</div>
</a>
</div>
<div class="p-4">
<h3 class="text-lg font-semibold text-gray-800">
From: {{ picture.subscriber_name }}
</h3>
<p class="text-sm text-gray-600">Uploaded: {{ picture.uploaded_at }}</p>
<div class="mt-4 space-y-2">
<a
href="{{ url_for('download_file', filename=picture.filename) }}"
class="block w-full bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors text-center"
>
Download Original
</a>
{% if not picture.posted %}
<form
action="{{ url_for('mark_posted', picture_id=picture.id) }}"
method="POST"
>
<button
type="submit"
class="w-full bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 transition-colors"
>
Mark as Posted
</button>
</form>
{% else %}
<div class="text-center text-green-600 font-semibold">✓ Posted</div>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="col-span-full text-center py-12">
<p class="text-gray-600 text-lg">No pet pictures uploaded yet.</p>
<a
href="{{ url_for('upload') }}"
class="mt-4 inline-block bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600 transition-colors"
>
Upload Your First Picture
</a>
</div>
{% endfor %}
</div>
{% endblock %}