tons of stuff but working again
BIN
.Trash-1000/files/beta-0.1.19.zip
Normal file
3
.Trash-1000/info/beta-0.1.19.zip.trashinfo
Normal file
@ -0,0 +1,3 @@
|
||||
[Trash Info]
|
||||
Path=beta-0.1.19.zip
|
||||
DeletionDate=2025-07-09T23:36:34
|
36
Dockerfile
@ -2,31 +2,35 @@ 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 \
|
||||
default-libmysqlclient-dev \
|
||||
pkg-config \
|
||||
netcat-openbsd \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# 1) Install build deps, netcat, curl—and gosu for privilege dropping
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
gcc \
|
||||
default-libmysqlclient-dev \
|
||||
pkg-config \
|
||||
netcat-openbsd \
|
||||
curl \
|
||||
gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 2) Copy & install Python requirements
|
||||
COPY requirements.txt .
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install -r requirements.txt
|
||||
|
||||
# 3) Copy the rest of the app
|
||||
COPY . .
|
||||
|
||||
# Create a non-root user and give it ownership of /app
|
||||
RUN useradd -ms /bin/bash appuser \
|
||||
&& chown -R appuser:appuser /app
|
||||
# 4) Create the non-root user and make sure the upload dir exists and is chown’d
|
||||
RUN groupadd -g 1000 appuser \
|
||||
&& useradd -u 1000 -ms /bin/bash -g appuser appuser \
|
||||
&& mkdir -p /app/data/uploads \
|
||||
&& chown -R appuser:appuser /app/data/uploads
|
||||
|
||||
# Switch to appuser for everything below
|
||||
USER appuser
|
||||
|
||||
# Prepare entrypoint
|
||||
COPY --chown=appuser:appuser entrypoint.sh /entrypoint.sh
|
||||
# 5) Install the entrypoint (keep this as root so it can chown the volume at runtime)
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
46
Makefile
@ -5,20 +5,23 @@ DOCKER_COMPOSE=docker compose
|
||||
PROJECT_NAME=plant_price_tracker
|
||||
|
||||
# Commands
|
||||
.PHONY: help up down rebuild logs status seed shell dbshell reset test
|
||||
.PHONY: help up down rebuild logs logs-web logs-worker logs-flower logs-all status seed shell dbshell reset test
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " up - Build and start the app (bootstraps .env if needed)"
|
||||
@echo " down - Stop and remove containers"
|
||||
@echo " rebuild - Rebuild containers from scratch"
|
||||
@echo " logs - Show logs for all services"
|
||||
@echo " status - Show container health status"
|
||||
@echo " seed - Manually seed the database"
|
||||
@echo " shell - Open a bash shell in the web container"
|
||||
@echo " dbshell - Open a MySQL shell"
|
||||
@echo " reset - Nuke everything and restart clean"
|
||||
@echo " test - Run test suite (TBD)"
|
||||
@echo " up - Build and start the app (bootstraps .env if needed)"
|
||||
@echo " down - Stop and remove containers"
|
||||
@echo " rebuild - Rebuild containers from scratch"
|
||||
@echo " logs-web - Show recent logs for the web service"
|
||||
@echo " logs-worker - Show recent logs for the Celery worker"
|
||||
@echo " logs-flower - Show recent logs for the Flower UI"
|
||||
@echo " logs-all - Show recent logs for all services"
|
||||
@echo " status - Show container health status"
|
||||
@echo " seed - Manually seed the database"
|
||||
@echo " shell - Open a bash shell in the web container"
|
||||
@echo " dbshell - Open a MySQL shell"
|
||||
@echo " reset - Nuke everything and restart clean"
|
||||
@echo " test - Run test suite (TBD)"
|
||||
|
||||
up:
|
||||
@if [ ! -f $(ENV_FILE) ]; then \
|
||||
@ -51,8 +54,24 @@ rebuild:
|
||||
preload:
|
||||
@docker exec -it $$(docker ps -qf "name=$(PROJECT_NAME)-web") flask preload-data
|
||||
|
||||
logs:
|
||||
$(DOCKER_COMPOSE) logs -f
|
||||
logs-web:
|
||||
@echo "[📜] Tailing last 200 lines of web service logs…"
|
||||
$(DOCKER_COMPOSE) logs --tail=200 -f web
|
||||
|
||||
logs-worker:
|
||||
@echo "[📜] Tailing last 200 lines of worker service logs…"
|
||||
$(DOCKER_COMPOSE) logs --tail=200 -f worker
|
||||
|
||||
logs-flower:
|
||||
@echo "[📜] Tailing last 200 lines of flower service logs…"
|
||||
$(DOCKER_COMPOSE) logs --tail=200 -f flower
|
||||
|
||||
logs-all:
|
||||
@echo "[📜] Tailing last 200 lines of ALL services logs…"
|
||||
$(DOCKER_COMPOSE) logs --tail=200 -f
|
||||
|
||||
# alias old 'logs' to unified
|
||||
logs: logs-all
|
||||
|
||||
status:
|
||||
@echo "[📊] Health status of containers:"
|
||||
@ -100,4 +119,3 @@ migrate:
|
||||
|
||||
upgrade:
|
||||
flask db upgrade
|
||||
|
||||
|
244
app/__init__.py
@ -1,260 +1,52 @@
|
||||
# File: app/__init__.py
|
||||
|
||||
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, upgrade, migrate as _migrate, stamp as _stamp
|
||||
from flask_login import LoginManager
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
|
||||
# ─── Core extensions ───────────────────────────────────────────────────────────
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
login_manager = LoginManager()
|
||||
csrf = CSRFProtect()
|
||||
|
||||
from .config import Config
|
||||
from .extensions import db, migrate, login_manager, csrf
|
||||
from .plugin_loader import register_plugins
|
||||
|
||||
def create_app():
|
||||
# ─── Load .env ───────────────────────────────────────────────────────────────
|
||||
dotenv_path = find_dotenv()
|
||||
if dotenv_path:
|
||||
load_dotenv(dotenv_path, override=True)
|
||||
# 1) load .env
|
||||
if p := find_dotenv():
|
||||
load_dotenv(p, override=True)
|
||||
|
||||
# ─── Flask setup ─────────────────────────────────────────────────────────────
|
||||
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
migrations_dir = os.path.join(project_root, 'migrations')
|
||||
# 2) create Flask
|
||||
app = Flask(__name__, static_folder=os.path.join(os.path.dirname(__file__), '..', 'static'))
|
||||
app.config.from_object(Config)
|
||||
|
||||
app = Flask(
|
||||
__name__,
|
||||
static_folder=os.path.join(project_root, 'static'),
|
||||
static_url_path='/static'
|
||||
)
|
||||
|
||||
# install INFO‐level logging handler
|
||||
# 3) logging
|
||||
handler = logging.StreamHandler()
|
||||
handler.setLevel(logging.INFO)
|
||||
app.logger.setLevel(logging.INFO)
|
||||
app.logger.addHandler(handler)
|
||||
app.logger.info("🚀 Starting create_app()")
|
||||
app.logger.info("🚀 Starting Flask app")
|
||||
|
||||
# main config
|
||||
app.config.from_object('app.config.Config')
|
||||
app.logger.info(f"🔧 Loaded config from {app.config.__class__.__name__}")
|
||||
|
||||
# ─── Init extensions ─────────────────────────────────────────────────────────
|
||||
# 4) init extensions
|
||||
csrf.init_app(app)
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db, directory=migrations_dir)
|
||||
migrate.init_app(app, db)
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = 'auth.login'
|
||||
app.logger.info("🔗 Initialized extensions (CSRF, SQLAlchemy, Migrate, LoginManager)")
|
||||
app.logger.info("🔗 Extensions initialized")
|
||||
|
||||
# ─── 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 ─────────────────────────────────────────────
|
||||
# 5) register core routes, errors, etc.
|
||||
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)
|
||||
|
||||
# ─── 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)):
|
||||
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" ✖ manifest load error for '{name}': {e}", flush=True)
|
||||
continue
|
||||
|
||||
# 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 '{model_path}': {e}")
|
||||
|
||||
# 2) user_loader
|
||||
ul = meta.get('user_loader')
|
||||
if ul:
|
||||
try:
|
||||
m = importlib.import_module(ul['module'])
|
||||
fn = getattr(m, ul['callable'])
|
||||
fn(login_manager)
|
||||
except Exception as e:
|
||||
errors.append(f"user_loader '{ul}': {e}")
|
||||
|
||||
# 3) routes
|
||||
rt = meta.get('routes')
|
||||
if rt:
|
||||
try:
|
||||
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 '{rt}': {e}")
|
||||
|
||||
# 4) CLI
|
||||
cli = meta.get('cli')
|
||||
if cli:
|
||||
try:
|
||||
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}': {e}")
|
||||
|
||||
# 5) template_globals
|
||||
for tg in meta.get('template_globals', []):
|
||||
try:
|
||||
mod_name, fn_name = tg['callable'].rsplit('.', 1)
|
||||
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}")
|
||||
|
||||
# 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}")
|
||||
srt = sp.get('routes')
|
||||
if srt:
|
||||
try:
|
||||
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 '{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}")
|
||||
|
||||
if errors:
|
||||
print(f" ✖ Plugin '{name}' errors: " + "; ".join(errors), flush=True)
|
||||
else:
|
||||
print(f" ✔ Plugin '{name}' loaded", flush=True)
|
||||
loaded_plugins.append(plugin_info)
|
||||
|
||||
# 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}
|
||||
# 6) register all plugins
|
||||
register_plugins(app)
|
||||
|
||||
# 8) any context-processors, before/after teardown…
|
||||
@app.context_processor
|
||||
def inject_now():
|
||||
return {'utcnow': datetime.utcnow()}
|
||||
|
||||
@app.before_request
|
||||
def start_timer():
|
||||
request._start_time = time.time()
|
||||
|
||||
@app.after_request
|
||||
def log_analytics(response):
|
||||
from plugins.admin.models import AnalyticsEvent # noqa: E402
|
||||
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'),
|
||||
)
|
||||
db.session.add(ev)
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
return response
|
||||
|
||||
@app.teardown_appcontext
|
||||
def shutdown_session(exception=None):
|
||||
db.session.remove()
|
||||
|
||||
print("✅ create_app() complete; ready to serve", flush=True)
|
||||
return app
|
||||
|
@ -1,12 +1,8 @@
|
||||
# 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')
|
||||
from app.celery_instance import celery
|
||||
|
||||
|
||||
def init_celery(app):
|
||||
@ -20,15 +16,18 @@ def init_celery(app):
|
||||
|
||||
# 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):
|
||||
@ -76,9 +75,7 @@ def init_celery(app):
|
||||
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.
|
||||
# Immediately bootstrap Celery whenever this module is imported
|
||||
from app import create_app
|
||||
_flask_app = create_app()
|
||||
init_celery(_flask_app)
|
||||
|
4
app/celery_instance.py
Normal file
@ -0,0 +1,4 @@
|
||||
from celery import Celery
|
||||
|
||||
# Simple, standalone Celery instance
|
||||
celery = Celery('natureinpots')
|
11
app/extensions.py
Normal file
@ -0,0 +1,11 @@
|
||||
# File: app/extensions.py
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
login_manager = LoginManager()
|
||||
csrf = CSRFProtect()
|
118
app/plugin_loader.py
Normal file
@ -0,0 +1,118 @@
|
||||
import os
|
||||
import json
|
||||
import importlib
|
||||
from flask import current_app
|
||||
from datetime import datetime
|
||||
|
||||
def register_plugins(app):
|
||||
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
plugins_dir = os.path.join(project_root, 'plugins')
|
||||
loaded = []
|
||||
|
||||
app.logger.info("🔌 Discovering plugins…")
|
||||
for name in sorted(os.listdir(plugins_dir)):
|
||||
manifest = os.path.join(plugins_dir, name, 'plugin.json')
|
||||
if not os.path.isfile(manifest):
|
||||
continue
|
||||
|
||||
errors = []
|
||||
try:
|
||||
meta = json.load(open(manifest))
|
||||
except Exception as e:
|
||||
app.logger.error(f" ✖ manifest load error for '{name}': {e}")
|
||||
continue
|
||||
|
||||
info = {'name': name, 'models': [], 'routes': None, 'cli': [],
|
||||
'template_globals': [], 'subplugins': []}
|
||||
|
||||
# 1) models
|
||||
for model_path in meta.get('models', []):
|
||||
try:
|
||||
importlib.import_module(model_path)
|
||||
info['models'].append(model_path)
|
||||
except Exception as e:
|
||||
errors.append(f"model '{model_path}': {e}")
|
||||
|
||||
# 2) user_loader
|
||||
ul = meta.get('user_loader')
|
||||
if ul:
|
||||
try:
|
||||
m = importlib.import_module(ul['module'])
|
||||
fn = getattr(m, ul['callable'])
|
||||
fn(app.login_manager)
|
||||
except Exception as e:
|
||||
errors.append(f"user_loader '{ul}': {e}")
|
||||
|
||||
# 3) routes
|
||||
rt = meta.get('routes')
|
||||
if rt:
|
||||
try:
|
||||
m = importlib.import_module(rt['module'])
|
||||
bp_obj = getattr(m, rt['blueprint'])
|
||||
app.register_blueprint(bp_obj, url_prefix=rt.get('url_prefix'), strict_slashes=False)
|
||||
info['routes'] = f"{rt['module']}::{rt['blueprint']}"
|
||||
except Exception as e:
|
||||
errors.append(f"routes '{rt}': {e}")
|
||||
|
||||
# 4) CLI
|
||||
raw_cli = meta.get('cli')
|
||||
if raw_cli:
|
||||
# wrap a single dict into a list so we can iterate uniformly
|
||||
cli_entries = raw_cli if isinstance(raw_cli, list) else [raw_cli]
|
||||
for cli_entry in cli_entries:
|
||||
try:
|
||||
module_path = cli_entry.get('module')
|
||||
callable_name = cli_entry.get('callable')
|
||||
m = importlib.import_module(module_path)
|
||||
fn = getattr(m, callable_name)
|
||||
app.cli.add_command(fn)
|
||||
info['cli'].append(f"{module_path}::{callable_name}")
|
||||
except Exception as e:
|
||||
errors.append(f"cli '{cli_entry}': {e}")
|
||||
|
||||
# 5) template_globals
|
||||
for tg in meta.get('template_globals', []):
|
||||
try:
|
||||
mod_name, fn_name = tg['callable'].rsplit('.', 1)
|
||||
m = importlib.import_module(mod_name)
|
||||
fn = getattr(m, fn_name)
|
||||
app.jinja_env.globals[tg['name']] = fn
|
||||
info['template_globals'].append(tg['name'])
|
||||
except Exception as e:
|
||||
errors.append(f"template_global '{tg}': {e}")
|
||||
|
||||
# 6) subplugins
|
||||
for sp in meta.get('subplugins', []):
|
||||
sub = {'name': sp['name'], 'models': [], 'routes': None}
|
||||
for mp in sp.get('models', []):
|
||||
try:
|
||||
importlib.import_module(mp)
|
||||
sub['models'].append(mp)
|
||||
except Exception as e:
|
||||
errors.append(f"subplugin model '{mp}': {e}")
|
||||
srt = sp.get('routes')
|
||||
if srt:
|
||||
try:
|
||||
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['routes'] = f"{srt['module']}::{srt['blueprint']}"
|
||||
except Exception as e:
|
||||
errors.append(f"subplugin routes '{srt}': {e}")
|
||||
info['subplugins'].append(sub)
|
||||
|
||||
# Report
|
||||
if errors:
|
||||
app.logger.error(f" ✖ Plugin '{name}' errors: " + "; ".join(errors))
|
||||
else:
|
||||
app.logger.info(f" ✔ Plugin '{name}' loaded")
|
||||
loaded.append(info)
|
||||
|
||||
# summary
|
||||
app.logger.info("🌟 Loaded plugins summary:")
|
||||
for p in loaded:
|
||||
app.logger.info(
|
||||
f" • {p['name']}: models={p['models']}, routes={p['routes']}, "
|
||||
f"cli={p['cli']}, template_globals={p['template_globals']}, "
|
||||
f"subplugins={[s['name'] for s in p['subplugins']]}"
|
||||
)
|
53
app/task_loader.py
Normal file
@ -0,0 +1,53 @@
|
||||
# File: app/task_loader.py
|
||||
|
||||
import os
|
||||
import json
|
||||
import importlib
|
||||
from app.celery_app import celery
|
||||
|
||||
def register_tasks(app):
|
||||
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
plugins_dir = os.path.join(project_root, 'plugins')
|
||||
task_modules = []
|
||||
|
||||
app.logger.info("🔌 Discovering Celery plugins…")
|
||||
for name in sorted(os.listdir(plugins_dir)):
|
||||
manifest = os.path.join(plugins_dir, name, 'plugin.json')
|
||||
if not os.path.isfile(manifest):
|
||||
continue
|
||||
|
||||
try:
|
||||
meta = json.load(open(manifest))
|
||||
except Exception as e:
|
||||
app.logger.error(f"[Celery] Failed to load {name}/plugin.json: {e}")
|
||||
continue
|
||||
|
||||
# a) collect task modules
|
||||
cfg = meta.get('tasks')
|
||||
if isinstance(cfg, str):
|
||||
task_modules.append(cfg)
|
||||
elif isinstance(cfg, dict) and cfg.get('module'):
|
||||
task_modules.append(cfg['module'])
|
||||
elif isinstance(cfg, list):
|
||||
for entry in cfg:
|
||||
if isinstance(entry, str):
|
||||
task_modules.append(entry)
|
||||
elif isinstance(entry, dict) and entry.get('module'):
|
||||
task_modules.append(entry['module'])
|
||||
|
||||
# b) run tasks_init hooks (now passing Celery, not Flask)
|
||||
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)
|
||||
fn(celery)
|
||||
except Exception as e:
|
||||
app.logger.error(f"[Celery] tasks_init {name}:{module_name}.{fn_name} failed: {e}")
|
||||
|
||||
if task_modules:
|
||||
celery.autodiscover_tasks(task_modules)
|
||||
app.logger.info(f"[Celery] Autodiscovered tasks: {task_modules}")
|
BIN
beta-0.2.0.zip
Normal file
BIN
betas/beta-0.1.16.zip
Normal file
BIN
betas/beta-0.1.17.zip
Normal file
BIN
betas/beta-0.1.18.zip
Normal file
BIN
betas/beta-0.1.19.zip
Normal file
BIN
betas/beta-0.1.20.zip
Normal file
BIN
data/uploads/plant/1/0ff30f3857b84c19a2ee8234098b6661.jpg
Normal file
After Width: | Height: | Size: 1.0 MiB |
BIN
data/uploads/plant/1/109934aa6d404c95bb3927b8db7b4508.jpg
Normal file
After Width: | Height: | Size: 1.5 MiB |
BIN
data/uploads/plant/1/1cf30a8eed194888b6e51b74638c4631.jpg
Normal file
After Width: | Height: | Size: 2.2 MiB |
BIN
data/uploads/plant/1/3b5c0aff41a94a08b2d8cc7045906f61.jpg
Normal file
After Width: | Height: | Size: 1.4 MiB |
BIN
data/uploads/plant/1/3c8f4291db3349029fdbcd4f7eccfeb6.jpg
Normal file
After Width: | Height: | Size: 1.6 MiB |
BIN
data/uploads/plant/1/7fa82c187d364ae889a97481102f1f1b.jpg
Normal file
After Width: | Height: | Size: 1.1 MiB |
BIN
data/uploads/plant/1/aceaa8e104b947f1bff699e74363023b.jpg
Normal file
After Width: | Height: | Size: 1.1 MiB |
BIN
data/uploads/plant/1/bf8a3b199c724aa080cceb251f23d893.jpg
Normal file
After Width: | Height: | Size: 1.8 MiB |
BIN
data/uploads/plant/1/c66926aae52347cfabf473a04796ff76.jpg
Normal file
After Width: | Height: | Size: 1.5 MiB |
BIN
data/uploads/plant/1/f13c92a973c644ad84d02d55b5c54ba7.jpg
Normal file
After Width: | Height: | Size: 1.9 MiB |
BIN
data/uploads/plant/1/f229419ce91940c7907ced10420f933a.jpg
Normal file
After Width: | Height: | Size: 1.6 MiB |
BIN
data/uploads/plant/10/186ff5c003db4a98b3536e547c7c779d.jpg
Normal file
After Width: | Height: | Size: 1.8 MiB |
BIN
data/uploads/plant/10/4050a825225541ccb16d96a6711dd120.jpg
Normal file
After Width: | Height: | Size: 576 KiB |
BIN
data/uploads/plant/10/56d7e13642664b40b57d7444442fa887.jpg
Normal file
After Width: | Height: | Size: 458 KiB |
BIN
data/uploads/plant/10/5f79c3ae0b2a4de491246e55b5fde44a.jpg
Normal file
After Width: | Height: | Size: 2.1 MiB |
BIN
data/uploads/plant/10/5f8ba9b97937453fbc38afc8c655e059.jpg
Normal file
After Width: | Height: | Size: 1.9 MiB |
BIN
data/uploads/plant/10/aa2c98341ae8495583e8b48862b36699.jpg
Normal file
After Width: | Height: | Size: 2.0 MiB |
BIN
data/uploads/plant/10/e320d9cd30b047bf9d4e261b4c780c1d.jpg
Normal file
After Width: | Height: | Size: 1.3 MiB |
BIN
data/uploads/plant/108/02c8face5d6a4005802797a14af7b837.jpg
Normal file
After Width: | Height: | Size: 2.7 MiB |
BIN
data/uploads/plant/108/f1994e3f0c5a4ebcaa19c66f2ce55241.jpg
Normal file
After Width: | Height: | Size: 2.1 MiB |
BIN
data/uploads/plant/109/145bd5d890184c32b2c6aa4eaf0d83ec.jpg
Normal file
After Width: | Height: | Size: 840 KiB |
BIN
data/uploads/plant/109/75f779aed67645e78483abc5d032cc73.jpg
Normal file
After Width: | Height: | Size: 1.0 MiB |
BIN
data/uploads/plant/109/76507a9eff9a4f71b164d6960e02cf7e.jpg
Normal file
After Width: | Height: | Size: 2.2 MiB |
BIN
data/uploads/plant/11/25e61ab4af294e97849c2e3d3ebf96e8.jpg
Normal file
After Width: | Height: | Size: 1.3 MiB |
BIN
data/uploads/plant/11/51485221dc344885b6550a7df2f79a94.jpg
Normal file
After Width: | Height: | Size: 1.1 MiB |
BIN
data/uploads/plant/11/69afe7199c814fb795c33423e16fc6af.jpg
Normal file
After Width: | Height: | Size: 579 KiB |
BIN
data/uploads/plant/11/b87d3ae057bd4dee849308cb522972e9.jpg
Normal file
After Width: | Height: | Size: 1.0 MiB |
BIN
data/uploads/plant/11/c0c68d91169944cca723c31c7d1430d2.jpg
Normal file
After Width: | Height: | Size: 498 KiB |
BIN
data/uploads/plant/110/266c7f8096384c819baaedb6ca180414.jpg
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
data/uploads/plant/110/67542c62605d4ad49a4858b92cbe7f09.jpg
Normal file
After Width: | Height: | Size: 2.8 MiB |
BIN
data/uploads/plant/110/df339dba5c344d3682e2390cacc085cd.jpg
Normal file
After Width: | Height: | Size: 1.3 MiB |
BIN
data/uploads/plant/12/0777c803d1f84c2da5d7db0779996a06.jpg
Normal file
After Width: | Height: | Size: 522 KiB |
BIN
data/uploads/plant/12/3ea746f738e34a50a2fe6ee7f4522a5f.jpg
Normal file
After Width: | Height: | Size: 1.0 MiB |
BIN
data/uploads/plant/12/3f9ac62901aa4ab0b4e0dff91ea0d601.jpg
Normal file
After Width: | Height: | Size: 496 KiB |
BIN
data/uploads/plant/12/715c4d4b27fa48c995dde3b323d40ca7.jpg
Normal file
After Width: | Height: | Size: 420 KiB |
BIN
data/uploads/plant/12/7dc0e91ec8544d19916ff7f1663aa994.jpg
Normal file
After Width: | Height: | Size: 761 KiB |
BIN
data/uploads/plant/13/1fe89ba5a9a5418daddda7388cf049d2.jpg
Normal file
After Width: | Height: | Size: 697 KiB |
BIN
data/uploads/plant/13/9cb645c2db2e4d269c245acf4a13c423.jpg
Normal file
After Width: | Height: | Size: 554 KiB |
BIN
data/uploads/plant/13/d0802efbc7094285a03f4a47b14b306c.jpg
Normal file
After Width: | Height: | Size: 642 KiB |
BIN
data/uploads/plant/14/4fb10fe3dee2460bac5e705ac7928bc2.jpg
Normal file
After Width: | Height: | Size: 657 KiB |
BIN
data/uploads/plant/14/7cfd8a984912483db2656a4e90799d6b.jpg
Normal file
After Width: | Height: | Size: 918 KiB |
BIN
data/uploads/plant/14/9c0d9fb448e34dc5ab62390b67bbdfd5.jpg
Normal file
After Width: | Height: | Size: 624 KiB |
BIN
data/uploads/plant/14/bdc8b95661874052beac3bda1ef99d60.jpg
Normal file
After Width: | Height: | Size: 1.3 MiB |
BIN
data/uploads/plant/14/e42aeff454c74e98926f22ef1d49368e.jpg
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
data/uploads/plant/15/0af823a45d514bb294edfb7d4536a402.jpg
Normal file
After Width: | Height: | Size: 517 KiB |
BIN
data/uploads/plant/15/86b618f3072d4b639c0bf3f6837df341.jpg
Normal file
After Width: | Height: | Size: 1.3 MiB |
BIN
data/uploads/plant/15/93461c166b56437ba46a81ed63d21bb0.jpg
Normal file
After Width: | Height: | Size: 1.6 MiB |
BIN
data/uploads/plant/15/cc20753d71c94dce96e4a45f71cea8e9.jpg
Normal file
After Width: | Height: | Size: 1.6 MiB |
BIN
data/uploads/plant/15/f5904d5f72104a6c84e9a6c84f1b8ff4.jpg
Normal file
After Width: | Height: | Size: 577 KiB |
BIN
data/uploads/plant/15/fa68d4e789bf4f57b778db5e29041839.jpg
Normal file
After Width: | Height: | Size: 1.5 MiB |
BIN
data/uploads/plant/16/0b52d55d99024d419ea7d5deea1fcb9d.jpg
Normal file
After Width: | Height: | Size: 596 KiB |
BIN
data/uploads/plant/16/2b8c0bedc0c4458487476803c39fa19d.jpg
Normal file
After Width: | Height: | Size: 2.4 MiB |
BIN
data/uploads/plant/16/2bd0c35393ba411cbdae21578868822c.jpg
Normal file
After Width: | Height: | Size: 566 KiB |
BIN
data/uploads/plant/16/93316f6df94e4a30809a12f81ef62cc6.jpg
Normal file
After Width: | Height: | Size: 1.3 MiB |
BIN
data/uploads/plant/16/bb986ac5a83e44b0a7d885cf940b4d02.jpg
Normal file
After Width: | Height: | Size: 2.3 MiB |
BIN
data/uploads/plant/16/d6f05f8a17c04b4287ce56cc242bad8a.jpg
Normal file
After Width: | Height: | Size: 1.5 MiB |
BIN
data/uploads/plant/16/e0d0dc570da44e33a610287108823c8a.jpg
Normal file
After Width: | Height: | Size: 1.6 MiB |
BIN
data/uploads/plant/17/0b80d1a06f4747619bac8ff137d4b8bd.jpg
Normal file
After Width: | Height: | Size: 635 KiB |
BIN
data/uploads/plant/17/0dd3a367f4f84927bbe6c849b1093de5.jpg
Normal file
After Width: | Height: | Size: 1.4 MiB |
BIN
data/uploads/plant/17/29997bfb01f84d0fb0a2bae3a040856a.jpg
Normal file
After Width: | Height: | Size: 547 KiB |
BIN
data/uploads/plant/17/2c6988132afa4bbc81328318c9ac4b4c.jpg
Normal file
After Width: | Height: | Size: 1.5 MiB |
BIN
data/uploads/plant/17/bf66b3c24c364664bae01f7534b942cd.jpg
Normal file
After Width: | Height: | Size: 1.4 MiB |
BIN
data/uploads/plant/17/ca41764ae3e24357ac35337145eba8c9.jpg
Normal file
After Width: | Height: | Size: 1.9 MiB |
BIN
data/uploads/plant/18/25e7efa6c0ab46e3b9d6fdddd780c997.jpg
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
data/uploads/plant/18/3f1da9868b664e72b882450c2bf85b40.jpg
Normal file
After Width: | Height: | Size: 1.6 MiB |
BIN
data/uploads/plant/18/5fe72b4241d244f29223d465ab1a2446.jpg
Normal file
After Width: | Height: | Size: 1.4 MiB |
BIN
data/uploads/plant/18/80a097675bc04dd5983016003491900a.jpg
Normal file
After Width: | Height: | Size: 1.3 MiB |
BIN
data/uploads/plant/18/ae9e8c539fc9418d9a8d4c5ee50e2367.jpg
Normal file
After Width: | Height: | Size: 550 KiB |
BIN
data/uploads/plant/18/b13e194b26994320ac2b380b876dd738.jpg
Normal file
After Width: | Height: | Size: 1.6 MiB |
BIN
data/uploads/plant/18/e530c42a0ea9454daf68c4164a7c6b3e.jpg
Normal file
After Width: | Height: | Size: 573 KiB |
BIN
data/uploads/plant/19/2644f53c6eaa4a16961d82c45f58b1e4.jpg
Normal file
After Width: | Height: | Size: 1.4 MiB |
BIN
data/uploads/plant/19/5441cf38ed8d48afb28225a5754d2ad6.jpg
Normal file
After Width: | Height: | Size: 1.3 MiB |
BIN
data/uploads/plant/19/65a5618aaf14483fa22e19d5c253432f.jpg
Normal file
After Width: | Height: | Size: 489 KiB |
BIN
data/uploads/plant/19/8c45ba9b7a9c4cf28aaa5eaa7148baa2.jpg
Normal file
After Width: | Height: | Size: 410 KiB |
BIN
data/uploads/plant/19/a587941b9db84f1abc7d7d689037b1e9.jpg
Normal file
After Width: | Height: | Size: 2.5 MiB |
BIN
data/uploads/plant/19/bab5ba66a91646f388d0f0ec2caf48b3.jpg
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
data/uploads/plant/19/d8b57faf9d8c45fb8f597023451e97fe.jpg
Normal file
After Width: | Height: | Size: 489 KiB |
BIN
data/uploads/plant/2/04853e14934843939e665e7b13065bec.jpg
Normal file
After Width: | Height: | Size: 1.5 MiB |
BIN
data/uploads/plant/2/193c31a376ff49179b761201ee2df5f1.jpg
Normal file
After Width: | Height: | Size: 1.5 MiB |
BIN
data/uploads/plant/2/35c39f482df44664be8948cff97f20da.jpg
Normal file
After Width: | Height: | Size: 3.1 MiB |
BIN
data/uploads/plant/2/38ec39518fd44d1ea3e53fc0abff551a.jpg
Normal file
After Width: | Height: | Size: 1017 KiB |
BIN
data/uploads/plant/2/5bb12e93391347d69708c69d0afaa343.jpg
Normal file
After Width: | Height: | Size: 1.4 MiB |
BIN
data/uploads/plant/2/6e99a10132664c0ea3e4c181c460a583.jpg
Normal file
After Width: | Height: | Size: 995 KiB |