broke currently

This commit is contained in:
2025-06-22 16:11:29 -05:00
parent e7a0f5b1be
commit 2bb7a29141
77 changed files with 1748 additions and 2298 deletions

View File

@ -1,43 +1,107 @@
# plugins/media/models.py
from app import db
from datetime import datetime
from flask import url_for
from app import db
class Media(db.Model):
__tablename__ = "media"
__table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True)
file_url = db.Column(db.String(256), nullable=False)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
uploader_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
caption = db.Column(db.String(255), nullable=True)
id = db.Column(db.Integer, primary_key=True)
plugin = db.Column(db.String(50), nullable=False)
related_id = db.Column(db.Integer, nullable=False)
filename = db.Column(db.String(256), nullable=False)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
uploader_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
caption = db.Column(db.String(255), nullable=True)
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)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
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)
# You already have a file_url column in your DB
file_url = db.Column(db.String(512), nullable=False)
hearts = db.relationship(
"plugins.media.models.ImageHeart",
backref="media",
lazy="dynamic",
cascade="all, delete-orphan",
)
featured_entries = db.relationship(
"plugins.media.models.FeaturedImage",
backref="media",
lazy="dynamic",
cascade="all, delete-orphan",
)
plant = db.relationship(
"plugins.plant.models.Plant",
back_populates="media_items",
foreign_keys=[plant_id],
lazy="joined",
)
growlog = db.relationship(
"plugins.growlog.models.GrowLog",
back_populates="media_items",
foreign_keys=[growlog_id],
lazy="joined",
)
def __init__(self, *args, **kwargs):
"""
Infer plugin & related_id from whichever FK is set,
and build the file_url path immediately so that INSERT
never tries to write plugin=None or related_id=None.
"""
super().__init__(*args, **kwargs)
# If they passed plant_id or growlog_id in kwargs, pick one:
if self.plant_id:
self.plugin = "plants"
self.related_id = self.plant_id
elif self.growlog_id:
self.plugin = "grow_logs"
self.related_id = self.growlog_id
else:
# fallback (you might choose to raise instead)
self.plugin = kwargs.get("plugin", "")
self.related_id = kwargs.get("related_id", 0)
# They must also supply `filename` before commit.
# Build `file_url` in the same format your property used to:
date_path = self.uploaded_at.strftime("%Y/%m/%d")
self.file_url = f"{self.plugin}/{self.related_id}/{date_path}/{self.filename}"
@property
def url(self):
return url_for("media.media_file", filename=self.file_url)
@property
def featured(self):
return any(
fe.context == "plant" and fe.is_featured
for fe in self.featured_entries
)
update = db.relationship("PlantUpdate", back_populates="media_items")
class ImageHeart(db.Model):
__tablename__ = "image_hearts"
__table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
media_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
media_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
media = db.relationship("Media", backref="hearts")
class FeaturedImage(db.Model):
__tablename__ = "featured_images"
__table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True)
media_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=False)
id = db.Column(db.Integer, primary_key=True)
media_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=False)
context = db.Column(db.String(50), nullable=False)
context_id = db.Column(db.Integer, nullable=False)
override_text = db.Column(db.String(255), nullable=True)
is_featured = db.Column(db.Boolean, default=True)
media = db.relationship("Media", backref="featured_entries")
is_featured = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)

View File

