# 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() )