stuff is working again

This commit is contained in:
2025-06-26 05:21:21 -05:00
parent 7a8ec5face
commit 00fd49c79b
12 changed files with 639 additions and 272 deletions

View File

@ -40,3 +40,6 @@ class Config:
STANDARD_IMG_SIZE = tuple( STANDARD_IMG_SIZE = tuple(
map(int, os.getenv('STANDARD_IMG_SIZE', '300x200').split('x')) map(int, os.getenv('STANDARD_IMG_SIZE', '300x200').split('x'))
) )
PLANT_CARDS_BASE_URL = "https://plant.cards"
ALLOW_REGISTRATION = False

BIN
nip.zip

Binary file not shown.

View File

@ -1,4 +1,4 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
from flask_login import login_user, logout_user, login_required from flask_login import login_user, logout_user, login_required
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from app import db from app import db
@ -29,6 +29,10 @@ def logout():
@bp.route('/register', methods=['GET', 'POST']) @bp.route('/register', methods=['GET', 'POST'])
def register(): def register():
if not current_app.config.get('ALLOW_REGISTRATION', True):
flash('Registration is currently closed.', 'warning')
return redirect(url_for('auth.login'))
if request.method == 'POST': if request.method == 'POST':
email = request.form['email'] email = request.form['email']
password = request.form['password'] password = request.form['password']

View File