@ -3,17 +3,12 @@
import os
from uuid import uuid4
from datetime import datetime
from PIL import Image
from PIL import Image, ExifTags
from flask import (
Blueprint,
redirect,
url_for,
request,
flash,
send_from_directory,
current_app,
jsonify
Blueprint, request, redirect, url_for,
flash, send_from_directory, current_app,
jsonify, abort
)
from flask_login import login_required, current_user
@ -21,54 +16,195 @@ from app import db
from .models import Media, ImageHeart, FeaturedImage
from plugins.plant.models import Plant
bp = Blueprint("media", __name__, url_prefix="/media", template_folder="templates")
bp = Blueprint(
"media",
__name__,
url_prefix="/media",
template_folder="templates"
)
# -----------------------------------------------------------------------------
# Make generate_image_url available in all templates
# -----------------------------------------------------------------------------
# ─── Context Processor ─────────────────────────────────────────────────────────
@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
# -----------------------------------------------------------------------------
# ─── 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"})
return ext in current_app.config.get(
"ALLOWED_EXTENSIONS",
{"png", "jpg", "jpeg", "gif", "webp"}
)
def get_upload_path():
base = current_app.config.get("UPLOAD_FOLDER", "static/uploads")
def get_upload_path(plugin: str, related_id: int):
"""
Build and return (absolute_dir, subdir) where uploads are stored:
{UPLOAD_FOLDER}/{plugin}s/{related_id}/YYYY/MM/DD/
"""
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
date_path = f"{now.year}/{now.month:02}/{now.day:02}"
subdir = f"{plugin}s/{related_id}/{date_path}"
base = current_app.config["UPLOAD_FOLDER"]
abs_dir = os.path.join(base, subdir)
os.makedirs(abs_dir, exist_ok=True)
return abs_dir, subdir
# -----------------------------------------------------------------------------
# Routes
# -----------------------------------------------------------------------------
def _strip_exif(image: Image.Image) -> Image.Image:
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 o == 3:
image = image.rotate(180, expand=True)
elif o == 6:
image = image.rotate(270, expand=True)
elif o == 8:
image = image.rotate(90, expand=True)
except Exception:
pass
return image
def _process_upload_file(
file,
uploader_id: int,
plugin: str,
related_id: int
):
"""
Save the uploaded image (strip EXIF), write Media row with
file_url, and return the Media instance.
"""
ext = os.path.splitext(file.filename)[1].lower()
if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
raise ValueError("Unsupported file type.")
# generate a stable filename
filename = f"{uuid4().hex}{ext}"
# determine disk path
abs_dir, subdir = get_upload_path(plugin, related_id)
full_path = os.path.join(abs_dir, filename)
# strip EXIF and save
img = Image.open(file)
img = _strip_exif(img)
img.save(full_path)
# create the DB record
now = datetime.utcnow()
media = Media(
uploader_id=uploader_id,
file_url=f"{subdir}/{filename}",
uploaded_at=now
)
# legacy relationships
if plugin == "plant":
media.plant_id = related_id
elif plugin == "growlog":
media.growlog_id = related_id
db.session.add(media)
db.session.commit()
return media
# ─── Exposed Utilities ─────────────────────────────────────────────────────────
def save_media_file(file, user_id, **ctx):
return _process_upload_file(file, user_id, **ctx)
def delete_media_file(media: Media):
"""
Remove file from disk and delete DB record.
"""
base = current_app.config["UPLOAD_FOLDER"]
path = os.path.join(base, media.file_url)
if os.path.exists(path):
os.remove(path)
db.session.delete(media)
db.session.commit()
def rotate_media_file(media: Media):
"""
Rotate the image on disk by -90° and save.
"""
base = current_app.config["UPLOAD_FOLDER"]
path = os.path.join(base, media.file_url)
with Image.open(path) as img:
img.rotate(-90, expand=True).save(path)
db.session.commit()
def generate_image_url(media: Media):
"""
Given a Media instance (or None), return its public URL
or a placeholder if no media.
"""
if media and media.file_url:
return url_for("media.media_file", filename=media.file_url)
w, h = current_app.config.get("STANDARD_IMG_SIZE", (300, 200))
return f"https://placehold.co/{w}x{h}"
# ─── Routes ────────────────────────────────────────────────────────────────────
@bp.route("/", methods=["GET"])
def media_index():
return redirect(url_for("core_ui.home"))
@bp.route("/files/<path:filename>", methods=["GET"])
@bp.route("/<plugin>/<filename>")
def serve(plugin, filename):
"""
Stream uploaded media by plugin & filename, enforcing Media lookup.
"""
m = Media.query.filter_by(file_url=f"{plugin}s/%/{filename}").first_or_404()
# reconstruct disk path
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("/files/<path:filename>")
def media_file(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)
base = current_app.config["UPLOAD_FOLDER"]
full = os.path.normpath(os.path.join(base, filename))
if not full.startswith(os.path.abspath(base)):
abort(404)
return send_from_directory(base, 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()
existing = ImageHeart.query.filter_by(
user_id=current_user.id, media_id=media_id
).first()
if existing:
db.session.delete(existing)
db.session.commit()
@ -78,6 +214,7 @@ def toggle_heart(media_id):
db.session.commit()
return jsonify({"status": "hearted"})
@bp.route("/add/<string:plant_uuid>", methods=["POST"])
@login_required
def add_media(plant_uuid):
@ -87,65 +224,84 @@ def add_media(plant_uuid):
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("\\", "/"),
_process_upload_file(
file=file,
uploader_id=current_user.id,
plant_id=plant.id
plugin="plant",
related_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):
media = Media.query.get_or_404(media_id)
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"))
FeaturedImage.query.filter_by(media_id=media_id).delete()
featured = FeaturedImage(media_id=media_id, is_featured=True)
db.session.add(featured)
@bp.route("/feature/<string:context>/<int:context_id>/<int:media_id>", methods=["POST"])
@login_required
def set_featured_image(context, context_id, media_id):
media = Media.query.get_or_404(media_id)
if media.uploader_id != current_user.id and current_user.role != "admin":
return jsonify({"error": "Not authorized"}), 403
FeaturedImage.query.filter_by(
context=context,
context_id=context_id
).delete()
feat = FeaturedImage(
media_id=media.id,
context=context,
context_id=context_id,
is_featured=True
)
db.session.add(feat)
if context == "plant":
plant = Plant.query.get_or_404(context_id)
plant.featured_media_id = media.id
db.session.commit()
flash("Image set as featured.", "success")
return redirect(request.referrer or url_for("plant.edit", uuid_val=media.plant.uuid))
return jsonify({"status": "success", "media_id": media.id})
@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":
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"))
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()
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 current_user.id != media.uploader_id and current_user.role != "admin":
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"))
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)
rotate_media_file(media)
flash("Image rotated successfully.", "success")
except Exception as e:
flash(f"Failed to rotate image: {e}", "danger")

