diff --git a/app/__init__.py b/app/__init__.py index 9bf3355..71e16a5 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -22,6 +22,8 @@ migrate = Migrate() login_manager = LoginManager() csrf = CSRFProtect() +from plugins.media.routes import generate_image_url # Import it here + def create_app(): app = Flask(__name__) @@ -111,5 +113,7 @@ def create_app(): def inject_current_year(): from datetime import datetime return {'current_year': datetime.now().year} + + app.jinja_env.globals['generate_image_url'] = generate_image_url return app diff --git a/nip.zip b/nip.zip index 891f64a..db63835 100644 Binary files a/nip.zip and b/nip.zip differ diff --git a/plugins/media/routes.py b/plugins/media/routes.py index 4a68307..cdd30dd 100644 --- a/plugins/media/routes.py +++ b/plugins/media/routes.py @@ -1,7 +1,8 @@ # plugins/media/routes.py import os -from uuid import uuid4 +import uuid +from werkzeug.utils import secure_filename from datetime import datetime from PIL import Image, ExifTags @@ -73,48 +74,40 @@ def _strip_exif(image: Image.Image) -> Image.Image: 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.") +def _process_upload_file(file, uploader_id, plugin='', related_id=0, plant_id=None, growlog_id=None, caption=None): + """Handles saving an uploaded file and creating the Media record.""" - # 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 + # Generate a unique filename now = datetime.utcnow() - media = Media( - uploader_id=uploader_id, - file_url=f"{subdir}/{filename}", - uploaded_at=now + unique_id = str(uuid.uuid4()).replace("-", "") + secure_name = secure_filename(file.filename) + filename = f"{unique_id}_{secure_name}" + + # Construct the save path + storage_path = os.path.join( + current_app.config['UPLOAD_FOLDER'], + str(uploader_id), + now.strftime('%Y/%m/%d') ) + os.makedirs(storage_path, exist_ok=True) - # legacy relationships - if plugin == "plant": - media.plant_id = related_id - elif plugin == "growlog": - media.growlog_id = related_id + full_path = os.path.join(storage_path, filename) + file.save(full_path) - db.session.add(media) - db.session.commit() + file_url = f"/{uploader_id}/{now.strftime('%Y/%m/%d')}/{filename}" + + 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 + ) return media diff --git a/plugins/plant/models.py b/plugins/plant/models.py index 237b137..88a2751 100644 --- a/plugins/plant/models.py +++ b/plugins/plant/models.py @@ -101,14 +101,14 @@ class Plant(db.Model): media_items = db.relationship( 'plugins.media.models.Media', back_populates='plant', - lazy='dynamic', + lazy='select', # ← this is the fix cascade='all, delete-orphan', foreign_keys='plugins.media.models.Media.plant_id' ) @property def media(self): - return self.media_items.all() + return self.media_items # already a list when lazy='select' # the one you see on the detail page featured_media = db.relationship( diff --git a/plugins/plant/routes.py b/plugins/plant/routes.py index cb1a641..165737c 100644 --- a/plugins/plant/routes.py +++ b/plugins/plant/routes.py @@ -1,5 +1,7 @@ from uuid import uuid4 import os +from sqlalchemy.orm import joinedload + from flask import ( Blueprint, render_template, @@ -10,6 +12,7 @@ from flask import ( current_app, ) from flask_login import login_required, current_user + from app import db from .models import Plant, PlantCommonName, PlantScientificName from .forms import PlantForm @@ -37,7 +40,9 @@ def inject_image_helper(): @login_required def index(): plants = ( - Plant.query.filter_by(owner_id=current_user.id) + Plant.query + .options(joinedload(Plant.media_items)) + .filter_by(owner_id=current_user.id) .order_by(Plant.id.desc()) .all() ) diff --git a/plugins/plant/templates/plant/index.html b/plugins/plant/templates/plant/index.html index afcfc55..1694249 100644 --- a/plugins/plant/templates/plant/index.html +++ b/plugins/plant/templates/plant/index.html @@ -154,10 +154,16 @@ {% endif %} - Image for {{ plant.common_name.name }} + {# pick featured → first media if no explicit featured #} + {% set featured = plant.featured_media %} + {% if not featured and plant.media %} + {% set featured = plant.media[0] %} + {% endif %} + Image for {{ plant.common_name.name }}
diff --git a/plugins/utility/routes.py b/plugins/utility/routes.py index a15dadc..0515ae0 100644 --- a/plugins/utility/routes.py +++ b/plugins/utility/routes.py @@ -9,6 +9,7 @@ import uuid import zipfile import tempfile import difflib +import traceback from datetime import datetime # Third‐party @@ -80,7 +81,7 @@ def upload(): filename = file.filename.lower().strip() - # ── ZIP Import Flow ─────────────────────────────────────────────────── + # ── ZIP Import Flow ──────────────────────────────────────────────── if filename.endswith(".zip"): tmp_zip = tempfile.NamedTemporaryFile(delete=False, suffix=".zip") file.save(tmp_zip.name) @@ -127,32 +128,34 @@ def upload(): tmpdir = tempfile.mkdtemp() z.extractall(tmpdir) + # --- load and validate plants.csv --- plant_path = os.path.join(tmpdir, "plants.csv") with open(plant_path, newline="", encoding="utf-8-sig") as pf: reader = csv.DictReader(pf) if reader.fieldnames != PLANT_HEADERS: missing = set(PLANT_HEADERS) - set(reader.fieldnames or []) - extra = set(reader.fieldnames or []) - set(PLANT_HEADERS) + extra = set(reader.fieldnames or []) - set(PLANT_HEADERS) os.remove(tmp_zip.name) flash(f"plants.csv header mismatch. Missing: {missing}, Extra: {extra}", "danger") return redirect(request.url) plant_rows = list(reader) + # --- load and validate media.csv --- media_path = os.path.join(tmpdir, "media.csv") with open(media_path, newline="", encoding="utf-8-sig") as mf: mreader = csv.DictReader(mf) if mreader.fieldnames != MEDIA_HEADERS: missing = set(MEDIA_HEADERS) - set(mreader.fieldnames or []) - extra = set(mreader.fieldnames or []) - set(MEDIA_HEADERS) + extra = set(mreader.fieldnames or []) - set(MEDIA_HEADERS) os.remove(tmp_zip.name) flash(f"media.csv header mismatch. Missing: {missing}, Extra: {extra}", "danger") return redirect(request.url) media_rows = list(mreader) + # --- import plants --- neo = get_neo4j_handler() added_plants = 0 plant_map = {} - for row in plant_rows: common = PlantCommonName.query.filter_by(name=row["Name"]).first() if not common: @@ -192,20 +195,23 @@ def upload(): neo.create_plant_node(p.uuid, row["Name"]) if row.get("Mother UUID"): - neo.create_lineage(child_uuid=p.uuid, parent_uuid=row["Mother UUID"]) + neo.create_lineage( + child_uuid=p.uuid, + parent_uuid=row["Mother UUID"] + ) added_plants += 1 - # ✅ Import media once for the full batch + # --- import media (FIX: now passing plant_id) --- added_media = 0 for mrow in media_rows: plant_uuid = mrow["Plant UUID"] - plant_id = plant_map.get(plant_uuid) + plant_id = plant_map.get(plant_uuid) if not plant_id: continue subpath = mrow["Image Path"].split('uploads/', 1)[-1] - src = os.path.join(tmpdir, "images", subpath) + src = os.path.join(tmpdir, "images", subpath) if not os.path.isfile(src): continue @@ -220,14 +226,18 @@ def upload(): file=file_storage, uploader_id=current_user.id, plugin="plant", - related_id=plant_id + related_id=plant_id, + plant_id=plant_id # ← ensure the FK is set! ) media.uploaded_at = datetime.fromisoformat(mrow["Uploaded At"]) - media.caption = mrow["Source Type"] + media.caption = mrow["Source Type"] db.session.add(media) added_media += 1 except Exception as e: - current_app.logger.warning(f"Failed to import media file: {subpath} → {e}") + current_app.logger.warning( + f"Failed to import media file: {subpath} → {e}" + ) + current_app.logger.debug(traceback.format_exc()) db.session.commit() neo.close() @@ -236,7 +246,7 @@ def upload(): flash(f"Imported {added_plants} plants and {added_media} images.", "success") return redirect(request.url) - # ── Standalone CSV Review Flow ───────────────────────────────────── + # ── CSV Review Flow ───────────────────────────────────────────────── if filename.endswith(".csv"): try: stream = io.StringIO(file.stream.read().decode("utf-8-sig")) @@ -255,18 +265,14 @@ def upload(): review_list = [] all_common = {c.name.lower(): c for c in PlantCommonName.query.all()} - all_sci = {s.name.lower(): s for s in PlantScientificName.query.all()} + all_sci = {s.name.lower(): s for s in PlantScientificName.query.all()} for row in reader: - uuid_raw = row.get("uuid", "") - uuid_val = uuid_raw.strip().strip('"') - name_raw = row.get("name", "") - name = name_raw.strip() - sci_raw = row.get("scientific_name", "") - sci_name = sci_raw.strip() + uuid_val = row.get("uuid", "").strip().strip('"') + name = row.get("name", "").strip() + sci_name = row.get("scientific_name", "").strip() plant_type = row.get("plant_type", "").strip() or "plant" - mother_raw = row.get("mother_uuid", "") - mother_uuid = mother_raw.strip().strip('"') + mother_uuid= row.get("mother_uuid", "").strip().strip('"') if not (uuid_val and name and plant_type): continue @@ -274,17 +280,20 @@ def upload(): suggestions = difflib.get_close_matches( sci_name.lower(), list(all_sci.keys()), - n=1, cutoff=0.8 + n=1, + cutoff=0.8 + ) + suggested = ( + all_sci[suggestions[0]].name + if suggestions and suggestions[0] != sci_name.lower() + else None ) - suggested = (all_sci[suggestions[0]].name - if suggestions and suggestions[0] != sci_name.lower() - else None) item = { - "uuid": uuid_val, - "name": name, - "sci_name": sci_name, - "suggested": suggested, + "uuid": uuid_val, + "name": name, + "sci_name": sci_name, + "suggested": suggested, "plant_type": plant_type, "mother_uuid": mother_uuid } @@ -294,7 +303,44 @@ def upload(): session["review_list"] = review_list return redirect(url_for("utility.review")) - flash("Unsupported file type. Please upload a ZIP or CSV.", "danger") + # ── Direct Media Upload Flow ─────────────────────────────────────── + plugin = request.form.get("plugin", '') + related_id = request.form.get("related_id", 0) + plant_id = request.form.get("plant_id", None) + growlog_id = request.form.get("growlog_id", None) + caption = request.form.get("caption", None) + + now = datetime.utcnow() + unique_id = str(uuid.uuid4()).replace("-", "") + secure_name= secure_filename(file.filename) + storage_path = os.path.join( + current_app.config['UPLOAD_FOLDER'], + str(current_user.id), + now.strftime('%Y/%m/%d') + ) + os.makedirs(storage_path, exist_ok=True) + + full_file_path = os.path.join(storage_path, f"{unique_id}_{secure_name}") + file.save(full_file_path) + + file_url = f"/{current_user.id}/{now.strftime('%Y/%m/%d')}/{unique_id}_{secure_name}" + + media = Media( + plugin=plugin, + related_id=related_id, + filename=f"{unique_id}_{secure_name}", + uploaded_at=now, + uploader_id=current_user.id, + caption=caption, + plant_id=plant_id, + growlog_id=growlog_id, + created_at=now, + file_url=file_url + ) + db.session.add(media) + db.session.commit() + + flash("File uploaded and saved successfully.", "success") return redirect(request.url) return render_template("utility/upload.html", csrf_token=generate_csrf()) @@ -419,10 +465,12 @@ def export_data(): plants_csv = plant_io.getvalue() # 2) Gather media - media_records = (Media.query - .filter_by(uploader_id=current_user.id) - .order_by(Media.id) - .all()) + media_records = ( + Media.query + .filter(Media.uploader_id == current_user.id, Media.plant_id.isnot(None)) + .order_by(Media.id) + .all() + ) # Build media.csv media_io = io.StringIO() mw = csv.writer(media_io)