@ -2,6 +2,8 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>{% block title %}Nature In Pots Community{% endblock %}</title> <title>{% block title %}Nature In Pots Community{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<style> <style>
@ -32,21 +34,23 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('core_ui.home') }}">Home</a> <a class="nav-link" href="{{ url_for('core_ui.home') }}">Home</a>
</li> </li>
<li class="nav-item"> {% if current_user.is_authenticated %}
<a class="nav-link" href="{{ url_for('plant.index') }}">Plants</a> <li class="nav-item">
</li> <a class="nav-link" href="{{ url_for('plant.index') }}">Plants</a>
<li class="nav-item"> </li>
<a class="nav-link" href="{{ url_for('plant.index') }}#plantContainer">Grow Logs</a> <li class="nav-item">
</li> <a class="nav-link" href="{{ url_for('utility.upload') }}">Import</a>
<li class="nav-item"> </li>
<a class="nav-link" href="{{ url_for('submission.list_submissions') }}">Submissions</a> <li class="nav-item">
</li> <a class="nav-link" href="{{ url_for('plant.index') }}#plantContainer">Grow Logs</a>
<li class="nav-item"> </li>
<a class="nav-link" href="{{ url_for('utility.upload') }}">Import</a> <li class="nav-item">
</li> <a class="nav-link" href="{{ url_for('submission.list_submissions') }}">Submissions</a>
<li class="nav-item"> </li>
<a class="nav-link" href="{{ url_for('utility.export_data') }}">Export</a> <li class="nav-item">
</li> <a class="nav-link" href="{{ url_for('utility.export_data') }}">Export</a>
</li>
{% endif %}
{% if current_user.is_authenticated and current_user.role == 'admin' %} {% if current_user.is_authenticated and current_user.role == 'admin' %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-danger" href="{{ url_for('admin.admin_dashboard') }}"> <a class="nav-link text-danger" href="{{ url_for('admin.admin_dashboard') }}">

View File

@ -1,5 +1,3 @@
# plugins/media/routes.py
import os import os
import uuid import uuid
import io import io
@ -19,7 +17,6 @@ from PIL import Image, ExifTags
from app import db from app import db
from .models import Media, ImageHeart, FeaturedImage from .models import Media, ImageHeart, FeaturedImage
from plugins.plant.models import Plant
bp = Blueprint( bp = Blueprint(
"media", "media",
@ -306,35 +303,41 @@ def set_featured_image(context, context_id, media_id):
""" """
Singleselect “featured” toggle for any plugin (plants, grow_logs, etc). Singleselect “featured” toggle for any plugin (plants, grow_logs, etc).
""" """
# normalize to plural # normalize to singular plugin name (matches Media.plugin & FeaturedImage.context)
plugin_ctx = context if context.endswith('s') else context + 's' valid = {'plant', 'growlog', 'user', 'vendor'}
if plugin_ctx not in ('plants', 'grow_logs', 'users', 'vendors'): if context in valid:
plugin_name = context
elif context.endswith('s') and context[:-1] in valid:
plugin_name = context[:-1]
else:
abort(404) abort(404)
# must own that media row # must own that media row
media = Media.query.filter_by( media = Media.query.filter_by(
plugin=plugin_ctx, plugin=plugin_name,
related_id=context_id, related_id=context_id,
id=media_id id=media_id
).first_or_404() ).first_or_404()
# clear out any existing # clear out any existing featured rows
FeaturedImage.query.filter_by( FeaturedImage.query.filter_by(
context=plugin_ctx, context=plugin_name,
context_id=context_id context_id=context_id
).delete() ).delete()
# insert new featured row # insert new featured row
fi = FeaturedImage( fi = FeaturedImage(
media_id=media.id, media_id=media.id,
context=plugin_ctx, context=plugin_name,
context_id=context_id, context_id=context_id,
is_featured=True is_featured=True
) )
db.session.add(fi) db.session.add(fi)
db.session.commit() db.session.commit()
return jsonify({"media_id": media.id}) # Redirect back with a flash instead of JSON
flash("Featured image updated.", "success")
return redirect(request.referrer or url_for("core_ui.home"))
@bp.route("/delete/<int:media_id>", methods=["POST"]) @bp.route("/delete/<int:media_id>", methods=["POST"])

View File

@ -82,6 +82,7 @@ class Plant(db.Model):
plant_type = db.Column(db.String(50), nullable=False) plant_type = db.Column(db.String(50), nullable=False)
notes = db.Column(db.Text, nullable=True) notes = db.Column(db.Text, nullable=True)
short_id = db.Column(db.String(8), unique=True, nullable=True, index=True)
vendor_name = db.Column(db.String(255), nullable=True) vendor_name = db.Column(db.String(255), nullable=True)
price = db.Column(db.Numeric(10, 2), nullable=True) price = db.Column(db.Numeric(10, 2), nullable=True)
@ -151,3 +152,16 @@ class Plant(db.Model):
def __repr__(self): def __repr__(self):
return f"<Plant {self.uuid} ({self.plant_type})>" return f"<Plant {self.uuid} ({self.plant_type})>"
@classmethod
def generate_short_id(cls, length: int = 6) -> str:
"""
Produce a random [az09] string of the given length
and ensure it doesnt collide with any existing plant.short_id.
"""
alphabet = string.ascii_lowercase + string.digits
while True:
candidate = ''.join(random.choices(alphabet, k=length))
# Check uniqueness
if not cls.query.filter_by(short_id=candidate).first():
return candidate

View File

@ -1,5 +1,6 @@
from uuid import uuid4 from uuid import uuid4
import os import os
import string
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from flask import ( from flask import (
@ -14,7 +15,7 @@ from flask import (
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db from app import db
from .models import Plant, PlantCommonName, PlantScientificName from .models import Plant, PlantScientificName, PlantCommonName
from .forms import PlantForm from .forms import PlantForm
from plugins.media.models import Media from plugins.media.models import Media
from plugins.media.routes import ( from plugins.media.routes import (
@ -76,11 +77,10 @@ def index():
stats=stats, stats=stats,
) )
@bp.route('/create', methods=['GET', 'POST']) @bp.route('/', methods=['GET', 'POST'])
@login_required @login_required
def create(): def create():
form = PlantForm() form = PlantForm()
form.plant_type.choices = [ form.plant_type.choices = [
('plant', 'Plant'), ('plant', 'Plant'),
('cutting', 'Cutting'), ('cutting', 'Cutting'),
@ -96,13 +96,6 @@ def create():
(s.id, s.name) (s.id, s.name)
for s in PlantScientificName.query.order_by(PlantScientificName.name).all() for s in PlantScientificName.query.order_by(PlantScientificName.name).all()
] ]
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(): if form.validate_on_submit():
new_plant = Plant( new_plant = Plant(
@ -111,11 +104,7 @@ def create():
plant_type=form.plant_type.data, plant_type=form.plant_type.data,
common_id=form.common_name.data, common_id=form.common_name.data,
scientific_id=form.scientific_name.data, scientific_id=form.scientific_name.data,
mother_uuid=( mother_uuid=(form.mother_uuid.data if form.mother_uuid.data != 'N/A' else None),
form.mother_uuid.data
if form.mother_uuid.data != 'N/A'
else None
),
custom_slug=(form.custom_slug.data.strip() or None), custom_slug=(form.custom_slug.data.strip() or None),
vendor_name=(form.vendor_name.data.strip() or None), vendor_name=(form.vendor_name.data.strip() or None),
price=(form.price.data or None), price=(form.price.data or None),
@ -130,25 +119,97 @@ def create():
return render_template('plant/create.html', form=form) return render_template('plant/create.html', form=form)
@bp.route('/<uuid:uuid_val>', methods=['GET']) @bp.route('/<uuid:uuid_val>', methods=['GET'])
@login_required @login_required
def detail(uuid_val): def detail(uuid_val):
plant = Plant.query.filter_by( # 1) load this plant (and its media) or 404
uuid=str(uuid_val), plant = (
owner_id=current_user.id, Plant.query
).first_or_404() .options(joinedload(Plant.media_items))
return render_template('plant/detail.html', plant=plant) .filter_by(uuid=str(uuid_val), owner_id=current_user.id)
.first_or_404()
)
# 2) load any child plants (same owner, mother_uuid pointing here)
children = (
Plant.query
.options(joinedload(Plant.media_items))
.filter_by(owner_id=current_user.id, mother_uuid=plant.uuid)
.order_by(Plant.id)
.all()
)
# 3) build linear nav of this user's plants (by insertion order)
all_plants = (
Plant.query
.filter_by(owner_id=current_user.id)
.order_by(Plant.id)
.all()
)
uuids = [p.uuid for p in all_plants]
try:
idx = uuids.index(str(uuid_val))
except ValueError:
idx = None
prev_uuid = uuids[idx - 1] if idx not in (None, 0) else None
next_uuid = uuids[idx + 1] if idx is not None and idx < len(uuids) - 1 else None
return render_template(
'plant/detail.html',
plant=plant,
children=children,
prev_uuid=prev_uuid,
next_uuid=next_uuid
)
@bp.route('/<uuid:uuid_val>/generate_children', methods=['POST'])
@login_required
def generate_children(uuid_val):
parent = (
Plant.query
.filter_by(uuid=str(uuid_val), owner_id=current_user.id)
.first_or_404()
)
try:
count = int(request.form.get('count', 1))
except ValueError:
count = 1
created = 0
for _ in range(count):
child = Plant(
uuid=str(uuid4()),
owner_id=current_user.id,
plant_type='cutting',
common_id=parent.common_id,
scientific_id=parent.scientific_id,
mother_uuid=parent.uuid,
custom_slug=None,
vendor_name=None,
price=None,
notes=None,
data_verified=False,
is_active=True
)
db.session.add(child)
created += 1
db.session.commit()
flash(f"Generated {created} cuttings for {parent.common_name.name}.", 'success')
return redirect(url_for('plant.detail', uuid_val=parent.uuid))
@bp.route('/<uuid:uuid_val>/edit', methods=['GET', 'POST']) @bp.route('/<uuid:uuid_val>/edit', methods=['GET', 'POST'])
@login_required @login_required
def edit(uuid_val): def edit(uuid_val):
plant = Plant.query.filter_by( plant = Plant.query.filter_by(
uuid=str(uuid_val), uuid=str(uuid_val),
owner_id=current_user.id, owner_id=current_user.id
).first_or_404() ).first_or_404()
form = PlantForm() form = PlantForm()
form.plant_type.choices = [ form.plant_type.choices = [
('plant', 'Plant'), ('plant', 'Plant'),
('cutting', 'Cutting'), ('cutting', 'Cutting'),
@ -165,10 +226,7 @@ def edit(uuid_val):
for s in PlantScientificName.query.order_by(PlantScientificName.name).all() for s in PlantScientificName.query.order_by(PlantScientificName.name).all()
] ]
form.mother_uuid.choices = [('N/A', 'None')] + [ form.mother_uuid.choices = [('N/A', 'None')] + [
( (p.uuid, f"{p.common_name.name if p.common_name else 'Unnamed'} {p.uuid}")
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() for p in Plant.query.filter(Plant.uuid != plant.uuid).all()
] ]
@ -188,22 +246,15 @@ def edit(uuid_val):
plant.plant_type = form.plant_type.data plant.plant_type = form.plant_type.data
plant.common_id = form.common_name.data plant.common_id = form.common_name.data
plant.scientific_id = form.scientific_name.data plant.scientific_id = form.scientific_name.data
plant.mother_uuid = ( plant.mother_uuid = (form.mother_uuid.data if form.mother_uuid.data != 'N/A' else None)
form.mother_uuid.data
if form.mother_uuid.data != 'N/A'
else None
)
plant.custom_slug = (form.custom_slug.data.strip() or None) plant.custom_slug = (form.custom_slug.data.strip() or None)
plant.vendor_name = (form.vendor_name.data.strip() or None) plant.vendor_name = (form.vendor_name.data.strip() or None)
plant.price = (form.price.data or None) plant.price = form.price.data or None
plant.notes = form.notes.data plant.notes = form.notes.data
plant.data_verified = form.data_verified.data plant.data_verified = form.data_verified.data
plant.is_active = form.is_active.data plant.is_active = form.is_active.data
# — patch to save whichever radio was checked — # (No more inline "featured_media_id" patch here—handled in media plugin)
featured_id = request.form.get("featured_media_id")
if featured_id and featured_id.isdigit():
plant.featured_media_id = int(featured_id)
db.session.commit() db.session.commit()
flash('Plant updated successfully.', 'success') flash('Plant updated successfully.', 'success')
@ -211,32 +262,32 @@ def edit(uuid_val):
return render_template('plant/edit.html', form=form, plant=plant) return render_template('plant/edit.html', form=form, plant=plant)
@bp.route('/<uuid:uuid_val>/upload', methods=['POST']) @bp.route('/<uuid:uuid_val>/upload', methods=['POST'])
@login_required @login_required
def upload_image(uuid_val): def upload_image(uuid_val):
plant = Plant.query.filter_by(uuid=str(uuid_val)).first_or_404() plant = Plant.query.filter_by(
file = request.files.get('file') uuid=str(uuid_val),
if file and file.filename: owner_id=current_user.id
save_media_file( ).first_or_404()
file,
current_user.id, file = request.files.get('image')
related_model='plant', if not file or file.filename == '':
related_uuid=str(plant.uuid), flash('No file selected.', 'danger')
) return redirect(url_for('plant.edit', uuid_val=plant.uuid))
flash('Image uploaded successfully.', 'success')
try:
media = save_media_file(file, 'plants', plant.id)
media.uploader_id = current_user.id
db.session.add(media)
db.session.commit()
flash('Image uploaded.', 'success')
except Exception as e:
current_app.logger.error(f"Upload failed: {e}")
flash('Failed to upload image.', 'danger')
return redirect(url_for('plant.edit', uuid_val=plant.uuid)) return redirect(url_for('plant.edit', uuid_val=plant.uuid))
@bp.route("/feature/<int:media_id>", methods=["POST"])
@login_required
def set_featured_image(media_id):
media = Media.query.get_or_404(media_id)
if current_user.id != media.uploader_id and current_user.role != "admin":
return jsonify({"error": "Not authorized"}), 403
plant = media.plant
plant.featured_media_id = media.id
db.session.commit()
return jsonify({"status": "success", "media_id": media.id})
@bp.route('/<uuid:uuid_val>/delete/<int:media_id>', methods=['POST']) @bp.route('/<uuid:uuid_val>/delete/<int:media_id>', methods=['POST'])
@login_required @login_required
@ -247,6 +298,7 @@ def delete_image(uuid_val, media_id):
flash('Image deleted.', 'success') flash('Image deleted.', 'success')
return redirect(url_for('plant.edit', uuid_val=plant.uuid)) return redirect(url_for('plant.edit', uuid_val=plant.uuid))
@bp.route('/<uuid:uuid_val>/rotate/<int:media_id>', methods=['POST']) @bp.route('/<uuid:uuid_val>/rotate/<int:media_id>', methods=['POST'])
@login_required @login_required
def rotate_image(uuid_val, media_id): def rotate_image(uuid_val, media_id):
@ -255,3 +307,17 @@ def rotate_image(uuid_val, media_id):
rotate_media_file(media) rotate_media_file(media)
flash('Image rotated.', 'success') flash('Image rotated.', 'success')
return redirect(url_for('plant.edit', uuid_val=plant.uuid)) return redirect(url_for('plant.edit', uuid_val=plant.uuid))
@bp.route('/f/<string:short_id>')
@bp.route('/<string:short_id>')
def view_card(short_id):
p = Plant.query.filter_by(short_id=short_id).first_or_404()
# 2) owner → straight to the normal detail page
if current_user.is_authenticated and p.owner_id == current_user.id:
return redirect(url_for('plant.detail', uuid_val=p.uuid))
# 3) everyone else → public card
featured = next((m for m in p.media_items if getattr(m, 'featured', False)), None)
return render_template('plant/card.html', plant=p, featured=featured)

View File

@ -0,0 +1,144 @@
{% extends 'core_ui/base.html' %}
{% block content %}
<div class="card mb-4">
<div class="card-body">
<h2 class="card-title">{{ plant.common_name.name }}</h2>
<h5 class="card-subtitle mb-3 text-muted">
{{ plant.scientific_name.name }}
</h5>
{# ── DESKTOP / TABLET GALLERY ──────────────────────────── #}
<div class="d-none d-md-block">
{# hero image #}
{% set hero = featured or plant.media_items|first %}
{% if hero %}
<div class="text-center mb-4">
<img
id="main-image"
src="{{ generate_image_url(hero) }}"
class="img-fluid"
style="max-height:60vh; object-fit:contain;"
alt="Image for {{ plant.common_name.name }}"
>
</div>
{% endif %}
{# thumbnails #}
<div class="d-flex flex-wrap justify-content-center gap-2 mb-3">
{% for m in plant.media_items %}
<img
src="{{ generate_image_url(m) }}"
class="img-thumbnail thumb{% if hero and m.id==hero.id %} active{% endif %}"
data-url="{{ generate_image_url(m) }}"
style="width:clamp(80px,15vw,120px); aspect-ratio:1/1; object-fit:cover; cursor:pointer;"
alt="Thumbnail"
>
{% endfor %}
</div>
</div>
{# ── MOBILE GRID GALLERY ─────────────────────────────── #}
<div class="d-block d-md-none">
<div class="row row-cols-3 g-2">
{% for m in plant.media_items %}
<div class="col">
<img
src="{{ generate_image_url(m) }}"
class="img-fluid mobile-thumb"
data-url="{{ generate_image_url(m) }}"
alt="Image for {{ plant.common_name.name }}"
style="aspect-ratio:1/1; object-fit:cover; cursor:pointer;"
>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{# ── LIGHTBOX OVERLAY ─────────────────────────────────── #}
<div id="lightbox-overlay" class="d-none">
<span class="lightbox-close">&times;</span>
<img id="lightbox-image" src="" alt="Zoomed image">
</div>
<script>
(function(){
// Desktop thumbnail swapping
document.querySelectorAll('.thumb').forEach(thumb => {
thumb.addEventListener('click', () => {
const main = document.getElementById('main-image');
main.src = thumb.dataset.url;
document.querySelectorAll('.thumb.active')
.forEach(el => el.classList.remove('active'));
thumb.classList.add('active');
});
});
// Lightbox logic for mobile
const overlay = document.getElementById('lightbox-overlay');
const lbImage = document.getElementById('lightbox-image');
const closeBtn = document.querySelector('.lightbox-close');
document.querySelectorAll('.mobile-thumb').forEach(img => {
img.addEventListener('click', () => {
lbImage.src = img.dataset.url;
overlay.classList.remove('d-none');
});
});
// close handlers
closeBtn.addEventListener('click', () => {
overlay.classList.add('d-none');
lbImage.src = '';
});
overlay.addEventListener('click', e => {
if (e.target === overlay) {
overlay.classList.add('d-none');
lbImage.src = '';
}
});
// toggle zoom on lightbox image
lbImage.addEventListener('click', () => {
lbImage.classList.toggle('zoomed');
});
})();
</script>
<style>
/* Lightbox backdrop */
#lightbox-overlay {
position: fixed;
top:0; left:0; width:100vw; height:100vh;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1050;
}
#lightbox-overlay.d-none { display: none; }
/* Zoomable image */
#lightbox-image {
max-width: 90%;
max-height: 90%;
cursor: zoom-in;
transition: transform 0.3s ease;
}
#lightbox-image.zoomed {
transform: scale(2);
cursor: zoom-out;
}
/* Close “X” */
.lightbox-close {
position: absolute;
top: 1rem; right: 1rem;
font-size: 2rem;
color: white;
cursor: pointer;
z-index: 1060;
}
</style>
{% endblock %}

View File

@ -1,71 +1,176 @@
{% extends 'core_ui/base.html' %} {% extends 'core_ui/base.html' %}
{% block title %} {% block title %}
{{ plant.common_name.name if plant.common_name else "Unnamed Plant" }} Nature In Pots {{ plant.common_name.name if plant.common_name else "Unnamed Plant" }} Nature In Pots
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container my-4"> <!-- Prev/Next Navigation -->
<div class="row gx-4"> <div class="d-flex justify-content-between mb-3">
<div class="col-md-4"> {% if prev_uuid %}
{% set featured = plant.featured_media or plant.media|first %} <a href="{{ url_for('plant.detail', uuid_val=prev_uuid) }}" class="btn btn-outline-primary">&larr; Previous</a>
<img {% else %}
src="{{ generate_image_url(featured) }}" <div></div>
alt="Image of {{ plant.common_name.name if plant.common_name else 'Plant' }}" {% endif %}
class="img-fluid rounded shadow-sm" {% if next_uuid %}
style="object-fit: cover; width: 100%; height: auto;" <a href="{{ url_for('plant.detail', uuid_val=next_uuid) }}" class="btn btn-outline-primary">Next &rarr;</a>
> {% else %}
</div> <div></div>
{% endif %}
</div>
<div class="col-md-8"> <div class="card mb-4">
<h2> <div class="card-header d-flex justify-content-between align-items-center">
<h2 class="mb-0">
{{ plant.common_name.name if plant.common_name else "Unnamed Plant" }} {{ plant.common_name.name if plant.common_name else "Unnamed Plant" }}
<small class="text-muted">({{ plant.uuid }})</small>
</h2> </h2>
{% if plant.scientific_name %} {% if current_user.id == plant.owner_id %}
<h5 class="text-muted"> <a href="{{ url_for('plant.edit', uuid_val=plant.uuid) }}"
{{ plant.scientific_name.name }} class="btn btn-sm btn-outline-secondary">Edit</a>
</h5>
{% endif %} {% endif %}
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-3">Type</dt>
<dd class="col-sm-9">{{ plant.plant_type }}</dd>
<p class="mt-3"> <dt class="col-sm-3">Scientific Name</dt>
{{ plant.notes or "No description provided." }} <dd class="col-sm-9">{{ plant.scientific_name.name }}</dd>
</p>
{% if plant.mother_uuid %} <dt class="col-sm-3">Mother UUID</dt>
<p class="text-muted"> <dd class="col-sm-9">
Parent: {% if plant.mother_uuid %}
<a href="{{ url_for('plant.detail', uuid_val=plant.mother_uuid) }}"> <a href="{{ url_for('plant.detail', uuid_val=plant.mother_uuid) }}">
{{ plant.mother_uuid }} {{ plant.mother_uuid }}
</a> </a>
</p> {% else %}
{% endif %} N/A
{% endif %}
</dd>
<div class="mt-4"> <dt class="col-sm-3">Notes</dt>
<a <dd class="col-sm-9">{{ plant.notes or '—' }}</dd>
href="{{ url_for('plant.edit', uuid_val=plant.uuid) }}" </dl>
class="btn btn-primary me-2"
>Edit</a> <div class="mb-3">
<a <a href="{{ url_for('utility.download_qr', uuid_val=plant.uuid) }}"
href="{{ url_for('plant.index') }}" class="btn btn-primary me-1">Direct QR</a>
class="btn btn-secondary" <a href="{{ url_for('utility.download_qr_card', uuid_val=plant.uuid) }}"
>Back to List</a> class="btn btn-secondary">Card QR</a>
</div> </div>
{% if plant.media %}
<h5>Images</h5>
<div class="row g-2">
{% for m in plant.media %}
<div class="col-6 col-md-3">
<a href="{{ generate_image_url(m) }}" target="_blank" rel="noopener">
<img
src="{{ generate_image_url(m) }}"
class="img-fluid"
style="width:100%; height:150px; object-fit:cover;"
alt="{{ m.filename }}"
>
</a>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted">No images uploaded yet.</p>
{% endif %}
{% if current_user.id == plant.owner_id %}
<hr>
<h5>Generate Cuttings</h5>
<form
method="POST"
action="{{ url_for('plant.generate_children', uuid_val=plant.uuid) }}"
class="row g-2 align-items-end"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="col-auto">
<label for="count" class="form-label">Number to generate</label>
<input
type="number"
id="count"
name="count"
class="form-control"
value="1"
min="1" max="100"
>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-success">Generate</button>
</div>
</form>
{% if children %}
<hr>
<h5>Child Plants</h5>
{% if children|length > 6 %}
<ul class="list-group">
{% for c in children %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<a href="{{ url_for('plant.detail', uuid_val=c.uuid) }}">
{{ c.common_name.name }} <small class="text-muted">({{ c.uuid }})</small>
</a>
<div>
<a href="{{ url_for('utility.download_qr', uuid_val=c.uuid) }}"
class="btn btn-sm btn-outline-primary me-1">Direct QR</a>
<a href="{{ url_for('utility.download_qr_card', uuid_val=c.uuid) }}"
class="btn btn-sm btn-outline-secondary">Card QR</a>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="row g-3">
{% for c in children %}
<div class="col-12 col-md-6">
<div class="card h-100">
<div class="d-flex justify-content-center align-items-center p-3">
{% if c.media %}
{% set first_media = c.media[0] %}
<a href="{{ generate_image_url(first_media) }}"
target="_blank" rel="noopener">
<img
src="{{ generate_image_url(first_media) }}"
style="width:150px; height:150px; object-fit:cover;"
alt="{{ first_media.filename }}"
>
</a>
{% else %}
<div style="width:150px; height:150px; background:#f0f0f0;"></div>
{% endif %}
</div>
<div class="card-body text-center">
<h6 class="card-title">
<a href="{{ url_for('plant.detail', uuid_val=c.uuid) }}">
{{ c.common_name.name }}
</a>
</h6>
<p class="card-subtitle mb-2 text-muted">
<a href="{{ url_for('plant.detail', uuid_val=c.uuid) }}">
{{ c.uuid }}
</a>
</p>
</div>
<div class="card-footer text-center">
<a href="{{ url_for('utility.download_qr', uuid_val=c.uuid) }}"
class="btn btn-sm btn-outline-primary me-1">Direct QR</a>
<a href="{{ url_for('utility.download_qr_card', uuid_val=c.uuid) }}"
class="btn btn-sm btn-outline-secondary">Card QR</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endif %}
{% endif %}
</div> </div>
</div> </div>
{% if plant.media|length > (1 if plant.featured_media else 0) %} <a href="{{ url_for('plant.index') }}" class="btn btn-link">&larr; Back to list</a>
<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) }}"
alt="Plant image"
class="img-thumbnail"
style="height: 160px; object-fit: cover;"
>
{% endfor %}
</div>
{% endif %}
</div>
{% endblock %} {% endblock %}

View File

@ -72,10 +72,12 @@
{# ——— Existing Images & Featured Toggle ——— #} {# ——— Existing Images & Featured Toggle ——— #}
<h4>Existing Images</h4> <h4>Existing Images</h4>
<form method="POST" <form
id="delete-images-form" method="POST"
action="{{ url_for('media.bulk_delete_media', plant_uuid=plant.uuid) }}" id="delete-images-form"
onsubmit="return confirm('Are you sure you want to delete selected images?');"> action="{{ url_for('media.bulk_delete_media', plant_uuid=plant.uuid) }}"
onsubmit="return confirm('Are you sure you want to delete selected images?');"
>
{{ form.csrf_token }} {{ form.csrf_token }}
<div class="row"> <div class="row">
{% for media in plant.media_items %} {% for media in plant.media_items %}
@ -84,32 +86,43 @@
<img <img
id="image-{{ media.id }}" id="image-{{ media.id }}"
src="{{ url_for('media.media_file', src="{{ url_for('media.media_file',
context='plants', context='plants',
context_id=plant.id, context_id=plant.id,
filename=media.filename) }}" filename=media.filename) }}"
class="card-img-top img-fluid" class="card-img-top img-fluid"
alt="Plant Image" alt="Plant Image"
style="object-fit:cover; height:150px;" style="object-fit:cover; height:150px;"
> >
<div class="card-body text-center"> <div class="card-body text-center">
{# Featured radio driven off media.featured #} {# — featured toggle — #}
<div class="form-check mb-2"> <form
action="{{ url_for('media.set_featured_image',
context='plants',
context_id=plant.id,
media_id=media.id) }}"
method="post"
class="d-inline"
>
<input <input
class="form-check-input featured-radio" type="hidden"
type="radio" name="csrf_token"
name="featured_media" value="{{ form.csrf_token._value() }}"
value="{{ media.id }}"
{% if media.featured %}checked{% endif %}
data-url="{{ url_for('media.set_featured_image',
context='plants',
context_id=plant.id,
media_id=media.id) }}"
> >
<label class="form-check-label">Featured</label> <div class="form-check mb-2">
</div> <input
class="form-check-input"
type="radio"
name="featured_media"
value="{{ media.id }}"
{% if media.featured %}checked{% endif %}
onchange="this.form.submit()"
>
<label class="form-check-label">Featured</label>
</div>
</form>
{# Rotate button #} {# — rotate button #}
<button <button
type="button" type="button"
class="btn btn-outline-secondary btn-sm rotate-btn" class="btn btn-outline-secondary btn-sm rotate-btn"
@ -117,66 +130,44 @@
data-id="{{ media.id }}" data-id="{{ media.id }}"
>Rotate</button> >Rotate</button>
{# Delete checkbox #} {# — delete checkbox #}
<div class="form-check mt-2"> <div class="form-check mt-2">
<input <input
class="form-check-input delete-checkbox" class="form-check-input delete-checkbox"
type="checkbox" type="checkbox"
name="delete_ids" name="delete_ids"
value="{{ media.id }}" value="{{ media.id }}"
id="del-{{ media.id }}"
> >
<label class="form-check-label">Delete</label> <label class="form-check-label" for="del-{{ media.id }}">Delete</label>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% else %}
<p class="text-muted">No images uploaded yet.</p>
{% endfor %} {% endfor %}
</div> </div>
<button type="submit" class="btn btn-danger mt-2">Delete Selected Images</button> <button type="submit" class="btn btn-danger mt-2">Delete Selected Images</button>
</form> </form>
</div> </div>
{% endblock %}
{% block scripts %} <script>
{{ super() }} const csrfToken = "{{ form.csrf_token._value() }}";
<script>
const csrfToken = "{{ form.csrf_token._value() }}";
// Rotate buttons // Rotate buttons
document.querySelectorAll('.rotate-btn').forEach(btn => { document.querySelectorAll('.rotate-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
fetch(btn.dataset.url, { fetch(btn.dataset.url, {
method: 'POST', method: 'POST',
headers: { 'X-CSRFToken': csrfToken } headers: { 'X-CSRFToken': csrfToken }
}) })
.then(r => { .then(r => {
if (!r.ok) throw Error(); if (!r.ok) throw Error();
const img = document.getElementById(`image-${btn.dataset.id}`); const img = document.getElementById(`image-${btn.dataset.id}`);
img.src = img.src.split('?')[0] + `?v=${Date.now()}`; img.src = img.src.split('?')[0] + `?v=${Date.now()}`;
}) })
.catch(() => alert('Failed to rotate image.')); .catch(() => alert('Failed to rotate image.'));
});
}); });
});
// Featuredradio AJAX </script>
document.querySelectorAll('.featured-radio').forEach(radio => {
radio.addEventListener('change', () => {
fetch(radio.dataset.url, {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken }
})
.then(r => {
if (!r.ok) throw Error();
// uncheck them all, then check this one
document.querySelectorAll('.featured-radio')
.forEach(r => r.checked = false);
radio.checked = true;
})
.catch(() => alert('Could not set featured image.'));
});
});
</script>
{% endblock %} {% endblock %}