View File

@ -1,14 +1,26 @@
{# plugins/media/templates/media/list.html #}
{% extends 'core_ui/base.html' %}
{% block content %}
<h2>All Uploaded Media</h2>
<ul>
{% for image in images %}
<li>
<img src="{{ url_for('media.media_file', filename=image.file_url) }}" alt="{{ image.caption }}" width="200"><br>
{{ image.caption or "No caption" }}
{% if image.plant_id %}<br>Plant ID: {{ image.plant_id }}{% endif %}
{% if image.growlog_id %}<br>GrowLog ID: {{ image.growlog_id }}{% endif %}
</li>
<li class="mb-3">
<img
src="{{ generate_image_url(image) }}"
alt="{{ image.caption or 'No caption' }}"
width="200"
class="img-thumbnail"
><br>
{{ image.caption or "No caption" }}
{% if image.plant_id %}
<br><small class="text-muted">Plant ID: {{ image.plant_id }}</small>
{% endif %}
{% if image.growlog_id %}
<br><small class="text-muted">GrowLog ID: {{ image.growlog_id }}</small>
{% endif %}
<br><small class="text-muted">Uploaded by user #{{ image.uploader_id }}</small>
</li>
{% endfor %}
</ul>
{% endblock %}
{% endblock %}

View File

@ -1,111 +0,0 @@
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):
"""
Preserve extension, randomize base name.
"""
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.
Supports common formats (JPEG, PNG).
"""
with Image.open(source_file) as img:
data = list(img.getdata())
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