messing stuff up

This commit is contained in:
2025-06-03 20:46:11 -05:00
parent e3ee3e6708
commit e8acd6bb20
25 changed files with 300 additions and 51 deletions

View File

@ -79,14 +79,14 @@ def create_app():
except Exception as e:
print(f"[⚠️] Failed to load CLI for plugin '{plugin}': {e}")
# 3. Auto-load plugin models for migrations
if os.path.isfile(model_file):
try:
spec = importlib.util.spec_from_file_location(f"plugins.{plugin}.models", model_file)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
except Exception as e:
print(f"[⚠️] Failed to load models from plugin '{plugin}': {e}")
## 3. Auto-load plugin models for migrations
#if os.path.isfile(model_file):
# try:
# spec = importlib.util.spec_from_file_location(f"plugins.{plugin}.models", model_file)
# mod = importlib.util.module_from_spec(spec)
# spec.loader.exec_module(mod)
# except Exception as e:
# print(f"[⚠️] Failed to load models from plugin '{plugin}': {e}")
@app.context_processor
def inject_current_year():

View File

@ -6,7 +6,7 @@
</head>
<body>
<h1>400 Bad Request</h1>
<p>{{ e.description or "Sorry, we couldnt understand that request." }}</p>
<p>{{ error.description or "Sorry, we couldnt understand that request." }}</p>
<a href="{{ url_for('main.index') }}">Return home</a>
</body>
</html>

View File

@ -34,7 +34,7 @@ services:
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
ports:
- "3306:3306"
- "42000:3306"
volumes:
- plant_price_tracker_mysql_data:/var/lib/mysql
healthcheck:

View File

@ -1,7 +1,7 @@
#!/bin/bash
set -e
# Wait for the database service to be ready
# 1) Wait for the database service to be ready
DB_HOST=${DB_HOST:-db}
DB_PORT=${DB_PORT:-3306}
echo "[⏳] Waiting for database at $DB_HOST:$DB_PORT..."
@ -10,22 +10,30 @@ until nc -z $DB_HOST $DB_PORT; do
done
echo "[✔] Database is up and reachable"
# Initialize migrations folder if needed
if [ ! -d migrations ]; then
echo "[] Initializing migrations directory"
flask db init
# If theres no migrations folder yet, initialize Alembic here:
if [ ! -d "./migrations" ]; then
echo "[🆕] No migrations directory found; initializing Alembic"
flask db init
fi
echo "[✔] Running migrations"
flask db migrate -m "auto"
# 2) Always apply any already-created migration scripts first
echo "[▶️] Applying existing migrations (upgrade)"
flask db upgrade
# Seed database if enabled (accept “1” or “true”)
# 3) Now attempt to autogenerate a new migration if the models changed
echo "[✨] Autogenerating new migration (if needed)"
flask db migrate -m "auto"
# 4) Apply that new migration (if one was generated)
echo "[▶️] Applying any newly autogenerated migration"
flask db upgrade
# 5) Optionally seed data
if [ "$ENABLE_DB_SEEDING" = "true" ] || [ "$ENABLE_DB_SEEDING" = "1" ]; then
echo "[🌱] Seeding Data"
flask preload-data
echo "[🌱] Seeding Data"
flask preload-data
fi
# Start the main process
# 6) Finally, run the Flask application
echo "[🚀] Starting Flask"
exec "$@"

Binary file not shown.

BIN
main.zip Normal file

Binary file not shown.

11
plugins/admin/routes.py Normal file
View File

@ -0,0 +1,11 @@
from flask import Blueprint, render_template
from flask_login import login_required, current_user
bp = Blueprint('admin', __name__, template_folder='templates')
@bp.route('/admin')
@login_required
def admin_dashboard():
if current_user.role != 'admin':
return "Access denied", 403
return render_template('admin/admin_dashboard.html')

View File

