lots of changes
This commit is contained in:
289
LineageCheck.md
Normal file
289
LineageCheck.md
Normal 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 Docker‐Compose 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 plant’s 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 (Multi‐Level)
|
||||||
|
|
||||||
|
If you want to see all ancestors of a given child, use a variable‐length 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 Walk‐Through
|
||||||
|
|
||||||
|
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, here’s 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!
|
@ -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) Auto‐import plugin models by their package names ─────────────────────
|
||||||
# 3) Auto-load each plugin’s 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) Auto‐discover & register plugin routes, CLI, entry‐points ────────────
|
||||||
# 4) Auto-discover & register each plugin’s 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 entry‐point
|
||||||
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
|
||||||
|
@ -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():
|
||||||
|
28
migrations/versions/0fcf1e150ae2_auto.py
Normal file
28
migrations/versions/0fcf1e150ae2_auto.py
Normal 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 ###
|
70
migrations/versions/373571dfe134_auto.py
Normal file
70
migrations/versions/373571dfe134_auto.py
Normal 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 ###
|
28
migrations/versions/401f262d79cc_auto.py
Normal file
28
migrations/versions/401f262d79cc_auto.py
Normal 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 ###
|
28
migrations/versions/4bdec754b085_auto.py
Normal file
28
migrations/versions/4bdec754b085_auto.py
Normal 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 ###
|
28
migrations/versions/501b54868875_auto.py
Normal file
28
migrations/versions/501b54868875_auto.py
Normal 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 ###
|
28
migrations/versions/583fab3f9f80_auto.py
Normal file
28
migrations/versions/583fab3f9f80_auto.py
Normal 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 ###
|
28
migrations/versions/58516c9892e9_auto.py
Normal file
28
migrations/versions/58516c9892e9_auto.py
Normal 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 ###
|
28
migrations/versions/5c85ebc9451b_auto.py
Normal file
28
migrations/versions/5c85ebc9451b_auto.py
Normal 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 ###
|
28
migrations/versions/64ec4065d18d_auto.py
Normal file
28
migrations/versions/64ec4065d18d_auto.py
Normal 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 ###
|
46
migrations/versions/72455429fdaf_auto.py
Normal file
46
migrations/versions/72455429fdaf_auto.py
Normal 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 ###
|
28
migrations/versions/7dbb6d550055_auto.py
Normal file
28
migrations/versions/7dbb6d550055_auto.py
Normal 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 ###
|
28
migrations/versions/85da58851d35_auto.py
Normal file
28
migrations/versions/85da58851d35_auto.py
Normal 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 ###
|
28
migrations/versions/8cd29b8fb6ec_auto.py
Normal file
28
migrations/versions/8cd29b8fb6ec_auto.py
Normal 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 ###
|
28
migrations/versions/c9495b058ab0_auto.py
Normal file
28
migrations/versions/c9495b058ab0_auto.py
Normal 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 ###
|
28
migrations/versions/d8bfe4d4c083_auto.py
Normal file
28
migrations/versions/d8bfe4d4c083_auto.py
Normal 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 ###
|
@ -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)
|
||||||
|
|
||||||
|
@ -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 fuzzy‐matching 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 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())
|
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 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()
|
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,
|
||||||
|
@ -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 scientific‐name 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 %}
|
||||||
|
—
|
||||||
|
{% 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 & 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 %}
|
||||||
|
@ -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>
|
||||||
|
@ -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"))
|
||||||
|
@ -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',
|
||||||
|
@ -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)
|
||||||
|
@ -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"))
|
0
plugins/transfer/__init__.py
Normal file
0
plugins/transfer/__init__.py
Normal file
41
plugins/transfer/models.py
Normal file
41
plugins/transfer/models.py
Normal 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}>"
|
6
plugins/transfer/plugin.json
Normal file
6
plugins/transfer/plugin.json
Normal 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
152
plugins/transfer/routes.py
Normal 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("You’re 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("You’re 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'))
|
25
plugins/transfer/templates/transfer/incoming.html
Normal file
25
plugins/transfer/templates/transfer/incoming.html
Normal 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 %}
|
28
plugins/transfer/templates/transfer/request_transfer.html
Normal file
28
plugins/transfer/templates/transfer/request_transfer.html
Normal 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 %}
|
Reference in New Issue
Block a user