227 lines
9.4 KiB
Python
227 lines
9.4 KiB
Python
# plugins/importer/routes.py
|
||
|
||
import csv
|
||
import io
|
||
import difflib
|
||
|
||
from datetime import datetime
|
||
from flask import Blueprint, request, render_template, redirect, flash, session, url_for
|
||
from flask_login import login_required, current_user
|
||
from flask_wtf.csrf import generate_csrf
|
||
|
||
from app.neo4j_utils import get_neo4j_handler
|
||
from plugins.plant.models import (
|
||
db,
|
||
Plant,
|
||
PlantCommonName,
|
||
PlantScientificName,
|
||
PlantOwnershipLog
|
||
)
|
||
|
||
bp = Blueprint('importer', __name__, template_folder='templates', url_prefix='/import')
|
||
|
||
# ────────────────────────────────────────────────────────────────────────────────
|
||
# Redirect “/import/” → “/import/upload”
|
||
# ────────────────────────────────────────────────────────────────────────────────
|
||
|
||
@bp.route("/", methods=["GET"])
|
||
@login_required
|
||
def index():
|
||
# When someone hits /import, send them to /import/upload
|
||
return redirect(url_for("importer.upload"))
|
||
|
||
|
||
# ────────────────────────────────────────────────────────────────────────────────
|
||
# Required CSV headers for import
|
||
# ────────────────────────────────────────────────────────────────────────────────
|
||
REQUIRED_HEADERS = {"uuid", "plant_type", "name", "scientific_name", "mother_uuid"}
|
||
|
||
|
||
@bp.route("/upload", methods=["GET", "POST"])
|
||
@login_required
|
||
def upload():
|
||
if request.method == "POST":
|
||
file = request.files.get("file")
|
||
if not file:
|
||
flash("No file selected", "error")
|
||
return redirect(request.url)
|
||
|
||
# Decode as UTF-8-SIG to strip any BOM, then parse with csv.DictReader
|
||
try:
|
||
stream = io.StringIO(file.stream.read().decode("utf-8-sig"))
|
||
reader = csv.DictReader(stream)
|
||
except Exception:
|
||
flash("Failed to read CSV file. Ensure it is valid UTF-8.", "error")
|
||
return redirect(request.url)
|
||
|
||
headers = set(reader.fieldnames or [])
|
||
missing = REQUIRED_HEADERS - headers
|
||
if missing:
|
||
flash(f"Missing required CSV headers: {missing}", "error")
|
||
return redirect(request.url)
|
||
|
||
# Prepare session storage for the rows under review
|
||
session["pending_rows"] = []
|
||
review_list = []
|
||
|
||
# Preload existing common/scientific names (lowercased keys for fuzzy matching)
|
||
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 reader:
|
||
uuid_raw = row.get("uuid", "")
|
||
uuid = uuid_raw.strip().strip('"')
|
||
|
||
name_raw = row.get("name", "")
|
||
name = name_raw.strip()
|
||
|
||
sci_raw = row.get("scientific_name", "")
|
||
sci_name = sci_raw.strip()
|
||
|
||
plant_type = row.get("plant_type", "").strip() or "plant"
|
||
|
||
mother_raw = row.get("mother_uuid", "")
|
||
mother_uuid = mother_raw.strip().strip('"')
|
||
|
||
# Skip any row where required fields are missing
|
||
if not (uuid and name and plant_type):
|
||
continue
|
||
|
||
# ─── If the scientific name doesn’t match exactly, suggest a close match ─────
|
||
# Only suggest if the “closest key” differs from the raw input:
|
||
suggestions = difflib.get_close_matches(
|
||
sci_name.lower(),
|
||
list(all_scientific.keys()),
|
||
n=1,
|
||
cutoff=0.8
|
||
)
|
||
if suggestions and suggestions[0] != sci_name.lower():
|
||
suggested = all_scientific[suggestions[0]].name
|
||
else:
|
||
suggested = None
|
||
|
||
review_item = {
|
||
"uuid": uuid,
|
||
"name": name,
|
||
"sci_name": sci_name,
|
||
"suggested": suggested,
|
||
"plant_type": plant_type,
|
||
"mother_uuid": mother_uuid
|
||
}
|
||
review_list.append(review_item)
|
||
session["pending_rows"].append(review_item)
|
||
|
||
session["review_list"] = review_list
|
||
return redirect(url_for("importer.review"))
|
||
|
||
# GET → show upload form
|
||
return render_template("importer/upload.html", csrf_token=generate_csrf())
|
||
|
||
|
||
@bp.route("/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
|
||
|
||
# Re-load preload maps to avoid NameError if used below
|
||
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 = 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")
|
||
|
||
# Check if user clicked "confirm" for a suggested scientific name
|
||
accepted_key = f"confirm_{uuid}"
|
||
accepted = request.form.get(accepted_key)
|
||
|
||
# ─── MySQL: PlantCommonName ────────────────────────────────────────────────
|
||
common = PlantCommonName.query.filter_by(name=name).first()
|
||
if not common:
|
||
common = PlantCommonName(name=name)
|
||
db.session.add(common)
|
||
db.session.flush()
|
||
all_common[common.name.lower()] = common
|
||
else:
|
||
all_common[common.name.lower()] = common
|
||
|
||
# ─── MySQL: PlantScientificName ───────────────────────────────────────────
|
||
sci_to_use = suggested if (suggested and accepted) else sci_name
|
||
scientific = PlantScientificName.query.filter_by(name=sci_to_use).first()
|
||
if not scientific:
|
||
scientific = PlantScientificName(
|
||
name = sci_to_use,
|
||
common_id = common.id
|
||
)
|
||
db.session.add(scientific)
|
||
db.session.flush()
|
||
all_scientific[scientific.name.lower()] = scientific
|
||
else:
|
||
all_scientific[scientific.name.lower()] = scientific
|
||
|
||
# ─── Decide if this plant’s data is “verified” by the user ────────────────
|
||
data_verified = False
|
||
if (not suggested) or (suggested and accepted):
|
||
data_verified = True
|
||
|
||
# ─── MySQL: Plant record ─────────────────────────────────────────────────
|
||
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,
|
||
data_verified = data_verified
|
||
)
|
||
db.session.add(plant)
|
||
db.session.flush() # so plant.id is now available
|
||
|
||
log = PlantOwnershipLog(
|
||
plant_id = plant.id,
|
||
user_id = current_user.id,
|
||
date_acquired = datetime.utcnow(),
|
||
transferred = False,
|
||
is_verified = data_verified
|
||
)
|
||
db.session.add(log)
|
||
added += 1
|
||
else:
|
||
# Skip duplicates if the same UUID already exists
|
||
pass
|
||
|
||
# ─── Neo4j: ensure the Plant node exists ─────────────────────────────────
|
||
neo.create_plant_node(uuid, name)
|
||
|
||
# ─── Neo4j: create a LINEAGE relationship if mother_uuid was provided ─────
|
||
if mother_uuid:
|
||
# Replace the old call with the correct method name:
|
||
neo.create_lineage(child_uuid=uuid, parent_uuid=mother_uuid)
|
||
|
||
# Commit all MySQL changes at once
|
||
db.session.commit()
|
||
neo.close()
|
||
|
||
flash(f"{added} plants added (MySQL) and Neo4j nodes/relationships created.", "success")
|
||
|
||
session.pop("pending_rows", None)
|
||
session.pop("review_list", None)
|
||
return redirect(url_for("importer.upload"))
|
||
|
||
# GET → re-render the review page with the same review_list
|
||
return render_template(
|
||
"importer/review.html",
|
||
review_list=review_list,
|
||
csrf_token=generate_csrf()
|
||
)
|