lots of changes

This commit is contained in:
2025-06-06 02:00:05 -05:00
parent 6cf2fdec61
commit 9daee50a3a
33 changed files with 1478 additions and 260 deletions

289
LineageCheck.md Normal file
View File

@ -0,0 +1,289 @@
# Neo4j Lineage Verification Guide
Use this guide to confirm that your plants and LINEAGE relationships have been imported correctly into Neo4j. Save this file as `neo4j_lineage_check.md` for future reference.
---
## 1. Open the Neo4j Browser
1. **Ensure Neo4j is running.**
In a DockerCompose setup, Neo4j is typically exposed at:
```
http://localhost:7474
```
2. **Log in** with your Neo4j credentials (e.g., username `neo4j`, password as configured).
Once logged in, you can execute Cypher commands in the query pane on the left.
---
## 2. Verify That Your `Plant` Nodes Exist
Before checking relationships, confirm that nodes were created:
```cypher
MATCH (p:Plant)
RETURN p.uuid AS uuid, p.name AS common_name
LIMIT 20;
```
* This query will return up to 20 plant nodes with their `uuid` and `name` properties.
* If you see your imported plants here, it means the nodes exist in the database.
---
## 3. Check Direct Parent→Child LINEAGE Pairs
To list all direct child→parent relationships:
```cypher
MATCH (child:Plant)-[:LINEAGE]->(parent:Plant)
RETURN child.uuid AS child_uuid, parent.uuid AS parent_uuid
LIMIT 50;
```
* Each row represents one `(:Plant)-[:LINEAGE]->(:Plant)` relationship.
* `child_uuid` is the UUID of the child node, and `parent_uuid` is the UUID of its direct parent.
---
## 4. Look Up a Specific Plant by UUID or Name
If you know a particular plants UUID, you can confirm its properties:
```cypher
MATCH (p:Plant {uuid: "YOUR_UUID_HERE"})
RETURN p.uuid AS uuid, p.name AS common_name, p.scientific_name AS sci_name;
```
Alternatively, if you only know the common name:
```cypher
MATCH (p:Plant)
WHERE p.name = "Common Name Here"
RETURN p.uuid AS uuid, p.name AS common_name, p.scientific_name AS sci_name;
```
This helps you find the exact UUID or check that the `name` and `scientific_name` properties were stored correctly.
---
## 5. Show Children of a Given Parent
To list all direct children of a specific parent by UUID:
```cypher
MATCH (parent:Plant {uuid: "PARENT_UUID_HERE"})<-[:LINEAGE]-(child:Plant)
RETURN child.uuid AS child_uuid, child.name AS child_name;
```
* This returns every plant node that points to the specified `parent_uuid` via a `LINEAGE` relationship.
---
## 6. Visualize a Subtree Around One Node
To visualize a parent node and its children in graph form:
```cypher
MATCH subtree = (parent:Plant {uuid: "PARENT_UUID_HERE"})<-[:LINEAGE]-(child:Plant)
RETURN subtree;
```
* Switch to the “Graph” view in the Neo4j browser to see a node for the parent with arrows pointing to each child.
---
## 7. Walk the Full Ancestor Chain (MultiLevel)
If you want to see all ancestors of a given child, use a variablelength pattern:
```cypher
MATCH path = (desc:Plant {uuid: "CHILD_UUID_HERE"})-[:LINEAGE*1..]->(anc:Plant)
RETURN path;
```
* `[:LINEAGE*1..]` indicates “follow one or more consecutive `LINEAGE` relationships upward.”
* In “Graph” view, Neo4j will display the entire chain from child → parent → grandparent → …
To return just the list of ancestor UUIDs:
```cypher
MATCH (start:Plant {uuid: "CHILD_UUID_HERE"})-[:LINEAGE*1..]->(anc:Plant)
RETURN DISTINCT anc.uuid AS ancestor_uuid;
```
---
## 8. Show All Descendants of a Given Parent
To find all descendants (children, grandchildren, etc.) of a root node:
```cypher
MATCH (root:Plant {uuid: "ROOT_UUID_HERE"})<-[:LINEAGE*]-(desc:Plant)
RETURN desc.uuid AS descendant_uuid, desc.name AS descendant_name;
```
* The pattern `[:LINEAGE*]` (with no lower bound specified) matches zero or more hops.
* To visualize the full descendant tree:
```cypher
MATCH subtree = (root:Plant {uuid: "ROOT_UUID_HERE"})<-[:LINEAGE*]-(desc:Plant)
RETURN subtree;
```
Then switch to “Graph” view.
---
## 9. Combining Queries for a Full WalkThrough
1. **List a few plants** (to copy a known UUID):
```cypher
MATCH (p:Plant)
RETURN p.uuid AS uuid, p.name AS common_name
LIMIT 10;
```
2. **Pick one UUID** (e.g. `"2ee2e0e7-69de-4b8f-abfe-4ed973c3d760"`).
3. **Show its direct children**:
```cypher
MATCH (p:Plant {uuid: "2ee2e0e7-69de-4b8f-abfe-4ed973c3d760"})<-[:LINEAGE]-(child:Plant)
RETURN child.uuid AS child_uuid, child.name AS child_name;
```
4. **Show its parent** (if any):
```cypher
MATCH (p:Plant {uuid: "8b1059c8-8dd3-487a-af19-1eb548788e87"})-[:LINEAGE]->(parent:Plant)
RETURN parent.uuid AS parent_uuid, parent.name AS parent_name;
```
5. **Get the full ancestor chain** of that child:
```cypher
MATCH path = (c:Plant {uuid: "8b1059c8-8dd3-487a-af19-1eb548788e87"})-[:LINEAGE*1..]->(anc:Plant)
RETURN path;
```
6. **Get the full descendant tree** of that parent:
```cypher
MATCH subtree = (root:Plant {uuid: "2ee2e0e7-69de-4b8f-abfe-4ed973c3d760"})<-[:LINEAGE*]-(desc:Plant)
RETURN subtree;
```
---
## 10. Checking via Python (Optional)
If you prefer to script these checks using the Neo4j Bolt driver from Python, heres a quick example:
```python
from neo4j import GraphDatabase
uri = "bolt://localhost:7687"
auth = ("neo4j", "your_password")
driver = GraphDatabase.driver(uri, auth=auth)
def print_lineage(tx, plant_uuid):
# Show direct parent
result = tx.run(
"MATCH (c:Plant {uuid:$u})-[:LINEAGE]->(p:Plant) "
"RETURN p.uuid AS parent_uuid, p.name AS parent_name",
u=plant_uuid
)
for row in result:
print(f"Parent of {plant_uuid}: {row['parent_uuid']} ({row['parent_name']})")
# Show all ancestors
result2 = tx.run(
"MATCH path = (c:Plant {uuid:$u})-[:LINEAGE*1..]->(anc:Plant) "
"RETURN [n IN nodes(path) | n.uuid] AS all_uuids",
u=plant_uuid
)
for row in result2:
print("Ancestor chain UUIDs:", row["all_uuids"])
with driver.session() as session:
session.read_transaction(print_lineage, "8b1059c8-8dd3-487a-af19-1eb548788e87")
driver.close()
```
* Install `neo4j` Python package if needed:
```bash
pip install neo4j
```
* Adjust the `uri` and `auth` values to match your Neo4j setup.
---
## 11. Summary of Key Cypher Queries
* **List all plants (sample):**
```cypher
MATCH (p:Plant)
RETURN p.uuid AS uuid, p.name AS common_name
LIMIT 20;
```
* **List direct parent→child relationships:**
```cypher
MATCH (child:Plant)-[:LINEAGE]->(parent:Plant)
RETURN child.uuid AS child_uuid, parent.uuid AS parent_uuid;
```
* **List direct children of a parent:**
```cypher
MATCH (parent:Plant {uuid:"PARENT_UUID"})<-[:LINEAGE]-(child:Plant)
RETURN child.uuid AS child_uuid, child.name AS child_name;
```
* **List direct parent of a child:**
```cypher
MATCH (child:Plant {uuid:"CHILD_UUID"})-[:LINEAGE]->(parent:Plant)
RETURN parent.uuid AS parent_uuid, parent.name AS parent_name;
```
* **Visualize parent + children subgraph:**
```cypher
MATCH subtree = (parent:Plant {uuid:"PARENT_UUID"})<-[:LINEAGE]-(child:Plant)
RETURN subtree;
```
* **Full ancestor chain for a child:**
```cypher
MATCH path = (c:Plant {uuid:"CHILD_UUID"})-[:LINEAGE*1..]->(anc:Plant)
RETURN path;
```
* **Full descendant tree for a parent:**
```cypher
MATCH subtree = (root:Plant {uuid:"PARENT_UUID"})<-[:LINEAGE*]-(desc:Plant)
RETURN subtree;
```
---
### Usage Tips
* **Switch between “Table” and “Graph” views** in the Neo4j Browser to see raw data vs. visual graph.
* Use `LIMIT` when you only want a quick preview of results.
* To filter by partial names, you can do:
```cypher
MATCH (p:Plant)
WHERE toLower(p.name) CONTAINS toLower("baltic")
RETURN p.uuid, p.name;
```
* Remember to enclose string literals in double quotes (`"..."`) and escape any internal quotes if needed.
Keep this guide handy for whenever you need to verify or debug your Neo4j lineage data!

