Compare commits
16 Commits
d1aace1c54
...
docker-ref
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7deeb681c0 | ||
|
|
8814dd8994 | ||
|
|
02247340d4 | ||
|
|
f3c14d649c | ||
|
|
b71187009b | ||
|
|
77cea8cbc5 | ||
|
|
918fc91604 | ||
|
|
8df728530e | ||
|
|
5081785dae | ||
|
|
1d30809507 | ||
|
|
7c62dc8d08 | ||
|
|
dd9cc42271 | ||
|
|
59e905162c | ||
|
|
febb1b67f6 | ||
|
|
57b1bb3ddd | ||
|
|
d76640e546 |
@@ -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
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
|
|
||||||
# Python
|
# IDE files
|
||||||
__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/
|
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
# Database
|
# Logs
|
||||||
*.db
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
# Uploads
|
# Development files
|
||||||
static/uploads/*
|
.env
|
||||||
!static/uploads/.gitkeep
|
.env.local
|
||||||
|
|
||||||
# Docker
|
# Cache directories
|
||||||
Dockerfile
|
.cache/
|
||||||
.dockerignore
|
|
||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -8,3 +8,16 @@ wheels/
|
|||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
pet_pictures.db
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -15,10 +15,13 @@ RUN pip install uv
|
|||||||
# Copy project files
|
# Copy project files
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
COPY main.py .
|
COPY main.py .
|
||||||
COPY templates/ templates/
|
COPY app/ app/
|
||||||
|
COPY migrate_session_changes.py .
|
||||||
|
COPY README_MIGRATION.md .
|
||||||
|
COPY README.md .
|
||||||
|
|
||||||
# Create uploads directory
|
# Create uploads directory in the correct location
|
||||||
RUN mkdir -p static/uploads
|
RUN mkdir -p app/static/uploads
|
||||||
|
|
||||||
# Create and activate virtual environment, then install dependencies
|
# Create and activate virtual environment, then install dependencies
|
||||||
RUN uv venv && \
|
RUN uv venv && \
|
||||||
@@ -29,9 +32,10 @@ RUN uv venv && \
|
|||||||
ENV FLASK_APP=main.py
|
ENV FLASK_APP=main.py
|
||||||
ENV FLASK_ENV=production
|
ENV FLASK_ENV=production
|
||||||
ENV PATH="/app/.venv/bin:$PATH"
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
ENV GUNICORN_CMD_ARGS="--workers=1 --bind=0.0.0.0:5000 --timeout=120"
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
# Run the application
|
# Run the application with Gunicorn
|
||||||
CMD ["python", "main.py"]
|
CMD ["gunicorn", "main:app"]
|
||||||
54
README.md
54
README.md
@@ -10,6 +10,7 @@ A web application to manage pet pictures from subscribers. Built with Flask, SQL
|
|||||||
- Modern, responsive UI using Tailwind CSS
|
- Modern, responsive UI using Tailwind CSS
|
||||||
- Click to view full-size images
|
- Click to view full-size images
|
||||||
- Download original images
|
- Download original images
|
||||||
|
- Add descriptions to pet pictures
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -29,8 +30,46 @@ python main.py
|
|||||||
|
|
||||||
The application will be available at `http://localhost:5000`
|
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
|
### 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:
|
1. Build the Docker image:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -41,28 +80,30 @@ docker build -t pet-picture-queue .
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
-p 5000:5000 \
|
-p 54321:5000 \
|
||||||
-v $(pwd)/static/uploads:/app/static/uploads \
|
-v $(pwd)/static/uploads:/app/static/uploads \
|
||||||
-v $(pwd)/pet_pictures.db:/app/pet_pictures.db \
|
-v $(pwd)/pet_pictures.db:/app/pet_pictures.db \
|
||||||
--name pet-picture-queue \
|
--name pet-picture-queue \
|
||||||
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
|
## Usage
|
||||||
|
|
||||||
1. Visit the homepage to see all uploaded pet pictures
|
1. Visit the homepage to see all uploaded pet pictures
|
||||||
2. Click "Upload New" to add a new pet picture
|
2. Click "Upload New" to add a new pet picture
|
||||||
3. Fill in the subscriber name and select a picture
|
3. Fill in the subscriber name and select a picture
|
||||||
4. View uploaded pictures on the homepage
|
4. Add an optional description for the pet picture
|
||||||
5. Click on any picture to view it in full size
|
5. View uploaded pictures on the homepage
|
||||||
6. Use the "Download Original" button to download the original file
|
6. Click on any picture to view it in full size
|
||||||
7. Mark pictures as posted using the "Mark as Posted" button
|
7. Use the "Download Original" button to download the original file
|
||||||
|
8. Mark pictures as posted using the "Mark as Posted" button
|
||||||
|
|
||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
- `main.py` - Main Flask application
|
- `main.py` - Main Flask application
|
||||||
|
- `migrate.py` - Database migration script
|
||||||
- `templates/` - Jinja2 templates
|
- `templates/` - Jinja2 templates
|
||||||
- `base.html` - Base template with common layout
|
- `base.html` - Base template with common layout
|
||||||
- `index.html` - Homepage with picture grid
|
- `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)
|
- `pet_pictures.db` - SQLite database (created automatically)
|
||||||
- `Dockerfile` - Docker configuration
|
- `Dockerfile` - Docker configuration
|
||||||
- `.dockerignore` - Docker build exclusions
|
- `.dockerignore` - Docker build exclusions
|
||||||
|
- `docker-compose.yml` - Docker Compose configuration
|
||||||
|
|||||||
160
README_DOCKER.md
Normal file
160
README_DOCKER.md
Normal 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
111
README_MIGRATION.md
Normal 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
48
app/__init__.py
Normal 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
40
app/config.py
Normal 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
1
app/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Models package
|
||||||
94
app/models/database.py
Normal file
94
app/models/database.py
Normal 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
1
app/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Routes package
|
||||||
33
app/routes/auth.py
Normal file
33
app/routes/auth.py
Normal 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
30
app/routes/main.py
Normal 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
81
app/routes/pictures.py
Normal 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
|
||||||
|
)
|
||||||
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 2.8 MiB |
134
app/templates/base.html
Normal file
134
app/templates/base.html
Normal 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">×</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>
|
||||||
12
app/templates/errors/404.html
Normal file
12
app/templates/errors/404.html
Normal 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 %}
|
||||||
12
app/templates/errors/413.html
Normal file
12
app/templates/errors/413.html
Normal 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 %}
|
||||||
12
app/templates/errors/500.html
Normal file
12
app/templates/errors/500.html
Normal 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
92
app/templates/index.html
Normal 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
50
app/templates/login.html
Normal 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 %}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
{% extends "base.html" %} {% block title %}Upload Pet Picture - Pet Picture
|
{% extends "base.html" %} {% block title %}Upload Pet Picture - Pets of
|
||||||
Queue{% endblock %} {% block content %}
|
Powerwashing{% endblock %} {% block content %}
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<div class="bg-white rounded-lg shadow-md p-6">
|
<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>
|
<h2 class="text-2xl font-bold text-gray-800 mb-6">Upload Pet Picture</h2>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
action="{{ url_for('upload') }}"
|
action="{{ url_for('pictures.upload') }}"
|
||||||
method="POST"
|
method="POST"
|
||||||
enctype="multipart/form-data"
|
enctype="multipart/form-data"
|
||||||
class="space-y-6"
|
class="space-y-6"
|
||||||
@@ -25,6 +25,19 @@ Queue{% endblock %} {% block content %}
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label for="picture" class="block text-sm font-medium text-gray-700"
|
<label for="picture" class="block text-sm font-medium text-gray-700"
|
||||||
>Pet Picture</label
|
>Pet Picture</label
|
||||||
1
app/utils/__init__.py
Normal file
1
app/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Utils package
|
||||||
38
app/utils/auth.py
Normal file
38
app/utils/auth.py
Normal 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
|
||||||
24
app/utils/error_handlers.py
Normal file
24
app/utils/error_handlers.py
Normal 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
60
app/utils/helpers.py
Normal 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
|
||||||
32
app/utils/logging_config.py
Normal file
32
app/utils/logging_config.py
Normal 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
44
docker-compose.yml
Normal 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
|
||||||
1
logs/pets_powerwashing.log
Normal file
1
logs/pets_powerwashing.log
Normal 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
131
main.py
@@ -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 os
|
||||||
import sqlite3
|
from app import create_app
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# Create Flask application using factory pattern
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
init_db()
|
# Development server configuration
|
||||||
app.run(debug=True)
|
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
33
migrate.py
Normal 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
206
migrate_session_changes.py
Executable 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)
|
||||||
@@ -10,8 +10,12 @@ dependencies = [
|
|||||||
"flask>=3.0.0",
|
"flask>=3.0.0",
|
||||||
"werkzeug>=3.0.0",
|
"werkzeug>=3.0.0",
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
|
"gunicorn>=21.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["."]
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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 %}
|
|
||||||
Reference in New Issue
Block a user