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 @@ +
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 @@ +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 %} -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 @@ - - - - -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 %} -No real submissions yet — submit a plant to see it here.
-
- ${{ '%.2f'|format(sub.price) }}
- Source: {{ sub.source }}
- Date: {{ sub.timestamp.strftime('%Y-%m-%d') }}
-
- ${{ '%.2f'|format(sub.price) }}
- Source: {{ sub.source }}
- Date: {{ sub.timestamp.strftime('%Y-%m-%d') }}
-
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 %} -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 %} +{{ media.caption }}
+ {% endif %} +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 %} +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