View File

@ -3,7 +3,9 @@
import os import os
import json import json
import glob import glob
import importlib
import importlib.util import importlib.util
from flask import Flask from flask import Flask
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate from flask_migrate import Migrate
@ -11,11 +13,10 @@ from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect from flask_wtf.csrf import CSRFProtect
from dotenv import load_dotenv from dotenv import load_dotenv
# Load environment variables from .env or system
load_dotenv() load_dotenv()
# ---------------------------------------------------------------- # ─── Initialize core extensions ─────────────────────────────────────────────────
# 1) Initialize core extensions
# ----------------------------------------------------------------
db = SQLAlchemy() db = SQLAlchemy()
migrate = Migrate() migrate = Migrate()
login_manager = LoginManager() login_manager = LoginManager()
@ -26,37 +27,43 @@ def create_app():
app = Flask(__name__) app = Flask(__name__)
app.config.from_object('app.config.Config') app.config.from_object('app.config.Config')
# Initialize extensions with app # ─── Initialize extensions with the app ───────────────────────────────────────
csrf.init_app(app) csrf.init_app(app)
db.init_app(app) db.init_app(app)
migrate.init_app(app, db) migrate.init_app(app, db)
login_manager.init_app(app) login_manager.init_app(app)
login_manager.login_view = 'auth.login' login_manager.login_view = 'auth.login'
# ---------------------------------------------------------------- # ─── Register user_loader for Flask-Login ───────────────────────────────────
# 2) Register error handlers from plugins.auth.models import User
# ----------------------------------------------------------------
@login_manager.user_loader
def load_user(user_id):
try:
return User.query.get(int(user_id))
except Exception:
return None
# ─── Register error handlers ─────────────────────────────────────────────────
from .errors import bp as errors_bp from .errors import bp as errors_bp
app.register_blueprint(errors_bp) app.register_blueprint(errors_bp)
# ---------------------------------------------------------------- # ─── 1) Autoimport plugin models by their package names ─────────────────────
# 3) Auto-load each plugins models.py so that SQLAlchemy metadata # This ensures that every plugins/<plugin>/models.py is imported exactly once
# knows about every table (Plant, PlantOwnershipLog, PlantUpdate, etc.) plugin_model_paths = glob.glob(
# ---------------------------------------------------------------- os.path.join(os.path.dirname(__file__), '..', 'plugins', '*', 'models.py')
plugin_model_paths = glob.glob(os.path.join(os.path.dirname(__file__), '..', 'plugins', '*', 'models.py')) )
for path in plugin_model_paths: for path in plugin_model_paths:
module_name = path.replace("/", ".").replace(".py", "") # path looks like ".../plugins/plant/models.py"
rel = path.split(os.sep)[-2] # e.g. "plant"
pkg = f"plugins.{rel}.models" # e.g. "plugins.plant.models"
try: try:
spec = importlib.util.spec_from_file_location(module_name, path) importlib.import_module(pkg)
mod = importlib.util.module_from_spec(spec) print(f"✅ (Startup) Loaded: {pkg}")
spec.loader.exec_module(mod)
print(f"✅ (Startup) Loaded: {module_name}")
except Exception as e: except Exception as e:
print(f"❌ (Startup) Failed to load {module_name}: {e}") print(f"❌ (Startup) Failed to load {pkg}: {e}")
# ---------------------------------------------------------------- # ─── 2) Autodiscover & register plugin routes, CLI, entrypoints ────────────
# 4) Auto-discover & register each plugins routes.py and CLI
# ----------------------------------------------------------------
plugin_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'plugins')) plugin_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'plugins'))
for plugin in os.listdir(plugin_path): for plugin in os.listdir(plugin_path):
if plugin.endswith('.noload'): if plugin.endswith('.noload'):
@ -67,7 +74,7 @@ def create_app():
if not os.path.isdir(plugin_dir): if not os.path.isdir(plugin_dir):
continue continue
# --- (a) Register routes blueprint if present --- # (a) Register routes.py
route_file = os.path.join(plugin_dir, 'routes.py') route_file = os.path.join(plugin_dir, 'routes.py')
if os.path.isfile(route_file): if os.path.isfile(route_file):
try: try:
@ -80,7 +87,7 @@ def create_app():
except Exception as e: except Exception as e:
print(f"❌ Failed to load routes from plugin '{plugin}': {e}") print(f"❌ Failed to load routes from plugin '{plugin}': {e}")
# --- (b) Register CLI & entry point if present --- # (b) Register CLI and entrypoint
init_file = os.path.join(plugin_dir, '__init__.py') init_file = os.path.join(plugin_dir, '__init__.py')
plugin_json = os.path.join(plugin_dir, 'plugin.json') plugin_json = os.path.join(plugin_dir, 'plugin.json')
if os.path.isfile(init_file): if os.path.isfile(init_file):
@ -99,6 +106,7 @@ def create_app():
except Exception as e: except Exception as e:
print(f"❌ Failed to load CLI for plugin '{plugin}': {e}") print(f"❌ Failed to load CLI for plugin '{plugin}': {e}")
# ─── Inject current year into templates ────────────────────────────────────────
@app.context_processor @app.context_processor
def inject_current_year(): def inject_current_year():
from datetime import datetime from datetime import datetime

