From d7a610a83bb68dd09389a7560bb3f73dc5e52d2c Mon Sep 17 00:00:00 2001 From: Lilfade Date: Wed, 9 Jul 2025 01:05:45 -0500 Subject: [PATCH] broke --- .dockerignore | 3 + .env.example | 4 + Dockerfile | 8 +- app/__init__.py | 218 ++++++---- app/celery_app.py | 84 ++++ app/config.py | 45 ++- app/templates/core/base.html | 31 +- beta-0.1.14.zip | Bin 0 -> 269448 bytes beta-0.1.0.zip => betas/beta-0.1.0.zip | Bin betas/beta-0.1.1.zip | Bin 0 -> 380227 bytes betas/beta-0.1.10.zip | Bin 0 -> 259446 bytes betas/beta-0.1.11.zip | Bin 0 -> 264819 bytes betas/beta-0.1.12.zip | Bin 0 -> 267076 bytes betas/beta-0.1.13.zip | Bin 0 -> 269405 bytes betas/beta-0.1.2.zip | Bin 0 -> 380375 bytes betas/beta-0.1.3.zip | Bin 0 -> 380426 bytes betas/beta-0.1.4.zip | Bin 0 -> 384884 bytes betas/beta-0.1.5.zip | Bin 0 -> 383845 bytes betas/beta-0.1.6.zip | Bin 0 -> 392202 bytes betas/beta-0.1.7.zip | Bin 0 -> 392333 bytes betas/beta-0.1.8.zip | Bin 0 -> 7475120 bytes betas/beta-0.1.9.zip | Bin 0 -> 6896157 bytes docker-compose.yml | 35 +- entrypoint.sh | 5 +- migrations/env.py | 98 ++--- .../versions/01876f89899b_auto_migrate.py | 28 -- .../versions/03650b9a0f3a_auto_migrate.py | 28 -- .../versions/0514fb24a61e_auto_migrate.py | 28 -- .../versions/06234a515bde_auto_migrate.py | 28 -- .../versions/076bfc1a441b_auto_migrate.py | 28 -- .../versions/08ebb5577232_auto_migrate.py | 28 -- .../versions/0964777a3294_auto_migrate.py | 28 -- .../versions/0efc1a18285f_auto_migrate.py | 28 -- .../versions/10e39b33d4e7_auto_migrate.py | 28 -- .../versions/12cc29f97b11_auto_migrate.py | 28 -- .../versions/12ef820b5618_auto_migrate.py | 28 -- .../versions/19e2a1b15b5e_auto_migrate.py | 28 -- .../versions/209596f02c2a_auto_migrate.py | 28 -- .../versions/228e71f1a33b_auto_migrate.py | 28 -- .../versions/24de4aa78a43_auto_migrate.py | 36 -- .../versions/27f1b3976f3f_auto_migrate.py | 28 -- .../versions/2d11e31941d9_auto_migrate.py | 28 -- .../versions/30376c514135_auto_migrate.py | 28 -- .../versions/310f500a3d2f_auto_migrate.py | 28 -- .../versions/390c977fe679_auto_migrate.py | 28 -- .../versions/4082065b932b_auto_migrate.py | 28 -- .../versions/42ce181f4eab_auto_migrate.py | 28 -- .../versions/493fbb46e881_auto_migrate.py | 28 -- .../versions/51640ecd70ee_auto_migrate.py | 28 -- .../versions/53d0e3d0cd47_auto_migrate.py | 28 -- .../versions/7229fe50de09_auto_migrate.py | 28 -- .../versions/807ca973d0cf_auto_migrate.py | 28 -- .../versions/85b7ca21ec19_auto_migrate.py | 28 -- .../versions/87c6df96bef3_auto_migrate.py | 28 -- .../versions/886aa234b3b7_auto_migrate.py | 28 -- .../versions/892da654697c_auto_migrate.py | 28 -- .../versions/8c1e8db7b3cb_auto_migrate.py | 28 -- .../versions/93f8a5cbc643_auto_migrate.py | 28 -- .../versions/9cc2626a6e79_auto_migrate.py | 28 -- .../versions/a5cb08298ee4_auto_migrate.py | 28 -- .../versions/a69f613f9cd5_auto_migrate.py | 28 -- .../versions/a79453aefa45_auto_migrate.py | 39 -- .../versions/a87d4c1df4e5_auto_migrate.py | 28 -- .../versions/acd3093204e7_auto_migrate.py | 28 -- .../versions/b1e37dc718f2_auto_migrate.py | 28 -- .../versions/b56cd5e57987_auto_migrate.py | 28 -- .../versions/b57c767ad0d6_auto_migrate.py | 28 -- .../versions/b684611b27b1_auto_migrate.py | 28 -- .../versions/c23c31ae3a1d_auto_migrate.py | 36 -- .../versions/c6fad4522e3c_auto_migrate.py | 28 -- .../versions/c92477263320_auto_migrate.py | 28 -- .../versions/d49ee8d82364_auto_migrate.py | 28 -- .../versions/d54a88422a68_auto_migrate.py | 28 -- .../versions/d647dd4d3fbd_auto_migrate.py | 28 -- .../versions/d7bbffbbc931_auto_migrate.py | 32 -- .../versions/dcc114909948_auto_migrate.py | 28 -- .../versions/dd2492e0ede0_auto_migrate.py | 28 -- .../versions/ee4be515bb55_auto_migrate.py | 28 -- .../versions/f00a9585a348_auto_migrate.py | 28 -- .../versions/f34b5e058563_auto_migrate.py | 28 -- .../versions/f741addef1a1_auto_migrate.py | 59 --- .../versions/f7f41136c073_auto_migrate.py | 28 -- .../versions/f81a9a44a7fb_auto_migrate.py | 28 -- .../versions/fa34eb3f6084_auto_migrate.py | 28 -- .../versions/faeca4f53b04_auto_migrate.py | 28 -- .../versions/fe0ebdec3255_auto_migrate.py | 28 -- plugins/admin/routes.py | 10 + .../templates/admin/orphaned_media_list.html | 33 ++ plugins/auth/forms.py | 67 ++- plugins/auth/models.py | 108 ++++- plugins/auth/routes.py | 116 +++++- plugins/auth/templates/auth/invite.html | 19 + plugins/auth/templates/auth/login.html | 46 ++- plugins/auth/templates/auth/register.html | 56 ++- plugins/media/models.py | 46 ++- plugins/media/plugin.json | 13 +- plugins/media/tasks.py | 112 ++++- plugins/plant/growlog/routes.py | 8 +- plugins/plant/models.py | 1 + plugins/utility/__init__.py | 10 - plugins/utility/celery.py | 46 --- plugins/utility/models.py | 36 +- plugins/utility/plugin.json | 11 +- plugins/utility/routes.py | 381 ++++++++++-------- plugins/utility/search.py | 94 +++++ plugins/utility/search/__init__.py | 0 plugins/utility/search/search.py | 63 --- plugins/utility/tasks.py | 225 ++++++++++- .../templates/search/_form_scripts.html | 0 plugins/utility/templates/search/results.html | 31 ++ .../{search => }/templates/search/search.html | 6 +- .../utility/templates/utility/imports.html | 20 + requirements.txt | 1 + 113 files changed, 1512 insertions(+), 2348 deletions(-) create mode 100644 app/celery_app.py create mode 100644 beta-0.1.14.zip rename beta-0.1.0.zip => betas/beta-0.1.0.zip (100%) create mode 100644 betas/beta-0.1.1.zip create mode 100644 betas/beta-0.1.10.zip create mode 100644 betas/beta-0.1.11.zip create mode 100644 betas/beta-0.1.12.zip create mode 100644 betas/beta-0.1.13.zip create mode 100644 betas/beta-0.1.2.zip create mode 100644 betas/beta-0.1.3.zip create mode 100644 betas/beta-0.1.4.zip create mode 100644 betas/beta-0.1.5.zip create mode 100644 betas/beta-0.1.6.zip create mode 100644 betas/beta-0.1.7.zip create mode 100644 betas/beta-0.1.8.zip create mode 100644 betas/beta-0.1.9.zip delete mode 100644 migrations/versions/01876f89899b_auto_migrate.py delete mode 100644 migrations/versions/03650b9a0f3a_auto_migrate.py delete mode 100644 migrations/versions/0514fb24a61e_auto_migrate.py delete mode 100644 migrations/versions/06234a515bde_auto_migrate.py delete mode 100644 migrations/versions/076bfc1a441b_auto_migrate.py delete mode 100644 migrations/versions/08ebb5577232_auto_migrate.py delete mode 100644 migrations/versions/0964777a3294_auto_migrate.py delete mode 100644 migrations/versions/0efc1a18285f_auto_migrate.py delete mode 100644 migrations/versions/10e39b33d4e7_auto_migrate.py delete mode 100644 migrations/versions/12cc29f97b11_auto_migrate.py delete mode 100644 migrations/versions/12ef820b5618_auto_migrate.py delete mode 100644 migrations/versions/19e2a1b15b5e_auto_migrate.py delete mode 100644 migrations/versions/209596f02c2a_auto_migrate.py delete mode 100644 migrations/versions/228e71f1a33b_auto_migrate.py delete mode 100644 migrations/versions/24de4aa78a43_auto_migrate.py delete mode 100644 migrations/versions/27f1b3976f3f_auto_migrate.py delete mode 100644 migrations/versions/2d11e31941d9_auto_migrate.py delete mode 100644 migrations/versions/30376c514135_auto_migrate.py delete mode 100644 migrations/versions/310f500a3d2f_auto_migrate.py delete mode 100644 migrations/versions/390c977fe679_auto_migrate.py delete mode 100644 migrations/versions/4082065b932b_auto_migrate.py delete mode 100644 migrations/versions/42ce181f4eab_auto_migrate.py delete mode 100644 migrations/versions/493fbb46e881_auto_migrate.py delete mode 100644 migrations/versions/51640ecd70ee_auto_migrate.py delete mode 100644 migrations/versions/53d0e3d0cd47_auto_migrate.py delete mode 100644 migrations/versions/7229fe50de09_auto_migrate.py delete mode 100644 migrations/versions/807ca973d0cf_auto_migrate.py delete mode 100644 migrations/versions/85b7ca21ec19_auto_migrate.py delete mode 100644 migrations/versions/87c6df96bef3_auto_migrate.py delete mode 100644 migrations/versions/886aa234b3b7_auto_migrate.py delete mode 100644 migrations/versions/892da654697c_auto_migrate.py delete mode 100644 migrations/versions/8c1e8db7b3cb_auto_migrate.py delete mode 100644 migrations/versions/93f8a5cbc643_auto_migrate.py delete mode 100644 migrations/versions/9cc2626a6e79_auto_migrate.py delete mode 100644 migrations/versions/a5cb08298ee4_auto_migrate.py delete mode 100644 migrations/versions/a69f613f9cd5_auto_migrate.py delete mode 100644 migrations/versions/a79453aefa45_auto_migrate.py delete mode 100644 migrations/versions/a87d4c1df4e5_auto_migrate.py delete mode 100644 migrations/versions/acd3093204e7_auto_migrate.py delete mode 100644 migrations/versions/b1e37dc718f2_auto_migrate.py delete mode 100644 migrations/versions/b56cd5e57987_auto_migrate.py delete mode 100644 migrations/versions/b57c767ad0d6_auto_migrate.py delete mode 100644 migrations/versions/b684611b27b1_auto_migrate.py delete mode 100644 migrations/versions/c23c31ae3a1d_auto_migrate.py delete mode 100644 migrations/versions/c6fad4522e3c_auto_migrate.py delete mode 100644 migrations/versions/c92477263320_auto_migrate.py delete mode 100644 migrations/versions/d49ee8d82364_auto_migrate.py delete mode 100644 migrations/versions/d54a88422a68_auto_migrate.py delete mode 100644 migrations/versions/d647dd4d3fbd_auto_migrate.py delete mode 100644 migrations/versions/d7bbffbbc931_auto_migrate.py delete mode 100644 migrations/versions/dcc114909948_auto_migrate.py delete mode 100644 migrations/versions/dd2492e0ede0_auto_migrate.py delete mode 100644 migrations/versions/ee4be515bb55_auto_migrate.py delete mode 100644 migrations/versions/f00a9585a348_auto_migrate.py delete mode 100644 migrations/versions/f34b5e058563_auto_migrate.py delete mode 100644 migrations/versions/f741addef1a1_auto_migrate.py delete mode 100644 migrations/versions/f7f41136c073_auto_migrate.py delete mode 100644 migrations/versions/f81a9a44a7fb_auto_migrate.py delete mode 100644 migrations/versions/fa34eb3f6084_auto_migrate.py delete mode 100644 migrations/versions/faeca4f53b04_auto_migrate.py delete mode 100644 migrations/versions/fe0ebdec3255_auto_migrate.py create mode 100644 plugins/admin/templates/admin/orphaned_media_list.html create mode 100644 plugins/auth/templates/auth/invite.html delete mode 100644 plugins/utility/celery.py create mode 100644 plugins/utility/search.py delete mode 100644 plugins/utility/search/__init__.py delete mode 100644 plugins/utility/search/search.py rename plugins/utility/{search => }/templates/search/_form_scripts.html (100%) create mode 100644 plugins/utility/templates/search/results.html rename plugins/utility/{search => }/templates/search/search.html (87%) create mode 100644 plugins/utility/templates/utility/imports.html 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 %} +