tons of fun

This commit is contained in:
2025-06-04 01:40:12 -05:00
parent e8acd6bb20
commit c885ede8af
18 changed files with 206 additions and 209 deletions

View File

@ -13,3 +13,7 @@ MYSQL_USER=plant_user
MYSQL_PASSWORD=plant_pass
MYSQL_ROOT_PASSWORD=supersecret
NEO4J_URI=bolt://neo4j:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=your_secure_password

View File

@ -24,3 +24,7 @@ class Config:
# Optional toggles
ENABLE_DB_SEEDING = os.environ.get('ENABLE_DB_SEEDING', '0') == '1'
DOCKER_ENV = os.environ.get('FLASK_ENV', 'production')
NEO4J_URI = os.getenv('NEO4J_URI', 'bolt://neo4j:7687')
NEO4J_USER = os.getenv('NEO4J_USER', 'neo4j')
NEO4J_PASSWORD = os.getenv('NEO4J_PASSWORD', 'your_secure_password')

32
app/neo4j_utils.py Normal file
View File

@ -0,0 +1,32 @@
from neo4j import GraphDatabase
from flask import current_app
class Neo4jHandler:
def __init__(self, uri, user, password):
self.driver = GraphDatabase.driver(uri, auth=(user, password))
def close(self):
self.driver.close()
def create_plant_node(self, uuid, name):
with self.driver.session() as session:
session.run(
"MERGE (p:Plant {uuid: $uuid}) "
"SET p.name = $name",
uuid=uuid, name=name
)
def create_lineage(self, child_uuid, parent_uuid):
with self.driver.session() as session:
session.run(
"MATCH (child:Plant {uuid: $child_uuid}), (parent:Plant {uuid: $parent_uuid}) "
"MERGE (parent)-[:PARENT_OF]->(child)",
child_uuid=child_uuid, parent_uuid=parent_uuid
)
def get_neo4j_handler():
uri = current_app.config['NEO4J_URI']
user = current_app.config['NEO4J_USER']
password = current_app.config['NEO4J_PASSWORD']
return Neo4jHandler(uri, user, password)

View File

@ -54,5 +54,17 @@ services:
depends_on:
- db
neo4j:
image: neo4j:5.18
container_name: nip_neo4j
ports:
- '7474:7474'
- '7687:7687'
environment:
- NEO4J_AUTH=neo4j/your_secure_password
volumes:
- neo4j_data:/data
volumes:
plant_price_tracker_mysql_data:
neo4j_data:

BIN
main.zip

Binary file not shown.

View File

@ -5,6 +5,7 @@ from app import db
class User(db.Model, UserMixin):
__tablename__ = 'users'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), unique=True, nullable=False)

View File

@ -31,7 +31,7 @@
<ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" href="{{ url_for('core_ui.home') }}">Home</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('plant.index') }}">Plants</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('importer.upload_csv') }}">Import</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('importer.upload') }}">Import</a></li>
{% if current_user.is_authenticated and current_user.role == 'admin' %}
<li class="nav-item"><a class="nav-link text-danger" href="{{ url_for('admin.admin_dashboard') }}">Admin Dashboard</a></li>
{% endif %}

View File

@ -1,12 +1,14 @@
from datetime import datetime
from app import db
from plugins.plant.models import Plant
class GrowLog(db.Model):
__tablename__ = 'grow_logs'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True)
plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False)
plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=False)
title = db.Column(db.String(255), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
@ -18,7 +20,7 @@ class PlantUpdate(db.Model):
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True)
plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False)
plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=False)
growlog_id = db.Column(db.Integer, db.ForeignKey('grow_logs.id'), nullable=True)
update_type = db.Column(db.String(50), nullable=False)
description = db.Column(db.Text, nullable=True)

View File

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

View File

