This commit is contained in:
2025-07-09 01:05:45 -05:00
parent 1bbe6e2743
commit d7a610a83b
113 changed files with 1512 additions and 2348 deletions

View File

@ -1,13 +1,3 @@
# plugins/utility/__init__.py
def register_cli(app):
# no CLI commands for now
pass
def init_celery(app):
# Called via plugin.json entry_point
from .celery import init_celery as _init, celery_app
_init(app)
# Attach it if you like: app.celery = celery_app
app.celery = celery_app
return celery_app

View File

@ -1,46 +0,0 @@
# plugins/utility/celery.py
from celery import Celery
# 1) Create Celery instance at import time so tasks can import celery_app
# Include your plugin's tasks module and leave room to add more as you go.
celery_app = Celery(
__name__,
include=[
'plugins.utility.tasks',
# 'plugins.backup.tasks',
# 'plugins.cleanup.tasks',
# …add other plugin.task modules here
]
)
def init_celery(app):
"""
Configure the global celery_app with Flask settings and
ensure tasks run inside the Flask application context.
"""
# Pull broker/backend from Flask config
celery_app.conf.broker_url = app.config['CELERY_BROKER_URL']
celery_app.conf.result_backend = app.config.get(
'CELERY_RESULT_BACKEND',
app.config['CELERY_BROKER_URL']
)
celery_app.conf.update(app.config)
# Wrap all tasks in Flask app context
TaskBase = celery_app.Task
class ContextTask(TaskBase):
def __call__(self, *args, **kwargs):
with app.app_context():
return super().__call__(*args, **kwargs)
celery_app.Task = ContextTask
# And auto-discover any other tasks modules you add under the plugins/ namespace
celery_app.autodiscover_tasks([
'plugins.utility',
# 'plugins.backup',
# 'plugins.cleanup',
# …your other plugins here
], force=True)
return celery_app

View File

@ -1,15 +1,41 @@
# File: plugins/utility/models.py
from datetime import datetime
from app import db # ← changed from plugins.plant.models
from app import db
class ImportBatch(db.Model):
__tablename__ = 'import_batches'
id = db.Column(db.Integer, primary_key=True)
export_id = db.Column(db.String(64), nullable=False)
user_id = db.Column(db.Integer, nullable=False, index=True)
imported_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
id = db.Column(db.Integer, primary_key=True)
export_id = db.Column(db.String(64), nullable=False)
user_id = db.Column(
db.Integer,
db.ForeignKey('users.id', ondelete='CASCADE'),
nullable=False,
index=True
)
imported_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
# New columns to track background processing status
status = db.Column(
db.String(20),
nullable=False,
default='pending',
doc="One of: pending, processing, done, failed"
)
error = db.Column(
db.Text,
nullable=True,
doc="If status=='failed', the exception text"
)
__table_args__ = (
# ensure a given user cant import the same export twice
db.UniqueConstraint('export_id', 'user_id', name='uix_export_user'),
)
def __repr__(self):
return (
f"<ImportBatch id={self.id!r} export_id={self.export_id!r} "
f"user_id={self.user_id!r} status={self.status!r}>"
)

View File

