16 Commits

Author SHA1 Message Date
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
36 changed files with 1489 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

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 @@
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]

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

@@ -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 %}