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

@ -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 %}

View File

@ -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,

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

View File

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

View File

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

View File

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

View 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 %}

View File

@ -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 %}

View 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 plantdata 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 %}

View File

@ -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 %}

View File

@ -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 %}