This commit is contained in:
2025-06-04 04:52:09 -05:00
parent d0338a0849
commit 98c868113c
6 changed files with 202 additions and 101 deletions

View File

@ -44,6 +44,22 @@ def preload_data(auto=False):
#db.session.add_all([monstera_common, deliciosa_sci, aurea_sci]) #db.session.add_all([monstera_common, deliciosa_sci, aurea_sci])
#db.session.commit() #db.session.commit()
joepii_common = PlantCommonName(name='Philodendron Joepii')
queen_common = PlantCommonName(name='Anthurium Warocqueanum Queen')
thai_common = PlantCommonName(name='Thai Constellation Monstera')
generic_common = PlantCommonName(name='Monstera')
db.session.add_all([joepii_common, queen_common, thai_common, generic_common])
db.session.flush() # ensures all common_name IDs are available
joepii_sci = PlantScientificName(name='Philodendron × joepii', common_id=joepii_common.id)
queen_sci = PlantScientificName(name='Anthurium warocqueanum', common_id=queen_common.id)
thai_sci = PlantScientificName(name="Monstera deliciosa 'Thai Constellation'", common_id=thai_common.id)
generic_sci = PlantScientificName(name='Monstera deliciosa', common_id=generic_common.id)
db.session.add_all([joepii_sci, queen_sci, thai_sci, generic_sci])
db.session.commit()
# PLANTS # PLANTS
""" parent_plant = Plant( """ parent_plant = Plant(
common_name_id=monstera_common.id, common_name_id=monstera_common.id,

View File

@ -1,2 +0,0 @@
def register_commands(app):
pass

View File

@ -1,16 +1,14 @@
import csv import csv
import io import io
from flask import Blueprint, request, render_template, redirect, flash import difflib
from flask import Blueprint, request, render_template, redirect, flash, session, url_for
from flask_login import login_required, current_user from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from app.neo4j_utils import get_neo4j_handler from app.neo4j_utils import get_neo4j_handler
from plugins.plant.models import db, Plant, PlantCommon, PlantScientific from plugins.plant.models import db, Plant, PlantCommon, PlantScientific
bp = Blueprint("importer", __name__, template_folder="templates") bp = Blueprint("importer", __name__, template_folder="templates")
REQUIRED_HEADERS = {"uuid", "plant_type", "name", "scientific_name", "mother_uuid"} REQUIRED_HEADERS = {"uuid", "plant_type", "name"}
REQUIRED_FIELDS = {"uuid", "plant_type", "name"}
@bp.route("/import/", methods=["GET", "POST"]) @bp.route("/import/", methods=["GET", "POST"])
@login_required @login_required
@ -26,91 +24,135 @@ def upload():
stream = io.StringIO(decoded) stream = io.StringIO(decoded)
reader = csv.DictReader(stream) reader = csv.DictReader(stream)
# Validate headers headers = set(reader.fieldnames)
if reader.fieldnames is None: if not REQUIRED_HEADERS.issubset(headers):
flash("Invalid CSV file: No headers found.", "error") flash(f"Missing required CSV headers: {REQUIRED_HEADERS - headers}", "error")
return redirect(request.url) return redirect(request.url)
headers = set(h.strip() for h in reader.fieldnames) session["pending_rows"] = []
missing = REQUIRED_HEADERS - headers review_list = []
if missing:
flash(f"Missing required column(s): {', '.join(missing)}", "error")
return redirect(request.url)
neo = get_neo4j_handler() all_common = {c.name.lower(): c for c in PlantCommon.query.all()}
added_count = 0 all_scientific = {s.name.lower(): s for s in PlantScientific.query.all()}
unknown_scientific_count = 0
for i, row in enumerate(reader, start=2): for row in reader:
uuid = row.get("uuid", "").strip() uuid = row.get("uuid")
name = row.get("name", "").strip() name = row.get("name", "").strip()
plant_type = row.get("plant_type", "").strip()
sci_name = row.get("scientific_name", "").strip() sci_name = row.get("scientific_name", "").strip()
plant_type = row.get("plant_type", "plant")
mother_uuid = row.get("mother_uuid", "").strip() mother_uuid = row.get("mother_uuid", "").strip()
# Ensure required fields are present
if not all([uuid, name, plant_type]): if not all([uuid, name, plant_type]):
flash(f"Row {i} skipped: missing required data (uuid, name, or plant_type).", "warning")
continue continue
# Common Name name_lc = name.lower()
common = PlantCommon.query.filter_by(name=name).first() sci_lc = sci_name.lower()
if not common: suggested_match = None
common = PlantCommon(name=name) original_input = sci_name
db.session.add(common)
db.session.flush()
# Scientific Name (fallback or assign 'Unknown') # Fuzzy match scientific name
scientific = None if sci_lc and sci_lc not in all_scientific:
close = difflib.get_close_matches(sci_lc, all_scientific.keys(), n=1, cutoff=0.85)
if close:
suggested_match = all_scientific[close[0]].name
if sci_name: # Infer from common name
scientific = PlantScientific.query.filter_by(name=sci_name).first() if not sci_lc and name_lc in all_common:
sci_obj = PlantScientific.query.filter_by(common_id=all_common[name_lc].id).first()
if sci_obj:
sci_name = sci_obj.name
elif not sci_lc:
close_common = difflib.get_close_matches(name_lc, all_common.keys(), n=1, cutoff=0.85)
if close_common:
match_name = close_common[0]
sci_obj = PlantScientific.query.filter_by(common_id=all_common[match_name].id).first()
if sci_obj:
suggested_match = sci_obj.name
sci_name = sci_obj.name
if not scientific: session["pending_rows"].append({
# Try resolving from existing records by common_id "uuid": uuid,
scientific = PlantScientific.query.filter_by(common_id=common.id).first() "name": name,
"sci_name": sci_name,
"original_sci_name": original_input,
"plant_type": plant_type,
"mother_uuid": mother_uuid,
"suggested_scientific_name": suggested_match,
})
if not scientific: if suggested_match and suggested_match != original_input:
# Fallback to 'Unknown' review_list.append({
unknown = PlantScientific.query.filter_by(name="Unknown").first() "uuid": uuid,
if not unknown: "common_name": name,
unknown = PlantScientific(name="Unknown", common_id=common.id) "user_input": original_input or "(blank)",
db.session.add(unknown) "suggested_name": suggested_match
db.session.flush() })
scientific = unknown
unknown_scientific_count += 1
# Plant session["review_list"] = review_list
plant = Plant.query.filter_by(uuid=uuid).first() return redirect(url_for("importer.review"))
if not plant:
plant = Plant(
uuid=uuid,
common_id=common.id,
scientific_id=scientific.id,
plant_type=plant_type,
owner_id=current_user.id
)
db.session.add(plant)
added_count += 1
# Neo4j
neo.create_plant_node(uuid, name)
if mother_uuid:
neo.create_plant_node(mother_uuid, "Parent")
neo.create_lineage(uuid, mother_uuid)
db.session.commit()
neo.close()
msg = f"CSV imported successfully: {added_count} plants added."
if unknown_scientific_count > 0:
msg += f" {unknown_scientific_count} assigned 'Unknown' as scientific name."
flash(msg, "success")
except Exception as e: except Exception as e:
flash(f"Import failed: {str(e)}", "error") flash(f"Import failed: {str(e)}", "error")
return redirect(request.url)
return render_template("importer/upload.html") return render_template("importer/upload.html")
@bp.route("/import/review", methods=["GET", "POST"])
@login_required
def review():
rows = session.get("pending_rows", [])
review_list = session.get("review_list", [])
if request.method == "POST":
neo = get_neo4j_handler()
added = 0
for row in rows:
uuid = row["uuid"]
name = row["name"]
sci_name = row["sci_name"]
user_input = row["original_sci_name"]
plant_type = row["plant_type"]
mother_uuid = row["mother_uuid"]
suggested = row.get("suggested_scientific_name")
common = PlantCommon.query.filter_by(name=name).first()
if not common:
common = PlantCommon(name=name)
db.session.add(common)
db.session.flush()
accepted = request.form.get(f"confirm_{uuid}")
sci_name_to_use = suggested if (suggested and accepted) else sci_name
scientific = PlantScientific.query.filter_by(name=sci_name_to_use).first()
if not scientific:
scientific = PlantScientific(name=sci_name_to_use, common_id=common.id)
db.session.add(scientific)
db.session.flush()
plant = Plant.query.filter_by(uuid=uuid).first()
if not plant:
plant = Plant(
uuid=uuid,
common_id=common.id,
scientific_id=scientific.id,
plant_type=plant_type,
owner_id=current_user.id,
is_verified=bool(accepted)
)
db.session.add(plant)
added += 1
neo.create_plant_node(uuid, name)
if mother_uuid:
neo.create_plant_node(mother_uuid, "Parent")
neo.create_lineage(uuid, mother_uuid)
db.session.commit()
neo.close()
flash(f"{added} plants added.", "success")
session.pop("pending_rows", None)
session.pop("review_list", None)
return redirect(url_for("importer.upload"))
return render_template("importer/review.html", review_list=review_list)