@ -38,14 +38,14 @@ def preload_data(auto=False):
db.session.commit()
# COMMON & SCIENTIFIC NAMES
monstera_common = PlantCommonName(name='Monstera')
deliciosa_sci = PlantScientificName(name='Monstera deliciosa')
aurea_sci = PlantScientificName(name='Monstera aurea')
db.session.add_all([monstera_common, deliciosa_sci, aurea_sci])
db.session.commit()
#monstera_common = PlantCommonName(name='Monstera')
#deliciosa_sci = PlantScientificName(name='Monstera deliciosa')
#aurea_sci = PlantScientificName(name='Monstera aurea')
#db.session.add_all([monstera_common, deliciosa_sci, aurea_sci])
#db.session.commit()
# PLANTS
parent_plant = Plant(
""" parent_plant = Plant(
common_name_id=monstera_common.id,
scientific_name_id=deliciosa_sci.id,
created_by_user_id=admin.id
@ -115,7 +115,7 @@ def preload_data(auto=False):
ImageHeart(user_id=admin.id, submission_image_id=image.id),
FeaturedImage(submission_image_id=image.id, override_text='Gorgeous coloration', is_featured=True)
])
db.session.commit()
db.session.commit() """
if not auto:
click.echo("🎉 Demo data seeded successfully.")

View File

@ -1 +1,6 @@
{ "name": "core_ui", "version": "1.1", "description": "Media rendering macros and styling helpers" }
{
"name": "core_ui",
"version": "1.1.0",
"description": "Media rendering macros and styling helpers",
"entry_point": null
}

View File

@ -7,13 +7,6 @@ bp = Blueprint('core_ui', __name__, template_folder='templates')
def home():
return render_template('core_ui/home.html')
@bp.route('/admin')
@login_required
def admin_dashboard():
if current_user.role != 'admin':
return "Access denied", 403
return render_template('core_ui/admin_dashboard.html')
@bp.route('/health')
def health():
return 'OK', 200

View File

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

View File

@ -1 +0,0 @@
# growlog plugin init

View File

@ -1 +1,6 @@
{ "name": "growlog", "version": "1.0", "description": "Tracks time-based plant care logs" }
{
"name": "growlog",
"version": "1.0.0",
"description": "Tracks time-based plant care logs",
"entry_point": null
}

View File

View File

@ -0,0 +1,6 @@
{
"name": "importer",
"version": "1.0.0",
"description": "Plant import from stand alone application",
"entry_point": null
}

174
plugins/importer/routes.py Normal file
View File

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

View File

@ -0,0 +1,32 @@
{% extends 'core_ui/base.html' %}
{% block content %}
<div class="container mt-4">
<h2 class="mb-3">Import Plants from CSV</h2>
<div class="alert alert-info" role="alert">
<strong>Expected CSV headers (in this exact order):</strong>
<code>{{ headers }}</code>
</div>
<form method="POST" enctype="multipart/form-data" class="needs-validation" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="file" class="form-label">Select CSV file to upload</label>
<input type="file" class="form-control" id="file" name="file" accept=".csv" required>
</div>
<button type="submit" class="btn btn-primary">Upload &amp; Import</button>
</form>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mt-4">
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }}" role="alert">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
{% endblock %}

View File

@ -1 +0,0 @@
# media plugin init

View File

@ -1 +1,6 @@
{ "name": "media", "version": "1.0", "description": "Upload and attach media to plants and grow logs" }
{
"name": "media",
"version": "1.0.0",
"description": "Upload and attach media to plants and grow logs",
"entry_point": null
}

View File

@ -37,6 +37,17 @@ class Plant(db.Model):
date_added = db.Column(db.DateTime, default=datetime.utcnow)
created_by_user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
class Plant(db.Model):
id = db.Column(db.Integer, primary_key=True)
uuid = db.Column(db.String(36), unique=True, nullable=False, default=lambda: str(uuid4()))
mother_uuid = db.Column(db.String(36), nullable=True) # Optional parent reference
name_id = db.Column(db.Integer, db.ForeignKey("plant_common_name.id"), nullable=True)
scientific_name_id = db.Column(db.Integer, db.ForeignKey("plant_scientific_name.id"), nullable=True)
plant_type = db.Column(db.String(50), nullable=False)
status = db.Column(db.String(50), nullable=False)
notes = db.Column(db.Text)
# Relationships
updates = db.relationship('PlantUpdate', backref='growlog', lazy=True)
lineage = db.relationship('PlantLineage', backref='child', lazy=True, foreign_keys='PlantLineage.child_plant_id')

View File

@ -1 +0,0 @@
# search plugin initialization

View File

@ -1 +1,6 @@
{ "name": "search", "version": "1.1", "description": "Updated search plugin with live Plant model integration" }
{
"name": "search",
"version": "1.1",
"description": "Updated search plugin with live Plant model integration",
"entry_point": null
}

View File

@ -1,5 +0,0 @@
def register(app):
"""Register submission routes & blueprint."""
from .routes import bp as submissions_bp
app.register_blueprint(submissions_bp)

View File

@ -1,5 +1,6 @@
{
"name": "submission",
"version": "1.0.0",
"description": "Plugin to handle user-submitted plant data and images."
"description": "Plugin to handle user-submitted plant data and images.",
"entry_point": null
}