@ -1,174 +1,77 @@
# plugins/importer/routes.py
import csv
import io
import re
import uuid
from flask import Blueprint, request, render_template, redirect, flash
from werkzeug.utils import secure_filename
from app.neo4j_utils import get_neo4j_handler
from plugins.plant.models import db, Plant, PlantCommon, PlantScientific
from flask import (
Blueprint, flash, redirect, render_template, request, url_for
)
from flask_login import login_required, current_user
from sqlalchemy.exc import SQLAlchemyError
from app import db
from plugins.plant.models import (
Plant, PlantCommonName, PlantScientificName, PlantLineage
)
# Blueprint setup
bp = Blueprint(
'importer',
__name__,
template_folder='templates',
url_prefix='/import'
)
# Expected CSV headers (exact, in this order)
EXPECTED_HEADERS = ['uuid', 'plant_type', 'name', 'scientific_name', 'mother_uuid']
# Strict regex for UUIDv4
UUID_REGEX = re.compile(
r'^[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}$'
)
def is_valid_uuid(u: str) -> bool:
"""Return True if 'u' matches UUIDv4 format exactly."""
return bool(UUID_REGEX.match(u))
def sanitize_field(field_value: str) -> str:
"""
Prevent CSV-injection by prefixing any cell that starts with
'=', '+', '-', or '@' with a space.
"""
if isinstance(field_value, str) and field_value and field_value[0] in ['=', '+', '-', '@']:
return ' ' + field_value
return field_value
def validate_row(row: dict, line_number: int):
"""
Validate a single CSV row. Returns (cleaned_row_dict, errors_list).
If errors_list is not empty, the row is invalid.
"""
errors = []
cleaned = {}
# 1) uuid: if provided, must be valid; otherwise generate a new one
raw_uuid = row.get('uuid', '').strip()
if raw_uuid:
if not is_valid_uuid(raw_uuid):
errors.append(f"Line {line_number}: Invalid UUID format '{raw_uuid}'.")
else:
cleaned['uuid'] = raw_uuid
else:
# auto-generate
cleaned['uuid'] = str(uuid.uuid4())
# 2) plant_type: required, <= 50 chars
plant_type = row.get('plant_type', '').strip()
if not plant_type:
errors.append(f"Line {line_number}: 'plant_type' is required.")
elif len(plant_type) > 50:
errors.append(f"Line {line_number}: 'plant_type' exceeds 50 characters.")
else:
cleaned['plant_type'] = sanitize_field(plant_type)
# 3) name: required, <= 100 chars
name = row.get('name', '').strip()
if not name:
errors.append(f"Line {line_number}: 'name' (common name) is required.")
elif len(name) > 100:
errors.append(f"Line {line_number}: 'name' exceeds 100 characters.")
else:
cleaned['name'] = sanitize_field(name)
# 4) scientific_name: required, <= 100 chars
sci = row.get('scientific_name', '').strip()
if not sci:
errors.append(f"Line {line_number}: 'scientific_name' is required.")
elif len(sci) > 100:
errors.append(f"Line {line_number}: 'scientific_name' exceeds 100 characters.")
else:
cleaned['scientific_name'] = sanitize_field(sci)
# 5) mother_uuid: optional. If present (and not 'N/A'), must be valid
raw_mother = row.get('mother_uuid', '').strip()
if raw_mother and raw_mother.upper() != 'N/A':
if not is_valid_uuid(raw_mother):
errors.append(f"Line {line_number}: 'mother_uuid' has invalid UUID '{raw_mother}'.")
else:
cleaned['mother_uuid'] = raw_mother
else:
cleaned['mother_uuid'] = None
if errors:
return None, errors
return cleaned, None
@bp.route("/", methods=["GET", "POST"])
@login_required
def upload_csv():
headers = [
"name", "scientific_name", "type", "status", "description"
]
bp = Blueprint("importer", __name__, template_folder="templates")
@bp.route("/import/", methods=["GET", "POST"])
def upload():
if request.method == "POST":
file = request.files.get("file")
if not file or not file.filename.endswith(".csv"):
flash("Please upload a valid CSV file.", "danger")
if not file:
flash("No file uploaded.", "error")
return redirect(request.url)
try:
stream = io.StringIO(file.stream.read().decode("UTF-8"))
csv_reader = csv.reader(stream)
data = list(csv_reader)
# Handle UTF-8 BOM (Byte Order Mark) if present
decoded = file.read().decode("utf-8-sig")
stream = io.StringIO(decoded)
reader = csv.DictReader(stream)
if not data or data[0] != headers:
flash("CSV headers must exactly match: " + ", ".join(headers), "danger")
return redirect(request.url)
neo = get_neo4j_handler()
# Skip header row
rows = data[1:]
for row in reader:
uuid = row.get("uuid")
name = row.get("name")
sci_name = row.get("scientific_name")
plant_type = row.get("plant_type", "plant")
mother_uuid = row.get("mother_uuid")
with db.session.begin_nested(): # rollback safe transaction
for row in rows:
name, sci_name, plant_type, plant_status, desc = row
if not all([uuid, name, sci_name]):
continue # skip incomplete rows
# Get or create scientific name entry
sci = PlantScientificName.query.filter_by(name=sci_name.strip()).first()
if not sci:
sci = PlantScientificName(name=sci_name.strip())
db.session.add(sci)
db.session.flush() # ensure sci.id is available
# Common Name
common = PlantCommon.query.filter_by(name=name).first()
if not common:
common = PlantCommon(name=name)
db.session.add(common)
db.session.flush()
# Create plant entry
new_plant = Plant(
uuid=str(uuid4()),
name=name.strip(),
scientific_name_id=sci.id,
type=plant_type.strip(),
status=plant_status.strip(),
description=desc.strip()
# Scientific Name
scientific = PlantScientific.query.filter_by(name=sci_name).first()
if not scientific:
scientific = PlantScientific(name=sci_name, common_id=common.id)
db.session.add(scientific)
db.session.flush()
# Plant
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
)
db.session.add(new_plant)
db.session.flush() # get new_plant.id for ownership log
db.session.add(plant)
# Log ownership
log = PlantOwnershipLog(
plant_id=new_plant.id,
user_id=current_user.id,
date_acquired=datetime.utcnow()
)
db.session.add(log)
# Neo4j
neo.create_plant_node(uuid, name)
if mother_uuid and mother_uuid.strip():
neo.create_plant_node(mother_uuid.strip(), "Parent")
neo.create_lineage(uuid, mother_uuid.strip())
db.session.commit()
flash(f"Successfully imported {len(rows)} plants.", "success")
return redirect(url_for("importer.upload_csv"))
db.session.commit()
neo.close()
flash("CSV imported successfully with lineage.", "success")
except Exception as e:
db.session.rollback()
flash(f"Error importing CSV: {str(e)}", "danger")
return redirect(request.url)
flash(f"Import failed: {str(e)}", "error")
return render_template("importer/upload.html", headers=", ".join(headers))
return redirect(request.url)
return render_template("importer/upload.html")