BIN
files.zip

Binary file not shown.

View File

@ -1,36 +1,38 @@
from __future__ import with_statement from __future__ import with_statement
import os import os
import logging import logging
import importlib.util import glob
import importlib
from alembic import context from alembic import context
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig from logging.config import fileConfig
from flask import current_app from flask import current_app
from app import db from app import db
# ----------------------------- # -----------------------------
# 🔍 Automatically import all plugin models # 🔍 Automatically import all plugin models under their real package name
# ----------------------------- # -----------------------------
import glob
import importlib.util
plugin_model_paths = glob.glob(os.path.join("plugins", "*", "models.py")) plugin_model_paths = glob.glob(os.path.join("plugins", "*", "models.py"))
for path in plugin_model_paths: for path in plugin_model_paths:
module_name = path.replace("/", ".").replace(".py", "") # e.g. path = "plugins/plant/models.py"
# We want to turn that into "plugins.plant.models"
rel = path[len("plugins/") : -len("/models.py")] # e.g. "plant"
pkg = f"plugins.{rel}.models" # e.g. "plugins.plant.models"
try: try:
spec = importlib.util.spec_from_file_location(module_name, path) importlib.import_module(pkg)
module = importlib.util.module_from_spec(spec) print(f"✅ Loaded: {pkg}")
spec.loader.exec_module(module)
print(f"✅ Loaded: {module_name}")
except Exception as e: except Exception as e:
print(f"❌ Failed to load {module_name}: {e}") print(f"❌ Failed to load {pkg}: {e}")
# ----------------------------- # -----------------------------
config = context.config config = context.config
fileConfig(config.config_file_name) fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env') logger = logging.getLogger("alembic.env")
# SQLAlchemy will look at this `target_metadata` when autogenerating
target_metadata = db.metadata target_metadata = db.metadata
def run_migrations_offline(): def run_migrations_offline():

View File

