From c19bedc54a2eb7af5a8be592f789a9841f10032b Mon Sep 17 00:00:00 2001 From: Lilfade Date: Sun, 18 May 2025 05:21:16 -0500 Subject: [PATCH] changes --- .env.example | 3 + LICENSE.md | 0 Makefile | 18 ++- README.md | 48 +++++- app/__init__.py | 29 ++-- app/cli.py | 35 ----- app/config.py | 27 ++++ app/core/auth.py | 38 ----- app/core/routes.py | 145 ------------------ app/errors.py | 11 ++ app/templates/404.html | 1 + app/templates/500.html | 1 + app/templates/admin_dashboard.html | 5 - app/templates/base.html | 98 ------------ app/templates/dashboard.html | 5 - app/templates/index.html | 36 ----- app/templates/search.html | 34 ---- app/templates/search_results.html | 21 --- app/templates/search_tags.html | 15 -- app/templates/submit.html | 65 -------- app/templates/unauthorized.html | 5 - config.py | 17 -- docker-compose.yml | 17 +- nuke-dev.sh | 33 ---- {app/core => plugins/auth}/models.py | 2 +- plugins/auth/routes.py | 28 ++++ .../auth}/templates/auth/login.html | 0 .../auth}/templates/auth/register.html | 0 plugins/cli/__init__.py | 2 + plugins/cli/preload.py | 79 ++++++++++ plugins/cli/seed.py | 26 ++++ plugins/core_ui/__init__.py | 1 + plugins/core_ui/plugin.json | 1 + .../templates/core_ui/_media_macros.html | 14 ++ .../templates/core_ui/media_styles.html | 18 +++ plugins/growlog/__init__.py | 1 + plugins/growlog/forms.py | 15 ++ plugins/growlog/models.py | 15 ++ plugins/growlog/plugin.json | 1 + plugins/growlog/routes.py | 31 ++++ .../growlog/templates/growlog/log_form.html | 10 ++ .../growlog/templates/growlog/log_list.html | 25 +++ plugins/media/__init__.py | 1 + plugins/media/forms.py | 14 ++ plugins/media/models.py | 12 ++ plugins/media/plugin.json | 1 + plugins/media/routes.py | 42 +++++ plugins/media/templates/media/list.html | 14 ++ plugins/media/templates/media/upload.html | 12 ++ plugins/plant/__init__.py | 1 + plugins/plant/forms.py | 10 ++ plugins/plant/models.py | 11 ++ plugins/plant/routes.py | 43 ++++++ plugins/plant/templates/plant/detail.html | 9 ++ plugins/plant/templates/plant/form.html | 12 ++ plugins/plant/templates/plant/index.html | 10 ++ plugins/search/__init__.py | 1 + plugins/search/forms.py | 15 ++ plugins/search/models.py | 8 + plugins/search/plugin.json | 1 + plugins/search/routes.py | 38 +++++ plugins/search/templates/search/search.html | 26 ++++ .../templates/search/search_results.html | 13 ++ .../search/templates/search/search_tags.html | 4 + requirements.txt | 6 +- 65 files changed, 705 insertions(+), 575 deletions(-) create mode 100644 LICENSE.md delete mode 100644 app/cli.py create mode 100644 app/config.py delete mode 100644 app/core/auth.py delete mode 100644 app/core/routes.py create mode 100644 app/errors.py create mode 100644 app/templates/404.html create mode 100644 app/templates/500.html delete mode 100644 app/templates/admin_dashboard.html delete mode 100644 app/templates/base.html delete mode 100644 app/templates/dashboard.html delete mode 100644 app/templates/index.html delete mode 100644 app/templates/search.html delete mode 100644 app/templates/search_results.html delete mode 100644 app/templates/search_tags.html delete mode 100644 app/templates/submit.html delete mode 100644 app/templates/unauthorized.html delete mode 100644 config.py delete mode 100755 nuke-dev.sh rename {app/core => plugins/auth}/models.py (99%) create mode 100644 plugins/auth/routes.py rename {app => plugins/auth}/templates/auth/login.html (100%) rename {app => plugins/auth}/templates/auth/register.html (100%) create mode 100644 plugins/cli/__init__.py create mode 100644 plugins/cli/preload.py create mode 100644 plugins/cli/seed.py create mode 100644 plugins/core_ui/__init__.py create mode 100644 plugins/core_ui/plugin.json create mode 100644 plugins/core_ui/templates/core_ui/_media_macros.html create mode 100644 plugins/core_ui/templates/core_ui/media_styles.html create mode 100644 plugins/growlog/__init__.py create mode 100644 plugins/growlog/forms.py create mode 100644 plugins/growlog/models.py create mode 100644 plugins/growlog/plugin.json create mode 100644 plugins/growlog/routes.py create mode 100644 plugins/growlog/templates/growlog/log_form.html create mode 100644 plugins/growlog/templates/growlog/log_list.html create mode 100644 plugins/media/__init__.py create mode 100644 plugins/media/forms.py create mode 100644 plugins/media/models.py create mode 100644 plugins/media/plugin.json create mode 100644 plugins/media/routes.py create mode 100644 plugins/media/templates/media/list.html create mode 100644 plugins/media/templates/media/upload.html create mode 100644 plugins/plant/__init__.py create mode 100644 plugins/plant/forms.py create mode 100644 plugins/plant/models.py create mode 100644 plugins/plant/routes.py create mode 100644 plugins/plant/templates/plant/detail.html create mode 100644 plugins/plant/templates/plant/form.html create mode 100644 plugins/plant/templates/plant/index.html create mode 100644 plugins/search/__init__.py create mode 100644 plugins/search/forms.py create mode 100644 plugins/search/models.py create mode 100644 plugins/search/plugin.json create mode 100644 plugins/search/routes.py create mode 100644 plugins/search/templates/search/search.html create mode 100644 plugins/search/templates/search/search_results.html create mode 100644 plugins/search/templates/search/search_tags.html diff --git a/.env.example b/.env.example index ce8a244..d8c9ff0 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,10 @@ # Environment toggles USE_REMOTE_MYSQL=0 +ENABLE_DB_PRELOAD=0 ENABLE_DB_SEEDING=1 SEED_EXTRA_DATA=false +UPLOAD_FOLDER=app/static/uploads +SECRET_KEY=supersecretplantappkey # MySQL configuration MYSQL_HOST=db diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..e69de29 diff --git a/Makefile b/Makefile index e9fd655..3522504 100644 --- a/Makefile +++ b/Makefile @@ -44,6 +44,9 @@ rebuild: $(DOCKER_COMPOSE) up --build -d @$(MAKE) wait +preload: + @docker exec -it $$(docker ps -qf "name=$(PROJECT_NAME)-web") flask preload-data + logs: $(DOCKER_COMPOSE) logs -f @@ -77,7 +80,14 @@ test: wait: @echo "[⏳] Waiting for web container to be healthy..." - @until [ "$$(docker inspect -f '{{.State.Health.Status}}' $(PROJECT_NAME)-web-1 2>/dev/null)" = "healthy" ]; do \ - printf "."; sleep 2; \ - done - @echo "\n[✅] Web container is healthy!" + @timeout 90 bash -c '\ + WEB_CONTAINER=$$($(DOCKER_COMPOSE) ps -q web); \ + if [ -z "$$WEB_CONTAINER" ]; then \ + echo "[❌] Could not detect web container!"; \ + exit 1; \ + fi; \ + echo "[ℹ] Detected container: $$WEB_CONTAINER"; \ + while [ "$$(docker inspect -f "{{.State.Health.Status}}" $$WEB_CONTAINER 2>/dev/null)" != "healthy" ]; do \ + sleep 2; echo -n "."; \ + done; echo "\n[✅] $$WEB_CONTAINER is healthy!"' + diff --git a/README.md b/README.md index 36bb035..c8800fb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,47 @@ -# Plant Price Tracker +# Nature In Pots – Modular Flask Plant Management App -Initial setup for the community plant tracking app. +This project is a modular, plugin-driven Flask application for tracking plants, their health, lineage, pricing, media, and more. + +--- + +## 📦 Core Features + +- 🔐 User Authentication (`auth`) +- 🌱 Plant Identity Profiles (`plant`) +- 📊 Grow Logs with Events (`growlog`) +- 🖼 Media Upload & Attachments (`media`) +- 🔍 Tag-based and name-based Search (`search`) +- 🛠 CLI Tools for Preloading and Seeding (`cli`) +- 🎨 UI Macros and Shared Layout (`core_ui`) + +--- + +## 🧱 Plugin System + +Each feature is implemented as a plugin under the `plugins/` directory. Each plugin is self-contained and includes: + +- `models.py` +- `routes.py` +- `forms.py` *(if applicable)* +- `templates/` +- `plugin.json` + +Plugins are auto-loaded via `app/__init__.py`. + +--- + +## 🏗 Installation + +### Prerequisites + +- Python 3.11+ +- MySQL server +- Virtualenv or Docker + +### Setup + +```bash +make install # Install dependencies +make dev # Start Flask development server +make db # Initialize database +make seed # Seed database with test data diff --git a/app/__init__.py b/app/__init__.py index 4bedb52..30588eb 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,5 @@ +import os +import importlib.util from flask import Flask from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate @@ -10,28 +12,33 @@ login_manager = LoginManager() def create_app(): app = Flask(__name__) - app.config.from_object('config.Config') + app.config.from_object('app.config.Config') # Initialize core extensions db.init_app(app) migrate.init_app(app, db) login_manager.init_app(app) - login_manager.login_view = 'auth.login' # Optional: redirect for @login_required + login_manager.login_view = 'auth.login' # Redirect for @login_required - # Register Blueprints - from .core.routes import core - from .core.auth import auth - app.register_blueprint(core) - app.register_blueprint(auth, url_prefix="/auth") + # Register error handlers + from .errors import bp as errors_bp + app.register_blueprint(errors_bp) - # Register CLI commands - from .cli import seed_admin - app.cli.add_command(seed_admin) + # Plugin auto-loader + plugin_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'plugins')) + for plugin in os.listdir(plugin_path): + full_path = os.path.join(plugin_path, plugin, 'routes.py') + if os.path.isfile(full_path): + spec = importlib.util.spec_from_file_location(f"plugins.{plugin}.routes", full_path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + if hasattr(mod, 'bp'): + app.register_blueprint(mod.bp) return app @login_manager.user_loader def load_user(user_id): - from .core.models import User + from plugins.auth.models import User return User.query.get(int(user_id)) diff --git a/app/cli.py b/app/cli.py deleted file mode 100644 index 170975d..0000000 --- a/app/cli.py +++ /dev/null @@ -1,35 +0,0 @@ -import click -from flask.cli import with_appcontext -from . import db -from .core.models import User -import os - -@click.command('seed-admin') -@with_appcontext -def seed_admin(): - """Seeds only the default admin user unless SEED_EXTRA_DATA=true""" - admin_email = os.getenv('ADMIN_EMAIL', 'admin@example.com') - admin_password = os.getenv('ADMIN_PASSWORD', 'admin123') - - if not User.query.filter_by(email=admin_email).first(): - click.echo(f"[✔] Creating default admin: {admin_email}") - user = User( - email=admin_email, - password_hash=admin_password, # In production, hash this - role='admin', - is_verified=True - ) - db.session.add(user) - db.session.commit() - else: - click.echo("[ℹ] Admin user already exists.") - - if os.getenv("SEED_EXTRA_DATA", "false").lower() == "true": - click.echo("[ℹ] SEED_EXTRA_DATA=true, seeding additional demo content...") - seed_extra_data() - else: - click.echo("[✔] Admin-only seed complete. Skipping extras.") - -def seed_extra_data(): - # Placeholder for future extended seed logic - pass diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..76e357f --- /dev/null +++ b/app/config.py @@ -0,0 +1,27 @@ +import os + +class Config: + SECRET_KEY = os.environ['SECRET_KEY'] + UPLOAD_FOLDER = os.environ['UPLOAD_FOLDER'] + MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH', 16 * 1024 * 1024)) # default 16MB + ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} + + # MySQL connection parameters from .env + MYSQL_USER = os.environ['MYSQL_USER'] + MYSQL_PASSWORD = os.environ['MYSQL_PASSWORD'] + MYSQL_HOST = os.environ['MYSQL_HOST'] + MYSQL_PORT = os.environ.get('MYSQL_PORT', 3306) + MYSQL_DB = os.environ['MYSQL_DATABASE'] + + # Build the SQLAlchemy database URI + SQLALCHEMY_DATABASE_URI = ( + f"mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}" + f"@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DB}" + ) + + SQLALCHEMY_TRACK_MODIFICATIONS = False + + # Optional toggles + ENABLE_DB_PRELOAD = os.environ.get('ENABLE_DB_PRELOAD', '0') == '1' + ENABLE_DB_SEEDING = os.environ.get('ENABLE_DB_SEEDING', '0') == '1' + SEED_EXTRA_DATA = os.environ.get('SEED_EXTRA_DATA', 'false').lower() == 'true' diff --git a/app/core/auth.py b/app/core/auth.py deleted file mode 100644 index 9e5cdb8..0000000 --- a/app/core/auth.py +++ /dev/null @@ -1,38 +0,0 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash -from werkzeug.security import generate_password_hash, check_password_hash -from flask_login import login_user, logout_user, login_required, current_user -from .. import db -from .models import User - -auth = Blueprint('auth', __name__) - -@auth.route('/login', methods=['GET', 'POST']) -def login(): - if request.method == 'POST': - user = User.query.filter_by(email=request.form['email']).first() - if user and check_password_hash(user.password_hash, request.form['password']): - login_user(user) - return redirect(url_for('core.index')) - flash('Invalid email or password.') - return render_template('auth/login.html') - -@auth.route('/register', methods=['GET', 'POST']) -def register(): - if request.method == 'POST': - email = request.form['email'] - password = request.form['password'] - if User.query.filter_by(email=email).first(): - flash('Email already registered.') - else: - user = User(email=email, password_hash=generate_password_hash(password)) - db.session.add(user) - db.session.commit() - flash('Account created, please log in.') - return redirect(url_for('auth.login')) - return render_template('auth/register.html') - -@auth.route('/logout') -@login_required -def logout(): - logout_user() - return redirect(url_for('core.index')) diff --git a/app/core/routes.py b/app/core/routes.py deleted file mode 100644 index 8725a16..0000000 --- a/app/core/routes.py +++ /dev/null @@ -1,145 +0,0 @@ -from flask import Blueprint, render_template, request, jsonify, redirect, url_for, flash -from datetime import datetime -from flask_login import login_required, current_user -from sqlalchemy import and_ -from markupsafe import escape -from werkzeug.utils import secure_filename -import os - -from .. import db -from .models import Submission, SubmissionImage - -core = Blueprint('core', __name__) - -UPLOAD_FOLDER = 'app/static/uploads' -ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg'} - -def allowed_file(filename): - return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS - -@core.route("/health") -def health_check(): - return "OK", 200 - -@core.route('/') -def index(): - submissions = Submission.query.order_by(Submission.timestamp.desc()).limit(6).all() - use_placeholder = len(submissions) == 0 - return render_template('index.html', submissions=submissions, use_placeholder=use_placeholder, current_year=datetime.now().year) - -@core.route('/dashboard') -@login_required -def user_dashboard(): - return render_template('dashboard.html', current_year=datetime.now().year) - -@core.route('/admin') -@login_required -def admin_dashboard(): - if current_user.role != 'admin': - return render_template('unauthorized.html'), 403 - return render_template('admin_dashboard.html', current_year=datetime.now().year) - -@core.route('/search', methods=['GET', 'POST']) -def search(): - query = escape(request.values.get('q', '').strip()) - source = escape(request.values.get('source', '').strip()) - min_price = request.values.get('min_price', type=float) - max_price = request.values.get('max_price', type=float) - from_date = request.values.get('from_date') - to_date = request.values.get('to_date') - - if request.method == 'POST' and request.is_json: - removed = request.json.get('remove') - if removed == "Name": - query = "" - elif removed == "Source": - source = "" - elif removed == "Min Price": - min_price = None - elif removed == "Max Price": - max_price = None - elif removed == "From": - from_date = None - elif removed == "To": - to_date = None - - filters = [] - filter_tags = {} - - if query: - filters.append(Submission.common_name.ilike(f"%{query}%")) - filter_tags['Name'] = query - if source: - filters.append(Submission.source.ilike(f"%{source}%")) - filter_tags['Source'] = source - if min_price is not None: - filters.append(Submission.price >= min_price) - filter_tags['Min Price'] = f"${min_price:.2f}" - if max_price is not None: - filters.append(Submission.price <= max_price) - filter_tags['Max Price'] = f"${max_price:.2f}" - if from_date: - filters.append(Submission.timestamp >= from_date) - filter_tags['From'] = from_date - if to_date: - filters.append(Submission.timestamp <= to_date) - filter_tags['To'] = to_date - - results = Submission.query.filter(and_(*filters)).order_by(Submission.timestamp.desc()).limit(50).all() - - if request.is_json: - return jsonify({ - "results_html": render_template("search_results.html", results=results), - "tags_html": render_template("search_tags.html", filter_tags=filter_tags) - }) - - return render_template('search.html', results=results, filter_tags=filter_tags) - -@core.route('/submit', methods=['GET', 'POST']) -@login_required -def submit(): - if request.method == 'POST': - common_name = escape(request.form.get('common_name', '').strip()) - scientific_name = escape(request.form.get('scientific_name', '').strip()) - price = request.form.get('price', type=float) - source = escape(request.form.get('source', '').strip()) - timestamp = request.form.get('timestamp') or datetime.utcnow() - height = request.form.get('height', type=float) - width = request.form.get('width', type=float) - leaf_count = request.form.get('leaf_count', type=int) - potting_mix = escape(request.form.get('potting_mix', '').strip()) - container_size = escape(request.form.get('container_size', '').strip()) - health_status = escape(request.form.get('health_status', '').strip()) - notes = escape(request.form.get('notes', '').strip()) - - new_submission = Submission( - user_id=current_user.id, - common_name=common_name, - scientific_name=scientific_name, - price=price, - source=source, - timestamp=timestamp, - height=height, - width=width, - leaf_count=leaf_count, - potting_mix=potting_mix, - container_size=container_size, - health_status=health_status, - notes=notes, - ) - db.session.add(new_submission) - db.session.commit() - - files = request.files.getlist('images') - for f in files[:5]: - if f and allowed_file(f.filename): - filename = secure_filename(f.filename) - save_path = os.path.join(UPLOAD_FOLDER, filename) - f.save(save_path) - db.session.add(SubmissionImage(submission_id=new_submission.id, file_path=save_path)) - - db.session.commit() - flash("Submission received!", "success") - return redirect(url_for('core.index')) - - return render_template('submit.html') diff --git a/app/errors.py b/app/errors.py new file mode 100644 index 0000000..e7419fb --- /dev/null +++ b/app/errors.py @@ -0,0 +1,11 @@ +from flask import render_template, Blueprint + +bp = Blueprint('errors', __name__) + +@bp.app_errorhandler(400) +def bad_request(error): + return render_template('400.html'), 400 + +@bp.app_errorhandler(500) +def internal_error(error): + return render_template('500.html'), 500 diff --git a/app/templates/404.html b/app/templates/404.html new file mode 100644 index 0000000..3b4f245 --- /dev/null +++ b/app/templates/404.html @@ -0,0 +1 @@ +

