changes
This commit is contained in:
@ -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,
|
||||||
|
@ -1,2 +0,0 @@
|
|||||||
def register_commands(app):
|
|
||||||
pass
|
|
||||||
|
@ -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)
|
||||||
|
43
plugins/importer/templates/importer/review.html
Normal file
43
plugins/importer/templates/importer/review.html
Normal 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 %}
|
@ -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 & 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 %}
|
||||||
|
@ -73,6 +73,8 @@ class Plant(db.Model):
|
|||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
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)
|
||||||
|
Reference in New Issue
Block a user