diff --git a/app/__init__.py b/app/__init__.py index 9178fcd..a98742e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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(): diff --git a/app/templates/400.html b/app/templates/400.html index 27a931e..b24eec9 100644 --- a/app/templates/400.html +++ b/app/templates/400.html @@ -6,7 +6,7 @@

400 – Bad Request

-

{{ e.description or "Sorry, we couldn’t understand that request." }}

+

{{ error.description or "Sorry, we couldn’t understand that request." }}

Return home diff --git a/docker-compose.yml b/docker-compose.yml index dcca79f..b64cabe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/entrypoint.sh b/entrypoint.sh index a15e9cb..fe2b541 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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 there’s 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 "$@" diff --git a/main-app.zip b/main-app.zip deleted file mode 100644 index 11e3dde..0000000 Binary files a/main-app.zip and /dev/null differ diff --git a/main.zip b/main.zip new file mode 100644 index 0000000..fc737e0 Binary files /dev/null and b/main.zip differ diff --git a/plugins/admin/routes.py b/plugins/admin/routes.py new file mode 100644 index 0000000..ff8994c --- /dev/null +++ b/plugins/admin/routes.py @@ -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') diff --git a/plugins/core_ui/templates/core_ui/admin_dashboard.html b/plugins/admin/templates/admin/admin_dashboard.html similarity index 100% rename from plugins/core_ui/templates/core_ui/admin_dashboard.html rename to plugins/admin/templates/admin/admin_dashboard.html diff --git a/plugins/cli/seed.py b/plugins/cli/seed.py index a0f17df..4dd7478 100644 --- a/plugins/cli/seed.py +++ b/plugins/cli/seed.py @@ -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.") diff --git a/plugins/core_ui/plugin.json b/plugins/core_ui/plugin.json index 9c4b841..adec916 100644 --- a/plugins/core_ui/plugin.json +++ b/plugins/core_ui/plugin.json @@ -1 +1,6 @@ -{ "name": "core_ui", "version": "1.1", "description": "Media rendering macros and styling helpers" } \ No newline at end of file +{ + "name": "core_ui", + "version": "1.1.0", + "description": "Media rendering macros and styling helpers", + "entry_point": null +} \ No newline at end of file diff --git a/plugins/core_ui/routes.py b/plugins/core_ui/routes.py index 83cd6fb..83fb440 100644 --- a/plugins/core_ui/routes.py +++ b/plugins/core_ui/routes.py @@ -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 \ No newline at end of file diff --git a/plugins/core_ui/templates/core_ui/base.html b/plugins/core_ui/templates/core_ui/base.html index eeb5b67..f970cd2 100644 --- a/plugins/core_ui/templates/core_ui/base.html +++ b/plugins/core_ui/templates/core_ui/base.html @@ -31,8 +31,9 @@ diff --git a/plugins/growlog/__init__.py b/plugins/growlog/__init__.py index 9427b39..e69de29 100644 --- a/plugins/growlog/__init__.py +++ b/plugins/growlog/__init__.py @@ -1 +0,0 @@ -# growlog plugin init \ No newline at end of file diff --git a/plugins/growlog/plugin.json b/plugins/growlog/plugin.json index 3e8bb76..73aa24d 100644 --- a/plugins/growlog/plugin.json +++ b/plugins/growlog/plugin.json @@ -1 +1,6 @@ -{ "name": "growlog", "version": "1.0", "description": "Tracks time-based plant care logs" } \ No newline at end of file +{ + "name": "growlog", + "version": "1.0.0", + "description": "Tracks time-based plant care logs", + "entry_point": null +} \ No newline at end of file diff --git a/plugins/importer/__init__.py b/plugins/importer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/importer/plugin.json b/plugins/importer/plugin.json new file mode 100644 index 0000000..e35308d --- /dev/null +++ b/plugins/importer/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "importer", + "version": "1.0.0", + "description": "Plant import from stand alone application", + "entry_point": null +} \ No newline at end of file diff --git a/plugins/importer/routes.py b/plugins/importer/routes.py new file mode 100644 index 0000000..4eca2b3 --- /dev/null +++ b/plugins/importer/routes.py @@ -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)) diff --git a/plugins/importer/templates/importer/upload.html b/plugins/importer/templates/importer/upload.html new file mode 100644 index 0000000..1da6414 --- /dev/null +++ b/plugins/importer/templates/importer/upload.html @@ -0,0 +1,32 @@ +{% extends 'core_ui/base.html' %} +{% block content %} +
+

Import Plants from CSV

+ + + +
+ +
+ + +
+ +
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + + {% endfor %} +
+ {% endif %} + {% endwith %} +
+{% endblock %} diff --git a/plugins/media/__init__.py b/plugins/media/__init__.py index e2fbe2d..e69de29 100644 --- a/plugins/media/__init__.py +++ b/plugins/media/__init__.py @@ -1 +0,0 @@ -# media plugin init \ No newline at end of file diff --git a/plugins/media/plugin.json b/plugins/media/plugin.json index 1fd4b13..db2f492 100644 --- a/plugins/media/plugin.json +++ b/plugins/media/plugin.json @@ -1 +1,6 @@ -{ "name": "media", "version": "1.0", "description": "Upload and attach media to plants and grow logs" } \ No newline at end of file +{ + "name": "media", + "version": "1.0.0", + "description": "Upload and attach media to plants and grow logs", + "entry_point": null +} \ No newline at end of file diff --git a/plugins/plant/models.py b/plugins/plant/models.py index ec6665e..24766e0 100644 --- a/plugins/plant/models.py +++ b/plugins/plant/models.py @@ -36,6 +36,17 @@ class Plant(db.Model): is_dead = db.Column(db.Boolean, default=False) date_added = db.Column(db.DateTime, default=datetime.utcnow) created_by_user_id = db.Column(db.Integer, db.ForeignKey('users.id')) + +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) diff --git a/plugins/search/__init__.py b/plugins/search/__init__.py index 7a5dbe5..e69de29 100644 --- a/plugins/search/__init__.py +++ b/plugins/search/__init__.py @@ -1 +0,0 @@ -# search plugin initialization \ No newline at end of file diff --git a/plugins/search/plugin.json b/plugins/search/plugin.json index 00d5eb3..1698e9e 100644 --- a/plugins/search/plugin.json +++ b/plugins/search/plugin.json @@ -1 +1,6 @@ -{ "name": "search", "version": "1.1", "description": "Updated search plugin with live Plant model integration" } \ No newline at end of file +{ + "name": "search", + "version": "1.1", + "description": "Updated search plugin with live Plant model integration", + "entry_point": null +} \ No newline at end of file diff --git a/plugins/submission/__init__.py b/plugins/submission/__init__.py index 587ff85..e69de29 100644 --- a/plugins/submission/__init__.py +++ b/plugins/submission/__init__.py @@ -1,5 +0,0 @@ -def register(app): - """Register submission routes & blueprint.""" - from .routes import bp as submissions_bp - app.register_blueprint(submissions_bp) - diff --git a/plugins/submission/plugin.json b/plugins/submission/plugin.json index 913d900..c4bbe65 100644 --- a/plugins/submission/plugin.json +++ b/plugins/submission/plugin.json @@ -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 }