sort of working, more changes

This commit is contained in:
2025-06-09 05:45:58 -05:00
parent d442cad0bb
commit f0b1abd622
102 changed files with 1448 additions and 2264 deletions

View File

@ -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")

View File

@ -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 standalone—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))

View File

@ -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 # 32char 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