@ -2,7 +2,7 @@
"name": "Utility",
"version": "0.1.0",
"author": "Bryson Shepard <bryson@natureinpots.com>",
"description": "Miscellaneous utilities (import/export, tasks).",
"description": "Import/export workflows for plant data and media orchestration.",
"module": "plugins.utility",
"routes": {
"module": "plugins.utility.routes",
@ -12,16 +12,15 @@
"models": [
"plugins.utility.models"
],
"tasks": {
"module": "plugins.utility.tasks",
"callable": "init_celery"
},
"tasks": [
"plugins.utility.tasks"
],
"subplugins": [
{
"name": "Utility Search",
"module": "plugins.utility.search",
"routes": {
"module": "plugins.utility.search.search",
"module": "plugins.utility.search",
"blueprint": "bp",
"url_prefix": "/utility/search"
},

View File

@ -1,4 +1,4 @@
# plugins/utility/routes.py
# File: plugins/utility/routes.py
# Standard library
import csv
@ -34,9 +34,8 @@ from plugins.plant.models import (
PlantOwnershipLog,
)
from plugins.media.models import Media
from plugins.media.routes import _process_upload_file
from plugins.utility.models import ImportBatch
from plugins.utility.tasks import import_text_data
bp = Blueprint(
'utility',
@ -52,18 +51,28 @@ def index():
return redirect(url_for("utility.upload"))
@bp.route("/imports", methods=["GET"])
@login_required
def imports():
batches = (
ImportBatch.query
.filter_by(user_id=current_user.id)
.order_by(ImportBatch.imported_at.desc())
.limit(20)
.all()
)
return render_template("utility/imports.html", batches=batches)
# ────────────────────────────────────────────────────────────────────────────────
# Required headers for your sub-app export ZIP
PLANT_HEADERS = [
PLANT_HEADERS = [
"UUID","Type","Name","Scientific Name",
"Vendor Name","Price","Mother UUID","Notes",
"Short ID"
]
MEDIA_HEADERS = [
MEDIA_HEADERS = [
"Plant UUID","Image Path","Uploaded At","Source Type"
]
# Headers for standalone CSV review flow
REQUIRED_HEADERS = {"uuid", "plant_type", "name", "scientific_name", "mother_uuid"}
@ -84,6 +93,7 @@ def upload():
file.save(tmp_zip.name)
tmp_zip.close()
# validate ZIP
try:
z = zipfile.ZipFile(tmp_zip.name)
except zipfile.BadZipFile:
@ -97,6 +107,7 @@ def upload():
flash("ZIP must contain both plants.csv and media.csv", "danger")
return redirect(request.url)
# extract export_id from metadata
export_id = None
if "metadata.txt" in names:
meta = z.read("metadata.txt").decode("utf-8", "ignore")
@ -109,23 +120,37 @@ def upload():
flash("metadata.txt missing or missing export_id", "danger")
return redirect(request.url)
# prevent duplicates
if ImportBatch.query.filter_by(export_id=export_id, user_id=current_user.id).first():
os.remove(tmp_zip.name)
flash("This export has already been imported.", "info")
return redirect(request.url)
# record batch
batch = ImportBatch(
export_id=export_id,
user_id=current_user.id,
imported_at=datetime.utcnow()
export_id = export_id,
user_id = current_user.id,
imported_at = datetime.utcnow(),
status = 'pending'
)
db.session.add(batch)
db.session.commit()
# hand off to Celery
try:
import_text_data.delay(tmp_zip.name, "zip", batch.id)
flash("ZIP received; import queued in background.", "success")
return redirect(request.url)
except Exception:
current_app.logger.exception("Failed to enqueue import_text_data")
flash("Failed to queue import job; falling back to inline import", "warning")
# ── Fallback: inline import ─────────────────────────────────────────
tmpdir = tempfile.mkdtemp()
z.extractall(tmpdir)
# --- load and validate plants.csv ---
# load plants.csv
plant_path = os.path.join(tmpdir, "plants.csv")
with open(plant_path, newline="", encoding="utf-8-sig") as pf:
reader = csv.DictReader(pf)
@ -137,7 +162,7 @@ def upload():
return redirect(request.url)
plant_rows = list(reader)
# --- load and validate media.csv ---
# load media.csv
media_path = os.path.join(tmpdir, "media.csv")
with open(media_path, newline="", encoding="utf-8-sig") as mf:
mreader = csv.DictReader(mf)
@ -149,113 +174,129 @@ def upload():
return redirect(request.url)
media_rows = list(mreader)
# --- import plants (first pass, only set mother_uuid if parent exists) ---
neo = get_neo4j_handler()
# import plants
neo = get_neo4j_handler()
plant_map = {}
added_plants = 0
plant_map = {}
for row in plant_rows:
# common name
common = PlantCommonName.query.filter_by(name=row["Name"]).first()
if not common:
common = PlantCommonName(name=row["Name"])
db.session.add(common)
db.session.flush()
scientific = PlantScientificName.query.filter_by(
name=row["Scientific Name"]
).first()
# scientific name
scientific = PlantScientificName.query.filter_by(name=row["Scientific Name"]).first()
if not scientific:
scientific = PlantScientificName(
name=row["Scientific Name"],
common_id=common.id
name = row["Scientific Name"],
common_id = common.id
)
db.session.add(scientific)
db.session.flush()
raw_mu = row.get("Mother UUID") or None
mu_for_insert = raw_mu if raw_mu in plant_map else None
raw_mu = row.get("Mother UUID") or None
mu_for_insert= raw_mu if raw_mu in plant_map else None
p = Plant(
uuid=row["UUID"],
common_id=common.id,
scientific_id=scientific.id,
plant_type=row["Type"],
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
uuid = row["UUID"],
common_id = common.id,
scientific_id = scientific.id,
plant_type = row["Type"],
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
)
db.session.add(p)
db.session.flush()
plant_map[p.uuid] = p.id
log = PlantOwnershipLog(
plant_id=p.id,
user_id=current_user.id,
date_acquired=datetime.utcnow(),
transferred=False,
is_verified=True
plant_id = p.id,
user_id = current_user.id,
date_acquired = datetime.utcnow(),
transferred = False,
is_verified = True
)
db.session.add(log)
neo.create_plant_node(p.uuid, row["Name"])
if raw_mu:
neo.create_lineage(
child_uuid=p.uuid,
parent_uuid=raw_mu
)
neo.create_lineage(child_uuid=p.uuid, parent_uuid=raw_mu)
added_plants += 1
db.session.commit()
# --- second pass: backfill mother_uuid for all rows ---
# backfill mothers
for row in plant_rows:
raw_mu = row.get("Mother UUID") or None
if raw_mu:
if row.get("Mother UUID"):
Plant.query.filter_by(uuid=row["UUID"]).update({
'mother_uuid': raw_mu
'mother_uuid': row["Mother UUID"]
})
db.session.commit()
# --- import media (unchanged) ---
# import media images
added_media = 0
for mrow in media_rows:
plant_uuid = mrow["Plant UUID"]
plant_id = plant_map.get(plant_uuid)
if not plant_id:
puuid = mrow["Plant UUID"]
pid = plant_map.get(puuid)
if not pid:
continue
subpath = mrow["Image Path"].split('uploads/', 1)[-1]
src = os.path.join(tmpdir, "images", subpath)
src = os.path.join(tmpdir, "images", subpath)
if not os.path.isfile(src):
continue
try:
# build FileStorage for convenience
with open(src, "rb") as f:
file_storage = FileStorage(
stream=io.BytesIO(f.read()),
filename=os.path.basename(subpath),
fs = FileStorage(
stream = io.BytesIO(f.read()),
filename = os.path.basename(subpath),
content_type='image/jpeg'
)
media = _process_upload_file(
file=file_storage,
uploader_id=current_user.id,
plugin="plant",
related_id=plant_id,
plant_id=plant_id
)
media.uploaded_at = datetime.fromisoformat(mrow["Uploaded At"])
media.caption = mrow["Source Type"]
db.session.add(media)
added_media += 1
except Exception as e:
current_app.logger.warning(
f"Failed to import media file: {subpath}{e}"
# now save to our UPLOAD_FOLDER
now = datetime.utcnow()
secure_name = secure_filename(fs.filename)
storage_dir = os.path.join(
current_app.config["UPLOAD_FOLDER"],
str(current_user.id),
now.strftime("%Y/%m/%d")
)
os.makedirs(storage_dir, exist_ok=True)
unique_name = f"{uuid.uuid4().hex}_{secure_name}"
full_path = os.path.join(storage_dir, unique_name)
fs.save(full_path)
file_url = f"/{current_user.id}/{now.strftime('%Y/%m/%d')}/{unique_name}"
media = Media(
plugin = "plant",
related_id = pid,
filename = unique_name,
uploaded_at = datetime.fromisoformat(mrow["Uploaded At"]),
uploader_id = current_user.id,
caption = mrow["Source Type"],
plant_id = pid,
created_at = datetime.fromisoformat(mrow["Uploaded At"]),
file_url = file_url
)
db.session.add(media)
added_media += 1
except Exception as e:
current_app.logger.warning(f"Failed to import media file: {subpath}{e}")
current_app.logger.debug(traceback.format_exc())
db.session.commit()
@ -282,9 +323,8 @@ def upload():
session["pending_rows"] = []
review_list = []
all_common = {c.name.lower(): c for c in PlantCommonName.query.all()}
all_sci = {s.name.lower(): s for s in PlantScientificName.query.all()}
all_common = {c.name.lower(): c for c in PlantCommonName.query.all()}
all_sci = {s.name.lower(): s for s in PlantScientificName.query.all()}
for row in reader:
uuid_val = row.get("uuid", "").strip().strip('"')
@ -297,23 +337,19 @@ def upload():
continue
suggestions = difflib.get_close_matches(
sci_name.lower(),
list(all_sci.keys()),
n=1,
cutoff=0.8
)
suggested = (
all_sci[suggestions[0]].name
if suggestions and suggestions[0] != sci_name.lower()
else None
sci_name.lower(), list(all_sci.keys()),
n=1, cutoff=0.8
)
suggested = None
if suggestions and suggestions[0] != sci_name.lower():
suggested = all_sci[suggestions[0]].name
item = {
"uuid": uuid_val,
"name": name,
"sci_name": sci_name,
"suggested": suggested,
"plant_type": plant_type,
"uuid": uuid_val,
"name": name,
"sci_name": sci_name,
"suggested": suggested,
"plant_type": plant_type,
"mother_uuid": mother_uuid
}
review_list.append(item)
@ -321,40 +357,41 @@ def upload():
session["review_list"] = review_list
return redirect(url_for("utility.review"))
# ── Direct Media Upload Flow ───────────────────────────────────────
plugin = request.form.get("plugin", "")
related_id = request.form.get("related_id", 0)
plant_id = request.form.get("plant_id", None)
growlog_id = request.form.get("growlog_id", None)
caption = request.form.get("caption", None)
now = datetime.utcnow()
unique_id = str(uuid.uuid4()).replace("-", "")
secure_name= secure_filename(file.filename)
storage_path = os.path.join(
now = datetime.utcnow()
unique_id = uuid.uuid4().hex
secure_name = secure_filename(file.filename)
storage_path= os.path.join(
current_app.config["UPLOAD_FOLDER"],
str(current_user.id),
now.strftime("%Y/%m/%d")
)
os.makedirs(storage_path, exist_ok=True)
full_file_path = os.path.join(storage_path, f"{unique_id}_{secure_name}")
file.save(full_file_path)
unique_name = f"{unique_id}_{secure_name}"
full_path = os.path.join(storage_path, unique_name)
file.save(full_path)
file_url = f"/{current_user.id}/{now.strftime('%Y/%m/%d')}/{unique_id}_{secure_name}"
file_url = f"/{current_user.id}/{now.strftime('%Y/%m/%d')}/{unique_name}"
media = Media(
plugin=plugin,
related_id=related_id,
filename=f"{unique_id}_{secure_name}",
uploaded_at=now,
uploader_id=current_user.id,
caption=caption,
plant_id=plant_id,
growlog_id=growlog_id,
created_at=now,
file_url=file_url
plugin = plugin,
related_id = related_id,
filename = unique_name,
uploaded_at = now,
uploader_id = current_user.id,
caption = caption,
plant_id = plant_id,
growlog_id = growlog_id,
created_at = now,
file_url = file_url
)
db.session.add(media)
db.session.commit()
@ -372,22 +409,21 @@ def review():
review_list = session.get("review_list", [])
if request.method == "POST":
neo = get_neo4j_handler()
added = 0
neo = get_neo4j_handler()
added = 0
all_common = {c.name.lower(): c for c in PlantCommonName.query.all()}
all_scientific = {s.name.lower(): s for s in PlantScientificName.query.all()}
for row in rows:
uuid_val = row.get("uuid")
name = row.get("name")
sci_name = row.get("sci_name")
suggested = row.get("suggested")
plant_type = row.get("plant_type")
mother_uuid = row.get("mother_uuid")
accepted = request.form.get(f"confirm_{uuid_val}")
uuid_val = row["uuid"]
name = row["name"]
sci_name = row["sci_name"]
suggested = row["suggested"]
plant_type = row["plant_type"]
mother_uuid = row["mother_uuid"]
accepted = request.form.get(f"confirm_{uuid_val}") == "yes"
# handle names
common = PlantCommonName.query.filter_by(name=name).first()
if not common:
common = PlantCommonName(name=name)
@ -395,7 +431,7 @@ def review():
db.session.flush()
all_common[common.name.lower()] = common
use_name = suggested if (suggested and accepted) else sci_name
use_name = suggested if (suggested and accepted) else sci_name
scientific = PlantScientificName.query.filter_by(name=use_name).first()
if not scientific:
scientific = PlantScientificName(
@ -407,7 +443,6 @@ def review():
all_scientific[scientific.name.lower()] = scientific
verified = not suggested or (suggested and accepted)
plant = Plant.query.filter_by(uuid=uuid_val).first()
if not plant:
plant = Plant(
@ -454,22 +489,18 @@ def review():
@bp.route('/export_data', methods=['GET'])
@login_required
def export_data():
# Unique export identifier
export_id = f"{uuid.uuid4()}_{int(datetime.utcnow().timestamp())}"
# 1) Gather plants
plants = (
Plant.query
.filter_by(owner_id=current_user.id)
.order_by(Plant.id)
.all()
Plant.query.filter_by(owner_id=current_user.id)
.order_by(Plant.id).all()
)
# Build plants.csv
# build plants.csv
plant_io = io.StringIO()
pw = csv.writer(plant_io)
pw.writerow([
'UUID', 'Type', 'Name', 'Scientific Name',
'Vendor Name', 'Price', 'Mother UUID', 'Notes'
'UUID','Type','Name','Scientific Name',
'Vendor Name','Price','Mother UUID','Notes'
])
for p in plants:
pw.writerow([
@ -477,26 +508,23 @@ def export_data():
p.plant_type,
p.common_name.name if p.common_name else '',
p.scientific_name.name if p.scientific_name else '',
getattr(p, 'vendor_name', '') or '',
getattr(p, 'price', '') or '',
getattr(p, 'vendor_name','') or '',
getattr(p, 'price','') or '',
p.mother_uuid or '',
p.notes or ''
])
plants_csv = plant_io.getvalue()
# 2) Gather media
# build media.csv
media_records = (
Media.query
.filter(Media.uploader_id == current_user.id, Media.plant_id.isnot(None))
.order_by(Media.id)
.all()
)
# Build media.csv
Media.query.filter(
Media.uploader_id==current_user.id,
Media.plant_id.isnot(None)
).order_by(Media.id).all()
)
media_io = io.StringIO()
mw = csv.writer(media_io)
mw.writerow([
'Plant UUID', 'Image Path', 'Uploaded At', 'Source Type'
])
mw.writerow(['Plant UUID','Image Path','Uploaded At','Source Type'])
for m in media_records:
mw.writerow([
m.plant.uuid,
@ -506,9 +534,9 @@ def export_data():
])
media_csv = media_io.getvalue()
# 3) Assemble ZIP with images from UPLOAD_FOLDER
# assemble ZIP
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, 'w', zipfile.ZIP_DEFLATED) as zf:
with zipfile.ZipFile(zip_buf,'w',zipfile.ZIP_DEFLATED) as zf:
meta = (
f"export_id,{export_id}\n"
f"user_id,{current_user.id}\n"
@ -517,19 +545,17 @@ def export_data():
zf.writestr('metadata.txt', meta)
zf.writestr('plants.csv', plants_csv)
zf.writestr('media.csv', media_csv)
media_root = current_app.config['UPLOAD_FOLDER']
for m in media_records:
rel = m.file_url.split('uploads/', 1)[-1]
rel = m.file_url.split('uploads/',1)[-1]
abs_path = os.path.join(media_root, rel)
if os.path.isfile(abs_path):
arcname = os.path.join('images', rel)
zf.write(abs_path, arcname)
zip_buf.seek(0)
safe_email = re.sub(r'\W+', '_', current_user.email)
filename = f"{safe_email}_export_{export_id}.zip"
safe_email = re.sub(r'\W+','_',current_user.email)
filename = f"{safe_email}_export_{export_id}.zip"
return send_file(
zip_buf,
mimetype='application/zip',
@ -538,39 +564,45 @@ def export_data():
)
# ────────────────────────────────────────────────────────────────────────────────
# 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
from flask import send_file
# Generate QR code
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.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")
# Create 1.5"x1.5" canvas at 300 DPI
dpi = 300
dpi = 300
label_px = int(1.5 * dpi)
label_img = Image.new("RGB", (label_px, label_px), "white")
label_img= Image.new("RGB", (label_px, label_px), "white")
# Resize QR code
qr_size = 350
qr_img = qr_img.resize((qr_size, qr_size), Image.LANCZOS)
qr_x = (label_px - qr_size) // 2
qr_size = 350
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_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
while font_size > 10:
try:
font = ImageFont.truetype(font_path, font_size)
@ -585,7 +617,6 @@ def generate_label_with_name(qr_url, name, filename):
name = name[:-1]
name += ""
# 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")
@ -605,27 +636,23 @@ def generate_label_with_name(qr_url, name, filename):
@bp.route('/download_qr/<string:uuid_val>', methods=['GET'])
@login_required
def download_qr(uuid_val):
# Private “Direct QR” → f/<short_id> on plant.cards
p = Plant.query.filter_by(uuid=uuid_val, owner_id=current_user.id).first_or_404()
if not getattr(p, 'short_id', None):
if not p.short_id:
p.short_id = Plant.generate_short_id()
db.session.commit()
base = current_app.config.get('PLANT_CARDS_BASE_URL', 'https://plant.cards')
qr_url = f"{base}/f/{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"
return generate_label_with_name(qr_url, p.common_name.name, filename)
@bp.route('/download_qr_card/<string:uuid_val>', methods=['GET'])
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()
if not getattr(p, 'short_id', None):
if not p.short_id:
p.short_id = Plant.generate_short_id()
db.session.commit()
base = current_app.config.get('PLANT_CARDS_BASE_URL', 'https://plant.cards')
qr_url = f"{base}/{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"
return generate_label_with_name(qr_url, p.common_name.name, filename)

94
plugins/utility/search.py Normal file
View File

@ -0,0 +1,94 @@
from flask import Blueprint, render_template, request, redirect, url_for, jsonify
from flask_login import login_required, current_user
from app import db
from sqlalchemy import or_
from plugins.plant.models import Plant, PlantCommonName, PlantScientificName, Tag
from flask_wtf import FlaskForm
from wtforms import StringField, SelectMultipleField, SubmitField
from wtforms.validators import Optional, Length, Regexp
bp = Blueprint(
'search',
__name__,
url_prefix='/search',
template_folder='templates/search'
)
class SearchForm(FlaskForm):
query = StringField(
'Search',
validators=[
Optional(),
Length(min=2, max=100, message="Search term must be between 2 and 100 characters."),
Regexp(r'^[\w\s\-]+$', message="Search can only include letters, numbers, spaces, and dashes.")
]
)
tags = SelectMultipleField('Tags', coerce=int)
submit = SubmitField('Search')
@bp.route('', methods=['GET', 'POST'])
@bp.route('/', methods=['GET', 'POST'])
@login_required
def search():
form = SearchForm()
form.tags.choices = [(t.id, t.name) for t in Tag.query.order_by(Tag.name).all()]
if form.validate_on_submit():
q = form.query.data or ''
selected = form.tags.data or []
tags_param = ','.join(map(str, selected)) if selected else ''
return redirect(url_for('search.results', q=q, tags=tags_param))
return render_template('search/search.html', form=form)
@bp.route('/results', methods=['GET'])
@login_required
def results():
q = request.args.get('q', '').strip()
tags_param = request.args.get('tags', '')
tags = [int(t) for t in tags_param.split(',') if t] if tags_param else []
like_term = f"%{q}%"
# Base query, joining name tables for text search
query = (
db.session.query(Plant)
.join(Plant.common_name)
.join(Plant.scientific_name)
)
if q:
query = query.filter(
or_(
PlantCommonName.name.ilike(like_term),
PlantScientificName.name.ilike(like_term),
Plant.plant_type.ilike(like_term),
)
)
if tags:
query = query.filter(Plant.tags.any(Tag.id.in_(tags)))
# Only include active plants…
query = query.filter(Plant.is_active.is_(True))
# …and either public plants or those owned by the current user
query = query.filter(
or_(
Plant.is_public.is_(True),
Plant.owner_id == current_user.id
)
)
results = query.all()
return render_template(
'search/results.html',
results=results,
query=q,
tags=tags
)
@bp.route('/tags')
@login_required
def search_tags():
term = request.args.get('term', '')
like_term = f"%{term}%"
matches = Tag.query.filter(Tag.name.ilike(like_term)).limit(10).all()
return jsonify([t.name for t in matches])

View File

@ -1,63 +0,0 @@
# plugins/utility/search/routes.py
from flask import Blueprint, render_template, request, jsonify
from flask_login import login_required, current_user
from app import db
from sqlalchemy import or_
from plugins.plant.models import Plant, PlantCommonName, PlantScientificName, Tag
from flask_wtf import FlaskForm
from wtforms import StringField, SelectMultipleField, SubmitField
from wtforms.validators import Optional, Length, Regexp
bp = Blueprint(
'search',
__name__,
url_prefix='/search',
template_folder='templates/search'
)
class SearchForm(FlaskForm):
query = StringField(
'Search',
validators=[
Optional(),
Length(min=2, max=100, message="Search term must be between 2 and 100 characters."),
Regexp(r'^[\w\s\-]+$', message="Search can only include letters, numbers, spaces, and dashes.")
]
)
tags = SelectMultipleField('Tags', coerce=int)
submit = SubmitField('Search')
@bp.route('', methods=['GET', 'POST'])
@login_required
def search():
form = SearchForm()
# populate tag choices
form.tags.choices = [(t.id, t.name) for t in Tag.query.order_by(Tag.name).all()]
results = []
if form.validate_on_submit():
q = form.query.data or ''
db_query = db.session.query(Plant).join(PlantScientific).join(PlantCommon)
if q:
like_term = f"%{q}%"
db_query = db_query.filter(
or_(
Plant.common_name.ilike(like_term),
Plant.scientific_name.ilike(like_term),
Plant.current_status.ilike(like_term)
)
)
if form.tags.data:
db_query = db_query.filter(Plant.tags.any(Tag.id.in_(form.tags.data)))
db_query = db_query.filter(Plant.owner_id == current_user.id)
results = db_query.all()
return render_template('search/search.html', form=form, results=results)
@bp.route('/tags')
@login_required
def search_tags():
term = request.args.get('term', '')
matches = Tag.query.filter(Tag.name.ilike(f"%{term}%")).limit(10).all()
return jsonify([t.name for t in matches])

View File

@ -1,17 +1,218 @@
from .celery import celery_app
# File: plugins/utility/tasks.py
# Example placeholder task
@celery_app.task
def ping():
return 'pong'
# Standard library
import csv
import os
import zipfile
import tempfile
import shutil
import io
from datetime import datetime
# Third-party
from celery.utils.log import get_task_logger
from celery.exceptions import Retry
from flask import current_app
from werkzeug.datastructures import FileStorage
# Application
from app import db
from app.neo4j_utils import get_neo4j_handler
from app.celery_app import celery
# Plugins
from plugins.plant.models import (
Plant,
PlantCommonName,
PlantScientificName,
PlantOwnershipLog,
)
from plugins.utility.models import ImportBatch
from plugins.media.routes import _process_upload_file
logger = get_task_logger(__name__)
def init_celery(app):
@celery.task(name="plugins.utility.tasks.import_text_data", bind=True)
def import_text_data(self, filepath, import_type, batch_id):
"""
Initialize the Celery app with the Flask app's config and context.
Called automatically by the JSON-driven loader.
Celery task entrypoint for both ZIP and CSV imports.
filepath: path to uploaded .zip or .csv
import_type: "zip" or "csv"
batch_id: ImportBatch.id to update status
"""
# Re-import the shared celery_app
from .celery import celery_app
celery_app.conf.update(app.config)
return celery_app
batch = ImportBatch.query.get(batch_id)
try:
# mark as started
batch.status = 'started'
db.session.commit()
# ZIP import
if import_type == "zip":
tmpdir = tempfile.mkdtemp()
try:
with zipfile.ZipFile(filepath) as zf:
zf.extractall(tmpdir)
_do_import_zip(tmpdir, batch)
finally:
os.remove(filepath)
shutil.rmtree(tmpdir, ignore_errors=True)
# CSV import (reviewed rows)
elif import_type == "csv":
_do_import_csv(filepath, batch)
# mark as complete
batch.status = 'complete'
db.session.commit()
except Exception as exc:
logger.exception("Import failed")
batch.status = 'failed'
batch.error = str(exc)
db.session.commit()
raise self.retry(exc=exc, countdown=60)
def _do_import_zip(tmpdir, batch):
"""
Perform the plants.csv + media.csv import from tmpdir and log into Neo4j.
"""
# 1) read plants.csv
plant_path = os.path.join(tmpdir, "plants.csv")
with open(plant_path, newline="", encoding="utf-8-sig") as pf:
reader = csv.DictReader(pf)
plant_rows = list(reader)
# 2) insert plants
neo = get_neo4j_handler()
plant_map = {}
for row in plant_rows:
common = PlantCommonName.query.filter_by(name=row["Name"]).first()
if not common:
common = PlantCommonName(name=row["Name"])
db.session.add(common)
db.session.flush()
scientific = PlantScientificName.query.filter_by(name=row["Scientific Name"]).first()
if not scientific:
scientific = PlantScientificName(
name=row["Scientific Name"],
common_id=common.id
)
db.session.add(scientific)
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(
uuid=row["UUID"],
common_id=common.id,
scientific_id=scientific.id,
plant_type=row["Type"],
owner_id=batch.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
)
db.session.add(p)
db.session.flush()
plant_map[p.uuid] = p.id
log = PlantOwnershipLog(
plant_id=p.id,
user_id=batch.user_id,
date_acquired=datetime.utcnow(),
transferred=False,
is_verified=True
)
db.session.add(log)
neo.create_plant_node(p.uuid, row["Name"])
if raw_mu:
neo.create_lineage(child_uuid=p.uuid, parent_uuid=raw_mu)
db.session.commit()
# 3) import media.csv
media_path = os.path.join(tmpdir, "media.csv")
with open(media_path, newline="", encoding="utf-8-sig") as mf:
mreader = csv.DictReader(mf)
media_rows = list(mreader)
for mrow in media_rows:
puuid = mrow["Plant UUID"]
pid = plant_map.get(puuid)
if not pid:
continue
subpath = mrow["Image Path"].split('uploads/', 1)[-1]
src = os.path.join(tmpdir, "images", subpath)
if not os.path.isfile(src):
continue
with open(src, "rb") as f:
fs = io.BytesIO(f.read())
file_storage = FileStorage(
stream=fs,
filename=os.path.basename(subpath),
content_type='image/jpeg'
)
media = _process_upload_file(
file=file_storage,
uploader_id=batch.user_id,
plugin="plant",
related_id=pid,
plant_id=pid
)
media.uploaded_at = datetime.fromisoformat(mrow["Uploaded At"])
media.caption = mrow["Source Type"]
db.session.add(media)
db.session.commit()
neo.close()
def _do_import_csv(filepath, batch):
"""
Perform a reviewedCSV import (only plants, no media) from filepath.
"""
stream = io.StringIO(open(filepath, encoding='utf-8-sig').read())
reader = csv.DictReader(stream)
neo = get_neo4j_handler()
for row in reader:
uuid_val = row.get("uuid", "").strip()
name = row.get("name", "").strip()
sci_name = row.get("scientific_name", "").strip()
plant_type = row.get("plant_type", "").strip() or "plant"
mother_uuid = row.get("mother_uuid", "").strip() or None
common = PlantCommonName.query.filter_by(name=name).first()
if not common:
common = PlantCommonName(name=name)
db.session.add(common)
db.session.flush()
scientific = PlantScientificName.query.filter_by(name=sci_name).first()
if not scientific:
scientific = PlantScientificName(
name=sci_name,
common_id=common.id
)
db.session.add(scientific)
db.session.flush()
plant = Plant.query.filter_by(uuid=uuid_val).first()
if not plant:
plant = Plant(
uuid=uuid_val,
common_id=common.id,
scientific_id=scientific.id,
plant_type=plant_type,
owner_id=batch.user_id,
mother_uuid=mother_uuid,
data_verified=True
)
db.session.add(plant)
db.session.flush()
neo.create_plant_node(plant.uuid, common.name)
if mother_uuid:
neo.create_lineage(child_uuid=plant.uuid, parent_uuid=mother_uuid)
db.session.commit()
neo.close()

View File

@ -0,0 +1,31 @@
{% extends "core/base.html" %}
{% block title %}Search Results{% endblock %}
{% block content %}
<div class="container mt-4">
<h1 class="mb-3">
Search Results{% if query %} for "{{ query }}"{% endif %}
</h1>
{% if results %}
<ul class="list-group">
{% for plant in results %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<a href="{{ url_for('plant.view', plant_uuid=plant.uuid) }}">
{{ plant.common_name[0].name if plant.common_name else plant.scientific_name[0].name }}
</a>
{% if plant.owner_id == current_user.id and not plant.is_public %}
<span class="badge bg-secondary">Private</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">
No results found{% if query %} for "{{ query }}"{% endif %}.
</p>
{% endif %}
<a href="{{ url_for('search.search') }}" class="btn btn-link mt-3">New Search</a>
</div>
{% endblock %}

View File

@ -1,4 +1,5 @@
{% extends "base.html" %}
<!-- File: plugins/utility/templates/search/search.html -->
{% extends "core/base.html" %}
{% block title %}Search{% endblock %}
{% block content %}
@ -33,7 +34,7 @@
<ul class="list-group">
{% for plant in results %}
<li class="list-group-item">
<a href="{{ url_for('plant.view', plant_id=plant.id) }}">
<a href="{{ url_for('plant.view_card', plant_id=plant.id) }}">
{{ plant.common_name or plant.scientific_name }}
</a>
</li>
@ -43,3 +44,4 @@
<p class="text-muted">No results found.</p>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,20 @@
{% extends "core/base.html" %}
{% block content %}
<h1>Your Recent Imports</h1>
<table class="table">
<thead>
<tr><th>#</th><th>Export ID</th><th>Uploaded</th><th>Status</th><th>Error</th></tr>
</thead>
<tbody>
{% for b in batches %}
<tr>
<td>{{ b.id }}</td>
<td>{{ b.export_id }}</td>
<td>{{ b.imported_at }}</td>
<td>{{ b.status }}</td>
<td>{{ b.error or "" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}