sort of working, more changes
This commit is contained in:
@ -51,12 +51,15 @@
|
||||
</div>
|
||||
</nav>
|
||||
<main class="container">
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="alert alert-warning">
|
||||
{% for message in messages %}
|
||||
<div>{{ message }}</div>
|
||||
{% endfor %}
|
||||
<div class="container mt-3">
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
@ -136,7 +136,7 @@ def upload():
|
||||
mreader = csv.DictReader(mf)
|
||||
if mreader.fieldnames != MEDIA_HEADERS:
|
||||
missing = set(MEDIA_HEADERS) - set(mreader.fieldnames or [])
|
||||
extra = set(reader.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)
|
||||
@ -213,13 +213,13 @@ def upload():
|
||||
with open(src, "rb") as sf, open(dst, "wb") as df:
|
||||
df.write(sf.read())
|
||||
|
||||
# 🔧 FIXED: match your Media model exactly
|
||||
media = Media(
|
||||
user_id=current_user.id,
|
||||
plant_id=plant_obj.id,
|
||||
original_filename=os.path.basename(src),
|
||||
path=f"uploads/{current_user.id}/{plant_obj.id}/{fname}",
|
||||
file_url=f"uploads/{current_user.id}/{plant_obj.id}/{fname}",
|
||||
uploaded_at=datetime.fromisoformat(mrow["Uploaded At"]),
|
||||
source_type=mrow["Source Type"]
|
||||
uploader_id=current_user.id,
|
||||
caption=mrow["Source Type"],
|
||||
plant_id=plant_obj.id
|
||||
)
|
||||
db.session.add(media)
|
||||
added_media += 1
|
||||
@ -285,7 +285,7 @@ def upload():
|
||||
"mother_uuid": mother_uuid
|
||||
}
|
||||
review_list.append(item)
|
||||
session["pending_rows"].append(item)
|
||||
session["pending_rows"].append(item)
|
||||
|
||||
session["review_list"] = review_list
|
||||
return redirect(url_for("importer.review"))
|
||||
@ -336,7 +336,7 @@ def review():
|
||||
)
|
||||
db.session.add(scientific)
|
||||
db.session.flush()
|
||||
all_sci = all_scientific[scientific.name.lower()] = scientific
|
||||
all_scientific[scientific.name.lower()] = scientific
|
||||
|
||||
verified = not suggested or (suggested and accepted)
|
||||
|
||||
@ -352,6 +352,7 @@ def review():
|
||||
)
|
||||
db.session.add(plant)
|
||||
db.session.flush()
|
||||
|
||||
log = PlantOwnershipLog(
|
||||
plant_id = plant.id,
|
||||
user_id = current_user.id,
|
||||
|
@ -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
|
||||
|
@ -1,10 +1,20 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, TextAreaField, BooleanField, SubmitField
|
||||
from wtforms.validators import DataRequired
|
||||
from wtforms import StringField, TextAreaField, BooleanField, SubmitField, SelectField
|
||||
from wtforms.validators import Optional, DataRequired
|
||||
|
||||
class PlantForm(FlaskForm):
|
||||
name = StringField('Name', validators=[DataRequired()])
|
||||
type = StringField('Type')
|
||||
notes = TextAreaField('Notes')
|
||||
common_name = SelectField('Common Name', validators=[Optional()], coerce=int)
|
||||
scientific_name = SelectField('Scientific Name', validators=[Optional()], coerce=int)
|
||||
mother_uuid = SelectField('Mother UUID', validators=[Optional()], coerce=str)
|
||||
plant_type = SelectField('Plant Type', validators=[DataRequired()], choices=[
|
||||
('cutting', 'Cutting'),
|
||||
('tissue_culture', 'Tissue Culture'),
|
||||
('plant', 'Plant'),
|
||||
('seed', 'Seed'),
|
||||
('division', 'Division'),
|
||||
])
|
||||
custom_slug = StringField('Custom Slug', validators=[Optional()])
|
||||
notes = TextAreaField('Notes', validators=[Optional()])
|
||||
data_verified = BooleanField('Data Verified', default=False)
|
||||
is_active = BooleanField('Active', default=True)
|
||||
submit = SubmitField('Save')
|
||||
|
@ -3,8 +3,7 @@
|
||||
from datetime import datetime
|
||||
import uuid as uuid_lib
|
||||
from app import db
|
||||
# from plugins.auth.models import User
|
||||
|
||||
from plugins.media.models import Media # import Media so we can refer to Media.plant_id
|
||||
|
||||
# Association table for Plant ↔ Tag (unchanged)
|
||||
plant_tags = db.Table(
|
||||
@ -21,7 +20,7 @@ class Tag(db.Model):
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(128), unique=True, nullable=False)
|
||||
# … any other columns you had …
|
||||
|
||||
|
||||
class PlantCommonName(db.Model):
|
||||
__tablename__ = 'plant_common_name'
|
||||
@ -38,6 +37,7 @@ class PlantCommonName(db.Model):
|
||||
cascade='all, delete-orphan'
|
||||
)
|
||||
|
||||
|
||||
class PlantScientificName(db.Model):
|
||||
__tablename__ = 'plant_scientific_name'
|
||||
__table_args__ = {'extend_existing': True}
|
||||
@ -47,22 +47,17 @@ class PlantScientificName(db.Model):
|
||||
common_id = db.Column(db.Integer, db.ForeignKey('plant_common_name.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# We removed the “plants” relationship from here to avoid backref conflicts.
|
||||
# If you need it, you can still do Plant.query.filter_by(scientific_id=<this id>).
|
||||
|
||||
class PlantOwnershipLog(db.Model):
|
||||
__tablename__ = 'plant_ownership_log'
|
||||
__table_args__ = {'extend_existing': True}
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
date_acquired = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
transferred = db.Column(db.Boolean, default=False, nullable=False)
|
||||
is_verified = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
# Optional: if you ever want to store a pointer to the Neo4j node, you can re-add:
|
||||
# graph_node_id = db.Column(db.String(255), nullable=True)
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
date_acquired = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
transferred = db.Column(db.Boolean, default=False, nullable=False)
|
||||
is_verified = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
user = db.relationship(
|
||||
'plugins.auth.models.User',
|
||||
@ -70,56 +65,73 @@ class PlantOwnershipLog(db.Model):
|
||||
lazy=True
|
||||
)
|
||||
|
||||
|
||||
class Plant(db.Model):
|
||||
__tablename__ = 'plant'
|
||||
__table_args__ = {'extend_existing': True}
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
uuid = db.Column(db.String(36), default=lambda: str(uuid_lib.uuid4()), unique=True, nullable=False)
|
||||
custom_slug = db.Column(db.String(255), unique=True, nullable=True)
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
uuid = db.Column(db.String(36), default=lambda: str(uuid_lib.uuid4()), unique=True, nullable=False)
|
||||
custom_slug = db.Column(db.String(255), unique=True, nullable=True)
|
||||
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
common_id = db.Column(db.Integer, db.ForeignKey('plant_common_name.id'), nullable=False)
|
||||
scientific_id = db.Column(db.Integer, db.ForeignKey('plant_scientific_name.id'), nullable=False)
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
common_id = db.Column(db.Integer, db.ForeignKey('plant_common_name.id'), nullable=False)
|
||||
scientific_id = db.Column(db.Integer, db.ForeignKey('plant_scientific_name.id'), nullable=False)
|
||||
|
||||
plant_type = db.Column(db.String(50), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow)
|
||||
mother_uuid = db.Column(db.String(36), db.ForeignKey('plant.uuid'), nullable=True)
|
||||
|
||||
# ─── NEW: Flag that indicates whether the common/scientific name pair was human-verified ─────────────────
|
||||
data_verified = db.Column(db.Boolean, default=False, nullable=False)
|
||||
plant_type = db.Column(db.String(50), nullable=False)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
featured_media_id = db.Column(db.Integer, db.ForeignKey('media.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
updates = db.relationship(
|
||||
'plugins.growlog.models.PlantUpdate',
|
||||
backref='plant',
|
||||
lazy=True,
|
||||
cascade='all, delete-orphan'
|
||||
)
|
||||
tags = db.relationship(
|
||||
'plugins.plant.models.Tag',
|
||||
secondary=plant_tags,
|
||||
backref='plants',
|
||||
lazy='dynamic'
|
||||
)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow)
|
||||
data_verified = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
common_name = db.relationship(
|
||||
'plugins.plant.models.PlantCommonName',
|
||||
backref=db.backref('plants', lazy='dynamic'),
|
||||
lazy=True
|
||||
)
|
||||
scientific_name = db.relationship(
|
||||
'plugins.plant.models.PlantScientificName',
|
||||
backref=db.backref('plants', lazy='dynamic'),
|
||||
lazy=True
|
||||
)
|
||||
# ─── FIXED: explicitly join on Media.plant_id ──────────────────────────────
|
||||
media = db.relationship(
|
||||
Media,
|
||||
backref='plant',
|
||||
lazy=True,
|
||||
cascade='all, delete-orphan',
|
||||
foreign_keys=[Media.plant_id]
|
||||
)
|
||||
|
||||
ownership_logs = db.relationship(
|
||||
'plugins.plant.models.PlantOwnershipLog',
|
||||
backref='plant',
|
||||
lazy=True,
|
||||
cascade='all, delete-orphan'
|
||||
)
|
||||
featured_media = db.relationship(
|
||||
Media,
|
||||
foreign_keys=[featured_media_id],
|
||||
uselist=False
|
||||
)
|
||||
|
||||
updates = db.relationship(
|
||||
'plugins.growlog.models.PlantUpdate',
|
||||
backref='plant',
|
||||
lazy=True,
|
||||
cascade='all, delete-orphan'
|
||||
)
|
||||
tags = db.relationship(
|
||||
Tag,
|
||||
secondary=plant_tags,
|
||||
backref='plants',
|
||||
lazy='dynamic'
|
||||
)
|
||||
common_name = db.relationship(
|
||||
PlantCommonName,
|
||||
backref=db.backref('plants', lazy='dynamic'),
|
||||
lazy=True
|
||||
)
|
||||
scientific_name = db.relationship(
|
||||
PlantScientificName,
|
||||
backref=db.backref('plants', lazy='dynamic'),
|
||||
lazy=True
|
||||
)
|
||||
ownership_logs = db.relationship(
|
||||
PlantOwnershipLog,
|
||||
backref='plant',
|
||||
lazy=True,
|
||||
cascade='all, delete-orphan'
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Plant {self.uuid} ({self.plant_type})>"
|
||||
|
@ -1,43 +1,205 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, request, flash
|
||||
from uuid import uuid4
|
||||
from flask import (
|
||||
Blueprint,
|
||||
render_template,
|
||||
redirect,
|
||||
url_for,
|
||||
request,
|
||||
flash,
|
||||
)
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from .models import Plant
|
||||
from .models import Plant, PlantCommonName, PlantScientificName
|
||||
from .forms import PlantForm
|
||||
from plugins.media.models import Media
|
||||
from plugins.media.utils import save_media_file, delete_media_file, rotate_media_file, generate_image_url
|
||||
|
||||
bp = Blueprint('plant', __name__, template_folder='templates')
|
||||
bp = Blueprint(
|
||||
'plant',
|
||||
__name__,
|
||||
url_prefix='/plants',
|
||||
template_folder='templates'
|
||||
)
|
||||
|
||||
@bp.route('/plants/')
|
||||
# -----------------------------------------------------------------------------
|
||||
# Make generate_image_url available in all templates
|
||||
# -----------------------------------------------------------------------------
|
||||
@bp.app_context_processor
|
||||
def inject_image_helper():
|
||||
return dict(generate_image_url=generate_image_url)
|
||||
|
||||
# ─── LIST ─────────────────────────────────────────────────────────────────────
|
||||
@bp.route('/', methods=['GET'])
|
||||
@login_required
|
||||
def index():
|
||||
plants = Plant.query.order_by(Plant.created_at.desc()).all()
|
||||
return render_template('plant/index.html', plants=plants)
|
||||
plants = (
|
||||
Plant.query
|
||||
.filter_by(owner_id=current_user.id)
|
||||
.order_by(Plant.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
stats = {
|
||||
'user_plants': Plant.query.filter_by(owner_id=current_user.id).count(),
|
||||
'user_images': Media.query.filter_by(uploader_id=current_user.id).count(),
|
||||
'total_plants': Plant.query.count(),
|
||||
'total_images': Media.query.count(),
|
||||
}
|
||||
return render_template('plant/index.html', plants=plants, stats=stats)
|
||||
|
||||
@bp.route('/plants/<int:plant_id>')
|
||||
def detail(plant_id):
|
||||
plant = Plant.query.get_or_404(plant_id)
|
||||
return render_template('plant/detail.html', plant=plant)
|
||||
|
||||
@bp.route('/plants/new', methods=['GET', 'POST'])
|
||||
# ─── CREATE ───────────────────────────────────────────────────────────────────
|
||||
@bp.route('/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create():
|
||||
form = PlantForm()
|
||||
if form.validate_on_submit():
|
||||
plant = Plant(
|
||||
name=form.name.data,
|
||||
type=form.type.data,
|
||||
notes=form.notes.data,
|
||||
is_active=form.is_active.data
|
||||
)
|
||||
db.session.add(plant)
|
||||
db.session.commit()
|
||||
flash('Plant created successfully.', 'success')
|
||||
return redirect(url_for('plant.index'))
|
||||
return render_template('plant/form.html', form=form)
|
||||
|
||||
@bp.route('/plants/<int:plant_id>/edit', methods=['GET', 'POST'])
|
||||
def edit(plant_id):
|
||||
plant = Plant.query.get_or_404(plant_id)
|
||||
form = PlantForm(obj=plant)
|
||||
# ─── dropdown choices ───────────────────────────────────────────────────────
|
||||
form.plant_type.choices = [
|
||||
('plant', 'Plant'),
|
||||
('cutting', 'Cutting'),
|
||||
('seed', 'Seed'),
|
||||
('tissue_culture', 'Tissue Culture'),
|
||||
('division', 'Division'),
|
||||
]
|
||||
form.common_name.choices = [
|
||||
(c.id, c.name)
|
||||
for c in PlantCommonName.query.order_by(PlantCommonName.name)
|
||||
]
|
||||
form.scientific_name.choices = [
|
||||
(s.id, s.name)
|
||||
for s in PlantScientificName.query.order_by(PlantScientificName.name)
|
||||
]
|
||||
form.mother_uuid.choices = [('N/A', 'None')] + [
|
||||
(p.uuid, f"{p.common_name.name if p.common_name else 'Unnamed'} – {p.uuid}")
|
||||
for p in Plant.query.order_by(Plant.created_at.desc()).all()
|
||||
]
|
||||
|
||||
if form.validate_on_submit():
|
||||
form.populate_obj(plant)
|
||||
new_plant = Plant(
|
||||
uuid=str(uuid4()),
|
||||
owner_id=current_user.id,
|
||||
plant_type=form.plant_type.data,
|
||||
common_id=form.common_name.data,
|
||||
scientific_id=form.scientific_name.data,
|
||||
mother_uuid=(
|
||||
form.mother_uuid.data
|
||||
if form.mother_uuid.data != 'N/A'
|
||||
else None
|
||||
),
|
||||
custom_slug=form.custom_slug.data,
|
||||
notes=form.notes.data,
|
||||
data_verified=form.data_verified.data,
|
||||
is_active=form.is_active.data,
|
||||
)
|
||||
db.session.add(new_plant)
|
||||
db.session.commit()
|
||||
flash('New plant created successfully.', 'success')
|
||||
return redirect(url_for('plant.edit', uuid_val=new_plant.uuid))
|
||||
|
||||
return render_template('plant/create.html', form=form)
|
||||
|
||||
# ─── DETAIL ───────────────────────────────────────────────────────────────────
|
||||
@bp.route('/<uuid:uuid_val>', methods=['GET'])
|
||||
@login_required
|
||||
def detail(uuid_val):
|
||||
plant = Plant.query.filter_by(uuid=str(uuid_val)).first_or_404()
|
||||
return render_template('plant/detail.html', plant=plant)
|
||||
|
||||
# ─── EDIT ─────────────────────────────────────────────────────────────────────
|
||||
@bp.route('/<uuid:uuid_val>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit(uuid_val):
|
||||
plant = Plant.query.filter_by(uuid=str(uuid_val)).first_or_404()
|
||||
form = PlantForm()
|
||||
|
||||
form.plant_type.choices = [
|
||||
('plant', 'Plant'),
|
||||
('cutting', 'Cutting'),
|
||||
('seed', 'Seed'),
|
||||
('tissue_culture', 'Tissue Culture'),
|
||||
('division', 'Division'),
|
||||
]
|
||||
form.common_name.choices = [
|
||||
(c.id, c.name)
|
||||
for c in PlantCommonName.query.order_by(PlantCommonName.name)
|
||||
]
|
||||
form.scientific_name.choices = [
|
||||
(s.id, s.name)
|
||||
for s in PlantScientificName.query.order_by(PlantScientificName.name)
|
||||
]
|
||||
form.mother_uuid.choices = [('N/A', 'None')] + [
|
||||
(p.uuid, f"{p.common_name.name if p.common_name else 'Unnamed'} – {p.uuid}")
|
||||
for p in Plant.query.filter(Plant.uuid != plant.uuid).all()
|
||||
]
|
||||
|
||||
if request.method == 'GET':
|
||||
form.plant_type.data = plant.plant_type
|
||||
form.common_name.data = plant.common_id
|
||||
form.scientific_name.data = plant.scientific_id
|
||||
form.mother_uuid.data = plant.mother_uuid or 'N/A'
|
||||
form.custom_slug.data = plant.custom_slug
|
||||
form.notes.data = plant.notes
|
||||
form.data_verified.data = plant.data_verified
|
||||
form.is_active.data = getattr(plant, 'is_active', True)
|
||||
|
||||
if form.validate_on_submit():
|
||||
plant.plant_type = form.plant_type.data
|
||||
plant.common_id = form.common_name.data
|
||||
plant.scientific_id = form.scientific_name.data
|
||||
plant.mother_uuid = (
|
||||
form.mother_uuid.data
|
||||
if form.mother_uuid.data != 'N/A'
|
||||
else None
|
||||
)
|
||||
plant.custom_slug = form.custom_slug.data
|
||||
plant.notes = form.notes.data
|
||||
plant.data_verified = form.data_verified.data
|
||||
plant.is_active = form.is_active.data
|
||||
db.session.commit()
|
||||
flash('Plant updated successfully.', 'success')
|
||||
return redirect(url_for('plant.detail', plant_id=plant.id))
|
||||
return render_template('plant/form.html', form=form, plant=plant)
|
||||
return redirect(url_for('plant.detail', uuid_val=plant.uuid))
|
||||
|
||||
return render_template('plant/edit.html', form=form, plant=plant)
|
||||
|
||||
# ─── IMAGE ROUTES ────────────────────────────────────────────────────────────
|
||||
@bp.route('/<uuid:uuid_val>/upload', methods=['POST'])
|
||||
@login_required
|
||||
def upload_image(uuid_val):
|
||||
plant = Plant.query.filter_by(uuid=uuid_val).first_or_404()
|
||||
file = request.files.get('file')
|
||||
if file and file.filename:
|
||||
save_media_file(
|
||||
file,
|
||||
current_user.id,
|
||||
related_model='plant',
|
||||
related_uuid=str(plant.uuid)
|
||||
)
|
||||
flash('Image uploaded successfully.', 'success')
|
||||
return redirect(url_for('plant.edit', uuid_val=plant.uuid))
|
||||
|
||||
@bp.route('/<uuid:uuid_val>/feature/<int:media_id>', methods=['POST'])
|
||||
@login_required
|
||||
def set_featured_image(uuid_val, media_id):
|
||||
plant = Plant.query.filter_by(uuid=uuid_val).first_or_404()
|
||||
media = Media.query.get_or_404(media_id)
|
||||
plant.featured_media_id = media.id
|
||||
db.session.commit()
|
||||
flash('Featured image set.', 'success')
|
||||
return redirect(url_for('plant.edit', uuid_val=plant.uuid))
|
||||
|
||||
@bp.route('/<uuid:uuid_val>/delete/<int:media_id>', methods=['POST'])
|
||||
@login_required
|
||||
def delete_image(uuid_val, media_id):
|
||||
plant = Plant.query.filter_by(uuid=uuid_val).first_or_404()
|
||||
media = Media.query.get_or_404(media_id)
|
||||
delete_media_file(media)
|
||||
flash('Image deleted.', 'success')
|
||||
return redirect(url_for('plant.edit', uuid_val=plant.uuid))
|
||||
|
||||
@bp.route('/<uuid:uuid_val>/rotate/<int:media_id>', methods=['POST'])
|
||||
@login_required
|
||||
def rotate_image(uuid_val, media_id):
|
||||
plant = Plant.query.filter_by(uuid=uuid_val).first_or_404()
|
||||
media = Media.query.get_or_404(media_id)
|
||||
rotate_media_file(media)
|
||||
flash('Image rotated.', 'success')
|
||||
return redirect(url_for('plant.edit', uuid_val=plant.uuid))
|
||||
|
53
plugins/plant/templates/plant/create.html
Normal file
53
plugins/plant/templates/plant/create.html
Normal file
@ -0,0 +1,53 @@
|
||||
{% extends 'core_ui/base.html' %}
|
||||
{% block title %}Add New Plant – Nature In Pots{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Create New Plant</h2>
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.plant_type.label(class="form-label") }}
|
||||
{{ form.plant_type(class="form-select") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.common_name.label(class="form-label") }}
|
||||
{{ form.common_name(class="form-select") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.scientific_name.label(class="form-label") }}
|
||||
{{ form.scientific_name(class="form-select") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.mother_uuid.label(class="form-label") }}
|
||||
{{ form.mother_uuid(class="form-select") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.custom_slug.label(class="form-label") }}
|
||||
{{ form.custom_slug(class="form-control") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.notes.label(class="form-label") }}
|
||||
{{ form.notes(class="form-control", rows=4) }}
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
{{ form.data_verified(class="form-check-input") }}
|
||||
{{ form.data_verified.label(class="form-check-label") }}
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
{{ form.is_active(class="form-check-input") }}
|
||||
{{ form.is_active.label(class="form-check-label") }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success">Create Plant</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,37 +1,73 @@
|
||||
{# plugins/plant/templates/plant/detail.html #}
|
||||
{% extends 'core_ui/base.html' %}
|
||||
{% block title %}{{ plant.common_name.name }} – Nature In Pots{% endblock %}
|
||||
|
||||
{% block title %}
|
||||
{{ plant.common_name.name if plant.common_name else "Unnamed Plant" }} – Nature In Pots
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-4">
|
||||
<div class="row">
|
||||
<div class="row gx-4">
|
||||
<div class="col-md-4">
|
||||
<img src="https://placehold.co/300x300"
|
||||
class="img-fluid rounded mb-3"
|
||||
alt="{{ plant.common_name.name }}">
|
||||
{# determine featured or fallback to first media item #}
|
||||
{% set featured = plant.featured_media or plant.media|first %}
|
||||
<img
|
||||
src="{{ generate_image_url(featured.file_url if featured else None) }}"
|
||||
alt="Image of {{ plant.common_name.name if plant.common_name else 'Plant' }}"
|
||||
class="img-fluid rounded shadow-sm"
|
||||
style="object-fit: cover; width: 100%; height: auto;"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<h1>{{ plant.common_name.name }}</h1>
|
||||
<h2>
|
||||
{{ plant.common_name.name if plant.common_name else "Unnamed Plant" }}
|
||||
</h2>
|
||||
{% if plant.scientific_name %}
|
||||
<p class="text-muted"><em>{{ plant.scientific_name.name }}</em></p>
|
||||
<h5 class="text-muted">
|
||||
{{ plant.scientific_name.name }}
|
||||
</h5>
|
||||
{% endif %}
|
||||
<dl class="row">
|
||||
<dt class="col-sm-3">Date Added</dt>
|
||||
<dd class="col-sm-9">{{ plant.created_at.strftime('%Y-%m-%d') }}</dd>
|
||||
|
||||
<dt class="col-sm-3">Status</dt>
|
||||
<dd class="col-sm-9">
|
||||
{{ 'Dead' if plant.is_dead else 'Active' }}
|
||||
</dd>
|
||||
</dl>
|
||||
<p class="mt-3">
|
||||
{{ plant.notes or "No description provided." }}
|
||||
</p>
|
||||
|
||||
<a href="{{ url_for('plant.index') }}" class="btn btn-secondary">
|
||||
← Back to list
|
||||
</a>
|
||||
<a href="{{ url_for('plant.edit', plant_id=plant.id) }}"
|
||||
class="btn btn-primary">
|
||||
Edit
|
||||
</a>
|
||||
{% if plant.mother_uuid %}
|
||||
<p class="text-muted">
|
||||
Parent:
|
||||
<a href="{{ url_for('plant.detail', uuid_val=plant.mother_uuid) }}">
|
||||
{{ plant.mother_uuid }}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-4">
|
||||
<a
|
||||
href="{{ url_for('plant.edit', uuid_val=plant.uuid) }}"
|
||||
class="btn btn-primary me-2"
|
||||
>Edit</a>
|
||||
<a
|
||||
href="{{ url_for('plant.index') }}"
|
||||
class="btn btn-secondary"
|
||||
>Back to List</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if plant.media|length > (1 if plant.featured_media else 0) %}
|
||||
<hr class="my-4">
|
||||
<h4>Additional Images</h4>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
{% for img in plant.media if img != featured %}
|
||||
<img
|
||||
src="{{ generate_image_url(img.file_url) }}"
|
||||
alt="Plant image"
|
||||
class="img-thumbnail"
|
||||
style="height: 160px; object-fit: cover;"
|
||||
>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
127
plugins/plant/templates/plant/edit.html
Normal file
127
plugins/plant/templates/plant/edit.html
Normal file
@ -0,0 +1,127 @@
|
||||
{% extends 'core_ui/base.html' %}
|
||||
{% block title %}Edit Plant – Nature In Pots{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Edit Plant</h2>
|
||||
|
||||
{# ─── Main plant‐data form ──────────────────────────────────────────── #}
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.plant_type.label(class="form-label") }}
|
||||
{{ form.plant_type(class="form-select") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.common_name.label(class="form-label") }}
|
||||
{{ form.common_name(class="form-select") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.scientific_name.label(class="form-label") }}
|
||||
{{ form.scientific_name(class="form-select") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.mother_uuid.label(class="form-label") }}
|
||||
{{ form.mother_uuid(class="form-select") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.custom_slug.label(class="form-label") }}
|
||||
{{ form.custom_slug(class="form-control") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.notes.label(class="form-label") }}
|
||||
{{ form.notes(class="form-control", rows=4) }}
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
{{ form.data_verified(class="form-check-input") }}
|
||||
{{ form.data_verified.label(class="form-check-label") }}
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
{{ form.is_active(class="form-check-input") }}
|
||||
{{ form.is_active.label(class="form-check-label") }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
|
||||
{# ─── Upload new image ─────────────────────────────────────────────── #}
|
||||
<h4>Upload Image</h4>
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ url_for('plant.upload_image', uuid_val=plant.uuid) }}"
|
||||
enctype="multipart/form-data"
|
||||
class="mb-4"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="csrf_token"
|
||||
value="{{ csrf_token() }}"
|
||||
>
|
||||
<div class="input-group">
|
||||
<input type="file" name="file" class="form-control" required>
|
||||
<button class="btn btn-secondary" type="submit">Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# ─── Existing images ──────────────────────────────────────────────── #}
|
||||
<h4>Existing Images</h4>
|
||||
<div class="row">
|
||||
{% for media in plant.media %}
|
||||
<div class="col-md-3 mb-4">
|
||||
<div class="card h-100">
|
||||
<img
|
||||
src="{{ generate_image_url(media.file_url) }}"
|
||||
class="card-img-top img-fluid"
|
||||
alt="Plant Image"
|
||||
style="object-fit:cover; height:150px;"
|
||||
>
|
||||
<div class="card-body text-center">
|
||||
{% if plant.featured_media_id == media.id %}
|
||||
<span class="badge bg-success mb-2">Featured</span>
|
||||
{% else %}
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ url_for('plant.set_featured_image', uuid_val=plant.uuid, media_id=media.id) }}"
|
||||
class="mb-2"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-outline-primary btn-sm">Set Featured</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-grid gap-1">
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ url_for('plant.rotate_image', uuid_val=plant.uuid, media_id=media.id) }}"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-outline-secondary btn-sm">Rotate</button>
|
||||
</form>
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ url_for('plant.delete_image', uuid_val=plant.uuid, media_id=media.id) }}"
|
||||
onsubmit="return confirm('Delete this image?');"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-outline-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No images uploaded yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,12 +0,0 @@
|
||||
{% extends 'core_ui/base.html' %}
|
||||
{% block content %}
|
||||
<h1>{% if plant %}Edit{% else %}New{% endif %} Plant</h1>
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
<p>{{ form.name.label }}<br>{{ form.name(size=40) }}</p>
|
||||
<p>{{ form.type.label }}<br>{{ form.type(size=40) }}</p>
|
||||
<p>{{ form.notes.label }}<br>{{ form.notes(rows=5, cols=40) }}</p>
|
||||
<p>{{ form.is_active() }} {{ form.is_active.label }}</p>
|
||||
<p>{{ form.submit() }}</p>
|
||||
</form>
|
||||
{% endblock %}
|
@ -1,32 +1,112 @@
|
||||
{# plugins/plant/templates/plant/index.html #}
|
||||
{% extends 'core_ui/base.html' %}
|
||||
|
||||
{% block title %}Plant List – Nature In Pots{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-4">
|
||||
<!-- Stats Section (responsive 2-col ↔ 1-col) -->
|
||||
<div class="mb-4 p-3 bg-light border rounded">
|
||||
<h5>Statistics</h5>
|
||||
<div class="row row-cols-1 row-cols-md-2 g-3 mt-3 text-center">
|
||||
<div class="col">
|
||||
<div class="d-flex align-items-center justify-content-center">
|
||||
<i class="bi bi-seedling fs-3 text-success me-3"></i>
|
||||
<div>
|
||||
<div class="small text-muted">Your plants</div>
|
||||
<div class="fw-bold">{{ stats.user_plants }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="d-flex align-items-center justify-content-center">
|
||||
<i class="bi bi-image fs-3 text-primary me-3"></i>
|
||||
<div>
|
||||
<div class="small text-muted">Your images</div>
|
||||
<div class="fw-bold">{{ stats.user_images }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="d-flex align-items-center justify-content-center">
|
||||
<i class="bi bi-tree fs-3 text-success me-3"></i>
|
||||
<div>
|
||||
<div class="small text-muted">Total plants</div>
|
||||
<div class="fw-bold">{{ stats.total_plants }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="d-flex align-items-center justify-content-center">
|
||||
<i class="bi bi-images fs-3 text-primary me-3"></i>
|
||||
<div>
|
||||
<div class="small text-muted">Total images</div>
|
||||
<div class="fw-bold">{{ stats.total_images }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="mb-4">Plant List</h1>
|
||||
|
||||
<!-- Search & Add -->
|
||||
<div class="mb-3 d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center gap-2">
|
||||
<div>
|
||||
<a href="{{ url_for('plant.create') }}" class="btn btn-success">Add New Plant</a>
|
||||
</div>
|
||||
<div class="input-group" style="max-width:300px; width:100%;">
|
||||
<span class="input-group-text">Search</span>
|
||||
<input id="searchInput" type="text" class="form-control" placeholder="by name…">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if plants %}
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 g-4">
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 g-4" id="plantContainer">
|
||||
{% for plant in plants %}
|
||||
<div class="col">
|
||||
<div class="col plant-card" data-name="{{ plant.common_name.name|lower if plant.common_name else '' }}">
|
||||
<div class="card h-100">
|
||||
<!-- placeholder until you wire up real media -->
|
||||
<img src="https://placehold.co/150x150"
|
||||
class="card-img-top"
|
||||
alt="{{ plant.common_name.name if plant.common_name else 'Plant' }}">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<h5 class="card-title">
|
||||
{{ plant.common_name.name if plant.common_name else 'Unnamed' }}
|
||||
</h5>
|
||||
{% if plant.scientific_name %}
|
||||
<p class="card-text text-muted">
|
||||
<em>{{ plant.scientific_name.name }}</em>
|
||||
</p>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('plant.detail', plant_id=plant.id) }}"
|
||||
class="mt-auto btn btn-primary">
|
||||
View Details
|
||||
{# pick the featured media entry, or fall back to first item #}
|
||||
{% set featured = plant.media
|
||||
|selectattr('id','equalto', plant.featured_media_id)
|
||||
|first %}
|
||||
{% if not featured and plant.media %}
|
||||
{% set featured = plant.media|first %}
|
||||
{% endif %}
|
||||
|
||||
{% if featured %}
|
||||
<a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}">
|
||||
<img
|
||||
src="{{ generate_image_url(featured.file_url) }}"
|
||||
class="card-img-top"
|
||||
style="height:200px;object-fit:cover;"
|
||||
alt="Image for {{ plant.common_name.name if plant.common_name else 'Plant' }}">
|
||||
</a>
|
||||
{% else %}
|
||||
<img
|
||||
src="https://placehold.co/300x200"
|
||||
class="card-img-top"
|
||||
style="height:200px;object-fit:cover;"
|
||||
alt="No image available">
|
||||
{% endif %}
|
||||
|
||||
<div class="card-body d-flex flex-column">
|
||||
<h5 class="card-title mb-1">
|
||||
<a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}">
|
||||
{{ plant.common_name.name if plant.common_name else 'Untitled' }}
|
||||
</a>
|
||||
</h5>
|
||||
<h6 class="text-muted small">{{ plant.uuid }}</h6>
|
||||
<p class="mb-1"><strong>Type:</strong> {{ plant.plant_type }}</p>
|
||||
<p class="mb-2"><strong>Scientific:</strong>
|
||||
{{ plant.scientific_name.name if plant.scientific_name else '–' }}
|
||||
</p>
|
||||
<div class="mt-auto d-flex flex-wrap gap-1">
|
||||
<a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}"
|
||||
class="btn btn-sm btn-primary">View</a>
|
||||
<a href="{{ url_for('plant.edit', uuid_val=plant.uuid) }}"
|
||||
class="btn btn-sm btn-secondary">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -35,12 +115,15 @@
|
||||
{% else %}
|
||||
<p>No plants found yet. <a href="{{ url_for('plant.create') }}">Add one now.</a></p>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{{ url_for('plant.create') }}"
|
||||
class="btn btn-success">
|
||||
Add New Plant
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// client-side filtering
|
||||
document.getElementById('searchInput').addEventListener('input', function() {
|
||||
const q = this.value.trim().toLowerCase();
|
||||
document.querySelectorAll('#plantContainer .plant-card').forEach(card => {
|
||||
card.style.display = card.dataset.name.includes(q) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
Reference in New Issue
Block a user