View File

@ -0,0 +1,43 @@
{% extends "core_ui/base.html" %}
{% block title %}Review Scientific Names{% endblock %}
{% block content %}
<div class="container py-4">
<h2 class="mb-4">🔍 Review Suggested Matches</h2>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if review_list %}
<div class="table-responsive">
<table class="table table-bordered align-middle">
<thead>
<tr>
<th>Common Name</th>
<th>Suggested Scientific Name</th>
<th>Confirm?</th>
</tr>
</thead>
<tbody>
{% for row in review_list %}
<tr>
<td>{{ row.common_name }}</td>
<td>{{ row.suggested_name }}</td>
<td>
<input class="form-check-input" type="checkbox" name="confirm_{{ row.uuid }}" value="1" checked>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No suggestions were made. You can safely continue.</p>
{% endif %}
<div class="mt-3">
<button type="submit" class="btn btn-primary">Confirm and Import</button>
</div>
</form>
</div>
{% endblock %}

View File

@ -1,32 +1,32 @@
{% extends 'core_ui/base.html' %} {% extends "core_ui/base.html" %}
{% block title %}CSV Import{% endblock %}
{% block content %} {% block content %}
<div class="container mt-4"> <div class="container py-4">
<h2 class="mb-3">Import Plants from CSV</h2> <h2 class="mb-4">📤 Import Plant Data</h2>
<div class="alert alert-info" role="alert"> {% with messages = get_flashed_messages(with_categories=true) %}
<strong>Expected CSV headers (in this exact order):</strong> {% if messages %}
<code>uuid,plant_type,name,scientific_name,mother_uuid</code> {% for category, message in messages %}
</div> <div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" enctype="multipart/form-data" class="needs-validation" novalidate> <form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3"> <div class="mb-3">
<label for="file" class="form-label">Select CSV file to upload</label> <label for="file" class="form-label">Choose CSV File</label>
<input type="file" class="form-control" id="file" name="file" accept=".csv" required> <input type="file" class="form-control" id="file" name="file" required>
</div> <div class="form-text">
<button type="submit" class="btn btn-primary">Upload &amp; Import</button> Must include: <code>uuid</code>, <code>plant_type</code>, <code>name</code><br>
</form> Optional: <code>scientific_name</code>, <code>mother_uuid</code>
</div>
{% with messages = get_flashed_messages(with_categories=true) %} </div>
{% if messages %} <button type="submit" class="btn btn-success">Upload</button>
<div class="mt-4"> </form>
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }}" role="alert">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -74,6 +74,8 @@ class Plant(db.Model):
transferred = db.Column(db.Boolean, default=False) transferred = db.Column(db.Boolean, default=False)
graph_node_id = db.Column(db.String(255), nullable=True) graph_node_id = db.Column(db.String(255), nullable=True)
is_verified = db.Column(db.Boolean, nullable=False, default=False)
# Relationships # Relationships
updates = db.relationship('PlantUpdate', backref='growlog', lazy=True) updates = db.relationship('PlantUpdate', backref='growlog', lazy=True)
lineage = db.relationship('PlantLineage', backref='child', lazy=True, foreign_keys='PlantLineage.child_plant_id') lineage = db.relationship('PlantLineage', backref='child', lazy=True, foreign_keys='PlantLineage.child_plant_id')