View File

@ -1,6 +1,6 @@
from app import db
from datetime import datetime
from plugins.plant.models import Plant
class Media(db.Model):
__tablename__ = 'media'
@ -10,7 +10,7 @@ class Media(db.Model):
file_url = db.Column(db.String(256), nullable=False)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=True)
plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=True)
growlog_id = db.Column(db.Integer, db.ForeignKey('grow_logs.id'), nullable=True)
update_id = db.Column(db.Integer, db.ForeignKey('plant_updates.id'), nullable=True)

View File

@ -1,59 +1,84 @@
from flask_sqlalchemy import SQLAlchemy
import uuid as uuid_lib
from datetime import datetime
from app import db
from plugins.search.models import Tag, plant_tags
from plugins.growlog.models import PlantUpdate
class PlantCommonName(db.Model):
__tablename__ = 'plants_common'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), unique=True, nullable=False)
# Association table for tags
plant_tags = db.Table(
'plant_tags',
db.metadata,
db.Column('plant_id', db.Integer, db.ForeignKey('plant.id'), primary_key=True),
db.Column('tag_id', db.Integer, db.ForeignKey('tag.id'), primary_key=True),
extend_existing=True
)
class Tag(db.Model):
__tablename__ = 'tag'
__table_args__ = {'extend_existing': True}
class PlantScientificName(db.Model):
__tablename__ = 'plants_scientific'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), unique=True, nullable=False)
class PlantCommonName(db.Model):
__tablename__ = 'plant_common_name'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), unique=True, nullable=False)
class PlantScientificName(db.Model):
__tablename__ = 'plant_scientific_name'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), unique=True, nullable=False)
common_id = db.Column(db.Integer, db.ForeignKey('plant_common_name.id'), nullable=False)
class PlantLineage(db.Model):
__tablename__ = 'plant_lineage'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True)
parent_plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False)
child_plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False)
child_plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=False)
parent_plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=False)
type = db.Column(db.String(50), nullable=False) # cutting, seed, division
class PlantOwnershipLog(db.Model):
__tablename__ = 'plant_ownership_log'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True)
plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False)
plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
date_acquired = db.Column(db.DateTime, default=datetime.utcnow)
date_relinquished = db.Column(db.DateTime, nullable=True)
start_time = db.Column(db.DateTime, nullable=False)
end_time = db.Column(db.DateTime, nullable=True)
transfer_note = db.Column(db.Text, nullable=True)
class Plant(db.Model):
__tablename__ = 'plants'
id = db.Column(db.Integer, primary_key=True)
common_name_id = db.Column(db.Integer, db.ForeignKey('plants_common.id'))
scientific_name_id = db.Column(db.Integer, db.ForeignKey('plants_scientific.id'))
parent_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=True)
is_dead = db.Column(db.Boolean, default=False)
date_added = db.Column(db.DateTime, default=datetime.utcnow)
created_by_user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
__tablename__ = 'plant'
__table_args__ = {'extend_existing': True}
class Plant(db.Model):
id = db.Column(db.Integer, primary_key=True)
uuid = db.Column(db.String(36), unique=True, nullable=False, default=lambda: str(uuid4()))
mother_uuid = db.Column(db.String(36), nullable=True) # Optional parent reference
name_id = db.Column(db.Integer, db.ForeignKey("plant_common_name.id"), nullable=True)
scientific_name_id = db.Column(db.Integer, db.ForeignKey("plant_scientific_name.id"), nullable=True)
uuid = db.Column(db.String(36), default=lambda: str(uuid_lib.uuid4()), unique=True, nullable=False)
custom_slug = db.Column(db.String(255), unique=True, nullable=True)
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
common_id = db.Column(db.Integer, db.ForeignKey('plant_common_name.id'), nullable=False)
scientific_id = db.Column(db.Integer, db.ForeignKey('plant_scientific_name.id'), nullable=False)
plant_type = db.Column(db.String(50), nullable=False)
status = db.Column(db.String(50), nullable=False)
notes = db.Column(db.Text)
status = db.Column(db.String(50), nullable=False, default='active')
notes = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
transferred = db.Column(db.Boolean, default=False)
graph_node_id = db.Column(db.String(255), nullable=True)
# Relationships
updates = db.relationship('PlantUpdate', backref='growlog', lazy=True)
lineage = db.relationship('PlantLineage', backref='child', lazy=True, foreign_keys='PlantLineage.child_plant_id')
tags = db.relationship('Tag', secondary=plant_tags, backref='plants')
# → relationships so we can pull in the actual names:
common_name = db.relationship(
'PlantCommonName',
backref=db.backref('plants', lazy='dynamic'),
@ -64,3 +89,6 @@ class Plant(db.Model):
backref=db.backref('plants', lazy='dynamic'),
lazy=True
)
PlantCommon = PlantCommonName
PlantScientific = PlantScientificName

