yurt
This commit is contained in:
45
app/__init__.py
Normal file
45
app/__init__.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
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
|
||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||
|
||||
# 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 'app/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
|
||||
)
|
||||
1
app/static/uploads/.gitkeep
Normal file
1
app/static/uploads/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
BIN
app/static/uploads/20250613_164801_IMG_2341.jpeg
Normal file
BIN
app/static/uploads/20250613_164801_IMG_2341.jpeg
Normal file
Binary file not shown.
|
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 %}
|
||||
95
app/templates/upload.html
Normal file
95
app/templates/upload.html
Normal file
@@ -0,0 +1,95 @@
|
||||
{% 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('pictures.upload') }}"
|
||||
method="POST"
|
||||
enctype="multipart/form-data"
|
||||
class="space-y-6"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="subscriber_name"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>Subscriber Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="subscriber_name"
|
||||
id="subscriber_name"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</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
|
||||
>
|
||||
<div
|
||||
class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md"
|
||||
>
|
||||
<div class="space-y-1 text-center">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-gray-400"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<path
|
||||
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<div class="flex text-sm text-gray-600">
|
||||
<label
|
||||
for="picture"
|
||||
class="relative cursor-pointer bg-white rounded-md font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-blue-500"
|
||||
>
|
||||
<span>Upload a file</span>
|
||||
<input
|
||||
id="picture"
|
||||
name="picture"
|
||||
type="file"
|
||||
class="sr-only"
|
||||
accept="image/*"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p class="pl-1">or drag and drop</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">PNG, JPG, GIF up to 10MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Upload Picture
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
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)
|
||||
Reference in New Issue
Block a user