diff --git a/.dockerignore b/.dockerignore index 8cc8923..b31d37d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,6 @@ mysql_data/ migrations/versions/ uploads/ static/uploads/ + +data/mysql_data +data/uploads diff --git a/.env.example b/.env.example index 8d78f4e..358d551 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,7 @@ NEO4J_PASSWORD=your_secure_password # Media Settings STANDARD_IMG_SIZE=300x200 + +# Celery broker + results (for both worker & flower) +CELERY_BROKER_URL=redis://redis:6379/0 +CELERY_RESULT_BACKEND=redis://redis:6379/0 diff --git a/Dockerfile b/Dockerfile index ae6c8c4..d3bbe74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ FROM python:3.11-slim +ENV PYTHONUNBUFFERED=1 + # Install build deps and netcat for the DB-wait RUN apt-get update && apt-get install -y \ gcc \ @@ -9,23 +11,21 @@ RUN apt-get update && apt-get install -y \ curl \ && rm -rf /var/lib/apt/lists/* -# Upgrade pip and install Python requirements WORKDIR /app COPY requirements.txt . RUN pip install --upgrade pip \ && pip install -r requirements.txt -# Copy the app code COPY . . # Create a non-root user and give it ownership of /app RUN useradd -ms /bin/bash appuser \ && chown -R appuser:appuser /app -# Switch to appuser for all subsequent commands +# Switch to appuser for everything below USER appuser -# Make the entrypoint script executable +# Prepare entrypoint COPY --chown=appuser:appuser entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/app/__init__.py b/app/__init__.py index 7cc9d6b..d70e83c 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -4,19 +4,15 @@ import os import json import importlib import time +import logging +from datetime import datetime +from dotenv import load_dotenv, find_dotenv from flask import Flask, request from flask_sqlalchemy import SQLAlchemy -from flask_migrate import Migrate +from flask_migrate import Migrate, upgrade, migrate as _migrate, stamp as _stamp from flask_login import LoginManager from flask_wtf.csrf import CSRFProtect -from datetime import datetime -from dotenv import load_dotenv, find_dotenv - -# ─── Load .env ──────────────────────────────────────────────────────────────── -dotenv_path = find_dotenv() -if dotenv_path: - load_dotenv(dotenv_path, override=True) # ─── Core extensions ─────────────────────────────────────────────────────────── db = SQLAlchemy() @@ -24,120 +20,206 @@ migrate = Migrate() login_manager = LoginManager() csrf = CSRFProtect() -# ─── Template helper (still in core) ────────────────────────────────────────── -from plugins.media.routes import generate_image_url # noqa: E402 def create_app(): - # ─── Configure Flask ──────────────────────────────────────────────────────── - project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) + # ─── Load .env ─────────────────────────────────────────────────────────────── + dotenv_path = find_dotenv() + if dotenv_path: + load_dotenv(dotenv_path, override=True) + + # ─── Flask setup ───────────────────────────────────────────────────────────── + project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) + migrations_dir = os.path.join(project_root, 'migrations') + app = Flask( __name__, static_folder=os.path.join(project_root, 'static'), static_url_path='/static' ) - app.config.from_object('app.config.Config') - # ─── Init extensions ─────────────────────────────────────────────────────── + # install INFO‐level logging handler + handler = logging.StreamHandler() + handler.setLevel(logging.INFO) + app.logger.setLevel(logging.INFO) + app.logger.addHandler(handler) + app.logger.info("🚀 Starting create_app()") + + # main config + app.config.from_object('app.config.Config') + app.logger.info(f"🔧 Loaded config from {app.config.__class__.__name__}") + + # ─── Init extensions ───────────────────────────────────────────────────────── csrf.init_app(app) db.init_app(app) - migrate.init_app(app, db) + migrate.init_app(app, db, directory=migrations_dir) login_manager.init_app(app) login_manager.login_view = 'auth.login' + app.logger.info("🔗 Initialized extensions (CSRF, SQLAlchemy, Migrate, LoginManager)") - # ─── Core routes & errors ─────────────────────────────────────────────────── + # ─── AUTOMATIC MIGRATIONS ──────────────────────────────────────────────────── + with app.app_context(): + app.logger.info("🛠️ Checking for schema changes…") + try: + upgrade() + app.logger.info("🛠️ Alembic reports DB is at head") + except Exception: + _stamp(revision='head') + upgrade() + app.logger.info("🛠️ Stamped and upgraded to head") + try: + _migrate(message="autogen", autogenerate=True) + app.logger.info("🛠️ Autogenerated migration revision created") + except Exception: + app.logger.debug("🛠️ No new migrations detected") + upgrade() + app.logger.info("🛠️ Database fully migrated") + + # ─── Core routes & error‐handlers ───────────────────────────────────────────── from .errors import bp as errors_bp # noqa: E402 app.register_blueprint(errors_bp) from .routes import init_app as register_core_routes # noqa: E402 register_core_routes(app) - app.logger.info("✔️ Registered core routes") - # ─── JSON‐driven plugin loader ────────────────────────────────────────────── - plugins_dir = os.path.join(project_root, 'plugins') + # ─── JSON‐driven plugin loader with unbuffered prints ───────────────────────── + print("🔌 Discovering plugins…", flush=True) + plugins_dir = os.path.join(project_root, 'plugins') + loaded_plugins = [] + for name in sorted(os.listdir(plugins_dir)): - plugin_path = os.path.join(plugins_dir, name) - manifest = os.path.join(plugin_path, 'plugin.json') + manifest = os.path.join(plugins_dir, name, 'plugin.json') if not os.path.isfile(manifest): continue + plugin_info = { + 'name': name, + 'models': [], + 'routes': None, + 'cli': [], + 'template_globals': [], + 'tasks': [], + 'tasks_init': [], + 'subplugins': [] + } errors = [] + try: meta = json.load(open(manifest)) except Exception as e: - print(f"Plugin '{name}' 🛑 manifest load failed: {e}") + print(f" ✖ manifest load error for '{name}': {e}", flush=True) continue - # 1) Import models + # 1) models for model_path in meta.get('models', []): try: importlib.import_module(model_path) + plugin_info['models'].append(model_path) except Exception as e: - errors.append(f"model import ({model_path}): {e}") + errors.append(f"model '{model_path}': {e}") - # 1.b) user_loader hook + # 2) user_loader ul = meta.get('user_loader') if ul: try: - mod = importlib.import_module(ul['module']) - fn = getattr(mod, ul['callable']) - fn(app) + m = importlib.import_module(ul['module']) + fn = getattr(m, ul['callable']) + fn(login_manager) except Exception as e: - errors.append(f"user_loader ({ul['module']}:{ul['callable']}): {e}") + errors.append(f"user_loader '{ul}': {e}") - # 2) Register routes - routes_cfg = meta.get('routes') - if routes_cfg: + # 3) routes + rt = meta.get('routes') + if rt: try: - mod = importlib.import_module(routes_cfg['module']) - bp_obj = getattr(mod, routes_cfg['blueprint']) - prefix = routes_cfg.get('url_prefix') + m = importlib.import_module(rt['module']) + bp_obj = getattr(m, rt['blueprint']) + prefix = rt.get('url_prefix') app.register_blueprint(bp_obj, url_prefix=prefix, strict_slashes=False) + plugin_info['routes'] = f"{rt['module']}::{rt['blueprint']}" except Exception as e: - errors.append(f"routes ({routes_cfg['module']}): {e}") + errors.append(f"routes '{rt}': {e}") - # 3) Register CLI commands - cli_cfg = meta.get('cli') - if cli_cfg: + # 4) CLI + cli = meta.get('cli') + if cli: try: - mod = importlib.import_module(cli_cfg['module']) - fn = getattr(mod, cli_cfg['callable']) + m = importlib.import_module(cli['module']) + fn = getattr(m, cli['callable']) app.cli.add_command(fn) + plugin_info['cli'].append(f"{cli['module']}::{cli['callable']}") except Exception as e: - errors.append(f"cli ({cli_cfg['module']}:{cli_cfg['callable']}): {e}") + errors.append(f"cli '{cli}': {e}") - # 4) Template globals + # 5) template_globals for tg in meta.get('template_globals', []): try: mod_name, fn_name = tg['callable'].rsplit('.', 1) - mod = importlib.import_module(mod_name) - fn = getattr(mod, fn_name) + m = importlib.import_module(mod_name) + fn = getattr(m, fn_name) app.jinja_env.globals[tg['name']] = fn + plugin_info['template_globals'].append(tg['name']) except Exception as e: - errors.append(f"template_global ({tg}): {e}") + errors.append(f"template_global '{tg}': {e}") - # 5) Subplugins (models + routes) + # 6) subplugins for sp in meta.get('subplugins', []): + sub_info = {'name': sp['name'], 'models': [], 'routes': None} for mp in sp.get('models', []): try: importlib.import_module(mp) + sub_info['models'].append(mp) except Exception as e: - errors.append(f"subplugin model ({mp}): {e}") - sp_rt = sp.get('routes') - if sp_rt: + errors.append(f"subplugin model '{mp}': {e}") + srt = sp.get('routes') + if srt: try: - mod = importlib.import_module(sp_rt['module']) - bp_obj = getattr(mod, sp_rt['blueprint']) - prefix = sp_rt.get('url_prefix') - app.register_blueprint(bp_obj, url_prefix=prefix, strict_slashes=False) + m = importlib.import_module(srt['module']) + bp_obj = getattr(m, srt['blueprint']) + app.register_blueprint(bp_obj, url_prefix=srt.get('url_prefix'), strict_slashes=False) + sub_info['routes'] = f"{srt['module']}::{srt['blueprint']}" except Exception as e: - errors.append(f"subplugin routes ({sp_rt['module']}): {e}") + errors.append(f"subplugin routes '{srt}': {e}") + plugin_info['subplugins'].append(sub_info) + + # 7) tasks + for task_mod in meta.get('tasks', []): + try: + importlib.import_module(task_mod) + plugin_info['tasks'].append(task_mod) + except Exception as e: + errors.append(f"task '{task_mod}': {e}") + + # 8) tasks_init + for hook in meta.get('tasks_init', []): + try: + m = importlib.import_module(hook['module']) + fn = getattr(m, hook['callable']) + fn(app) + plugin_info['tasks_init'].append(f"{hook['module']}::{hook['callable']}") + except Exception as e: + errors.append(f"tasks_init '{hook}': {e}") - # Final status if errors: - print(f"Plugin '{name}' 🛑 failed to load: {'; '.join(errors)}") + print(f" ✖ Plugin '{name}' errors: " + "; ".join(errors), flush=True) else: - print(f"Plugin '{name}' ✔️ Loaded Successfully.") + print(f" ✔ Plugin '{name}' loaded", flush=True) + loaded_plugins.append(plugin_info) - # ─── Context processors, analytics, teardown ─────────────────────────────── + # summary + print("🌟 Loaded plugins summary:", flush=True) + for info in loaded_plugins: + print( + f" • {info['name']}: " + f"models={info['models']}, " + f"routes={info['routes']}, " + f"cli={info['cli']}, " + f"template_globals={info['template_globals']}, " + f"tasks={info['tasks']}, " + f"tasks_init={info['tasks_init']}, " + f"subplugins={[s['name'] for s in info['subplugins']]}", + flush=True + ) + + # ─── Context processors, before/after request, teardown ───────────────────── @app.context_processor def inject_current_year(): return {'current_year': datetime.now().year} @@ -156,13 +238,13 @@ def create_app(): try: duration = time.time() - getattr(request, '_start_time', time.time()) ev = AnalyticsEvent( - method = request.method, - path = request.path, - status_code = response.status_code, - response_time = duration, - user_agent = request.headers.get('User-Agent'), - referer = request.headers.get('Referer'), - accept_language=request.headers.get('Accept-Language'), + method = request.method, + path = request.path, + status_code = response.status_code, + response_time = duration, + user_agent = request.headers.get('User-Agent'), + referer = request.headers.get('Referer'), + accept_language = request.headers.get('Accept-Language'), ) db.session.add(ev) db.session.commit() @@ -174,7 +256,5 @@ def create_app(): def shutdown_session(exception=None): db.session.remove() - # ─── Keep the template helper exposed ────────────────────────────────────── - app.jinja_env.globals['generate_image_url'] = generate_image_url - + print("✅ create_app() complete; ready to serve", flush=True) return app diff --git a/app/celery_app.py b/app/celery_app.py new file mode 100644 index 0000000..9bd0816 --- /dev/null +++ b/app/celery_app.py @@ -0,0 +1,84 @@ +# File: app/celery_app.py + +import os +import json +import importlib +from celery import Celery + +# 1) Create the Celery object (broker/backend will be set from our Flask config) +celery = Celery('natureinpots') + + +def init_celery(app): + """ + Wire up Celery to our Flask app, discover all plugin task modules + and run any tasks_init hooks. + """ + # 1. Configure broker & result backend from Flask config + celery.conf.broker_url = app.config['CELERY_BROKER_URL'] + celery.conf.result_backend = app.config['CELERY_RESULT_BACKEND'] + + # 2. Make all tasks run inside the Flask application context + TaskBase = celery.Task + class ContextTask(TaskBase): + def __call__(self, *args, **kwargs): + with app.app_context(): + return TaskBase.__call__(self, *args, **kwargs) + celery.Task = ContextTask + + # 3. Discover all plugins by looking for plugin.json in plugins/ + plugins_dir = os.path.join(app.root_path, '..', 'plugins') + task_modules = [] + for plugin_name in sorted(os.listdir(plugins_dir)): + manifest = os.path.join(plugins_dir, plugin_name, 'plugin.json') + if not os.path.isfile(manifest): + continue + + # Load plugin metadata + try: + meta = json.load(open(manifest)) + except Exception: + app.logger.error(f"[Celery] Failed to load plugin.json for '{plugin_name}'") + continue + + # a) Gather task modules (string or dict or list) + tasks_cfg = meta.get('tasks') + if isinstance(tasks_cfg, str): + task_modules.append(tasks_cfg) + elif isinstance(tasks_cfg, dict) and tasks_cfg.get('module'): + task_modules.append(tasks_cfg['module']) + elif isinstance(tasks_cfg, list): + for entry in tasks_cfg: + if isinstance(entry, str): + task_modules.append(entry) + elif isinstance(entry, dict) and entry.get('module'): + task_modules.append(entry['module']) + + # b) Run any tasks_init hooks (e.g. to register schedules) + for hook in meta.get('tasks_init', []): + module_name = hook.get('module') + fn_name = hook.get('callable') + if not module_name or not fn_name: + continue + try: + m = importlib.import_module(module_name) + fn = getattr(m, fn_name) + # pass the Celery instance so they can register schedules, etc. + fn(celery) + except Exception as e: + app.logger.error(f"[Celery] Failed tasks_init for '{plugin_name}': {e}") + + # 4. Autodiscover all gathered task modules + if task_modules: + celery.autodiscover_tasks(task_modules) + app.logger.info(f"[Celery] Autodiscovered tasks in: {task_modules}") + + return celery + + +# 5) Immediately bootstrap Celery with our Flask app so that +# any `celery -A app.celery_app:celery worker --beat` invocation +# will pick up your plugins’ tasks and schedules. +from app import create_app +_flask_app = create_app() +init_celery(_flask_app) diff --git a/app/config.py b/app/config.py index 5d8f0d6..96a19ed 100644 --- a/app/config.py +++ b/app/config.py @@ -1,41 +1,47 @@ import os from dotenv import load_dotenv, find_dotenv -# ─── Load .env from project root or any parent ──────────────────────────────── +# ─── Load .env from project root or parents ────────────────────────────────── dotenv_path = find_dotenv() if dotenv_path: load_dotenv(dotenv_path, override=True) -# ─── Paths ──────────────────────────────────────────────────────────────────── +# ─── Paths ─────────────────────────────────────────────────────────────────── CONFIG_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_ROOT = os.path.dirname(CONFIG_DIR) class Config: - # ─── Environment ───────────────────────────────────────────────────────────── + # ─── Environment ───────────────────────────────────────────────────────── ENV = ( os.getenv('FLASK_ENV') or os.getenv('DOCKER_ENV') or 'production' ).lower() - # ─── Secret Key ────────────────────────────────────────────────────────────── + # ─── Debug / Exceptions ─────────────────────────────────────────────────── + DEBUG = ENV != 'production' + PROPAGATE_EXCEPTIONS = True + TRAP_HTTP_EXCEPTIONS = True + + # ─── Secret Key ─────────────────────────────────────────────────────────── if ENV == 'production': SECRET_KEY = os.getenv('SECRET_KEY') if not SECRET_KEY: raise RuntimeError( "SECRET_KEY environment variable not set! " - "Generate one with `openssl rand -hex 32` and export it." + "Generate one with `openssl rand -hex 32`." ) else: - # dev/test: fall back to env or a random one SECRET_KEY = os.getenv('SECRET_KEY') or os.urandom(24).hex() - # ─── Uploads ──────────────────────────────────────────────────────────────── - # Default to PROJECT_ROOT/static/uploads; if UPLOAD_FOLDER env is set, resolve relative to PROJECT_ROOT + # ─── Uploads ─────────────────────────────────────────────────────────────── _env_upload = os.getenv('UPLOAD_FOLDER', '') if _env_upload: - # if absolute, use directly; otherwise join to project root - UPLOAD_FOLDER = _env_upload if os.path.isabs(_env_upload) else os.path.join(PROJECT_ROOT, _env_upload) + UPLOAD_FOLDER = ( + _env_upload + if os.path.isabs(_env_upload) + else os.path.join(PROJECT_ROOT, _env_upload) + ) else: UPLOAD_FOLDER = os.path.join(PROJECT_ROOT, "static", "uploads") @@ -48,7 +54,7 @@ class Config: raise RuntimeError("CELERY_BROKER_URL environment variable not set!") CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND', CELERY_BROKER_URL) - # ─── MySQL ────────────────────────────────────────────────────────────────── + # ─── MySQL ───────────────────────────────────────────────────────────────── MYSQL_USER = os.getenv('MYSQL_USER') MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD') if not MYSQL_PASSWORD: @@ -65,11 +71,17 @@ class Config: ) SQLALCHEMY_TRACK_MODIFICATIONS = False - # ─── Cookies / Session ────────────────────────────────────────────────────── - SESSION_COOKIE_SECURE = True + # ─── Cookies / Session ───────────────────────────────────────────────────── + # only mark cookies Secure in production; in dev we need them over HTTP + if ENV == 'production': + SESSION_COOKIE_SECURE = True + REMEMBER_COOKIE_SECURE = True + else: + SESSION_COOKIE_SECURE = False + REMEMBER_COOKIE_SECURE = False + SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = 'Lax' - REMEMBER_COOKIE_SECURE = True REMEMBER_COOKIE_HTTPONLY = True REMEMBER_COOKIE_SAMESITE = 'Lax' PREFERRED_URL_SCHEME = 'https' @@ -77,6 +89,7 @@ class Config: # ─── Toggles ──────────────────────────────────────────────────────────────── ENABLE_DB_SEEDING = os.getenv('ENABLE_DB_SEEDING', '0') == '1' DOCKER_ENV = os.getenv('DOCKER_ENV', 'production') + INVITES_PER_USER = int(os.getenv('INVITES_PER_USER', 5)) # ─── Neo4j ────────────────────────────────────────────────────────────────── NEO4J_URI = os.getenv('NEO4J_URI', 'bolt://neo4j:7687') @@ -85,8 +98,8 @@ class Config: if not NEO4J_PASSWORD: raise RuntimeError("NEO4J_PASSWORD environment variable not set!") - # ─── Misc ────────────────────────────────────────────────────────────────── - STANDARD_IMG_SIZE = tuple( + # ─── Misc ─────────────────────────────────────────────────────────────────── + STANDARD_IMG_SIZE = tuple( map(int, os.getenv('STANDARD_IMG_SIZE', '300x200').split('x')) ) PLANT_CARDS_BASE_URL = "https://plant.cards" diff --git a/app/templates/core/base.html b/app/templates/core/base.html index 7f3d24a..e8a5628 100644 --- a/app/templates/core/base.html +++ b/app/templates/core/base.html @@ -97,16 +97,33 @@ - + {% if current_user.role == 'admin' %} - - Admin Dashboard - +
+ + +
{% endif %} + +{% endblock %} diff --git a/plugins/utility/templates/utility/imports.html b/plugins/utility/templates/utility/imports.html new file mode 100644 index 0000000..abfd4fd --- /dev/null +++ b/plugins/utility/templates/utility/imports.html @@ -0,0 +1,20 @@ +{% extends "core/base.html" %} +{% block content %} +

Your Recent Imports

+ + + + + + {% for b in batches %} + + + + + + + + {% endfor %} + +
#Export IDUploadedStatusError
{{ b.id }}{{ b.export_id }}{{ b.imported_at }}{{ b.status }}{{ b.error or "" }}
+{% endblock %} diff --git a/requirements.txt b/requirements.txt index 4964291..b0c035a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ qrcode celery redis>=4.5.0 email-validator>=1.2.0 +flower>=1.0.0