View File

@ -148,17 +148,13 @@
data-name="{{ plant.common_name.name|lower }}" data-name="{{ plant.common_name.name|lower }}"
data-type="{{ plant.plant_type|lower }}"> data-type="{{ plant.plant_type|lower }}">
<div class="card h-100"> <div class="card h-100">
{% set featured = plant.featured_media %} {# Determine featured image: first any marked featured, else first media #}
{% set featured = plant.media|selectattr('featured')|first %}
{% if not featured and plant.media %} {% if not featured and plant.media %}
{% set featured = plant.media[0] %} {% set featured = plant.media[0] %}
{% endif %} {% endif %}
<a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}"> <a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}">
{# 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 %}
<img <img
src="{{ generate_image_url(featured) }}" src="{{ generate_image_url(featured) }}"
class="card-img-top" class="card-img-top"

View File

@ -21,10 +21,6 @@ from flask_login import login_required, current_user
from flask_wtf.csrf import generate_csrf from flask_wtf.csrf import generate_csrf
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from werkzeug.datastructures import FileStorage from werkzeug.datastructures import FileStorage
import qrcode
from PIL import Image, ImageDraw, ImageFont
from qrcode.image.pil import PilImage
from qrcode.constants import ERROR_CORRECT_H
# Application # Application
from app import db from app import db
@ -60,7 +56,8 @@ def index():
# Required headers for your sub-app export ZIP # Required headers for your sub-app export ZIP
PLANT_HEADERS = [ PLANT_HEADERS = [
"UUID","Type","Name","Scientific Name", "UUID","Type","Name","Scientific Name",
"Vendor Name","Price","Mother UUID","Notes" "Vendor Name","Price","Mother UUID","Notes",
"Short ID"
] ]
MEDIA_HEADERS = [ MEDIA_HEADERS = [
"Plant UUID","Image Path","Uploaded At","Source Type" "Plant UUID","Image Path","Uploaded At","Source Type"
@ -152,10 +149,11 @@ def upload():
return redirect(request.url) return redirect(request.url)
media_rows = list(mreader) media_rows = list(mreader)
# --- import plants --- # --- import plants (first pass, only set mother_uuid if parent exists) ---
neo = get_neo4j_handler() neo = get_neo4j_handler()
added_plants = 0 added_plants = 0
plant_map = {} plant_map = {}
for row in plant_rows: for row in plant_rows:
common = PlantCommonName.query.filter_by(name=row["Name"]).first() common = PlantCommonName.query.filter_by(name=row["Name"]).first()
if not common: if not common:
@ -163,7 +161,9 @@ def upload():
db.session.add(common) db.session.add(common)
db.session.flush() db.session.flush()
scientific = PlantScientificName.query.filter_by(name=row["Scientific Name"]).first() scientific = PlantScientificName.query.filter_by(
name=row["Scientific Name"]
).first()
if not scientific: if not scientific:
scientific = PlantScientificName( scientific = PlantScientificName(
name=row["Scientific Name"], name=row["Scientific Name"],
@ -172,12 +172,20 @@ def upload():
db.session.add(scientific) db.session.add(scientific)
db.session.flush() db.session.flush()
raw_mu = row.get("Mother UUID") or None
mu_for_insert = raw_mu if raw_mu in plant_map else None
p = Plant( p = Plant(
uuid=row["UUID"], uuid=row["UUID"],
common_id=common.id, common_id=common.id,
scientific_id=scientific.id, scientific_id=scientific.id,
plant_type=row["Type"], plant_type=row["Type"],
owner_id=current_user.id, owner_id=current_user.id,
vendor_name=row["Vendor Name"] or None,
price=float(row["Price"]) if row["Price"] else None,
mother_uuid=mu_for_insert,
notes=row["Notes"] or None,
short_id=(row.get("Short ID") or None),
data_verified=True data_verified=True
) )
db.session.add(p) db.session.add(p)
@ -194,15 +202,26 @@ def upload():
db.session.add(log) db.session.add(log)
neo.create_plant_node(p.uuid, row["Name"]) neo.create_plant_node(p.uuid, row["Name"])
if row.get("Mother UUID"): if raw_mu:
neo.create_lineage( neo.create_lineage(
child_uuid=p.uuid, child_uuid=p.uuid,
parent_uuid=row["Mother UUID"] parent_uuid=raw_mu
) )
added_plants += 1 added_plants += 1
# --- import media (FIX: now passing plant_id) --- db.session.commit()
# --- second pass: backfill mother_uuid for all rows ---
for row in plant_rows:
raw_mu = row.get("Mother UUID") or None
if raw_mu:
Plant.query.filter_by(uuid=row["UUID"]).update({
'mother_uuid': raw_mu
})
db.session.commit()
# --- import media (unchanged) ---
added_media = 0 added_media = 0
for mrow in media_rows: for mrow in media_rows:
plant_uuid = mrow["Plant UUID"] plant_uuid = mrow["Plant UUID"]
@ -211,7 +230,7 @@ def upload():
continue continue
subpath = mrow["Image Path"].split('uploads/', 1)[-1] 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): if not os.path.isfile(src):
continue continue
@ -227,7 +246,7 @@ def upload():
uploader_id=current_user.id, uploader_id=current_user.id,
plugin="plant", plugin="plant",
related_id=plant_id, related_id=plant_id,
plant_id=plant_id # ← ensure the FK is set! plant_id=plant_id
) )
media.uploaded_at = datetime.fromisoformat(mrow["Uploaded At"]) media.uploaded_at = datetime.fromisoformat(mrow["Uploaded At"])
media.caption = mrow["Source Type"] media.caption = mrow["Source Type"]
@ -268,11 +287,11 @@ def upload():
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: for row in reader:
uuid_val = row.get("uuid", "").strip().strip('"') uuid_val = row.get("uuid", "").strip().strip('"')
name = row.get("name", "").strip() name = row.get("name", "").strip()
sci_name = row.get("scientific_name", "").strip() sci_name = row.get("scientific_name", "").strip()
plant_type = row.get("plant_type", "").strip() or "plant" plant_type = row.get("plant_type", "").strip() or "plant"
mother_uuid= row.get("mother_uuid", "").strip().strip('"') mother_uuid = row.get("mother_uuid", "").strip().strip('"')
if not (uuid_val and name and plant_type): if not (uuid_val and name and plant_type):
continue continue
@ -290,11 +309,11 @@ def upload():
) )
item = { item = {
"uuid": uuid_val, "uuid": uuid_val,
"name": name, "name": name,
"sci_name": sci_name, "sci_name": sci_name,
"suggested": suggested, "suggested": suggested,
"plant_type": plant_type, "plant_type": plant_type,
"mother_uuid": mother_uuid "mother_uuid": mother_uuid
} }
review_list.append(item) review_list.append(item)
@ -304,7 +323,7 @@ def upload():
return redirect(url_for("utility.review")) return redirect(url_for("utility.review"))
# ── Direct Media Upload Flow ─────────────────────────────────────── # ── Direct Media Upload Flow ───────────────────────────────────────
plugin = request.form.get("plugin", '') plugin = request.form.get("plugin", "")
related_id = request.form.get("related_id", 0) related_id = request.form.get("related_id", 0)
plant_id = request.form.get("plant_id", None) plant_id = request.form.get("plant_id", None)
growlog_id = request.form.get("growlog_id", None) growlog_id = request.form.get("growlog_id", None)
@ -314,9 +333,9 @@ def upload():
unique_id = str(uuid.uuid4()).replace("-", "") unique_id = str(uuid.uuid4()).replace("-", "")
secure_name= secure_filename(file.filename) secure_name= secure_filename(file.filename)
storage_path = os.path.join( storage_path = os.path.join(
current_app.config['UPLOAD_FOLDER'], current_app.config["UPLOAD_FOLDER"],
str(current_user.id), str(current_user.id),
now.strftime('%Y/%m/%d') now.strftime("%Y/%m/%d")
) )
os.makedirs(storage_path, exist_ok=True) os.makedirs(storage_path, exist_ok=True)
@ -397,6 +416,7 @@ def review():
scientific_id = scientific.id, scientific_id = scientific.id,
plant_type = plant_type, plant_type = plant_type,
owner_id = current_user.id, owner_id = current_user.id,
mother_uuid = mother_uuid or None,
data_verified = verified data_verified = verified
) )
db.session.add(plant) db.session.add(plant)
@ -521,74 +541,91 @@ def export_data():
# ──────────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────────
# QR-Code Generation Helpers & Routes # QR-Code Generation Helpers & Routes
# ──────────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────────
def generate_label_with_name(qr_url, name, filename):
from PIL import Image, ImageDraw, ImageFont
import qrcode
from qrcode.image.pil import PilImage
from qrcode.constants import ERROR_CORRECT_H
from flask import current_app, send_file
def generate_label_with_name(qr_url, name, download_filename): # Generate QR code
"""
Build a 1.5"x1.5" PNG (300dpi) with a QR code
and the plant name underneath.
"""
qr = qrcode.QRCode(version=2, error_correction=ERROR_CORRECT_H, box_size=10, border=1) qr = qrcode.QRCode(version=2, error_correction=ERROR_CORRECT_H, box_size=10, border=1)
qr.add_data(qr_url) qr.add_data(qr_url)
qr.make(fit=True) qr.make(fit=True)
qr_img = qr.make_image(image_factory=PilImage, fill_color="black", back_color="white").convert("RGB") qr_img = qr.make_image(image_factory=PilImage, fill_color="black", back_color="white").convert("RGB")
dpi = 300 # Create 1.5"x1.5" canvas at 300 DPI
dpi = 300
label_px = int(1.5 * dpi) label_px = int(1.5 * dpi)
canvas_h = label_px + 400 label_img = Image.new("RGB", (label_px, label_px), "white")
label_img = Image.new("RGB", (label_px, canvas_h), "white")
label_img.paste(qr_img.resize((label_px, label_px)), (0, 0))
font_path = os.path.join(current_app.root_path, '..', 'font', 'ARIALLGT.TTF') # Resize QR code
draw = ImageDraw.Draw(label_img) qr_size = 350
text = (name or '').strip() qr_img = qr_img.resize((qr_size, qr_size), Image.LANCZOS)
qr_x = (label_px - qr_size) // 2
label_img.paste(qr_img, (qr_x, 10))
# Load font
font_path = os.path.abspath(os.path.join(current_app.root_path, '..', 'font', 'ARIALLGT.TTF'))
draw = ImageDraw.Draw(label_img)
name = (name or '').strip()
font_size = 28 font_size = 28
while font_size > 10: while font_size > 10:
try: try:
font = ImageFont.truetype(font_path, font_size) font = ImageFont.truetype(font_path, font_size)
except OSError: except OSError:
font = ImageFont.load_default() font = ImageFont.load_default()
if draw.textlength(text, font=font) <= label_px - 20: if draw.textlength(name, font=font) <= label_px - 20:
break break
font_size -= 1 font_size -= 1
while draw.textlength(text, font=font) > label_px - 20 and len(text) > 1:
text = text[:-1] if draw.textlength(name, font=font) > label_px - 20:
if len(text) < len((name or '').strip()): while draw.textlength(name + "", font=font) > label_px - 20 and len(name) > 1:
text += "" name = name[:-1]
x = (label_px - draw.textlength(text, font=font)) // 2 name += ""
y = label_px + 20
draw.text((x, y), text, font=font, fill="black") # Draw text centered
text_x = (label_px - draw.textlength(name, font=font)) // 2
text_y = 370
draw.text((text_x, text_y), name, font=font, fill="black")
buf = io.BytesIO() buf = io.BytesIO()
label_img.save(buf, format='PNG', dpi=(dpi, dpi)) label_img.save(buf, format='PNG', dpi=(dpi, dpi))
buf.seek(0) buf.seek(0)
return send_file(buf, mimetype='image/png', download_name=download_filename, as_attachment=True)
return send_file(
buf,
mimetype='image/png',
as_attachment=True,
download_name=filename
)
@bp.route('/<uuid:uuid_val>/download_qr', methods=['GET']) @bp.route('/download_qr/<string:uuid_val>', methods=['GET'])
@login_required
def download_qr(uuid_val): def download_qr(uuid_val):
p = Plant.query.filter_by(uuid=uuid_val).first_or_404() # Private “Direct QR” → f/<short_id> on plant.cards
if not p.short_id: p = Plant.query.filter_by(uuid=uuid_val, owner_id=current_user.id).first_or_404()
if not getattr(p, 'short_id', None):
p.short_id = Plant.generate_short_id() p.short_id = Plant.generate_short_id()
db.session.commit() db.session.commit()
qr_url = f'https://plant.cards/{p.short_id}'
base = current_app.config.get('PLANT_CARDS_BASE_URL', 'https://plant.cards')
qr_url = f"{base}/f/{p.short_id}"
filename = f"{p.short_id}.png" filename = f"{p.short_id}.png"
return generate_label_with_name( return generate_label_with_name(qr_url, p.common_name.name, filename)
qr_url,
p.common_name.name or p.scientific_name,
filename
)
@bp.route('/<uuid:uuid_val>/download_qr_card', methods=['GET']) @bp.route('/download_qr_card/<string:uuid_val>', methods=['GET'])
def download_qr_card(uuid_val): def download_qr_card(uuid_val):
# Public “Card QR” → /<short_id> on plant.cards
p = Plant.query.filter_by(uuid=uuid_val).first_or_404() p = Plant.query.filter_by(uuid=uuid_val).first_or_404()
if not p.short_id: if not getattr(p, 'short_id', None):
p.short_id = Plant.generate_short_id() p.short_id = Plant.generate_short_id()
db.session.commit() db.session.commit()
qr_url = f'https://plant.cards/{p.short_id}'
base = current_app.config.get('PLANT_CARDS_BASE_URL', 'https://plant.cards')
qr_url = f"{base}/{p.short_id}"
filename = f"{p.short_id}_card.png" filename = f"{p.short_id}_card.png"
return generate_label_with_name( return generate_label_with_name(qr_url, p.common_name.name, filename)
qr_url,
p.common_name.name or p.scientific_name,
filename
)