404 ERROR

Something went wrong.

\ No newline at end of file diff --git a/app/templates/500.html b/app/templates/500.html new file mode 100644 index 0000000..4b351ce --- /dev/null +++ b/app/templates/500.html @@ -0,0 +1 @@ +

500 ERROR

Something went wrong.

\ No newline at end of file diff --git a/app/templates/admin_dashboard.html b/app/templates/admin_dashboard.html deleted file mode 100644 index df80656..0000000 --- a/app/templates/admin_dashboard.html +++ /dev/null @@ -1,5 +0,0 @@ -{% extends 'base.html' %} -{% block content %} -

Admin Dashboard

-

You are logged in as an administrator.

-{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html deleted file mode 100644 index 4ffe201..0000000 --- a/app/templates/base.html +++ /dev/null @@ -1,98 +0,0 @@ - - - - - Nature In Pots Community - - - - - - -
- {% with messages = get_flashed_messages() %} - {% if messages %} -
- {% for message in messages %} -
{{ message }}
- {% endfor %} -
- {% endif %} - {% endwith %} - {% block content %}{% endblock %} -
- - - - - - diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html deleted file mode 100644 index 8d6fd75..0000000 --- a/app/templates/dashboard.html +++ /dev/null @@ -1,5 +0,0 @@ -{% extends 'base.html' %} -{% block content %} -

User Dashboard

-

Welcome to your dashboard, {{ current_user.email }}.

-{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html deleted file mode 100644 index d399ed1..0000000 --- a/app/templates/index.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends 'base.html' %} -{% block content %} -

Welcome to the Plant Price Tracker!

- -
- {% if use_placeholder %} - {% for i in range(6) %} -
-
- Placeholder Plant {{ i+1 }} -
-
Placeholder Plant {{ i+1 }}
-

No real submissions yet — submit a plant to see it here.

-
-
-
- {% endfor %} - {% else %} - {% for sub in submissions %} -
-
- {{ sub.common_name }} -
-
{{ sub.common_name }}
-

- ${{ '%.2f'|format(sub.price) }}
- Source: {{ sub.source }}
- Date: {{ sub.timestamp.strftime('%Y-%m-%d') }} -

-
-
-
- {% endfor %} - {% endif %} -
-{% endblock %} diff --git a/app/templates/search.html b/app/templates/search.html deleted file mode 100644 index c405e23..0000000 --- a/app/templates/search.html +++ /dev/null @@ -1,34 +0,0 @@ -{% extends 'base.html' %} -{% block content %} -

Search Results

- -
- {% include 'search_tags.html' %} -
- -
- {% include 'search_results.html' %} -
- - -{% endblock %} diff --git a/app/templates/search_results.html b/app/templates/search_results.html deleted file mode 100644 index bf1d102..0000000 --- a/app/templates/search_results.html +++ /dev/null @@ -1,21 +0,0 @@ -{% if results %} -
- {% for sub in results %} -
-
- {{ sub.common_name }} -
-
{{ sub.common_name }}
-

- ${{ '%.2f'|format(sub.price) }}
- Source: {{ sub.source }}
- Date: {{ sub.timestamp.strftime('%Y-%m-%d') }} -

-
-
-
- {% endfor %} -
-{% else %} -

No results found.

-{% endif %} diff --git a/app/templates/search_tags.html b/app/templates/search_tags.html deleted file mode 100644 index d5a7993..0000000 --- a/app/templates/search_tags.html +++ /dev/null @@ -1,15 +0,0 @@ - -{% if filter_tags %} -
- Filters: - {% for key, value in filter_tags.items() %} - - {{ key }}: {{ value }} - - - {% endfor %} - -
-{% endif %} diff --git a/app/templates/submit.html b/app/templates/submit.html deleted file mode 100644 index 0552bbd..0000000 --- a/app/templates/submit.html +++ /dev/null @@ -1,65 +0,0 @@ -{% extends 'base.html' %} -{% block content %} -

Submit a New Plant

-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-
-{% endblock %} diff --git a/app/templates/unauthorized.html b/app/templates/unauthorized.html deleted file mode 100644 index 811a86c..0000000 --- a/app/templates/unauthorized.html +++ /dev/null @@ -1,5 +0,0 @@ -{% extends 'base.html' %} -{% block content %} -

Unauthorized

-

You do not have permission to access this page.

-{% endblock %} diff --git a/config.py b/config.py deleted file mode 100644 index 267be88..0000000 --- a/config.py +++ /dev/null @@ -1,17 +0,0 @@ -import os - -class Config: - SECRET_KEY = os.getenv('SECRET_KEY', 'dev') - - DB_USER = os.getenv('MYSQL_USER', 'plant_user') - DB_PASSWORD = os.getenv('MYSQL_PASSWORD', 'plant_pass') - DB_HOST = os.getenv('MYSQL_HOST', 'db') - DB_PORT = os.getenv('MYSQL_PORT', '3306') - DB_NAME = os.getenv('MYSQL_DATABASE', 'plant_db') - - SQLALCHEMY_DATABASE_URI = os.getenv( - 'DATABASE_URL', - f'mysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}' - ) - - SQLALCHEMY_TRACK_MODIFICATIONS = False diff --git a/docker-compose.yml b/docker-compose.yml index d1d7956..8c79e5d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - FLASK_APP=app - FLASK_ENV=development - USE_REMOTE_MYSQL=${USE_REMOTE_MYSQL} + - ENABLE_DB_PRELOAD=${ENABLE_DB_PRELOAD} - ENABLE_DB_SEEDING=${ENABLE_DB_SEEDING} - MYSQL_DATABASE=${MYSQL_DATABASE} - MYSQL_USER=${MYSQL_USER} @@ -18,10 +19,12 @@ services: depends_on: - db healthcheck: - test: ["CMD", "curl", "-f", "http://127.0.0.1:5000/health"] - interval: 30s - timeout: 10s - retries: 5 + test: ["CMD-SHELL", "curl -fs http://127.0.0.1:5000/health || exit 1"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 30s + command: > bash -c " set -e @@ -43,6 +46,12 @@ services: echo '[✔] Running DB migrations...' flask db upgrade + + if [ "$$ENABLE_DB_PRELOAD" = "1" ]; then + echo '[📥] Preloading data...'; flask preload-data; + else + echo '[⚠️] Skipping preload...'; + fi if [ \"$$ENABLE_DB_SEEDING\" = \"1\" ]; then echo '[🌱] Seeding admin user...' diff --git a/nuke-dev.sh b/nuke-dev.sh deleted file mode 100755 index 1e8ca9a..0000000 --- a/nuke-dev.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash - -echo "⚠️ This will DELETE your containers, SQLite DB, Alembic migrations, cache, uploads, logs." -read -p "Continue? (y/N): " confirm - -if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then - echo "❌ Aborted." - exit 1 -fi - -echo "🛑 Stopping and removing containers and volumes..." -docker compose down --volumes --remove-orphans - -echo "🗑 Removing mounted DB file..." -rm -f ./app/app.db - -echo "🧹 Removing Alembic migrations..." -sudo rm -rf migrations/ - -echo "🧼 Removing Python cache and compiled files..." -sudo find . -type d -name '__pycache__' -exec rm -rf {} + -sudo find . -name '*.pyc' -delete -sudo find . -name '*.pyo' -delete - -echo "🧽 Removing static uploads and logs..." -rm -rf app/static/uploads/* -rm -rf logs/ - -echo "🔨 Rebuilding containers..." -docker compose build --no-cache - -echo "🚀 Starting up..." -docker compose up --force-recreate diff --git a/app/core/models.py b/plugins/auth/models.py similarity index 99% rename from app/core/models.py rename to plugins/auth/models.py index 6465a2a..b0eabd6 100644 --- a/app/core/models.py +++ b/plugins/auth/models.py @@ -1,6 +1,6 @@ from flask_login import UserMixin from datetime import datetime -from .. import db +from app import db class User(db.Model, UserMixin): __tablename__ = 'users' diff --git a/plugins/auth/routes.py b/plugins/auth/routes.py new file mode 100644 index 0000000..379aa21 --- /dev/null +++ b/plugins/auth/routes.py @@ -0,0 +1,28 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash +from flask_login import login_user, logout_user, login_required +from werkzeug.security import check_password_hash +from app import db +from .models import User + +auth = Blueprint('auth', __name__) + +@auth.route('/auth/login', methods=['GET', 'POST']) +def login(): + if request.method == 'POST': + email = request.form['email'] + password = request.form['password'] + user = User.query.filter_by(email=email).first() + if user and check_password_hash(user.password_hash, password): + login_user(user) + flash('Logged in successfully.', 'success') + return redirect(url_for('core.user_dashboard')) + else: + flash('Invalid credentials.', 'danger') + return render_template('login.html') + +@auth.route('/auth/logout') +@login_required +def logout(): + logout_user() + flash('Logged out.', 'info') + return redirect(url_for('core.index')) diff --git a/app/templates/auth/login.html b/plugins/auth/templates/auth/login.html similarity index 100% rename from app/templates/auth/login.html rename to plugins/auth/templates/auth/login.html diff --git a/app/templates/auth/register.html b/plugins/auth/templates/auth/register.html similarity index 100% rename from app/templates/auth/register.html rename to plugins/auth/templates/auth/register.html diff --git a/plugins/cli/__init__.py b/plugins/cli/__init__.py new file mode 100644 index 0000000..e25577c --- /dev/null +++ b/plugins/cli/__init__.py @@ -0,0 +1,2 @@ +from .seed import seed_admin +from .preload import preload_data diff --git a/plugins/cli/preload.py b/plugins/cli/preload.py new file mode 100644 index 0000000..458f495 --- /dev/null +++ b/plugins/cli/preload.py @@ -0,0 +1,79 @@ +import click +from flask.cli import with_appcontext +from datetime import datetime +from app import db +from app.models.user import ( + User, Plant, PlantCommonName, PlantScientificName, Submission, SubmissionImage +) + +@click.command("preload-data") +@with_appcontext +def preload_data(): + click.echo("[📦] Preloading demo data...") + + if not User.query.filter_by(email="demo@example.com").first(): + demo_user = User( + email="demo@example.com", + password_hash="fakehash123", # you can hash a real one if needed + role="user", + is_verified=True + ) + db.session.add(demo_user) + db.session.commit() + + common_entries = { + "Monstera Albo": "Monstera deliciosa 'Albo-Variegata'", + "Philodendron Pink Princess": "Philodendron erubescens", + "Cebu Blue Pothos": "Epipremnum pinnatum" + } + + for common_name, sci_name in common_entries.items(): + common = PlantCommonName.query.filter_by(name=common_name).first() + if not common: + common = PlantCommonName(name=common_name) + db.session.add(common) + + scientific = PlantScientificName.query.filter_by(name=sci_name).first() + if not scientific: + scientific = PlantScientificName(name=sci_name) + db.session.add(scientific) + + db.session.commit() + + common_map = {c.name: c.id for c in PlantCommonName.query.all()} + scientific_map = {s.name: s.id for s in PlantScientificName.query.all()} + demo_user = User.query.filter_by(email="demo@example.com").first() + + plants = [] + for i in range(1, 4): + plant = Plant( + common_name_id=common_map["Monstera Albo"], + scientific_name_id=scientific_map["Monstera deliciosa 'Albo-Variegata'"], + created_by_user_id=demo_user.id + ) + db.session.add(plant) + plants.append(plant) + + db.session.commit() + + for plant in plants: + for i in range(2): + submission = Submission( + user_id=demo_user.id, + plant_id=plant.id, + common_name="Monstera Albo", + scientific_name="Monstera deliciosa 'Albo-Variegata'", + price=85.0 + i * 15, + source="Etsy", + height=12 + i * 2, + width=10 + i, + potting_mix="Pumice:Bark:Coco 2:1:1", + container_size="4 inch", + health_status="Healthy", + notes="Good variegation", + timestamp=datetime.utcnow() + ) + db.session.add(submission) + + db.session.commit() + click.echo("[✅] Demo data loaded.") diff --git a/plugins/cli/seed.py b/plugins/cli/seed.py new file mode 100644 index 0000000..cea40b7 --- /dev/null +++ b/plugins/cli/seed.py @@ -0,0 +1,26 @@ +import click +from flask.cli import with_appcontext +from werkzeug.security import generate_password_hash +from ..core.models import User +from .. import db + +@click.command("seed-admin") +@with_appcontext +def seed_admin(): + """Seed a default admin user if none exists.""" + admin_email = "admin@example.com" + admin_password = "admin123" + + if User.query.filter_by(email=admin_email).first(): + click.echo("[ℹ] Admin user already exists.") + return + + user = User( + email=admin_email, + password_hash=generate_password_hash(admin_password), + role="admin", + is_verified=True + ) + db.session.add(user) + db.session.commit() + click.echo(f"[✔] Created default admin: {admin_email}") diff --git a/plugins/core_ui/__init__.py b/plugins/core_ui/__init__.py new file mode 100644 index 0000000..e2c485a --- /dev/null +++ b/plugins/core_ui/__init__.py @@ -0,0 +1 @@ +# core_ui media patch \ No newline at end of file diff --git a/plugins/core_ui/plugin.json b/plugins/core_ui/plugin.json new file mode 100644 index 0000000..9c4b841 --- /dev/null +++ b/plugins/core_ui/plugin.json @@ -0,0 +1 @@ +{ "name": "core_ui", "version": "1.1", "description": "Media rendering macros and styling helpers" } \ No newline at end of file diff --git a/plugins/core_ui/templates/core_ui/_media_macros.html b/plugins/core_ui/templates/core_ui/_media_macros.html new file mode 100644 index 0000000..774d214 --- /dev/null +++ b/plugins/core_ui/templates/core_ui/_media_macros.html @@ -0,0 +1,14 @@ +{% macro render_media_list(media_list, thumb_width=150) -%} + {% if media_list %} + + {% endif %} +{%- endmacro %} \ No newline at end of file diff --git a/plugins/core_ui/templates/core_ui/media_styles.html b/plugins/core_ui/templates/core_ui/media_styles.html new file mode 100644 index 0000000..6761500 --- /dev/null +++ b/plugins/core_ui/templates/core_ui/media_styles.html @@ -0,0 +1,18 @@ + \ No newline at end of file diff --git a/plugins/growlog/__init__.py b/plugins/growlog/__init__.py new file mode 100644 index 0000000..9427b39 --- /dev/null +++ b/plugins/growlog/__init__.py @@ -0,0 +1 @@ +# growlog plugin init \ No newline at end of file diff --git a/plugins/growlog/forms.py b/plugins/growlog/forms.py new file mode 100644 index 0000000..ddf1bd7 --- /dev/null +++ b/plugins/growlog/forms.py @@ -0,0 +1,15 @@ +from flask_wtf import FlaskForm +from wtforms import TextAreaField, SelectField, SubmitField +from wtforms.validators import DataRequired, Length + +class GrowLogForm(FlaskForm): + event_type = SelectField('Event Type', choices=[ + ('water', 'Watered'), + ('fertilizer', 'Fertilized'), + ('repot', 'Repotted'), + ('note', 'Note'), + ('pest', 'Pest Observed') + ], validators=[DataRequired()]) + + note = TextAreaField('Notes', validators=[Length(max=1000)]) + submit = SubmitField('Add Log') \ No newline at end of file diff --git a/plugins/growlog/models.py b/plugins/growlog/models.py new file mode 100644 index 0000000..fc0f8fd --- /dev/null +++ b/plugins/growlog/models.py @@ -0,0 +1,15 @@ +from app import db +from datetime import datetime + +class GrowLog(db.Model): + __tablename__ = 'grow_logs' + id = db.Column(db.Integer, primary_key=True) + plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False) + timestamp = db.Column(db.DateTime, default=datetime.utcnow) + event_type = db.Column(db.String(64), nullable=False) + note = db.Column(db.Text) + + media = db.relationship("Media", backref="growlog", lazy=True) + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/plugins/growlog/plugin.json b/plugins/growlog/plugin.json new file mode 100644 index 0000000..3e8bb76 --- /dev/null +++ b/plugins/growlog/plugin.json @@ -0,0 +1 @@ +{ "name": "growlog", "version": "1.0", "description": "Tracks time-based plant care logs" } \ No newline at end of file diff --git a/plugins/growlog/routes.py b/plugins/growlog/routes.py new file mode 100644 index 0000000..5987941 --- /dev/null +++ b/plugins/growlog/routes.py @@ -0,0 +1,31 @@ +from flask import Blueprint, render_template, redirect, url_for, request +from flask_login import login_required +from app import db +from .models import GrowLog +from .forms import GrowLogForm +from plugins.plant.models import Plant + +bp = Blueprint('growlog', __name__, template_folder='templates') + +@bp.route('/plants//logs') +@login_required +def view_logs(plant_id): + plant = Plant.query.get_or_404(plant_id) + logs = GrowLog.query.filter_by(plant_id=plant.id).order_by(GrowLog.timestamp.desc()).all() + return render_template('growlog/log_list.html', plant=plant, logs=logs) + +@bp.route('/plants//logs/add', methods=['GET', 'POST']) +@login_required +def add_log(plant_id): + plant = Plant.query.get_or_404(plant_id) + form = GrowLogForm() + if form.validate_on_submit(): + log = GrowLog( + plant_id=plant.id, + event_type=form.event_type.data, + note=form.note.data + ) + db.session.add(log) + db.session.commit() + return redirect(url_for('growlog.view_logs', plant_id=plant.id)) + return render_template('growlog/log_form.html', form=form, plant=plant) \ No newline at end of file diff --git a/plugins/growlog/templates/growlog/log_form.html b/plugins/growlog/templates/growlog/log_form.html new file mode 100644 index 0000000..0f0424b --- /dev/null +++ b/plugins/growlog/templates/growlog/log_form.html @@ -0,0 +1,10 @@ +{% extends 'base.html' %} +{% block content %} +

Add Log for Plant #{{ plant.id }}

+
+ {{ form.hidden_tag() }} +

{{ form.event_type.label }}
{{ form.event_type() }}

+

{{ form.note.label }}
{{ form.note(rows=4) }}

+

{{ form.submit() }}

+
+{% endblock %} \ No newline at end of file diff --git a/plugins/growlog/templates/growlog/log_list.html b/plugins/growlog/templates/growlog/log_list.html new file mode 100644 index 0000000..6f812de --- /dev/null +++ b/plugins/growlog/templates/growlog/log_list.html @@ -0,0 +1,25 @@ +{% import 'core_ui/_media_macros.html' as media %} +{% extends 'base.html' %} +{% block content %} +

Logs for Plant #{{ plant.id }}

+Add New Log +
    + {% for log in logs %} +
  • + {{ log.timestamp.strftime('%Y-%m-%d') }}: {{ log.event_type }} - {{ log.note }} + {% if log.media %} +
    Images: +
      + {% for image in log.media %} +
    • +
      + {{ image.caption or "No caption" }} +
    • + {% endfor %} +
    + {% endif %} +
  • + {% endfor %} +
+{{ media.render_media_list(log.media) }} +{% endblock %} \ No newline at end of file diff --git a/plugins/media/__init__.py b/plugins/media/__init__.py new file mode 100644 index 0000000..e2fbe2d --- /dev/null +++ b/plugins/media/__init__.py @@ -0,0 +1 @@ +# media plugin init \ No newline at end of file diff --git a/plugins/media/forms.py b/plugins/media/forms.py new file mode 100644 index 0000000..08c5d5f --- /dev/null +++ b/plugins/media/forms.py @@ -0,0 +1,14 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, SelectField, SubmitField, IntegerField +from flask_wtf.file import FileField, FileAllowed, FileRequired +from wtforms.validators import DataRequired + +class MediaUploadForm(FlaskForm): + image = FileField('Image', validators=[ + FileRequired(), + FileAllowed(['jpg', 'jpeg', 'png', 'gif'], 'Images only!') + ]) + caption = StringField('Caption') + plant_id = IntegerField('Plant ID') + growlog_id = IntegerField('GrowLog ID') + submit = SubmitField('Upload') \ No newline at end of file diff --git a/plugins/media/models.py b/plugins/media/models.py new file mode 100644 index 0000000..3fbbc0b --- /dev/null +++ b/plugins/media/models.py @@ -0,0 +1,12 @@ +from app import db +from datetime import datetime + +class Media(db.Model): + __tablename__ = 'media' + id = db.Column(db.Integer, primary_key=True) + file_url = db.Column(db.String(256), nullable=False) + uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) + + plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=True) + growlog_id = db.Column(db.Integer, db.ForeignKey('grow_logs.id'), nullable=True) + caption = db.Column(db.String(255), nullable=True) \ No newline at end of file diff --git a/plugins/media/plugin.json b/plugins/media/plugin.json new file mode 100644 index 0000000..1fd4b13 --- /dev/null +++ b/plugins/media/plugin.json @@ -0,0 +1 @@ +{ "name": "media", "version": "1.0", "description": "Upload and attach media to plants and grow logs" } \ No newline at end of file diff --git a/plugins/media/routes.py b/plugins/media/routes.py new file mode 100644 index 0000000..2e964a3 --- /dev/null +++ b/plugins/media/routes.py @@ -0,0 +1,42 @@ +import os +import uuid +from flask import Blueprint, render_template, redirect, url_for, request, current_app, flash +from flask_login import login_required +from werkzeug.utils import secure_filename +from app import db +from .models import Media +from .forms import MediaUploadForm + +bp = Blueprint('media', __name__, template_folder='templates') + +@bp.route('/media/upload', methods=['GET', 'POST']) +@login_required +def upload_media(): + form = MediaUploadForm() + if form.validate_on_submit(): + file = form.image.data + filename = f"{uuid.uuid4().hex}_{secure_filename(file.filename)}" + upload_path = os.path.join(current_app.config['UPLOAD_FOLDER'], filename) + file.save(upload_path) + media = Media( + file_url=filename, + caption=form.caption.data, + plant_id=form.plant_id.data or None, + growlog_id=form.growlog_id.data or None + ) + db.session.add(media) + db.session.commit() + flash("Image uploaded successfully.", "success") + return redirect(url_for('media.upload_media')) + return render_template('media/upload.html', form=form) + +@bp.route('/media') +@login_required +def list_media(): + images = Media.query.order_by(Media.uploaded_at.desc()).all() + return render_template('media/list.html', images=images) + +@bp.route('/media/files/') +def media_file(filename): + from flask import send_from_directory + return send_from_directory(current_app.config['UPLOAD_FOLDER'], filename) \ No newline at end of file diff --git a/plugins/media/templates/media/list.html b/plugins/media/templates/media/list.html new file mode 100644 index 0000000..af7bfb7 --- /dev/null +++ b/plugins/media/templates/media/list.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} +{% block content %} +

All Uploaded Media

+
    + {% for image in images %} +
  • + {{ image.caption }}
    + {{ image.caption or "No caption" }} + {% if image.plant_id %}
    Plant ID: {{ image.plant_id }}{% endif %} + {% if image.growlog_id %}
    GrowLog ID: {{ image.growlog_id }}{% endif %} +
  • + {% endfor %} +
+{% endblock %} \ No newline at end of file diff --git a/plugins/media/templates/media/upload.html b/plugins/media/templates/media/upload.html new file mode 100644 index 0000000..9190e9a --- /dev/null +++ b/plugins/media/templates/media/upload.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} +{% block content %} +

Upload Media

+
+ {{ form.hidden_tag() }} +

{{ form.image.label }}
{{ form.image() }}

+

{{ form.caption.label }}
{{ form.caption(size=40) }}

+

{{ form.plant_id.label }}
{{ form.plant_id() }}

+

{{ form.growlog_id.label }}
{{ form.growlog_id() }}

+

{{ form.submit() }}

+
+{% endblock %} \ No newline at end of file diff --git a/plugins/plant/__init__.py b/plugins/plant/__init__.py new file mode 100644 index 0000000..587a922 --- /dev/null +++ b/plugins/plant/__init__.py @@ -0,0 +1 @@ +# plant plugin init diff --git a/plugins/plant/forms.py b/plugins/plant/forms.py new file mode 100644 index 0000000..140627a --- /dev/null +++ b/plugins/plant/forms.py @@ -0,0 +1,10 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, TextAreaField, BooleanField, SubmitField +from wtforms.validators import DataRequired + +class PlantForm(FlaskForm): + name = StringField('Name', validators=[DataRequired()]) + type = StringField('Type') + notes = TextAreaField('Notes') + is_active = BooleanField('Active', default=True) + submit = SubmitField('Save') diff --git a/plugins/plant/models.py b/plugins/plant/models.py new file mode 100644 index 0000000..a61fa3c --- /dev/null +++ b/plugins/plant/models.py @@ -0,0 +1,11 @@ +from datetime import datetime +from app import db + +class Plant(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128), nullable=False) + type = db.Column(db.String(64)) + notes = db.Column(db.Text) + is_active = db.Column(db.Boolean, default=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/plugins/plant/routes.py b/plugins/plant/routes.py new file mode 100644 index 0000000..482394a --- /dev/null +++ b/plugins/plant/routes.py @@ -0,0 +1,43 @@ +from flask import Blueprint, render_template, redirect, url_for, request, flash +from app import db +from .models import Plant +from .forms import PlantForm + +bp = Blueprint('plant', __name__, template_folder='templates') + +@bp.route('/plants') +def index(): + plants = Plant.query.order_by(Plant.created_at.desc()).all() + return render_template('plant/index.html', plants=plants) + +@bp.route('/plants/') +def detail(plant_id): + plant = Plant.query.get_or_404(plant_id) + return render_template('plant/detail.html', plant=plant) + +@bp.route('/plants/new', methods=['GET', 'POST']) +def create(): + form = PlantForm() + if form.validate_on_submit(): + plant = Plant( + name=form.name.data, + type=form.type.data, + notes=form.notes.data, + is_active=form.is_active.data + ) + db.session.add(plant) + db.session.commit() + flash('Plant created successfully.', 'success') + return redirect(url_for('plant.index')) + return render_template('plant/form.html', form=form) + +@bp.route('/plants//edit', methods=['GET', 'POST']) +def edit(plant_id): + plant = Plant.query.get_or_404(plant_id) + form = PlantForm(obj=plant) + if form.validate_on_submit(): + form.populate_obj(plant) + db.session.commit() + flash('Plant updated successfully.', 'success') + return redirect(url_for('plant.detail', plant_id=plant.id)) + return render_template('plant/form.html', form=form, plant=plant) diff --git a/plugins/plant/templates/plant/detail.html b/plugins/plant/templates/plant/detail.html new file mode 100644 index 0000000..ef65ffb --- /dev/null +++ b/plugins/plant/templates/plant/detail.html @@ -0,0 +1,9 @@ +{% extends 'core_ui/base.html' %} +{% block content %} +

{{ plant.name }}

+

Type: {{ plant.type }}

+

{{ plant.notes }}

+

Status: {% if plant.is_active %}Active{% else %}Inactive{% endif %}

+Edit +Back to list +{% endblock %} diff --git a/plugins/plant/templates/plant/form.html b/plugins/plant/templates/plant/form.html new file mode 100644 index 0000000..214d312 --- /dev/null +++ b/plugins/plant/templates/plant/form.html @@ -0,0 +1,12 @@ +{% extends 'core_ui/base.html' %} +{% block content %} +

{% if plant %}Edit{% else %}New{% endif %} Plant

+
+ {{ form.hidden_tag() }} +

{{ form.name.label }}
{{ form.name(size=40) }}

+

{{ form.type.label }}
{{ form.type(size=40) }}

+

{{ form.notes.label }}
{{ form.notes(rows=5, cols=40) }}

+

{{ form.is_active() }} {{ form.is_active.label }}

+

{{ form.submit() }}

+
+{% endblock %} diff --git a/plugins/plant/templates/plant/index.html b/plugins/plant/templates/plant/index.html new file mode 100644 index 0000000..c44e423 --- /dev/null +++ b/plugins/plant/templates/plant/index.html @@ -0,0 +1,10 @@ +{% extends 'core_ui/base.html' %} +{% block content %} +

Plant List

+ +Add New Plant +{% endblock %} diff --git a/plugins/search/__init__.py b/plugins/search/__init__.py new file mode 100644 index 0000000..7a5dbe5 --- /dev/null +++ b/plugins/search/__init__.py @@ -0,0 +1 @@ +# search plugin initialization \ No newline at end of file diff --git a/plugins/search/forms.py b/plugins/search/forms.py new file mode 100644 index 0000000..bdee2bc --- /dev/null +++ b/plugins/search/forms.py @@ -0,0 +1,15 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, SelectMultipleField, SubmitField +from wtforms.validators import Optional, Length, Regexp + +class SearchForm(FlaskForm): + query = StringField( + 'Search', + validators=[ + Optional(), + Length(min=2, max=100, message="Search term must be between 2 and 100 characters."), + Regexp(r'^[\w\s\-]+$', message="Search can only include letters, numbers, spaces, and dashes.") + ] + ) + tags = SelectMultipleField('Tags', coerce=int) + submit = SubmitField('Search') \ No newline at end of file diff --git a/plugins/search/models.py b/plugins/search/models.py new file mode 100644 index 0000000..1c9dbf1 --- /dev/null +++ b/plugins/search/models.py @@ -0,0 +1,8 @@ +from app import db + +class Tag(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(64), unique=True, nullable=False) + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/plugins/search/plugin.json b/plugins/search/plugin.json new file mode 100644 index 0000000..00d5eb3 --- /dev/null +++ b/plugins/search/plugin.json @@ -0,0 +1 @@ +{ "name": "search", "version": "1.1", "description": "Updated search plugin with live Plant model integration" } \ No newline at end of file diff --git a/plugins/search/routes.py b/plugins/search/routes.py new file mode 100644 index 0000000..987b79f --- /dev/null +++ b/plugins/search/routes.py @@ -0,0 +1,38 @@ +from flask import Blueprint, render_template, request, jsonify +from flask_login import login_required, current_user +from app import db +from .models import Tag +from .forms import SearchForm +from plugins.plant.models import Plant + +bp = Blueprint('search', __name__, template_folder='templates') + +@bp.route('/search', methods=['GET', 'POST']) +@login_required +def search(): + form = SearchForm() + form.tags.choices = [(tag.id, tag.name) for tag in Tag.query.order_by(Tag.name).all()] + results = [] + if form.validate_on_submit(): + query = db.session.query(Plant).join(PlantScientific).join(PlantCommon) + if form.query.data: + q = f"%{form.query.data}%" + query = query.filter( + db.or_( + PlantScientific.name.ilike(q), + PlantCommon.name.ilike(q), + Plant.current_status.ilike(q) + ) + ) + if form.tags.data: + query = query.filter(Plant.tags.any(Tag.id.in_(form.tags.data))) + query = query.filter(Plant.owner_id == current_user.id) + results = query.all() + return render_template('search/search.html', form=form, results=results) + +@bp.route('/search/tags') +@login_required +def search_tags(): + term = request.args.get('term', '') + tags = Tag.query.filter(Tag.name.ilike(f"%{term}%")).limit(10).all() + return jsonify([tag.name for tag in tags]) \ No newline at end of file diff --git a/plugins/search/templates/search/search.html b/plugins/search/templates/search/search.html new file mode 100644 index 0000000..7cc8666 --- /dev/null +++ b/plugins/search/templates/search/search.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} +{% block content %} +

Search Plants

+
+ {{ form.hidden_tag() }} +

+ {{ form.query.label }}
+ {{ form.query(size=32) }} +

+

+ {{ form.tags.label }}
+ {{ form.tags(multiple=True) }} +

+

{{ form.submit() }}

+
+ +{% if results %} +

Search Results

+
    + {% for result in results %} +
  • {{ result.name }}
  • + {% endfor %} +
+{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/plugins/search/templates/search/search_results.html b/plugins/search/templates/search/search_results.html new file mode 100644 index 0000000..0f1715d --- /dev/null +++ b/plugins/search/templates/search/search_results.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} +{% block content %} +

Search Results

+{% if results %} +
    + {% for result in results %} +
  • {{ result.name }}
  • + {% endfor %} +
+{% else %} +

No results found.

+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/plugins/search/templates/search/search_tags.html b/plugins/search/templates/search/search_tags.html new file mode 100644 index 0000000..68a2309 --- /dev/null +++ b/plugins/search/templates/search/search_tags.html @@ -0,0 +1,4 @@ +{% extends 'base.html' %} +{% block content %} +

This page was replaced by AJAX functionality.

+{% endblock %} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 6648268..71b8d71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,9 @@ Flask Flask-Login Flask-Migrate Flask-SQLAlchemy -mysqlclient +Flask-WTF +WTForms +Werkzeug>=2.3.0 +pymysql python-dotenv +cryptography