sort of working, more changes
This commit is contained in:
@ -16,6 +16,7 @@ class Media(db.Model):
|
||||
plant_id = db.Column(db.Integer, db.ForeignKey("plant.id"), nullable=True)
|
||||
growlog_id = db.Column(db.Integer, db.ForeignKey("grow_logs.id"), nullable=True)
|
||||
update_id = db.Column(db.Integer, db.ForeignKey("plant_updates.id"), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
update = db.relationship("PlantUpdate", back_populates="media_items")
|
||||
|
||||
|
@ -1,5 +1,10 @@
|
||||
# plugins/media/routes.py
|
||||
|
||||
import os
|
||||
from uuid import uuid4
|
||||
from datetime import datetime
|
||||
from PIL import Image
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
redirect,
|
||||
@ -10,76 +15,139 @@ from flask import (
|
||||
current_app,
|
||||
jsonify
|
||||
)
|
||||
from flask_login import current_user, login_required
|
||||
import os
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app import db
|
||||
from .models import Media, ImageHeart, FeaturedImage
|
||||
from plugins.plant.models import Plant
|
||||
|
||||
bp = Blueprint("media", __name__, template_folder="templates")
|
||||
bp = Blueprint("media", __name__, url_prefix="/media", template_folder="templates")
|
||||
|
||||
# We store only "YYYY/MM/DD/<uuid>.ext" in Media.file_url.
|
||||
# All files live under "/app/static/uploads/YYYY/MM/DD/<uuid>.ext" in the container.
|
||||
BASE_UPLOAD_FOLDER = "static/uploads"
|
||||
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif"}
|
||||
# -----------------------------------------------------------------------------
|
||||
# Make generate_image_url available in all templates
|
||||
# -----------------------------------------------------------------------------
|
||||
@bp.app_context_processor
|
||||
def utility_processor():
|
||||
def generate_image_url(path):
|
||||
if path:
|
||||
return url_for("media.media_file", filename=path)
|
||||
w, h = current_app.config.get("STANDARD_IMG_SIZE", (300, 200))
|
||||
return f"https://placehold.co/{w}x{h}"
|
||||
return dict(generate_image_url=generate_image_url)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helpers & config
|
||||
# -----------------------------------------------------------------------------
|
||||
def allowed_file(filename):
|
||||
return (
|
||||
"." in filename
|
||||
and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
)
|
||||
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
return ext in current_app.config.get("ALLOWED_EXTENSIONS", {"png","jpg","jpeg","gif"})
|
||||
|
||||
@bp.route("/media/", methods=["GET"])
|
||||
def get_upload_path():
|
||||
base = current_app.config.get("UPLOAD_FOLDER", "static/uploads")
|
||||
now = datetime.utcnow()
|
||||
subdir = os.path.join(str(now.year), f"{now.month:02}", f"{now.day:02}")
|
||||
full = os.path.join(base, subdir)
|
||||
os.makedirs(full, exist_ok=True)
|
||||
return full, subdir
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Routes
|
||||
# -----------------------------------------------------------------------------
|
||||
@bp.route("/", methods=["GET"])
|
||||
def media_index():
|
||||
"""
|
||||
/media/ is not used stand‐alone—redirect back to homepage.
|
||||
"""
|
||||
return redirect(url_for("core_ui.home"))
|
||||
|
||||
@bp.route("/media/files/<path:filename>", methods=["GET"])
|
||||
@bp.route("/files/<path:filename>", methods=["GET"])
|
||||
def media_file(filename):
|
||||
"""
|
||||
Serve files from "/app/static/uploads/<filename>".
|
||||
Example: GET /media/files/2025/06/07/abcdef1234abcd.jpg
|
||||
"""
|
||||
# Use os.getcwd() to guarantee "/app/static/uploads" (not "/app/app/static/uploads")
|
||||
full_dir = os.path.join(os.getcwd(), BASE_UPLOAD_FOLDER)
|
||||
return send_from_directory(full_dir, filename)
|
||||
# Strip leading "uploads/" if present
|
||||
if filename.startswith("uploads/"):
|
||||
filename = filename[len("uploads/"):]
|
||||
folder = current_app.config.get("UPLOAD_FOLDER", "static/uploads")
|
||||
return send_from_directory(folder, filename)
|
||||
|
||||
@bp.route("/media/heart/<int:media_id>", methods=["POST"])
|
||||
@bp.route("/heart/<int:media_id>", methods=["POST"])
|
||||
@login_required
|
||||
def toggle_heart(media_id):
|
||||
"""
|
||||
Add/remove a "heart" from an image.
|
||||
"""
|
||||
existing = ImageHeart.query.filter_by(
|
||||
user_id=current_user.id, media_id=media_id
|
||||
).first()
|
||||
|
||||
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"})
|
||||
else:
|
||||
heart = ImageHeart(user_id=current_user.id, media_id=media_id)
|
||||
db.session.add(heart)
|
||||
db.session.commit()
|
||||
return jsonify({"status": "hearted"})
|
||||
heart = ImageHeart(user_id=current_user.id, media_id=media_id)
|
||||
db.session.add(heart)
|
||||
db.session.commit()
|
||||
return jsonify({"status": "hearted"})
|
||||
|
||||
@bp.route("/media/feature/<int:media_id>", methods=["POST"])
|
||||
@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))
|
||||
|
||||
ext = file.filename.rsplit(".", 1)[-1].lower()
|
||||
filename = f"{uuid4()}.{ext}"
|
||||
full_path, subdir = get_upload_path()
|
||||
file.save(os.path.join(full_path, filename))
|
||||
|
||||
media = Media(
|
||||
file_url=os.path.join(subdir, filename).replace("\\", "/"),
|
||||
uploader_id=current_user.id,
|
||||
plant_id=plant.id
|
||||
)
|
||||
db.session.add(media)
|
||||
db.session.commit()
|
||||
flash("Media uploaded successfully.", "success")
|
||||
return redirect(request.referrer or url_for("plant.edit", uuid_val=plant_uuid))
|
||||
|
||||
@bp.route("/feature/<int:media_id>", methods=["POST"])
|
||||
@login_required
|
||||
def set_featured_image(media_id):
|
||||
"""
|
||||
Toggle featured status on a media item. Only the uploader or an admin may do so.
|
||||
"""
|
||||
media = Media.query.get_or_404(media_id)
|
||||
if (current_user.id != media.uploader_id) and (current_user.role != "admin"):
|
||||
if current_user.id != media.uploader_id and current_user.role != "admin":
|
||||
flash("Not authorized to set featured image.", "danger")
|
||||
return redirect(request.referrer or url_for("core_ui.home"))
|
||||
|
||||
# Remove any existing featured entries for this media
|
||||
FeaturedImage.query.filter_by(media_id=media_id).delete()
|
||||
featured = FeaturedImage(media_id=media_id, is_featured=True)
|
||||
db.session.add(featured)
|
||||
db.session.commit()
|
||||
flash("Image set as featured.", "success")
|
||||
return redirect(request.referrer or url_for("core_ui.home"))
|
||||
return redirect(request.referrer or url_for("plant.edit", uuid_val=media.plant.uuid))
|
||||
|
||||
@bp.route("/delete/<int:media_id>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_media(media_id):
|
||||
media = Media.query.get_or_404(media_id)
|
||||
if current_user.id != media.uploader_id and current_user.role != "admin":
|
||||
flash("Not authorized to delete this media.", "danger")
|
||||
return redirect(request.referrer or url_for("core_ui.home"))
|
||||
|
||||
full_path = os.path.join(current_app.config.get("UPLOAD_FOLDER", "static/uploads"), media.file_url)
|
||||
if os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
|
||||
db.session.delete(media)
|
||||
db.session.commit()
|
||||
flash("Media deleted.", "success")
|
||||
return redirect(request.referrer or url_for("plant.edit", uuid_val=media.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 current_user.id != media.uploader_id and current_user.role != "admin":
|
||||
flash("Not authorized to rotate this media.", "danger")
|
||||
return redirect(request.referrer or url_for("core_ui.home"))
|
||||
|
||||
full_path = os.path.join(current_app.config.get("UPLOAD_FOLDER", "static/uploads"), media.file_url)
|
||||
try:
|
||||
with Image.open(full_path) as img:
|
||||
img.rotate(-90, expand=True).save(full_path)
|
||||
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))
|
||||
|
@ -1,21 +1,38 @@
|
||||
# plugins/media/utils.py
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from PIL import Image
|
||||
from flask import current_app, url_for
|
||||
from app import db
|
||||
from .models import Media
|
||||
from plugins.plant.models import Plant
|
||||
|
||||
|
||||
def get_upload_path():
|
||||
"""
|
||||
Return (full_disk_path, subdir) based on UTC date,
|
||||
creating directories if needed.
|
||||
e.g. ('/app/static/uploads/2025/06/09', '2025/06/09')
|
||||
"""
|
||||
base = current_app.config.get("UPLOAD_FOLDER", "static/uploads")
|
||||
now = datetime.utcnow()
|
||||
subdir = os.path.join(str(now.year), f"{now.month:02}", f"{now.day:02}")
|
||||
full = os.path.join(base, subdir)
|
||||
os.makedirs(full, exist_ok=True)
|
||||
return full, subdir
|
||||
|
||||
|
||||
def generate_random_filename(original_filename):
|
||||
"""
|
||||
Returns a random filename preserving the original extension.
|
||||
e.g. “abcd1234efgh.jpg” for “myphoto.jpg”.
|
||||
Preserve extension, randomize base name.
|
||||
"""
|
||||
ext = os.path.splitext(original_filename)[1].lower() # includes dot, e.g. ".jpg"
|
||||
random_name = uuid.uuid4().hex # 32‐char hex string
|
||||
return f"{random_name}{ext}"
|
||||
ext = os.path.splitext(original_filename)[1].lower()
|
||||
return f"{uuid.uuid4().hex}{ext}"
|
||||
|
||||
|
||||
def strip_metadata_and_save(source_file, destination_path):
|
||||
"""
|
||||
Opens an image with Pillow, strips EXIF (metadata), and saves it cleanly.
|
||||
Opens an image with Pillow, strips EXIF metadata, and saves it.
|
||||
Supports common formats (JPEG, PNG).
|
||||
"""
|
||||
with Image.open(source_file) as img:
|
||||
@ -23,3 +40,72 @@ def strip_metadata_and_save(source_file, destination_path):
|
||||
clean_image = Image.new(img.mode, img.size)
|
||||
clean_image.putdata(data)
|
||||
clean_image.save(destination_path)
|
||||
|
||||
|
||||
def generate_image_url(path):
|
||||
"""
|
||||
If path is set, route through /media/files/<path>; otherwise
|
||||
return a placehold.co URL sized to STANDARD_IMG_SIZE.
|
||||
"""
|
||||
if path:
|
||||
return url_for("media.media_file", filename=path)
|
||||
w, h = current_app.config.get("STANDARD_IMG_SIZE", (300, 200))
|
||||
return f"https://placehold.co/{w}x{h}"
|
||||
|
||||
|
||||
def save_media_file(file_storage, uploader_id, related_model=None, related_uuid=None):
|
||||
"""
|
||||
- file_storage: Werkzeug FileStorage
|
||||
- uploader_id: current_user.id
|
||||
- related_model: e.g. 'plant'
|
||||
- related_uuid: the Plant.uuid string
|
||||
Returns the new Media instance.
|
||||
"""
|
||||
full_path, subdir = get_upload_path()
|
||||
filename = generate_random_filename(file_storage.filename)
|
||||
disk_path = os.path.join(full_path, filename)
|
||||
file_storage.save(disk_path)
|
||||
|
||||
media = Media(
|
||||
file_url=os.path.join(subdir, filename).replace("\\", "/"),
|
||||
uploader_id=uploader_id
|
||||
)
|
||||
|
||||
# Associate to plant if requested
|
||||
if related_model == "plant" and related_uuid:
|
||||
plant = Plant.query.filter_by(uuid=related_uuid).first()
|
||||
if plant:
|
||||
media.plant_id = plant.id
|
||||
|
||||
db.session.add(media)
|
||||
db.session.commit()
|
||||
return media
|
||||
|
||||
|
||||
def delete_media_file(media):
|
||||
"""
|
||||
Remove file from disk and delete DB record.
|
||||
"""
|
||||
base = current_app.config.get("UPLOAD_FOLDER", "static/uploads")
|
||||
path = os.path.join(base, media.file_url)
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
db.session.delete(media)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def rotate_media_file(media, angle=-90):
|
||||
"""
|
||||
Rotate the file on disk (in place) and leave DB record intact.
|
||||
"""
|
||||
base = current_app.config.get("UPLOAD_FOLDER", "static/uploads")
|
||||
path = os.path.join(base, media.file_url)
|
||||
try:
|
||||
with Image.open(path) as img:
|
||||
rotated = img.rotate(angle, expand=True)
|
||||
rotated.save(path)
|
||||
except Exception:
|
||||
pass
|
||||
# no DB changes needed
|
||||
|
Reference in New Issue
Block a user