a ton of fun happened, refactored alot
This commit is contained in:
@ -1,9 +1,6 @@
|
||||
import os
|
||||
import zipfile
|
||||
import uuid
|
||||
import io
|
||||
import traceback
|
||||
import tempfile
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from werkzeug.utils import secure_filename
|
||||
from werkzeug.datastructures import FileStorage
|
||||
@ -13,10 +10,11 @@ from flask import (
|
||||
jsonify, abort
|
||||
)
|
||||
from flask_login import login_required, current_user
|
||||
from PIL import Image, ExifTags
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from app import db
|
||||
from .models import Media, ImageHeart, FeaturedImage
|
||||
from .models import Media, ZipJob, ImageHeart, FeaturedImage
|
||||
from .tasks import process_zip
|
||||
|
||||
bp = Blueprint(
|
||||
"media",
|
||||
@ -25,43 +23,91 @@ bp = Blueprint(
|
||||
template_folder="templates"
|
||||
)
|
||||
|
||||
# ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
DOC_EXTS = {".pdf", ".txt", ".csv"}
|
||||
ZIP_EXT = ".zip"
|
||||
MAX_ZIP_FILES = 1000
|
||||
MAX_IMAGE_PIXELS = 8000 * 8000 # ~64M pixels
|
||||
|
||||
# ─── Context Processor ──────────────────────────────────────────────────────
|
||||
|
||||
# ─── Context Processor ─────────────────────────────────────────────────────────
|
||||
@bp.app_context_processor
|
||||
def utility_processor():
|
||||
def inject_helpers():
|
||||
"""Expose generate_image_url in all media templates."""
|
||||
return dict(generate_image_url=generate_image_url)
|
||||
|
||||
# ─── Helper Functions ───────────────────────────────────────────────────────
|
||||
|
||||
# ─── Helpers & Config ─────────────────────────────────────────────────────────
|
||||
def allowed_file(filename):
|
||||
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
return ext in current_app.config.get(
|
||||
"ALLOWED_EXTENSIONS",
|
||||
{"png", "jpg", "jpeg", "gif", "webp"}
|
||||
)
|
||||
|
||||
|
||||
def get_upload_path(plugin: str, related_id: int):
|
||||
def allowed_file(filename: str) -> bool:
|
||||
"""
|
||||
Return (absolute_dir, subdir) where uploads are stored:
|
||||
<UPLOAD_FOLDER>/<plugin>/<related_id>/
|
||||
Return True if the file extension is allowed.
|
||||
"""
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
allowed = current_app.config.get(
|
||||
"ALLOWED_EXTENSIONS",
|
||||
IMAGE_EXTS | DOC_EXTS | {ZIP_EXT}
|
||||
)
|
||||
return ext in allowed
|
||||
|
||||
def get_upload_path(plugin: str, related_id: int) -> (str, str):
|
||||
"""
|
||||
Build and return (absolute_dir, relative_subdir) under UPLOAD_FOLDER.
|
||||
"""
|
||||
base = current_app.config["UPLOAD_FOLDER"]
|
||||
subdir = os.path.join(plugin, str(related_id))
|
||||
abs_dir = os.path.join(base, subdir)
|
||||
abs_dir = os.path.abspath(os.path.join(base, subdir))
|
||||
if not abs_dir.startswith(os.path.abspath(base) + os.sep):
|
||||
raise RuntimeError("Upload path escapes base directory")
|
||||
os.makedirs(abs_dir, exist_ok=True)
|
||||
return abs_dir, subdir
|
||||
|
||||
def validate_image(path: str) -> bool:
|
||||
"""
|
||||
Verify image integrity and enforce pixel-size limit.
|
||||
"""
|
||||
try:
|
||||
with Image.open(path) as img:
|
||||
img.verify()
|
||||
w, h = Image.open(path).size
|
||||
return w * h <= MAX_IMAGE_PIXELS
|
||||
except (UnidentifiedImageError, IOError):
|
||||
return False
|
||||
|
||||
def _strip_exif(image: Image.Image) -> Image.Image:
|
||||
def validate_pdf(path: str) -> bool:
|
||||
"""
|
||||
Quick header check for PDF files.
|
||||
"""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return f.read(5) == b"%PDF-"
|
||||
except IOError:
|
||||
return False
|
||||
|
||||
def validate_text(path: str) -> bool:
|
||||
"""
|
||||
Ensure the file is valid UTF-8 text/CSV.
|
||||
"""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
f.read(1024).decode("utf-8")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def strip_exif(image: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Rotate per EXIF orientation and strip metadata.
|
||||
"""
|
||||
try:
|
||||
exif = image._getexif()
|
||||
orient_key = next(
|
||||
(k for k, v in ExifTags.TAGS.items() if v == "Orientation"),
|
||||
None
|
||||
)
|
||||
if exif and orient_key in exif:
|
||||
o = exif[orient_key]
|
||||
if exif:
|
||||
orientation_key = next(
|
||||
(k for k, v in Image.ExifTags.TAGS.items()
|
||||
if v == "Orientation"), None
|
||||
)
|
||||
o = exif.get(orientation_key)
|
||||
if o == 3:
|
||||
image = image.rotate(180, expand=True)
|
||||
elif o == 6:
|
||||
@ -70,8 +116,259 @@ def _strip_exif(image: Image.Image) -> Image.Image:
|
||||
image = image.rotate(90, expand=True)
|
||||
except Exception:
|
||||
pass
|
||||
return image
|
||||
data = list(image.getdata())
|
||||
clean = Image.new(image.mode, image.size)
|
||||
clean.putdata(data)
|
||||
return clean
|
||||
|
||||
def generate_image_url(media: Media):
|
||||
"""
|
||||
Given a Media instance, return its public URL or a placeholder.
|
||||
"""
|
||||
if media and media.file_url:
|
||||
return url_for(
|
||||
"media.serve_context_media",
|
||||
context=media.plugin,
|
||||
context_id=media.related_id,
|
||||
filename=media.filename
|
||||
)
|
||||
# fallback placeholder
|
||||
w, h = current_app.config.get("STANDARD_IMG_SIZE", (300, 200))
|
||||
return f"https://placehold.co/{w}x{h}"
|
||||
|
||||
# ─── Core Media Routes ──────────────────────────────────────────────────────
|
||||
|
||||
@bp.route("/upload", methods=["POST"])
|
||||
@login_required
|
||||
def upload_media():
|
||||
"""
|
||||
Accept images, PDFs, text/CSV inline; enqueue ZIPs for async processing.
|
||||
"""
|
||||
uploaded: FileStorage = request.files.get("media")
|
||||
if not uploaded or uploaded.filename == "":
|
||||
flash("No file selected.", "warning")
|
||||
return redirect(request.referrer or url_for("home"))
|
||||
|
||||
filename = secure_filename(uploaded.filename)
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if not allowed_file(filename):
|
||||
flash("Unsupported file type.", "danger")
|
||||
return redirect(request.referrer)
|
||||
|
||||
# Determine plugin & ID
|
||||
plugin = request.form.get("plugin", "user")
|
||||
related_id = int(request.form.get("related_id", current_user.id))
|
||||
|
||||
# Save location
|
||||
abs_dir, subdir = get_upload_path(plugin, related_id)
|
||||
save_path = os.path.join(abs_dir, filename)
|
||||
uploaded.save(save_path)
|
||||
|
||||
# Validate & post-process
|
||||
if ext in IMAGE_EXTS:
|
||||
if not validate_image(save_path):
|
||||
os.remove(save_path)
|
||||
flash("Invalid or oversized image.", "danger")
|
||||
return redirect(request.referrer)
|
||||
with Image.open(save_path) as img:
|
||||
clean = strip_exif(img)
|
||||
clean.save(save_path)
|
||||
|
||||
elif ext == ".pdf":
|
||||
if not validate_pdf(save_path):
|
||||
os.remove(save_path)
|
||||
flash("Invalid PDF.", "danger")
|
||||
return redirect(request.referrer)
|
||||
|
||||
elif ext in {".txt", ".csv"}:
|
||||
if not validate_text(save_path):
|
||||
os.remove(save_path)
|
||||
flash("Invalid text/CSV.", "danger")
|
||||
return redirect(request.referrer)
|
||||
|
||||
elif ext == ZIP_EXT:
|
||||
# Create and enqueue a ZipJob
|
||||
job = ZipJob(user_id=current_user.id, filename=filename)
|
||||
db.session.add(job)
|
||||
db.session.commit()
|
||||
process_zip.delay(job.id, save_path)
|
||||
flash("ZIP received; processing in background.", "info")
|
||||
return redirect(url_for("media.upload_status", job_id=job.id))
|
||||
|
||||
# Record small-file upload in DB
|
||||
media = Media(
|
||||
plugin = plugin,
|
||||
related_id = related_id,
|
||||
filename = filename,
|
||||
file_url = f"{subdir}/{filename}",
|
||||
uploader_id = current_user.id,
|
||||
uploaded_at = datetime.utcnow()
|
||||
)
|
||||
db.session.add(media)
|
||||
db.session.commit()
|
||||
flash("File uploaded successfully.", "success")
|
||||
return redirect(request.referrer or url_for("home"))
|
||||
|
||||
@bp.route("/upload/<int:job_id>/status", methods=["GET"])
|
||||
@login_required
|
||||
def upload_status(job_id: int):
|
||||
"""
|
||||
Return JSON status for a background ZIP processing job.
|
||||
"""
|
||||
job = ZipJob.query.get_or_404(job_id)
|
||||
if job.user_id != current_user.id:
|
||||
abort(403)
|
||||
return jsonify({
|
||||
"job_id": job.id,
|
||||
"status": job.status,
|
||||
"error": job.error
|
||||
})
|
||||
|
||||
@bp.route("/<context>/<int:context_id>/<filename>")
|
||||
def serve_context_media(context: str, context_id: int, filename: str):
|
||||
"""
|
||||
Serve a file from UPLOAD_FOLDER/<plugin>/<id>/<filename>,
|
||||
with path‐traversal guard and DB check.
|
||||
"""
|
||||
# Normalize plugin name
|
||||
valid = {"user", "plant", "growlog", "vendor"}
|
||||
if context in valid:
|
||||
plugin_name = context
|
||||
elif context.endswith("s") and context[:-1] in valid:
|
||||
plugin_name = context[:-1]
|
||||
else:
|
||||
abort(404)
|
||||
|
||||
# Sanitize filename
|
||||
safe_filename = secure_filename(filename)
|
||||
if safe_filename != filename:
|
||||
abort(404)
|
||||
|
||||
# Build and verify path
|
||||
base_dir = current_app.config["UPLOAD_FOLDER"]
|
||||
dir_path = os.path.join(base_dir, plugin_name, str(context_id))
|
||||
full_path = os.path.abspath(os.path.join(dir_path, safe_filename))
|
||||
if not full_path.startswith(os.path.abspath(base_dir) + os.sep):
|
||||
abort(404)
|
||||
|
||||
# Confirm DB row
|
||||
Media.query.filter_by(
|
||||
plugin = plugin_name,
|
||||
related_id = context_id,
|
||||
filename = filename
|
||||
).first_or_404()
|
||||
|
||||
return send_from_directory(dir_path, filename)
|
||||
|
||||
# ─── Utility Routes ─────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route("/heart/<int:media_id>", methods=["POST"])
|
||||
@login_required
|
||||
def toggle_heart(media_id: int):
|
||||
"""
|
||||
Toggle a “heart” (like) on an image for the current user.
|
||||
"""
|
||||
existing = ImageHeart.query.filter_by(
|
||||
user_id = current_user.id,
|
||||
media_id = media_id
|
||||
).first()
|
||||
if existing:
|
||||
db.session.delete(existing)
|
||||
db.session.commit()
|
||||
return jsonify(status="unhearted")
|
||||
heart = ImageHeart(user_id=current_user.id, media_id=media_id)
|
||||
db.session.add(heart)
|
||||
db.session.commit()
|
||||
return jsonify(status="hearted")
|
||||
|
||||
@bp.route("/featured/<context>/<int:context_id>/<int:media_id>", methods=["POST"])
|
||||
@login_required
|
||||
def set_featured_image(context: str, context_id: int, media_id: int):
|
||||
"""
|
||||
Mark a single image as featured for a given context.
|
||||
"""
|
||||
valid = {"plant", "growlog", "user", "vendor"}
|
||||
if context in valid:
|
||||
plugin_name = context
|
||||
elif context.endswith("s") and context[:-1] in valid:
|
||||
plugin_name = context[:-1]
|
||||
else:
|
||||
abort(404)
|
||||
|
||||
media = Media.query.filter_by(
|
||||
plugin = plugin_name,
|
||||
related_id = context_id,
|
||||
id = media_id
|
||||
).first_or_404()
|
||||
|
||||
if media.uploader_id != current_user.id and current_user.role != "admin":
|
||||
abort(403)
|
||||
|
||||
FeaturedImage.query.filter_by(
|
||||
context = plugin_name,
|
||||
context_id = context_id
|
||||
).delete()
|
||||
fi = FeaturedImage(
|
||||
media_id = media.id,
|
||||
context = plugin_name,
|
||||
context_id = context_id,
|
||||
is_featured = True
|
||||
)
|
||||
db.session.add(fi)
|
||||
db.session.commit()
|
||||
flash("Featured image updated.", "success")
|
||||
return redirect(request.referrer or url_for("home"))
|
||||
|
||||
@bp.route("/delete/<int:media_id>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_media(media_id: int):
|
||||
"""
|
||||
Delete a media file and its DB record (soft‐delete by permission).
|
||||
"""
|
||||
media = Media.query.get_or_404(media_id)
|
||||
if media.uploader_id != current_user.id and current_user.role != "admin":
|
||||
flash("Not authorized to delete this media.", "danger")
|
||||
return redirect(request.referrer or url_for("home"))
|
||||
|
||||
# Remove file on disk
|
||||
base = current_app.config["UPLOAD_FOLDER"]
|
||||
full = os.path.abspath(os.path.join(base, media.file_url))
|
||||
try:
|
||||
os.remove(full)
|
||||
except OSError:
|
||||
current_app.logger.error(f"Failed to delete file {full}")
|
||||
|
||||
# Remove DB record
|
||||
db.session.delete(media)
|
||||
db.session.commit()
|
||||
flash("Media deleted.", "success")
|
||||
return redirect(request.referrer or url_for("home"))
|
||||
|
||||
@bp.route("/rotate/<int:media_id>", methods=["POST"])
|
||||
@login_required
|
||||
def rotate_media(media_id: int):
|
||||
"""
|
||||
Rotate an image −90° and strip its EXIF metadata.
|
||||
"""
|
||||
media = Media.query.get_or_404(media_id)
|
||||
if media.uploader_id != current_user.id and current_user.role != "admin":
|
||||
abort(403)
|
||||
|
||||
base = current_app.config["UPLOAD_FOLDER"]
|
||||
full = os.path.abspath(os.path.join(base, media.file_url))
|
||||
try:
|
||||
with Image.open(full) as img:
|
||||
rotated = img.rotate(-90, expand=True)
|
||||
clean = strip_exif(rotated)
|
||||
clean.save(full)
|
||||
flash("Image rotated successfully.", "success")
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Rotation failed for {full}: {e}")
|
||||
flash("Failed to rotate image.", "danger")
|
||||
|
||||
return redirect(request.referrer or url_for("home"))
|
||||
|
||||
# ─── Legacy Helpers for Other Plugins ───────────────────────────────────────
|
||||
|
||||
def _process_upload_file(
|
||||
file: FileStorage,
|
||||
@ -105,26 +402,29 @@ def _process_upload_file(
|
||||
# 5) Build the Media row
|
||||
now = datetime.utcnow()
|
||||
media = Media(
|
||||
plugin=plugin,
|
||||
related_id=related_id,
|
||||
filename=filename,
|
||||
uploaded_at=now,
|
||||
uploader_id=uploader_id,
|
||||
caption=caption,
|
||||
plant_id=plant_id,
|
||||
growlog_id=growlog_id,
|
||||
created_at=now,
|
||||
file_url=file_url
|
||||
plugin = plugin,
|
||||
related_id = related_id,
|
||||
filename = filename,
|
||||
uploaded_at = now,
|
||||
uploader_id = uploader_id,
|
||||
caption = caption,
|
||||
plant_id = plant_id,
|
||||
growlog_id = growlog_id,
|
||||
created_at = now,
|
||||
file_url = file_url
|
||||
)
|
||||
return media
|
||||
|
||||
|
||||
# ─── Exposed Utilities ─────────────────────────────────────────────────────────
|
||||
def save_media_file(file, user_id, **ctx):
|
||||
def save_media_file(file: FileStorage, user_id: int, **ctx) -> Media:
|
||||
"""
|
||||
Simple wrapper for other plugins to save an upload via the same logic.
|
||||
"""
|
||||
return _process_upload_file(file, user_id, **ctx)
|
||||
|
||||
|
||||
def delete_media_file(media: Media):
|
||||
"""
|
||||
Remove a Media record and its file from disk, commit immediately.
|
||||
"""
|
||||
base = current_app.config["UPLOAD_FOLDER"]
|
||||
full = os.path.normpath(os.path.join(base, media.file_url))
|
||||
if os.path.exists(full):
|
||||
@ -132,256 +432,12 @@ def delete_media_file(media: Media):
|
||||
db.session.delete(media)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def rotate_media_file(media: Media):
|
||||
"""
|
||||
Rotate a Media file −90° in place and commit metadata-only change.
|
||||
"""
|
||||
base = current_app.config["UPLOAD_FOLDER"]
|
||||
full = os.path.normpath(os.path.join(base, media.file_url))
|
||||
with Image.open(full) as img:
|
||||
img.rotate(-90, expand=True).save(full)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def generate_image_url(media: Media):
|
||||
"""
|
||||
Given a Media instance (or None), return its public URL
|
||||
under our new schema, or a placeholder if no media.
|
||||
"""
|
||||
if media and media.file_url:
|
||||
# use singular context
|
||||
return url_for(
|
||||
"media.serve_context_media",
|
||||
context=media.plugin,
|
||||
context_id=media.related_id,
|
||||
filename=media.filename
|
||||
)
|
||||
# fallback
|
||||
w, h = current_app.config.get("STANDARD_IMG_SIZE", (300, 200))
|
||||
return f"https://placehold.co/{w}x{h}"
|
||||
|
||||
|
||||
@bp.route("/<context>/<int:context_id>/<filename>")
|
||||
def serve_context_media(context, context_id, filename):
|
||||
"""
|
||||
Serve files saved under:
|
||||
<UPLOAD_FOLDER>/<plugin>/<context_id>/<filename>
|
||||
Accepts both singular and trailing-'s' contexts:
|
||||
/media/plant/1/foo.jpg OR /media/plants/1/foo.jpg
|
||||
"""
|
||||
|
||||
# — determine plugin name (always singular) —
|
||||
valid = {"user", "plant", "growlog", "vendor"}
|
||||
if context in valid:
|
||||
plugin = context
|
||||
elif context.endswith("s") and context[:-1] in valid:
|
||||
plugin = context[:-1]
|
||||
else:
|
||||
logging.debug(f"Invalid context '{context}' in URL")
|
||||
abort(404)
|
||||
|
||||
# — build filesystem path —
|
||||
base = current_app.config["UPLOAD_FOLDER"]
|
||||
directory = os.path.join(base, plugin, str(context_id))
|
||||
full_path = os.path.join(directory, filename)
|
||||
|
||||
# — Debug log what we’re about to do —
|
||||
logging.debug(f"[serve_context_media] plugin={plugin!r}, "
|
||||
f"context_id={context_id!r}, filename={filename!r}")
|
||||
logging.debug(f"[serve_context_media] checking DB for media row…")
|
||||
logging.debug(f"[serve_context_media] filesystem path = {full_path!r}, exists? {os.path.exists(full_path)}")
|
||||
|
||||
# — Check the DB row (but don’t abort if missing) —
|
||||
media = Media.query.filter_by(
|
||||
plugin=plugin,
|
||||
related_id=context_id,
|
||||
filename=filename
|
||||
).first()
|
||||
if not media:
|
||||
logging.warning(f"[serve_context_media] no Media DB row for "
|
||||
f"{plugin}/{context_id}/{filename!r}, "
|
||||
"will try serving from disk anyway")
|
||||
|
||||
# — If the file exists on disk, serve it — otherwise 404 —
|
||||
if os.path.exists(full_path):
|
||||
return send_from_directory(directory, filename)
|
||||
|
||||
logging.error(f"[serve_context_media] file not found on disk: {full_path!r}")
|
||||
abort(404)
|
||||
|
||||
|
||||
|
||||
# ─── Legacy / Other Routes (you can leave these for backward compatibility) ────
|
||||
@bp.route("/", methods=["GET"])
|
||||
def media_index():
|
||||
return redirect(url_for("core_ui.home"))
|
||||
|
||||
|
||||
@bp.route("/<plugin>/<filename>")
|
||||
def serve(plugin, filename):
|
||||
# optional legacy support
|
||||
m = Media.query.filter_by(file_url=f"{plugin}s/%/{filename}").first_or_404()
|
||||
date_path = m.uploaded_at.strftime("%Y/%m/%d")
|
||||
disk_dir = os.path.join(
|
||||
current_app.config["UPLOAD_FOLDER"],
|
||||
f"{plugin}s",
|
||||
str(m.plant_id or m.growlog_id),
|
||||
date_path
|
||||
)
|
||||
return send_from_directory(disk_dir, filename)
|
||||
|
||||
|
||||
@bp.route("/<filename>")
|
||||
def media_public(filename):
|
||||
base = current_app.config["UPLOAD_FOLDER"]
|
||||
m = Media.query.filter(Media.file_url.endswith(filename)).first_or_404()
|
||||
full = os.path.normpath(os.path.join(base, m.file_url))
|
||||
if not full.startswith(os.path.abspath(base)):
|
||||
abort(404)
|
||||
return send_from_directory(base, m.file_url)
|
||||
|
||||
|
||||
@bp.route("/heart/<int:media_id>", methods=["POST"])
|
||||
@login_required
|
||||
def toggle_heart(media_id):
|
||||
existing = ImageHeart.query.filter_by(
|
||||
user_id=current_user.id, media_id=media_id
|
||||
).first()
|
||||
if existing:
|
||||
db.session.delete(existing)
|
||||
db.session.commit()
|
||||
return jsonify({"status": "unhearted"})
|
||||
heart = ImageHeart(user_id=current_user.id, media_id=media_id)
|
||||
db.session.add(heart)
|
||||
db.session.commit()
|
||||
return jsonify({"status": "hearted"})
|
||||
|
||||
|
||||
@bp.route("/add/<string:plant_uuid>", methods=["POST"])
|
||||
@login_required
|
||||
def add_media(plant_uuid):
|
||||
plant = Plant.query.filter_by(uuid=plant_uuid).first_or_404()
|
||||
file = request.files.get("file")
|
||||
if not file or not allowed_file(file.filename):
|
||||
flash("Invalid or missing file.", "danger")
|
||||
return redirect(request.referrer or url_for("plant.edit", uuid_val=plant_uuid))
|
||||
|
||||
_process_upload_file(
|
||||
file=file,
|
||||
uploader_id=current_user.id,
|
||||
plugin="plant",
|
||||
related_id=plant.id
|
||||
)
|
||||
flash("Media uploaded successfully.", "success")
|
||||
return redirect(request.referrer or url_for("plant.edit", uuid_val=plant_uuid))
|
||||
|
||||
|
||||
@bp.route("/<context>/<int:context_id>/<filename>")
|
||||
def media_file(context, context_id, filename):
|
||||
# your existing serve_context_media logic here
|
||||
# (unchanged)
|
||||
from flask import current_app, send_from_directory
|
||||
import os
|
||||
valid = {"user", "plant", "growlog", "vendor"}
|
||||
if context in valid:
|
||||
plugin = context
|
||||
elif context.endswith("s") and context[:-1] in valid:
|
||||
plugin = context[:-1]
|
||||
else:
|
||||
abort(404)
|
||||
|
||||
media = Media.query.filter_by(
|
||||
plugin=plugin,
|
||||
related_id=context_id,
|
||||
filename=filename
|
||||
).first_or_404()
|
||||
|
||||
base = current_app.config["UPLOAD_FOLDER"]
|
||||
directory = os.path.join(base, plugin, str(context_id))
|
||||
return send_from_directory(directory, filename)
|
||||
|
||||
@bp.route('/featured/<context>/<int:context_id>/<int:media_id>', methods=['POST'])
|
||||
def set_featured_image(context, context_id, media_id):
|
||||
"""
|
||||
Single‐select “featured” toggle for any plugin (plants, grow_logs, etc).
|
||||
"""
|
||||
# normalize to singular plugin name (matches Media.plugin & FeaturedImage.context)
|
||||
valid = {'plant', 'growlog', 'user', 'vendor'}
|
||||
if context in valid:
|
||||
plugin_name = context
|
||||
elif context.endswith('s') and context[:-1] in valid:
|
||||
plugin_name = context[:-1]
|
||||
else:
|
||||
abort(404)
|
||||
|
||||
# must own that media row
|
||||
media = Media.query.filter_by(
|
||||
plugin=plugin_name,
|
||||
related_id=context_id,
|
||||
id=media_id
|
||||
).first_or_404()
|
||||
|
||||
# clear out any existing featured rows
|
||||
FeaturedImage.query.filter_by(
|
||||
context=plugin_name,
|
||||
context_id=context_id
|
||||
).delete()
|
||||
|
||||
# insert new featured row
|
||||
fi = FeaturedImage(
|
||||
media_id=media.id,
|
||||
context=plugin_name,
|
||||
context_id=context_id,
|
||||
is_featured=True
|
||||
)
|
||||
db.session.add(fi)
|
||||
db.session.commit()
|
||||
|
||||
# Redirect back with a flash instead of JSON
|
||||
flash("Featured image updated.", "success")
|
||||
return redirect(request.referrer or url_for("core_ui.home"))
|
||||
|
||||
|
||||
@bp.route("/delete/<int:media_id>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_media(media_id):
|
||||
media = Media.query.get_or_404(media_id)
|
||||
if media.uploader_id != current_user.id and current_user.role != "admin":
|
||||
flash("Not authorized to delete this media.", "danger")
|
||||
return redirect(request.referrer or url_for("core_ui.home"))
|
||||
|
||||
delete_media_file(media)
|
||||
flash("Media deleted.", "success")
|
||||
return redirect(request.referrer or url_for("plant.edit", uuid_val=media.plant.uuid))
|
||||
|
||||
|
||||
@bp.route("/bulk_delete/<string:plant_uuid>", methods=["POST"])
|
||||
@login_required
|
||||
def bulk_delete_media(plant_uuid):
|
||||
plant = Plant.query.filter_by(uuid=plant_uuid).first_or_404()
|
||||
media_ids = request.form.getlist("delete_ids")
|
||||
deleted = 0
|
||||
|
||||
for mid in media_ids:
|
||||
m = Media.query.filter_by(id=mid, plant_id=plant.id).first()
|
||||
if m and (m.uploader_id == current_user.id or current_user.role == "admin"):
|
||||
delete_media_file(m)
|
||||
deleted += 1
|
||||
|
||||
flash(f"{deleted} image(s) deleted.", "success")
|
||||
return redirect(request.referrer or url_for("plant.edit", uuid_val=plant_uuid))
|
||||
|
||||
|
||||
@bp.route("/rotate/<int:media_id>", methods=["POST"])
|
||||
@login_required
|
||||
def rotate_media(media_id):
|
||||
media = Media.query.get_or_404(media_id)
|
||||
if media.uploader_id != current_user.id and current_user.role != "admin":
|
||||
flash("Not authorized to rotate this media.", "danger")
|
||||
return redirect(request.referrer or url_for("core_ui.home"))
|
||||
|
||||
try:
|
||||
rotate_media_file(media)
|
||||
flash("Image rotated successfully.", "success")
|
||||
except Exception as e:
|
||||
flash(f"Failed to rotate image: {e}", "danger")
|
||||
|
||||
return redirect(request.referrer or url_for("plant.edit", uuid_val=media.plant.uuid))
|
||||
|
Reference in New Issue
Block a user