View File

@ -7,7 +7,7 @@ bp = Blueprint('plant', __name__, template_folder='templates')
@bp.route('/plants')
def index():
plants = Plant.query.order_by(Plant.date_added.desc()).all()
plants = Plant.query.order_by(Plant.created_at.desc()).all()
return render_template('plant/index.html', plants=plants)
@bp.route('/plants/<int:plant_id>')

View File

@ -16,7 +16,7 @@
{% endif %}
<dl class="row">
<dt class="col-sm-3">Date Added</dt>
<dd class="col-sm-9">{{ plant.date_added.strftime('%Y-%m-%d') }}</dd>
<dd class="col-sm-9">{{ plant.created_at.strftime('%Y-%m-%d') }}</dd>
<dt class="col-sm-3">Status</dt>
<dd class="col-sm-9">

View File

@ -1,12 +1,17 @@
from app import db
#from plugins.plant.models import Plant
plant_tags = db.Table(
'plant_tags',
db.Column('plant_id', db.Integer, db.ForeignKey('plants.id'), primary_key=True),
db.Column('tag_id', db.Integer, db.ForeignKey('tags.id'), primary_key=True)
)
# plant_tags = db.Table(
# 'plant_tags',
# db.metadata,
# db.Column('plant_id', db.Integer, db.ForeignKey('plant.id'), primary_key=True),
# db.Column('tag_id', db.Integer, db.ForeignKey('tags.id'), primary_key=True),
# extend_existing=True
# )
class Tag(db.Model):
__tablename__ = 'tags'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
# class Tag(db.Model):
# __tablename__ = 'tags'
# __table_args__ = {'extend_existing': True}
# id = db.Column(db.Integer, primary_key=True)
# name = db.Column(db.String(100), unique=True, nullable=False)

View File

@ -1,9 +1,8 @@
from flask import Blueprint, render_template, request, jsonify
from flask_login import login_required, current_user
from app import db
from .models import Tag
from .forms import SearchForm
from plugins.plant.models import Plant
from plugins.plant.models import Plant, Tag
bp = Blueprint('search', __name__, template_folder='templates')

View File

@ -1,13 +1,15 @@
from datetime import datetime
from app import db
from plugins.plant.models import Plant
class Submission(db.Model):
__tablename__ = 'submissions'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=True)
plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=True)
common_name = db.Column(db.String(120), nullable=False)
scientific_name = db.Column(db.String(120))
@ -29,6 +31,7 @@ class Submission(db.Model):
class SubmissionImage(db.Model):
__tablename__ = 'submission_images'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True)
submission_id = db.Column(db.Integer, db.ForeignKey('submissions.id'), nullable=False)

View File

@ -8,3 +8,5 @@ Werkzeug>=2.3.0
pymysql
python-dotenv
cryptography
neo4j>=5.18.0