@ -0,0 +1,28 @@
"""auto
Revision ID: 0fcf1e150ae2
Revises: 58516c9892e9
Create Date: 2025-06-05 09:31:44.116783
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0fcf1e150ae2'
down_revision = '58516c9892e9'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,70 @@
"""auto
Revision ID: 373571dfe134
Revises: 0fcf1e150ae2
Create Date: 2025-06-05 09:38:55.414193
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '373571dfe134'
down_revision = '0fcf1e150ae2'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('submission_images', sa.Column('file_url', sa.String(length=256), nullable=False))
op.add_column('submission_images', sa.Column('uploaded_at', sa.DateTime(), nullable=True))
op.drop_column('submission_images', 'is_visible')
op.drop_column('submission_images', 'file_path')
op.add_column('submissions', sa.Column('submitted_at', sa.DateTime(), nullable=True))
op.add_column('submissions', sa.Column('plant_name', sa.String(length=100), nullable=False))
op.add_column('submissions', sa.Column('approved', sa.Boolean(), nullable=True))
op.add_column('submissions', sa.Column('approved_at', sa.DateTime(), nullable=True))
op.add_column('submissions', sa.Column('reviewed_by', sa.Integer(), nullable=True))
op.drop_constraint(op.f('submissions_ibfk_1'), 'submissions', type_='foreignkey')
op.create_foreign_key(None, 'submissions', 'users', ['reviewed_by'], ['id'])
op.drop_column('submissions', 'common_name')
op.drop_column('submissions', 'height')
op.drop_column('submissions', 'container_size')
op.drop_column('submissions', 'timestamp')
op.drop_column('submissions', 'price')
op.drop_column('submissions', 'plant_id')
op.drop_column('submissions', 'width')
op.drop_column('submissions', 'health_status')
op.drop_column('submissions', 'leaf_count')
op.drop_column('submissions', 'potting_mix')
op.drop_column('submissions', 'source')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('submissions', sa.Column('source', mysql.VARCHAR(length=120), nullable=True))
op.add_column('submissions', sa.Column('potting_mix', mysql.VARCHAR(length=255), nullable=True))
op.add_column('submissions', sa.Column('leaf_count', mysql.INTEGER(), autoincrement=False, nullable=True))
op.add_column('submissions', sa.Column('health_status', mysql.VARCHAR(length=50), nullable=True))
op.add_column('submissions', sa.Column('width', mysql.FLOAT(), nullable=True))
op.add_column('submissions', sa.Column('plant_id', mysql.INTEGER(), autoincrement=False, nullable=True))
op.add_column('submissions', sa.Column('price', mysql.FLOAT(), nullable=False))
op.add_column('submissions', sa.Column('timestamp', mysql.DATETIME(), nullable=True))
op.add_column('submissions', sa.Column('container_size', mysql.VARCHAR(length=120), nullable=True))
op.add_column('submissions', sa.Column('height', mysql.FLOAT(), nullable=True))
op.add_column('submissions', sa.Column('common_name', mysql.VARCHAR(length=120), nullable=False))
op.drop_constraint(None, 'submissions', type_='foreignkey')
op.create_foreign_key(op.f('submissions_ibfk_1'), 'submissions', 'plant', ['plant_id'], ['id'])
op.drop_column('submissions', 'reviewed_by')
op.drop_column('submissions', 'approved_at')
op.drop_column('submissions', 'approved')
op.drop_column('submissions', 'plant_name')
op.drop_column('submissions', 'submitted_at')
op.add_column('submission_images', sa.Column('file_path', mysql.VARCHAR(length=255), nullable=False))
op.add_column('submission_images', sa.Column('is_visible', mysql.TINYINT(display_width=1), autoincrement=False, nullable=True))
op.drop_column('submission_images', 'uploaded_at')
op.drop_column('submission_images', 'file_url')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto
Revision ID: 401f262d79cc
Revises: 583fab3f9f80
Create Date: 2025-06-05 04:48:49.440383
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '401f262d79cc'
down_revision = '583fab3f9f80'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto
Revision ID: 4bdec754b085
Revises: 27a65a4e055c
Create Date: 2025-06-05 04:34:19.085549
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4bdec754b085'
down_revision = '27a65a4e055c'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto
Revision ID: 501b54868875
Revises: 401f262d79cc
Create Date: 2025-06-05 04:51:52.183453
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '501b54868875'
down_revision = '401f262d79cc'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto
Revision ID: 583fab3f9f80
Revises: 64ec4065d18d
Create Date: 2025-06-05 04:47:05.679772
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '583fab3f9f80'
down_revision = '64ec4065d18d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto
Revision ID: 58516c9892e9
Revises: 85da58851d35
Create Date: 2025-06-05 05:28:30.947641
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '58516c9892e9'
down_revision = '85da58851d35'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto
Revision ID: 5c85ebc9451b
Revises: d8bfe4d4c083
Create Date: 2025-06-05 09:47:14.478039
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5c85ebc9451b'
down_revision = 'd8bfe4d4c083'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto
Revision ID: 64ec4065d18d
Revises: 4bdec754b085
Create Date: 2025-06-05 04:40:02.186807
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '64ec4065d18d'
down_revision = '4bdec754b085'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,46 @@
"""auto
Revision ID: 72455429fdaf
Revises: 501b54868875
Create Date: 2025-06-05 05:07:43.605568
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '72455429fdaf'
down_revision = '501b54868875'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('transfer_request',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('plant_id', sa.Integer(), nullable=False),
sa.Column('seller_id', sa.Integer(), nullable=False),
sa.Column('buyer_id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('seller_message', sa.String(length=512), nullable=True),
sa.Column('buyer_message', sa.String(length=512), nullable=True),
sa.ForeignKeyConstraint(['buyer_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['plant_id'], ['plant.id'], ),
sa.ForeignKeyConstraint(['seller_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.add_column('plant', sa.Column('data_verified', sa.Boolean(), nullable=False))
op.drop_column('plant_ownership_log', 'graph_node_id')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('plant_ownership_log', sa.Column('graph_node_id', mysql.VARCHAR(length=255), nullable=True))
op.drop_column('plant', 'data_verified')
op.drop_table('transfer_request')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto
Revision ID: 7dbb6d550055
Revises: 72455429fdaf
Create Date: 2025-06-05 05:10:43.392181
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7dbb6d550055'
down_revision = '72455429fdaf'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto
Revision ID: 85da58851d35
Revises: 8cd29b8fb6ec
Create Date: 2025-06-05 05:20:46.638884
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '85da58851d35'
down_revision = '8cd29b8fb6ec'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto
Revision ID: 8cd29b8fb6ec
Revises: 7dbb6d550055
Create Date: 2025-06-05 05:12:50.608338
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8cd29b8fb6ec'
down_revision = '7dbb6d550055'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto
Revision ID: c9495b058ab0
Revises: 373571dfe134
Create Date: 2025-06-05 09:42:35.228096
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c9495b058ab0'
down_revision = '373571dfe134'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto
Revision ID: d8bfe4d4c083
Revises: c9495b058ab0
Create Date: 2025-06-05 09:44:47.740029
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd8bfe4d4c083'
down_revision = 'c9495b058ab0'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -15,9 +15,21 @@ class User(db.Model, UserMixin):
excluded_from_analytics = db.Column(db.Boolean, default=False) excluded_from_analytics = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Optional: relationship to submissions # Use back_populates, not backref
submissions = db.relationship('Submission', backref='user', lazy=True) submitted_submissions = db.relationship(
"Submission",
foreign_keys="Submission.user_id",
back_populates="submitter",
lazy=True
)
reviewed_submissions = db.relationship(
"Submission",
foreign_keys="Submission.reviewed_by",
back_populates="reviewer",
lazy=True
)
def set_password(self, password): def set_password(self, password):
self.password_hash = generate_password_hash(password) self.password_hash = generate_password_hash(password)

View File

@ -4,6 +4,7 @@ import csv
import io import io
import difflib import difflib
from datetime import datetime
from flask import Blueprint, request, render_template, redirect, flash, session, url_for 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 flask_wtf.csrf import generate_csrf from flask_wtf.csrf import generate_csrf
@ -11,109 +12,109 @@ from flask_wtf.csrf import generate_csrf
from app.neo4j_utils import get_neo4j_handler from app.neo4j_utils import get_neo4j_handler
from plugins.plant.models import ( from plugins.plant.models import (
db, db,
Plant, PlantCommonName, PlantScientificName, PlantOwnershipLog Plant,
PlantCommonName,
PlantScientificName,
PlantOwnershipLog
) )
bp = Blueprint("importer", __name__, template_folder="templates", url_prefix="/import") bp = Blueprint('importer', __name__, template_folder='templates', url_prefix='/import')
REQUIRED_HEADERS = {"uuid", "plant_type", "name"} # ────────────────────────────────────────────────────────────────────────────────
# 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"))
@bp.route("/", methods=["GET", "POST"]) # ────────────────────────────────────────────────────────────────────────────────
# Required CSV headers for import
# ────────────────────────────────────────────────────────────────────────────────
REQUIRED_HEADERS = {"uuid", "plant_type", "name", "scientific_name", "mother_uuid"}
@bp.route("/upload", methods=["GET", "POST"])
@login_required @login_required
def upload(): def upload():
if request.method == "POST": if request.method == "POST":
file = request.files.get("file") file = request.files.get("file")
if not file: if not file:
flash("No file uploaded.", "error") flash("No file selected", "error")
return redirect(request.url) return redirect(request.url)
# Decode as UTF-8-SIG to strip any BOM, then parse with csv.DictReader
try: try:
decoded = file.read().decode("utf-8-sig") stream = io.StringIO(file.stream.read().decode("utf-8-sig"))
stream = io.StringIO(decoded)
reader = csv.DictReader(stream) reader = csv.DictReader(stream)
except Exception:
headers = set(reader.fieldnames or []) flash("Failed to read CSV file. Ensure it is valid UTF-8.", "error")
missing = REQUIRED_HEADERS - headers
if missing:
flash(f"Missing required CSV headers: {missing}", "error")
return redirect(request.url)
session["pending_rows"] = []
review_list = []
# Preload existing common/scientific names
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('"')
# If any required field is missing, skip
if not (uuid and name and plant_type):
continue
# Try fuzzymatching scientific names if needed
suggested_match = None
original_sci = sci_name
name_lc = name.lower()
sci_lc = sci_name.lower()
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 not sci_lc and name_lc in all_common:
sci_obj = PlantScientificName.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 = PlantScientificName.query.filter_by(common_id=all_common[match_name].id).first()
if sci_obj:
suggested_match = sci_obj.name
sci_name = sci_obj.name
session["pending_rows"].append({
"uuid": uuid,
"name": name,
"sci_name": sci_name,
"original_sci_name": original_sci,
"plant_type": plant_type,
"mother_uuid": mother_uuid,
"suggested_scientific_name": suggested_match,
})
if suggested_match and suggested_match != original_sci:
review_list.append({
"uuid": uuid,
"common_name": name,
"user_input": original_sci or "(blank)",
"suggested_name": suggested_match
})
session["review_list"] = review_list
return redirect(url_for("importer.review"))
except Exception as e:
flash(f"Import failed: {e}", "error")
return redirect(request.url) 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 doesnt 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()) return render_template("importer/upload.html", csrf_token=generate_csrf())
@ -127,107 +128,97 @@ def review():
neo = get_neo4j_handler() neo = get_neo4j_handler()
added = 0 added = 0
# ————————————————————————————————————————————— # Re-load preload maps to avoid NameError if used below
# (1) CREATE MySQL records & MERGE every Neo4j node 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: for row in rows:
uuid_raw = row["uuid"] uuid = row.get("uuid")
uuid = uuid_raw.strip().strip('"') 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")
name_raw = row["name"] # Check if user clicked "confirm" for a suggested scientific name
name = name_raw.strip() accepted_key = f"confirm_{uuid}"
accepted = request.form.get(accepted_key)
sci_raw = row["sci_name"] # ─── MySQL: PlantCommonName ────────────────────────────────────────────────
sci_name = sci_raw.strip()
plant_type = row["plant_type"].strip()
mother_raw = row["mother_uuid"]
mother_uuid = mother_raw.strip().strip('"')
suggested = row.get("suggested_scientific_name")
# ——— MySQL: PlantCommonName ———
common = PlantCommonName.query.filter_by(name=name).first() common = PlantCommonName.query.filter_by(name=name).first()
if not common: if not common:
common = PlantCommonName(name=name) common = PlantCommonName(name=name)
db.session.add(common) db.session.add(common)
db.session.flush() db.session.flush()
all_common[common.name.lower()] = common
else:
all_common[common.name.lower()] = common
# ——— MySQL: PlantScientificName ——— # ─── MySQL: PlantScientificName ───────────────────────────────────────────
accepted = request.form.get(f"confirm_{uuid}")
sci_to_use = suggested if (suggested and accepted) else sci_name sci_to_use = suggested if (suggested and accepted) else sci_name
scientific = PlantScientificName.query.filter_by(name=sci_to_use).first() scientific = PlantScientificName.query.filter_by(name=sci_to_use).first()
if not scientific: if not scientific:
scientific = PlantScientificName(name=sci_to_use, common_id=common.id) scientific = PlantScientificName(
name = sci_to_use,
common_id = common.id
)
db.session.add(scientific) db.session.add(scientific)
db.session.flush() db.session.flush()
all_scientific[scientific.name.lower()] = scientific
else:
all_scientific[scientific.name.lower()] = scientific
# ——— MySQL: Plant row ——— # ─── Decide if this plants 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() plant = Plant.query.filter_by(uuid=uuid).first()
if not plant: if not plant:
plant = Plant( plant = Plant(
uuid=uuid, uuid = uuid,
common_id=common.id, common_id = common.id,
scientific_id=scientific.id, scientific_id = scientific.id,
plant_type=plant_type, plant_type = plant_type,
owner_id=current_user.id, owner_id = current_user.id,
is_verified=bool(accepted) data_verified = data_verified
) )
db.session.add(plant) db.session.add(plant)
db.session.flush() # so plant.id is available immediately db.session.flush() # so plant.id is now available
added += 1
# ——— MySQL: Create initial ownership log entry ———
log = PlantOwnershipLog( log = PlantOwnershipLog(
plant_id = plant.id, plant_id = plant.id,
user_id = current_user.id, user_id = current_user.id,
date_acquired = datetime.utcnow(), date_acquired = datetime.utcnow(),
transferred = False, transferred = False,
is_verified = bool(accepted) is_verified = data_verified
) )
db.session.add(log) db.session.add(log)
added += 1
else:
# Skip duplicates if the same UUID already exists
pass
# ——— Neo4j: ensure a node exists for this plant UUID ——— # ─── Neo4j: ensure the Plant node exists ─────────────────────────────────
neo.create_plant_node(uuid, name) neo.create_plant_node(uuid, name)
# Commit MySQL so that all Plant/OwnershipLog rows exist # ─── Neo4j: create a LINEAGE relationship if mother_uuid was provided ─────
db.session.commit()
# —————————————————————————————————————————————
# (2) CREATE Neo4j LINEAGE relationships (child → parent). (Unchanged)
# —————————————————————————————————————————————
for row in rows:
child_raw = row.get("uuid", "")
child_uuid = child_raw.strip().strip('"')
mother_raw = row.get("mother_uuid", "")
mother_uuid = mother_raw.strip().strip('"')
print(
f"[DEBUG] row → child_raw={child_raw!r}, child_uuid={child_uuid!r}; "
f"mother_raw={mother_raw!r}, mother_uuid={mother_uuid!r}"
)
if mother_uuid: if mother_uuid:
neo.create_plant_node(mother_uuid, name="Unknown") # Replace the old call with the correct method name:
neo.create_lineage(child_uuid, mother_uuid) neo.create_lineage(child_uuid=uuid, parent_uuid=mother_uuid)
else:
print(f"[DEBUG] Skipping LINEAGE creation for child {child_uuid!r} (no mother_uuid)")
# (Optional) Check two known UUIDs
neo.debug_check_node("8b1059c8-8dd3-487a-af19-1eb548788e87")
neo.debug_check_node("2ee2e0e7-69de-4d8f-abfe-4ed973c3d760")
# Commit all MySQL changes at once
db.session.commit()
neo.close() neo.close()
flash(f"{added} plants added (MySQL) + Neo4j nodes/relations created.", "success")
flash(f"{added} plants added (MySQL) and Neo4j nodes/relationships created.", "success")
session.pop("pending_rows", None) session.pop("pending_rows", None)
session.pop("review_list", None) session.pop("review_list", None)
return redirect(url_for("importer.upload")) return redirect(url_for("importer.upload"))
# GET → re-render the review page with the same review_list
return render_template( return render_template(
"importer/review.html", "importer/review.html",
review_list=review_list, review_list=review_list,

View File

@ -1,17 +1,39 @@
{% extends "core_ui/base.html" %} {% extends "core_ui/base.html" %}
{% block title %}Review Matches{% endblock %} {% block title %}Review Suggested Matches{% endblock %}
{% block content %} {% block content %}
<div class="container py-4"> <div class="container py-4">
<h2 class="mb-4">🔍 Review Suggested Matches</h2> <h2 class="mb-4">🔍 Review Suggested Matches</h2>
<form method="POST"> <p>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> Confirm the suggested scientificname replacements below.
{% if review_list %} Only checked boxes (“Confirm”) will override the raw user input.
<p class="text-muted mb-3">Confirm the suggested scientific name replacements below. Only confirmed matches will override user input.</p> </p>
<table class="table table-bordered table-sm align-middle">
{# Display flash messages (error, success, etc.) #}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<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 %}
{% if review_list and review_list|length > 0 %}
<form method="POST">
{# Hidden CSRF token #}
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th>Common Name</th> <th>Common Name</th>
<th>User Input</th> <th>User Input (Scientific Name)</th>
<th>Suggested Match</th> <th>Suggested Match</th>
<th>Confirm</th> <th>Confirm</th>
</tr> </tr>
@ -19,20 +41,32 @@
<tbody> <tbody>
{% for row in review_list %} {% for row in review_list %}
<tr> <tr>
<td>{{ row.common_name }}</td> <td>{{ row.name }}</td>
<td><code>{{ row.user_input }}</code></td> <td>{{ row.sci_name }}</td>
<td><code>{{ row.suggested_name }}</code></td> <td>{{ row.suggested or '-' }}</td>
<td> <td>
<input type="checkbox" name="confirm_{{ row.uuid }}" value="1"> {% if row.suggested %}
<input
type="checkbox"
name="confirm_{{ row.uuid }}"
aria-label="Confirm suggested match for {{ row.uuid }}"
>
{% else %}
&mdash;
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %}
<p>No matches found that need confirmation.</p> <button type="submit" class="btn btn-success">Confirm &amp; Import</button>
{% endif %} <a href="{{ url_for('importer.upload') }}" class="btn btn-secondary ms-2">Cancel</a>
<button type="submit" class="btn btn-primary mt-3">Finalize Import</button> </form>
</form> {% else %}
<div class="alert alert-info">
No rows to review. <a href="{{ url_for('importer.upload') }}">Upload another CSV?</a>
</div>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,28 +1,45 @@
{% extends "core_ui/base.html" %} {% extends "core_ui/base.html" %}
{% block title %}CSV Import{% endblock %} {% block title %}CSV Import{% endblock %}
{% block content %} {% block content %}
<div class="container py-4"> <div class="container py-4">
<h2 class="mb-4">📤 Import Plant Data</h2> <h2 class="mb-4">📤 Import Plant Data</h2>
{# Display flash messages (error, success, etc.) #}
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
{% for category, message in messages %} {% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert"> <div
class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show"
role="alert"
>
{{ message }} {{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<form method="POST" enctype="multipart/form-data"> <form method="POST" enctype="multipart/form-data">
{# Hidden CSRF token #}
<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">Choose CSV File</label> <label for="file" class="form-label">Choose CSV File</label>
<input type="file" class="form-control" id="file" name="file" required> <input
type="file"
class="form-control"
id="file"
name="file"
accept=".csv"
required
>
<div class="form-text"> <div class="form-text">
Required: <code>uuid</code>, <code>plant_type</code>, <code>name</code><br> Required columns: <code>uuid</code>, <code>plant_type</code>, <code>name</code><br>
Optional: <code>scientific_name</code>, <code>mother_uuid</code> Optional columns: <code>scientific_name</code>, <code>mother_uuid</code>
</div> </div>
</div> </div>
<button type="submit" class="btn btn-success">Upload</button> <button type="submit" class="btn btn-success">Upload</button>
</form> </form>
</div> </div>

View File

@ -1,5 +1,78 @@
from flask import Blueprint from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_from_directory, current_app
from flask_login import current_user, login_required
from werkzeug.utils import secure_filename
import os
from app import db
from .models import Media, ImageHeart, FeaturedImage
from plugins.plant.models import Plant
media_bp = Blueprint('media', __name__) bp = Blueprint("media", __name__, template_folder="templates")
# Add routes here as needed; do NOT define models here. UPLOAD_FOLDER = "static/uploads"
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif"}
def allowed_file(filename):
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
@bp.route("/media/upload", methods=["GET", "POST"])
@login_required
def upload_media():
if request.method == "POST":
file = request.files.get("image")
caption = request.form.get("caption")
plant_id = request.form.get("plant_id")
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
save_path = os.path.join(current_app.root_path, UPLOAD_FOLDER)
os.makedirs(save_path, exist_ok=True)
file.save(os.path.join(save_path, filename))
media = Media(file_url=f"{UPLOAD_FOLDER}/{filename}", caption=caption, plant_id=plant_id)
db.session.add(media)
db.session.commit()
flash("Image uploaded successfully.", "success")
return redirect(url_for("media.upload_media"))
else:
flash("Invalid file or no file uploaded.", "danger")
return render_template("media/upload.html")
@bp.route("/media/files/<path:filename>")
def media_file(filename):
return send_from_directory(os.path.join(current_app.root_path, "static/uploads"), filename)
@bp.route("/media/heart/<int:image_id>", methods=["POST"])
@login_required
def toggle_heart(image_id):
existing = ImageHeart.query.filter_by(user_id=current_user.id, submission_image_id=image_id).first()
if existing:
db.session.delete(existing)
db.session.commit()
return jsonify({"status": "unhearted"})
else:
heart = ImageHeart(user_id=current_user.id, submission_image_id=image_id)
db.session.add(heart)
db.session.commit()
return jsonify({"status": "hearted"})
@bp.route("/media/feature/<int:image_id>", methods=["POST"])
@login_required
def set_featured_image(image_id):
image = Media.query.get_or_404(image_id)
plant = image.plant
if not plant:
flash("This image is not linked to a plant.", "danger")
return redirect(request.referrer or url_for("core_ui.home"))
if current_user.id != plant.owner_id and current_user.role != "admin":
flash("Not authorized to set featured image.", "danger")
return redirect(request.referrer or url_for("core_ui.home"))
FeaturedImage.query.filter_by(submission_image_id=image_id).delete()
featured = FeaturedImage(submission_image_id=image_id, is_featured=True)
db.session.add(featured)
db.session.commit()
flash("Image set as featured.", "success")
return redirect(request.referrer or url_for("core_ui.home"))

View File

@ -2,18 +2,11 @@
from datetime import datetime from datetime import datetime
import uuid as uuid_lib import uuid as uuid_lib
# Import the central SQLAlchemy instance, not a new one
from app import db from app import db
# from plugins.auth.models import User
# If your User model lives in plugins/auth/models.py, import it here:
from plugins.auth.models import User
# ----------------------------- # Association table for Plant ↔ Tag (unchanged)
# (We no longer need PlantLineage)
# -----------------------------
# Association table for tags (unchanged)
plant_tags = db.Table( plant_tags = db.Table(
'plant_tags', 'plant_tags',
db.metadata, db.metadata,
@ -30,7 +23,6 @@ class Tag(db.Model):
name = db.Column(db.String(128), unique=True, nullable=False) name = db.Column(db.String(128), unique=True, nullable=False)
# … any other columns you had … # … any other columns you had …
class PlantCommonName(db.Model): class PlantCommonName(db.Model):
__tablename__ = 'plant_common_name' __tablename__ = 'plant_common_name'
__table_args__ = {'extend_existing': True} __table_args__ = {'extend_existing': True}
@ -46,7 +38,6 @@ class PlantCommonName(db.Model):
cascade='all, delete-orphan' cascade='all, delete-orphan'
) )
class PlantScientificName(db.Model): class PlantScientificName(db.Model):
__tablename__ = 'plant_scientific_name' __tablename__ = 'plant_scientific_name'
__table_args__ = {'extend_existing': True} __table_args__ = {'extend_existing': True}
@ -56,12 +47,8 @@ class PlantScientificName(db.Model):
common_id = db.Column(db.Integer, db.ForeignKey('plant_common_name.id'), nullable=False) common_id = db.Column(db.Integer, db.ForeignKey('plant_common_name.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
plants = db.relationship( # We removed the “plants” relationship from here to avoid backref conflicts.
'plugins.plant.models.Plant', # If you need it, you can still do Plant.query.filter_by(scientific_id=<this id>).
backref='scientific',
lazy='dynamic'
)
class PlantOwnershipLog(db.Model): class PlantOwnershipLog(db.Model):
__tablename__ = 'plant_ownership_log' __tablename__ = 'plant_ownership_log'
@ -72,11 +59,16 @@ class PlantOwnershipLog(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey('users.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_acquired = db.Column(db.DateTime, default=datetime.utcnow)
transferred = db.Column(db.Boolean, default=False, nullable=False) transferred = db.Column(db.Boolean, default=False, nullable=False)
graph_node_id = db.Column(db.String(255), nullable=True) # optional
is_verified = db.Column(db.Boolean, default=False, nullable=False) is_verified = db.Column(db.Boolean, default=False, nullable=False)
user = db.relationship('plugins.auth.models.User', backref='ownership_logs', lazy=True) # Optional: if you ever want to store a pointer to the Neo4j node, you can re-add:
# graph_node_id = db.Column(db.String(255), nullable=True)
user = db.relationship(
'plugins.auth.models.User',
backref='ownership_logs',
lazy=True
)
class Plant(db.Model): class Plant(db.Model):
__tablename__ = 'plant' __tablename__ = 'plant'
@ -94,6 +86,10 @@ class Plant(db.Model):
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow)
# ─── NEW: Flag that indicates whether the common/scientific name pair was human-verified ─────────────────
data_verified = db.Column(db.Boolean, default=False, nullable=False)
# Relationships
updates = db.relationship( updates = db.relationship(
'plugins.growlog.models.PlantUpdate', 'plugins.growlog.models.PlantUpdate',
backref='plant', backref='plant',

View File

@ -1,39 +1,35 @@
from datetime import datetime from datetime import datetime
from app import db from app import db
from plugins.plant.models import Plant from plugins.plant.models import Plant
from plugins.auth.models import User
class Submission(db.Model): class Submission(db.Model):
__tablename__ = 'submissions' __tablename__ = "submissions"
__table_args__ = {'extend_existing': True} __table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=True) submitted_at = db.Column(db.DateTime, default=datetime.utcnow)
plant_name = db.Column(db.String(100), nullable=False)
scientific_name = db.Column(db.String(120), nullable=True)
notes = db.Column(db.Text, nullable=True)
approved = db.Column(db.Boolean, default=None)
approved_at = db.Column(db.DateTime, nullable=True)
reviewed_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
common_name = db.Column(db.String(120), nullable=False) # Explicit bidirectional relationships
scientific_name = db.Column(db.String(120)) submitter = db.relationship("User", foreign_keys=[user_id], back_populates="submitted_submissions")
price = db.Column(db.Float, nullable=False) reviewer = db.relationship("User", foreign_keys=[reviewed_by], back_populates="reviewed_submissions")
source = db.Column(db.String(120))
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
height = db.Column(db.Float) images = db.relationship("SubmissionImage", backref="submission", lazy=True)
width = db.Column(db.Float)
leaf_count = db.Column(db.Integer)
potting_mix = db.Column(db.String(255))
container_size = db.Column(db.String(120))
health_status = db.Column(db.String(50))
notes = db.Column(db.Text)
# Image references via SubmissionImage table
images = db.relationship('SubmissionImage', backref='submission', lazy=True)
class SubmissionImage(db.Model): class SubmissionImage(db.Model):
__tablename__ = 'submission_images' __tablename__ = "submission_images"
__table_args__ = {'extend_existing': True} __table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
submission_id = db.Column(db.Integer, db.ForeignKey('submissions.id'), nullable=False) submission_id = db.Column(db.Integer, db.ForeignKey("submissions.id"), nullable=False)
file_path = db.Column(db.String(255), nullable=False) file_url = db.Column(db.String(256), nullable=False)
is_visible = db.Column(db.Boolean, default=True) uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)

View File

@ -1,10 +1,78 @@
from flask import Blueprint, render_template from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_from_directory, current_app
from .models import Submission from flask_login import current_user, login_required
from werkzeug.utils import secure_filename
import os
from app import db from app import db
from .models import SubmissionImage, ImageHeart, FeaturedImage
from plugins.plant.models import Plant
bp = Blueprint('submission', __name__, url_prefix='/submission') bp = Blueprint("submission", __name__, template_folder="templates")
@bp.route('/') UPLOAD_FOLDER = "static/uploads"
def index(): ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif"}
submissions = Submission.query.order_by(Submission.timestamp.desc()).all()
return render_template('submission/index.html', submissions=submissions) def allowed_file(filename):
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
@bp.route("/submissions/upload", methods=["GET", "POST"])
@login_required
def upload_submissions():
if request.method == "POST":
file = request.files.get("image")
caption = request.form.get("caption")
plant_id = request.form.get("plant_id")
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
save_path = os.path.join(current_app.root_path, UPLOAD_FOLDER)
os.makedirs(save_path, exist_ok=True)
file.save(os.path.join(save_path, filename))
submissions = SubmissionImage(file_url=f"{UPLOAD_FOLDER}/{filename}", caption=caption, plant_id=plant_id)
db.session.add(submissions)
db.session.commit()
flash("Image uploaded successfully.", "success")
return redirect(url_for("submissions.upload_submissions"))
else:
flash("Invalid file or no file uploaded.", "danger")
return render_template("submissions/upload.html")
@bp.route("/submissions/files/<path:filename>")
def submissions_file(filename):
return send_from_directory(os.path.join(current_app.root_path, "static/uploads"), filename)
@bp.route("/submissions/heart/<int:image_id>", methods=["POST"])
@login_required
def toggle_heart(image_id):
existing = ImageHeart.query.filter_by(user_id=current_user.id, submission_image_id=image_id).first()
if existing:
db.session.delete(existing)
db.session.commit()
return jsonify({"status": "unhearted"})
else:
heart = ImageHeart(user_id=current_user.id, submission_image_id=image_id)
db.session.add(heart)
db.session.commit()
return jsonify({"status": "hearted"})
@bp.route("/submissions/feature/<int:image_id>", methods=["POST"])
@login_required
def set_featured_image(image_id):
image = SubmissionImage.query.get_or_404(image_id)
plant = image.plant
if not plant:
flash("This image is not linked to a plant.", "danger")
return redirect(request.referrer or url_for("core_ui.home"))
if current_user.id != plant.owner_id and current_user.role != "admin":
flash("Not authorized to set featured image.", "danger")
return redirect(request.referrer or url_for("core_ui.home"))
FeaturedImage.query.filter_by(submission_image_id=image_id).delete()
featured = FeaturedImage(submission_image_id=image_id, is_featured=True)
db.session.add(featured)
db.session.commit()
flash("Image set as featured.", "success")
return redirect(request.referrer or url_for("core_ui.home"))

View File

View File

@ -0,0 +1,41 @@
from datetime import datetime
from app import db
class TransferRequest(db.Model):
__tablename__ = 'transfer_request'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=False)
seller_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
buyer_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
status = db.Column(
db.String(20),
nullable=False,
default='pending'
)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow)
seller_message = db.Column(db.String(512), nullable=True)
buyer_message = db.Column(db.String(512), nullable=True)
plant = db.relationship(
'plugins.plant.models.Plant',
backref=db.backref('transfer_requests', lazy='dynamic'),
lazy=True
)
seller = db.relationship(
'plugins.auth.models.User',
foreign_keys=[seller_id],
backref='outgoing_transfers',
lazy=True
)
buyer = db.relationship(
'plugins.auth.models.User',
foreign_keys=[buyer_id],
backref='incoming_transfers',
lazy=True
)
def __repr__(self):
return f"<TransferRequest id={self.id} plant={self.plant_id} seller={self.seller_id} buyer={self.buyer_id} status={self.status}>"

View File

@ -0,0 +1,6 @@
{
"name": "transfer",
"version": "1.0.0",
"description": "Handles plant transfer requests between users",
"entry_point": ""
}

152
plugins/transfer/routes.py Normal file
View File

@ -0,0 +1,152 @@
from datetime import datetime
from flask import Blueprint, request, redirect, url_for, flash, render_template
from flask_login import login_required, current_user
from plugins.plant.models import db, Plant, PlantOwnershipLog
from plugins.transfer.models import TransferRequest
from plugins.auth.models import User
bp = Blueprint('transfer', __name__, template_folder='templates', url_prefix='/transfer')
@bp.route('/request/<int:plant_id>', methods=['GET', 'POST'])
@login_required
def request_transfer(plant_id):
plant = Plant.query.get_or_404(plant_id)
if plant.owner_id == current_user.id:
seller = current_user
if request.method == 'POST':
buyer_id = request.form.get('buyer_id', type=int)
buyer = User.query.get(buyer_id)
if not buyer or buyer.id == seller.id:
flash("Please select a valid buyer.", "error")
return redirect(request.url)
tr = TransferRequest(
plant_id=plant.id,
seller_id=seller.id,
buyer_id=buyer.id,
status='pending',
seller_message=request.form.get('seller_message', '').strip()
)
db.session.add(tr)
db.session.commit()
flash("Transfer request sent to buyer. Waiting for their approval.", "info")
return redirect(url_for('plant.view', plant_id=plant.id))
all_users = User.query.filter(User.id != seller.id).all()
return render_template(
'transfer/request_transfer.html',
plant=plant,
all_users=all_users
)
else:
buyer = current_user
if request.method == 'POST':
seller_id = request.form.get('seller_id', type=int)
seller = User.query.get(seller_id)
if not seller or seller.id != plant.owner_id:
flash("Please select the correct seller (current owner).", "error")
return redirect(request.url)
tr = TransferRequest(
plant_id=plant.id,
seller_id=seller.id,
buyer_id=buyer.id,
status='pending',
buyer_message=request.form.get('buyer_message', '').strip()
)
db.session.add(tr)
db.session.commit()
flash("Transfer request sent to seller. Waiting for their approval.", "info")
return redirect(url_for('plant.view', plant_id=plant.id))
return render_template(
'transfer/request_transfer.html',
plant=plant,
all_users=[User.query.get(plant.owner_id)]
)
@bp.route('/incoming', methods=['GET'])
@login_required
def incoming_requests():
pending = TransferRequest.query.filter_by(
buyer_id=current_user.id,
status='pending'
).all()
return render_template('transfer/incoming.html', pending=pending)
@bp.route('/approve/<int:request_id>', methods=['POST'])
@login_required
def approve_request(request_id):
tr = TransferRequest.query.get_or_404(request_id)
if current_user.id not in (tr.seller_id, tr.buyer_id):
flash("Youre not authorized to approve this transfer.", "error")
return redirect(url_for('transfer.incoming_requests'))
if current_user.id == tr.buyer_id:
tr.status = 'buyer_approved'
tr.buyer_message = request.form.get('message', tr.buyer_message)
else:
tr.status = 'seller_approved'
tr.seller_message = request.form.get('message', tr.seller_message)
tr.updated_at = datetime.utcnow()
db.session.commit()
flash("You have approved the transfer. Waiting on the other party.", "info")
return redirect(url_for('transfer.incoming_requests'))
@bp.route('/finalize/<int:request_id>', methods=['POST'])
@login_required
def finalize_request(request_id):
tr = TransferRequest.query.get_or_404(request_id)
buyer_approved = (
TransferRequest.query
.filter_by(id=tr.id, buyer_id=tr.buyer_id, status='buyer_approved')
.first() is not None
)
seller_approved = (
TransferRequest.query
.filter_by(id=tr.id, seller_id=tr.seller_id, status='seller_approved')
.first() is not None
)
if not (buyer_approved and seller_approved):
flash("Both parties must approve before finalizing.", "error")
return redirect(url_for('transfer.incoming_requests'))
new_log = PlantOwnershipLog(
plant_id=tr.plant_id,
user_id=tr.buyer_id,
date_acquired=datetime.utcnow(),
transferred=True,
is_verified=True
)
db.session.add(new_log)
plant = Plant.query.get(tr.plant_id)
plant.owner_id = tr.buyer_id
tr.status = 'complete'
tr.updated_at = datetime.utcnow()
db.session.commit()
flash("Transfer finalized—ownership updated.", "success")
return redirect(url_for('plant.view', plant_id=tr.plant_id))
@bp.route('/reject/<int:request_id>', methods=['POST'])
@login_required
def reject_request(request_id):
tr = TransferRequest.query.get_or_404(request_id)
if current_user.id not in (tr.seller_id, tr.buyer_id):
flash("Youre not authorized to reject this transfer.", "error")
return redirect(url_for('transfer.incoming_requests'))
tr.status = 'rejected'
tr.updated_at = datetime.utcnow()
db.session.commit()
flash("Transfer request has been rejected.", "warning")
return redirect(url_for('transfer.incoming_requests'))

View File

@ -0,0 +1,25 @@
{% extends 'core_ui/base.html' %}
{% block content %}
<h2>Incoming Transfer Requests</h2>
{% if pending %}
<ul>
{% for tr in pending %}
<li>
Plant: {{ tr.plant.custom_slug or tr.plant.uuid }} |
From: {{ tr.seller.username }} |
<form action="{{ url_for('transfer.approve_request', request_id=tr.id) }}" method="post" style="display:inline">
{% csrf_token %}
<button type="submit">Approve</button>
</form>
<form action="{{ url_for('transfer.reject_request', request_id=tr.id) }}" method="post" style="display:inline">
{% csrf_token %}
<button type="submit">Reject</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p>No pending requests.</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,28 @@
{% extends 'core_ui/base.html' %}
{% block content %}
<h2>Request Transfer: {{ plant.custom_slug or plant.uuid }}</h2>
<form method="post">
{% csrf_token %}
{% if plant.owner_id == current_user.id %}
<label for="buyer_id">Select Buyer:</label>
<select name="buyer_id" id="buyer_id">
{% for user in all_users %}
<option value="{{ user.id }}">{{ user.username }}</option>
{% endfor %}
</select>
<br>
<label for="seller_message">Message (optional):</label><br>
<textarea name="seller_message" id="seller_message"></textarea><br>
{% else %}
<label for="seller_id">Confirm Seller:</label>
<select name="seller_id" id="seller_id">
<option value="{{ all_users[0].id }}">{{ all_users[0].username }}</option>
</select>
<br>
<label for="buyer_message">Message (optional):</label><br>
<textarea name="buyer_message" id="buyer_message"></textarea><br>
{% endif %}
<button type="submit">Send Request</button>
</form>
{% endblock %}