This commit is contained in:
2025-07-09 01:05:45 -05:00
parent 1bbe6e2743
commit d7a610a83b
113 changed files with 1512 additions and 2348 deletions

View File

@ -9,3 +9,6 @@ mysql_data/
migrations/versions/ migrations/versions/
uploads/ uploads/
static/uploads/ static/uploads/
data/mysql_data
data/uploads

View File

@ -21,3 +21,7 @@ NEO4J_PASSWORD=your_secure_password
# Media Settings # Media Settings
STANDARD_IMG_SIZE=300x200 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

View File

@ -1,5 +1,7 @@
FROM python:3.11-slim FROM python:3.11-slim
ENV PYTHONUNBUFFERED=1
# Install build deps and netcat for the DB-wait # Install build deps and netcat for the DB-wait
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
gcc \ gcc \
@ -9,23 +11,21 @@ RUN apt-get update && apt-get install -y \
curl \ curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Upgrade pip and install Python requirements
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --upgrade pip \ RUN pip install --upgrade pip \
&& pip install -r requirements.txt && pip install -r requirements.txt
# Copy the app code
COPY . . COPY . .
# Create a non-root user and give it ownership of /app # Create a non-root user and give it ownership of /app
RUN useradd -ms /bin/bash appuser \ RUN useradd -ms /bin/bash appuser \
&& chown -R appuser:appuser /app && chown -R appuser:appuser /app
# Switch to appuser for all subsequent commands # Switch to appuser for everything below
USER appuser USER appuser
# Make the entrypoint script executable # Prepare entrypoint
COPY --chown=appuser:appuser entrypoint.sh /entrypoint.sh COPY --chown=appuser:appuser entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh

View File

@ -4,19 +4,15 @@ import os
import json import json
import importlib import importlib
import time import time
import logging
from datetime import datetime
from dotenv import load_dotenv, find_dotenv
from flask import Flask, request from flask import Flask, request
from flask_sqlalchemy import SQLAlchemy 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_login import LoginManager
from flask_wtf.csrf import CSRFProtect 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 ─────────────────────────────────────────────────────────── # ─── Core extensions ───────────────────────────────────────────────────────────
db = SQLAlchemy() db = SQLAlchemy()
@ -24,120 +20,206 @@ migrate = Migrate()
login_manager = LoginManager() login_manager = LoginManager()
csrf = CSRFProtect() csrf = CSRFProtect()
# ─── Template helper (still in core) ──────────────────────────────────────────
from plugins.media.routes import generate_image_url # noqa: E402
def create_app(): def create_app():
# ─── Configure Flask ──────────────────────────────────────────────────────── # ─── Load .env ───────────────────────────────────────────────────────────────
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) 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( app = Flask(
__name__, __name__,
static_folder=os.path.join(project_root, 'static'), static_folder=os.path.join(project_root, 'static'),
static_url_path='/static' static_url_path='/static'
) )
app.config.from_object('app.config.Config')
# ─── Init extensions ─────────────────────────────────────────────────────── # install INFOlevel 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) csrf.init_app(app)
db.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.init_app(app)
login_manager.login_view = 'auth.login' 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 & errorhandlers ─────────────────────────────────────────────
from .errors import bp as errors_bp # noqa: E402 from .errors import bp as errors_bp # noqa: E402
app.register_blueprint(errors_bp) app.register_blueprint(errors_bp)
from .routes import init_app as register_core_routes # noqa: E402 from .routes import init_app as register_core_routes # noqa: E402
register_core_routes(app) register_core_routes(app)
app.logger.info("✔️ Registered core routes")
# ─── JSONdriven plugin loader ────────────────────────────────────────────── # ─── JSONdriven plugin loader with unbuffered prints ─────────────────────────
plugins_dir = os.path.join(project_root, 'plugins') print("🔌 Discovering plugins…", flush=True)
plugins_dir = os.path.join(project_root, 'plugins')
loaded_plugins = []
for name in sorted(os.listdir(plugins_dir)): for name in sorted(os.listdir(plugins_dir)):
plugin_path = os.path.join(plugins_dir, name) manifest = os.path.join(plugins_dir, name, 'plugin.json')
manifest = os.path.join(plugin_path, 'plugin.json')
if not os.path.isfile(manifest): if not os.path.isfile(manifest):
continue continue
plugin_info = {
'name': name,
'models': [],
'routes': None,
'cli': [],
'template_globals': [],
'tasks': [],
'tasks_init': [],
'subplugins': []
}
errors = [] errors = []
try: try:
meta = json.load(open(manifest)) meta = json.load(open(manifest))
except Exception as e: except Exception as e:
print(f"Plugin '{name}' 🛑 manifest load failed: {e}") print(f" ✖ manifest load error for '{name}': {e}", flush=True)
continue continue
# 1) Import models # 1) models
for model_path in meta.get('models', []): for model_path in meta.get('models', []):
try: try:
importlib.import_module(model_path) importlib.import_module(model_path)
plugin_info['models'].append(model_path)
except Exception as e: 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') ul = meta.get('user_loader')
if ul: if ul:
try: try:
mod = importlib.import_module(ul['module']) m = importlib.import_module(ul['module'])
fn = getattr(mod, ul['callable']) fn = getattr(m, ul['callable'])
fn(app) fn(login_manager)
except Exception as e: except Exception as e:
errors.append(f"user_loader ({ul['module']}:{ul['callable']}): {e}") errors.append(f"user_loader '{ul}': {e}")
# 2) Register routes # 3) routes
routes_cfg = meta.get('routes') rt = meta.get('routes')
if routes_cfg: if rt:
try: try:
mod = importlib.import_module(routes_cfg['module']) m = importlib.import_module(rt['module'])
bp_obj = getattr(mod, routes_cfg['blueprint']) bp_obj = getattr(m, rt['blueprint'])
prefix = routes_cfg.get('url_prefix') prefix = rt.get('url_prefix')
app.register_blueprint(bp_obj, url_prefix=prefix, strict_slashes=False) app.register_blueprint(bp_obj, url_prefix=prefix, strict_slashes=False)
plugin_info['routes'] = f"{rt['module']}::{rt['blueprint']}"
except Exception as e: except Exception as e:
errors.append(f"routes ({routes_cfg['module']}): {e}") errors.append(f"routes '{rt}': {e}")
# 3) Register CLI commands # 4) CLI
cli_cfg = meta.get('cli') cli = meta.get('cli')
if cli_cfg: if cli:
try: try:
mod = importlib.import_module(cli_cfg['module']) m = importlib.import_module(cli['module'])
fn = getattr(mod, cli_cfg['callable']) fn = getattr(m, cli['callable'])
app.cli.add_command(fn) app.cli.add_command(fn)
plugin_info['cli'].append(f"{cli['module']}::{cli['callable']}")
except Exception as e: 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', []): for tg in meta.get('template_globals', []):
try: try:
mod_name, fn_name = tg['callable'].rsplit('.', 1) mod_name, fn_name = tg['callable'].rsplit('.', 1)
mod = importlib.import_module(mod_name) m = importlib.import_module(mod_name)
fn = getattr(mod, fn_name) fn = getattr(m, fn_name)
app.jinja_env.globals[tg['name']] = fn app.jinja_env.globals[tg['name']] = fn
plugin_info['template_globals'].append(tg['name'])
except Exception as e: 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', []): for sp in meta.get('subplugins', []):
sub_info = {'name': sp['name'], 'models': [], 'routes': None}
for mp in sp.get('models', []): for mp in sp.get('models', []):
try: try:
importlib.import_module(mp) importlib.import_module(mp)
sub_info['models'].append(mp)
except Exception as e: except Exception as e:
errors.append(f"subplugin model ({mp}): {e}") errors.append(f"subplugin model '{mp}': {e}")
sp_rt = sp.get('routes') srt = sp.get('routes')
if sp_rt: if srt:
try: try:
mod = importlib.import_module(sp_rt['module']) m = importlib.import_module(srt['module'])
bp_obj = getattr(mod, sp_rt['blueprint']) bp_obj = getattr(m, srt['blueprint'])
prefix = sp_rt.get('url_prefix') app.register_blueprint(bp_obj, url_prefix=srt.get('url_prefix'), strict_slashes=False)
app.register_blueprint(bp_obj, url_prefix=prefix, strict_slashes=False) sub_info['routes'] = f"{srt['module']}::{srt['blueprint']}"
except Exception as e: 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: if errors:
print(f"Plugin '{name}' 🛑 failed to load: {'; '.join(errors)}") print(f"Plugin '{name}' errors: " + "; ".join(errors), flush=True)
else: 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 @app.context_processor
def inject_current_year(): def inject_current_year():
return {'current_year': datetime.now().year} return {'current_year': datetime.now().year}
@ -156,13 +238,13 @@ def create_app():
try: try:
duration = time.time() - getattr(request, '_start_time', time.time()) duration = time.time() - getattr(request, '_start_time', time.time())
ev = AnalyticsEvent( ev = AnalyticsEvent(
method = request.method, method = request.method,
path = request.path, path = request.path,
status_code = response.status_code, status_code = response.status_code,
response_time = duration, response_time = duration,
user_agent = request.headers.get('User-Agent'), user_agent = request.headers.get('User-Agent'),
referer = request.headers.get('Referer'), referer = request.headers.get('Referer'),
accept_language=request.headers.get('Accept-Language'), accept_language = request.headers.get('Accept-Language'),
) )
db.session.add(ev) db.session.add(ev)
db.session.commit() db.session.commit()
@ -174,7 +256,5 @@ def create_app():
def shutdown_session(exception=None): def shutdown_session(exception=None):
db.session.remove() db.session.remove()
# ─── Keep the template helper exposed ────────────────────────────────────── print("✅ create_app() complete; ready to serve", flush=True)
app.jinja_env.globals['generate_image_url'] = generate_image_url
return app return app

84
app/celery_app.py Normal file
View File

@ -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)

View File

@ -1,41 +1,47 @@
import os import os
from dotenv import load_dotenv, find_dotenv 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() dotenv_path = find_dotenv()
if dotenv_path: if dotenv_path:
load_dotenv(dotenv_path, override=True) load_dotenv(dotenv_path, override=True)
# ─── Paths ─────────────────────────────────────────────────────────────────── # ─── Paths ───────────────────────────────────────────────────────────────────
CONFIG_DIR = os.path.dirname(os.path.abspath(__file__)) CONFIG_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(CONFIG_DIR) PROJECT_ROOT = os.path.dirname(CONFIG_DIR)
class Config: class Config:
# ─── Environment ───────────────────────────────────────────────────────────── # ─── Environment ─────────────────────────────────────────────────────────
ENV = ( ENV = (
os.getenv('FLASK_ENV') os.getenv('FLASK_ENV')
or os.getenv('DOCKER_ENV') or os.getenv('DOCKER_ENV')
or 'production' or 'production'
).lower() ).lower()
# ─── Secret Key ────────────────────────────────────────────────────────────── # ─── Debug / Exceptions ───────────────────────────────────────────────────
DEBUG = ENV != 'production'
PROPAGATE_EXCEPTIONS = True
TRAP_HTTP_EXCEPTIONS = True
# ─── Secret Key ───────────────────────────────────────────────────────────
if ENV == 'production': if ENV == 'production':
SECRET_KEY = os.getenv('SECRET_KEY') SECRET_KEY = os.getenv('SECRET_KEY')
if not SECRET_KEY: if not SECRET_KEY:
raise RuntimeError( raise RuntimeError(
"SECRET_KEY environment variable not set! " "SECRET_KEY environment variable not set! "
"Generate one with `openssl rand -hex 32` and export it." "Generate one with `openssl rand -hex 32`."
) )
else: else:
# dev/test: fall back to env or a random one
SECRET_KEY = os.getenv('SECRET_KEY') or os.urandom(24).hex() SECRET_KEY = os.getenv('SECRET_KEY') or os.urandom(24).hex()
# ─── Uploads ─────────────────────────────────────────────────────────────── # ─── Uploads ───────────────────────────────────────────────────────────────
# Default to PROJECT_ROOT/static/uploads; if UPLOAD_FOLDER env is set, resolve relative to PROJECT_ROOT
_env_upload = os.getenv('UPLOAD_FOLDER', '') _env_upload = os.getenv('UPLOAD_FOLDER', '')
if _env_upload: if _env_upload:
# if absolute, use directly; otherwise join to project root UPLOAD_FOLDER = (
UPLOAD_FOLDER = _env_upload if os.path.isabs(_env_upload) else os.path.join(PROJECT_ROOT, _env_upload) _env_upload
if os.path.isabs(_env_upload)
else os.path.join(PROJECT_ROOT, _env_upload)
)
else: else:
UPLOAD_FOLDER = os.path.join(PROJECT_ROOT, "static", "uploads") UPLOAD_FOLDER = os.path.join(PROJECT_ROOT, "static", "uploads")
@ -48,7 +54,7 @@ class Config:
raise RuntimeError("CELERY_BROKER_URL environment variable not set!") raise RuntimeError("CELERY_BROKER_URL environment variable not set!")
CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND', CELERY_BROKER_URL) CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND', CELERY_BROKER_URL)
# ─── MySQL ───────────────────────────────────────────────────────────────── # ─── MySQL ─────────────────────────────────────────────────────────────────
MYSQL_USER = os.getenv('MYSQL_USER') MYSQL_USER = os.getenv('MYSQL_USER')
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD') MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD')
if not MYSQL_PASSWORD: if not MYSQL_PASSWORD:
@ -65,11 +71,17 @@ class Config:
) )
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
# ─── Cookies / Session ───────────────────────────────────────────────────── # ─── Cookies / Session ─────────────────────────────────────────────────────
SESSION_COOKIE_SECURE = True # 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_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax' SESSION_COOKIE_SAMESITE = 'Lax'
REMEMBER_COOKIE_SECURE = True
REMEMBER_COOKIE_HTTPONLY = True REMEMBER_COOKIE_HTTPONLY = True
REMEMBER_COOKIE_SAMESITE = 'Lax' REMEMBER_COOKIE_SAMESITE = 'Lax'
PREFERRED_URL_SCHEME = 'https' PREFERRED_URL_SCHEME = 'https'
@ -77,6 +89,7 @@ class Config:
# ─── Toggles ──────────────────────────────────────────────────────────────── # ─── Toggles ────────────────────────────────────────────────────────────────
ENABLE_DB_SEEDING = os.getenv('ENABLE_DB_SEEDING', '0') == '1' ENABLE_DB_SEEDING = os.getenv('ENABLE_DB_SEEDING', '0') == '1'
DOCKER_ENV = os.getenv('DOCKER_ENV', 'production') DOCKER_ENV = os.getenv('DOCKER_ENV', 'production')
INVITES_PER_USER = int(os.getenv('INVITES_PER_USER', 5))
# ─── Neo4j ────────────────────────────────────────────────────────────────── # ─── Neo4j ──────────────────────────────────────────────────────────────────
NEO4J_URI = os.getenv('NEO4J_URI', 'bolt://neo4j:7687') NEO4J_URI = os.getenv('NEO4J_URI', 'bolt://neo4j:7687')
@ -85,8 +98,8 @@ class Config:
if not NEO4J_PASSWORD: if not NEO4J_PASSWORD:
raise RuntimeError("NEO4J_PASSWORD environment variable not set!") raise RuntimeError("NEO4J_PASSWORD environment variable not set!")
# ─── Misc ────────────────────────────────────────────────────────────────── # ─── Misc ──────────────────────────────────────────────────────────────────
STANDARD_IMG_SIZE = tuple( STANDARD_IMG_SIZE = tuple(
map(int, os.getenv('STANDARD_IMG_SIZE', '300x200').split('x')) map(int, os.getenv('STANDARD_IMG_SIZE', '300x200').split('x'))
) )
PLANT_CARDS_BASE_URL = "https://plant.cards" PLANT_CARDS_BASE_URL = "https://plant.cards"

View File

@ -97,16 +97,33 @@
</ul> </ul>
</div> </div>
<!-- Admin link --> <!-- Admin dropdown -->
{% if current_user.role == 'admin' %} {% if current_user.role == 'admin' %}
<a <div class="btn-group me-3">
class="btn btn-outline-danger me-3" <button
href="{{ url_for('admin.dashboard') }}" type="button"
> class="btn btn-outline-danger dropdown-toggle"
Admin Dashboard data-bs-toggle="dropdown"
</a> aria-expanded="false"
>
Admin
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for('admin.dashboard') }}">
Dashboard
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('admin.orphaned_media_list') }}">
Orphaned Media
</a>
</li>
</ul>
</div>
{% endif %} {% endif %}
<!-- Plugins dropdown --> <!-- Plugins dropdown -->
<div class="dropdown me-3"> <div class="dropdown me-3">
<a <a

BIN
beta-0.1.14.zip Normal file

Binary file not shown.

BIN
betas/beta-0.1.1.zip Normal file

Binary file not shown.

BIN
betas/beta-0.1.10.zip Normal file

Binary file not shown.

BIN
betas/beta-0.1.11.zip Normal file

Binary file not shown.

BIN
betas/beta-0.1.12.zip Normal file

Binary file not shown.

BIN
betas/beta-0.1.13.zip Normal file

Binary file not shown.

BIN
betas/beta-0.1.2.zip Normal file

Binary file not shown.

BIN
betas/beta-0.1.3.zip Normal file

Binary file not shown.

BIN
betas/beta-0.1.4.zip Normal file

Binary file not shown.

BIN
betas/beta-0.1.5.zip Normal file

Binary file not shown.

BIN
betas/beta-0.1.6.zip Normal file

Binary file not shown.

BIN
betas/beta-0.1.7.zip Normal file

Binary file not shown.

BIN
betas/beta-0.1.8.zip Normal file

Binary file not shown.

BIN
betas/beta-0.1.9.zip Normal file

Binary file not shown.

View File

@ -10,9 +10,6 @@ services:
build: . build: .
ports: ports:
- "5000:5000" - "5000:5000"
volumes:
- .:/app
- ./static/uploads:/app/static/uploads
env_file: env_file:
- .env - .env
environment: environment:
@ -37,6 +34,9 @@ services:
timeout: 3s timeout: 3s
retries: 3 retries: 3
start_period: 30s start_period: 30s
volumes:
- .:/app:delegated
- ./${UPLOAD_FOLDER}:/app/${UPLOAD_FOLDER}
networks: networks:
- appnet - appnet
@ -53,7 +53,7 @@ services:
ports: ports:
- "42000:3306" - "42000:3306"
volumes: volumes:
- ./mysql_data:/var/lib/mysql - ./${MYSQL_DATA_FOLDER}:/var/lib/mysql
entrypoint: > entrypoint: >
sh -c "mkdir -p /var/lib/mysql && sh -c "mkdir -p /var/lib/mysql &&
chown -R 1000:998 /var/lib/mysql && chown -R 1000:998 /var/lib/mysql &&
@ -72,6 +72,8 @@ services:
restart: always restart: always
ports: ports:
- "8080:8080" - "8080:8080"
env_file:
- .env
environment: environment:
- ADMINER_DEFAULT_SERVER=db - ADMINER_DEFAULT_SERVER=db
depends_on: depends_on:
@ -86,7 +88,6 @@ services:
- "7474:7474" - "7474:7474"
- "7687:7687" - "7687:7687"
environment: environment:
# only the one var Neo4j actually needs
- NEO4J_AUTH=neo4j/${NEO4J_PASSWORD} - NEO4J_AUTH=neo4j/${NEO4J_PASSWORD}
volumes: volumes:
- neo4j_data:/data - neo4j_data:/data
@ -104,13 +105,35 @@ services:
- CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0
user: "appuser" user: "appuser"
command: celery -A plugins.utility.celery:celery_app worker --loglevel=info command: celery -A app.celery_app:celery worker --beat --loglevel=info
depends_on: depends_on:
- redis - redis
- db - db
networks: networks:
- appnet - appnet
flower:
build: .
env_file:
- .env
environment:
- FLASK_ENV=production
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
command: >
celery
--app app.celery_app.celery
--broker redis://redis:6379/0
flower
--port=5555
depends_on:
- redis
- db
ports:
- "5555:5555"
networks:
- appnet
volumes: volumes:
neo4j_data: neo4j_data:

View File

@ -14,11 +14,10 @@ echo "[✔] Database is up"
# Only the "flask" entrypoint needs uploads + migrations # Only the "flask" entrypoint needs uploads + migrations
if [ "$1" = "flask" ]; then if [ "$1" = "flask" ]; then
# Prepare upload dir (web only) # Prepare upload dir (web only) — path comes from .env UPLOAD_FOLDER (e.g. "data/uploads")
UPLOAD_DIR="/app/${UPLOAD_FOLDER:-static/uploads}" UPLOAD_DIR="/app/${UPLOAD_FOLDER:-static/uploads}"
mkdir -p "$UPLOAD_DIR" mkdir -p "$UPLOAD_DIR"
chown -R 1000:998 "$UPLOAD_DIR" echo "⏺️ Ensured upload directory exists: $UPLOAD_DIR"
chmod -R 775 "$UPLOAD_DIR"
# Run DB migrations # Run DB migrations
echo "[🛠️] Applying database migrations" echo "[🛠️] Applying database migrations"

View File

@ -2,32 +2,30 @@
import os import os
import sys import sys
import warnings
import json import json
import importlib import importlib
from logging.config import fileConfig from logging.config import fileConfig
from sqlalchemy import create_engine, pool from sqlalchemy import create_engine, pool
from alembic import context from alembic import context
# ─── Ensure we can load .env and app code ──────────────────────────────────── # ─── Suppress harmless warnings about FK cycles ───────────────────────────────
warnings.filterwarnings(
"ignore",
r"Cannot correctly sort tables; there are unresolvable cycles between tables.*"
)
# ─── Ensure project root is on sys.path ──────────────────────────────────────
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, project_root) sys.path.insert(0, project_root)
# ─── Load .env (so MYSQL_* and other vars are available) ───────────────────── # ─── Load environment vars so DB URL is available ────────────────────────────
from dotenv import load_dotenv, find_dotenv from dotenv import load_dotenv, find_dotenv
dotenv_path = find_dotenv() # looks in project root or parents dotenv = find_dotenv()
if dotenv_path: if dotenv:
load_dotenv(dotenv_path, override=True) load_dotenv(dotenv, override=True)
# ─── Alembic Config & Logging ──────────────────────────────────────────────── # ─── Dynamically import every plugins models *before* capturing metadata ────
config = context.config
fileConfig(config.config_file_name)
# ─── Import your apps metadata for 'autogenerate' support ─────────────────
from app import db
target_metadata = db.metadata
# ─── Dynamically import all plugin models listed in plugin.json ─────────────
plugins_dir = os.path.join(project_root, "plugins") plugins_dir = os.path.join(project_root, "plugins")
for plugin in sorted(os.listdir(plugins_dir)): for plugin in sorted(os.listdir(plugins_dir)):
manifest = os.path.join(plugins_dir, plugin, "plugin.json") manifest = os.path.join(plugins_dir, plugin, "plugin.json")
@ -37,7 +35,6 @@ for plugin in sorted(os.listdir(plugins_dir)):
meta = json.load(open(manifest)) meta = json.load(open(manifest))
except Exception: except Exception:
continue continue
for model_mod in meta.get("models", []): for model_mod in meta.get("models", []):
try: try:
importlib.import_module(model_mod) importlib.import_module(model_mod)
@ -50,59 +47,70 @@ for plugin in sorted(os.listdir(plugins_dir)):
except ImportError: except ImportError:
pass pass
# ─── Build or retrieve the database URL ────────────────────────────────────── # ─── Alembic config & logging ────────────────────────────────────────────────
def get_database_url(): config = context.config
# 1) alembic.ini setting fileConfig(config.config_file_name)
# ─── Now import the applications metadata ───────────────────────────────────
from app import db
target_metadata = db.metadata
# ─── Hook to skip unwanted objects (never drop tables) ───────────────────────
def include_object(obj, name, type_, reflected, compare_to):
# skip tables present in DB but not in models
if type_ == "table" and reflected and compare_to is None:
return False
# skip constraints & indexes
if type_ in ("foreign_key_constraint", "unique_constraint", "index"):
return False
return True
# ─── Helper to build the DB URL ───────────────────────────────────────────────
def get_url():
url = config.get_main_option("sqlalchemy.url") url = config.get_main_option("sqlalchemy.url")
if url: if url:
return url return url.strip()
# 2) Generic DATABASE_URL env var
url = os.environ.get("DATABASE_URL") url = os.environ.get("DATABASE_URL")
if url: if url:
return url return url
u = os.environ.get("MYSQL_USER")
p = os.environ.get("MYSQL_PASSWORD")
h = os.environ.get("MYSQL_HOST", "db")
pt= os.environ.get("MYSQL_PORT", "3306")
dbn= os.environ.get("MYSQL_DATABASE")
if u and p and dbn:
return f"mysql+pymysql://{u}:{p}@{h}:{pt}/{dbn}"
raise RuntimeError("No DB URL configured")
# 3) MySQL env vars (from .env or docker-compose) # ─── Offline migrations ──────────────────────────────────────────────────────
user = os.environ.get("MYSQL_USER")
pwd = os.environ.get("MYSQL_PASSWORD")
host = os.environ.get("MYSQL_HOST", "db")
port = os.environ.get("MYSQL_PORT", "3306")
dbn = os.environ.get("MYSQL_DATABASE")
if user and pwd and dbn:
return f"mysql+pymysql://{user}:{pwd}@{host}:{port}/{dbn}"
raise RuntimeError(
"Database URL not configured for Alembic migrations; "
"set 'sqlalchemy.url' in alembic.ini, or DATABASE_URL, "
"or MYSQL_USER/MYSQL_PASSWORD/MYSQL_DATABASE in the environment"
)
# ─── Offline migration ───────────────────────────────────────────────────────
def run_migrations_offline(): def run_migrations_offline():
url = get_database_url()
context.configure( context.configure(
url=url, url=get_url(),
target_metadata=target_metadata, target_metadata=target_metadata,
literal_binds=True, literal_binds=True,
dialect_opts={"paramstyle": "named"}, dialect_opts={"paramstyle": "named"},
compare_type=True,
compare_server_default=True,
include_object=include_object,
) )
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
# ─── Online migration ─────────────────────────────────────────────────────── # ─── Online migrations ───────────────────────────────────────────────────────
def run_migrations_online(): def run_migrations_online():
url = get_database_url() engine = create_engine(get_url(), poolclass=pool.NullPool)
connectable = create_engine(url, poolclass=pool.NullPool) with engine.connect() as conn:
with connectable.connect() as connection:
context.configure( context.configure(
connection=connection, connection=conn,
target_metadata=target_metadata, target_metadata=target_metadata,
compare_type=True, compare_type=True,
compare_server_default=True, compare_server_default=True,
include_object=include_object,
) )
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
# ─── Entrypoint ─────────────────────────────────────────────────────────────
if context.is_offline_mode(): if context.is_offline_mode():
run_migrations_offline() run_migrations_offline()
else: else:

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 01876f89899b
Revises: a69f613f9cd5
Create Date: 2025-06-28 08:15:57.708963
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '01876f89899b'
down_revision = 'a69f613f9cd5'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 03650b9a0f3a
Revises: 85b7ca21ec19
Create Date: 2025-06-28 07:57:56.370633
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '03650b9a0f3a'
down_revision = '85b7ca21ec19'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 0514fb24a61e
Revises: fe0ebdec3255
Create Date: 2025-06-28 09:25:58.833912
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0514fb24a61e'
down_revision = 'fe0ebdec3255'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 06234a515bde
Revises: 87c6df96bef3
Create Date: 2025-06-30 09:44:06.865642
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '06234a515bde'
down_revision = '87c6df96bef3'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 076bfc1a441b
Revises: 7229fe50de09
Create Date: 2025-06-30 08:22:10.087506
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '076bfc1a441b'
down_revision = '7229fe50de09'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 08ebb5577232
Revises: 0efc1a18285f
Create Date: 2025-06-28 09:34:14.207419
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '08ebb5577232'
down_revision = '0efc1a18285f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 0964777a3294
Revises: 53d0e3d0cd47
Create Date: 2025-06-30 09:37:40.005273
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0964777a3294'
down_revision = '53d0e3d0cd47'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 0efc1a18285f
Revises: 886aa234b3b7
Create Date: 2025-06-28 09:30:43.185721
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0efc1a18285f'
down_revision = '886aa234b3b7'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 10e39b33d4e7
Revises: ee4be515bb55
Create Date: 2025-06-30 10:06:13.159708
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '10e39b33d4e7'
down_revision = 'ee4be515bb55'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 12cc29f97b11
Revises: dcc114909948
Create Date: 2025-06-30 07:59:46.612023
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '12cc29f97b11'
down_revision = 'dcc114909948'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 12ef820b5618
Revises: 228e71f1a33b
Create Date: 2025-06-30 08:45:15.427549
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '12ef820b5618'
down_revision = '228e71f1a33b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 19e2a1b15b5e
Revises: f00a9585a348
Create Date: 2025-06-27 22:59:54.162560
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '19e2a1b15b5e'
down_revision = 'f00a9585a348'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 209596f02c2a
Revises: 42ce181f4eab
Create Date: 2025-06-27 09:50:03.962692
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '209596f02c2a'
down_revision = '42ce181f4eab'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 228e71f1a33b
Revises: 493fbb46e881
Create Date: 2025-06-30 08:40:05.646744
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '228e71f1a33b'
down_revision = '493fbb46e881'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,36 +0,0 @@
"""auto-migrate
Revision ID: 24de4aa78a43
Revises: 4082065b932b
Create Date: 2025-06-28 23:24:05.909001
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '24de4aa78a43'
down_revision = '4082065b932b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('zip_jobs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('error', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('zip_jobs')
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 27f1b3976f3f
Revises: 10e39b33d4e7
Create Date: 2025-06-30 10:09:47.442196
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '27f1b3976f3f'
down_revision = '10e39b33d4e7'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 2d11e31941d9
Revises: acd3093204e7
Create Date: 2025-06-30 07:45:03.061969
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2d11e31941d9'
down_revision = 'acd3093204e7'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 30376c514135
Revises: 01876f89899b
Create Date: 2025-06-28 08:20:23.577743
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '30376c514135'
down_revision = '01876f89899b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 310f500a3d2f
Revises: d49ee8d82364
Create Date: 2025-06-30 10:13:54.468427
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '310f500a3d2f'
down_revision = 'd49ee8d82364'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 390c977fe679
Revises: c23c31ae3a1d
Create Date: 2025-06-28 08:34:18.914877
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '390c977fe679'
down_revision = 'c23c31ae3a1d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 4082065b932b
Revises: 08ebb5577232
Create Date: 2025-06-28 09:41:11.323777
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4082065b932b'
down_revision = '08ebb5577232'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 42ce181f4eab
Revises: 93f8a5cbc643
Create Date: 2025-06-27 09:47:28.698481
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '42ce181f4eab'
down_revision = '93f8a5cbc643'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 493fbb46e881
Revises: faeca4f53b04
Create Date: 2025-06-30 08:28:50.667633
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '493fbb46e881'
down_revision = 'faeca4f53b04'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 51640ecd70ee
Revises: 390c977fe679
Create Date: 2025-06-28 08:35:37.016653
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '51640ecd70ee'
down_revision = '390c977fe679'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 53d0e3d0cd47
Revises: c6fad4522e3c
Create Date: 2025-06-30 09:32:22.487970
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '53d0e3d0cd47'
down_revision = 'c6fad4522e3c'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 7229fe50de09
Revises: 12cc29f97b11
Create Date: 2025-06-30 08:20:50.414985
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7229fe50de09'
down_revision = '12cc29f97b11'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 807ca973d0cf
Revises: 51640ecd70ee
Create Date: 2025-06-28 08:46:56.744709
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '807ca973d0cf'
down_revision = '51640ecd70ee'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 85b7ca21ec19
Revises: 8c1e8db7b3cb
Create Date: 2025-06-27 23:34:04.669553
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '85b7ca21ec19'
down_revision = '8c1e8db7b3cb'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 87c6df96bef3
Revises: f34b5e058563
Create Date: 2025-06-30 09:43:22.353321
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '87c6df96bef3'
down_revision = 'f34b5e058563'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 886aa234b3b7
Revises: 0514fb24a61e
Create Date: 2025-06-28 09:27:59.962665
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '886aa234b3b7'
down_revision = '0514fb24a61e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 892da654697c
Revises: f7f41136c073
Create Date: 2025-06-28 08:56:06.592485
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '892da654697c'
down_revision = 'f7f41136c073'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 8c1e8db7b3cb
Revises: 19e2a1b15b5e
Create Date: 2025-06-27 23:21:19.031362
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8c1e8db7b3cb'
down_revision = '19e2a1b15b5e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 93f8a5cbc643
Revises: 9cc2626a6e79
Create Date: 2025-06-27 09:31:27.528072
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '93f8a5cbc643'
down_revision = '9cc2626a6e79'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: 9cc2626a6e79
Revises: d54a88422a68
Create Date: 2025-06-27 09:28:40.656166
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9cc2626a6e79'
down_revision = 'd54a88422a68'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: a5cb08298ee4
Revises: 0964777a3294
Create Date: 2025-06-30 09:40:06.234651
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a5cb08298ee4'
down_revision = '0964777a3294'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: a69f613f9cd5
Revises: a87d4c1df4e5
Create Date: 2025-06-28 08:13:00.288626
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a69f613f9cd5'
down_revision = 'a87d4c1df4e5'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,39 +0,0 @@
"""auto-migrate
Revision ID: a79453aefa45
Revises: 892da654697c
Create Date: 2025-06-28 09:18:37.918669
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a79453aefa45'
down_revision = '892da654697c'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('analytics_event',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('timestamp', sa.DateTime(), nullable=False),
sa.Column('method', sa.String(length=10), nullable=False),
sa.Column('path', sa.String(length=200), nullable=False),
sa.Column('status_code', sa.Integer(), nullable=False),
sa.Column('response_time', sa.Float(), nullable=False),
sa.Column('user_agent', sa.String(length=200), nullable=True),
sa.Column('referer', sa.String(length=200), nullable=True),
sa.Column('accept_language', sa.String(length=200), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('analytics_event')
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: a87d4c1df4e5
Revises: 03650b9a0f3a
Create Date: 2025-06-28 08:04:13.547254
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a87d4c1df4e5'
down_revision = '03650b9a0f3a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: acd3093204e7
Revises: f741addef1a1
Create Date: 2025-06-30 07:29:07.401797
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'acd3093204e7'
down_revision = 'f741addef1a1'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: b1e37dc718f2
Revises: c92477263320
Create Date: 2025-06-30 09:46:40.791979
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b1e37dc718f2'
down_revision = 'c92477263320'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: b56cd5e57987
Revises: a79453aefa45
Create Date: 2025-06-28 09:20:55.842491
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b56cd5e57987'
down_revision = 'a79453aefa45'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: b57c767ad0d6
Revises: 310f500a3d2f
Create Date: 2025-06-30 10:15:24.093788
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b57c767ad0d6'
down_revision = '310f500a3d2f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: b684611b27b1
Revises: 12ef820b5618
Create Date: 2025-06-30 08:51:21.461638
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b684611b27b1'
down_revision = '12ef820b5618'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,36 +0,0 @@
"""auto-migrate
Revision ID: c23c31ae3a1d
Revises: 30376c514135
Create Date: 2025-06-28 08:31:46.351949
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c23c31ae3a1d'
down_revision = '30376c514135'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.add_column(sa.Column('is_deleted', sa.Boolean(), nullable=False))
batch_op.add_column(sa.Column('is_banned', sa.Boolean(), nullable=False))
batch_op.add_column(sa.Column('suspended_until', sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_column('suspended_until')
batch_op.drop_column('is_banned')
batch_op.drop_column('is_deleted')
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: c6fad4522e3c
Revises: dd2492e0ede0
Create Date: 2025-06-30 09:30:35.084623
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c6fad4522e3c'
down_revision = 'dd2492e0ede0'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: c92477263320
Revises: fa34eb3f6084
Create Date: 2025-06-30 09:45:35.016682
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c92477263320'
down_revision = 'fa34eb3f6084'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: d49ee8d82364
Revises: 27f1b3976f3f
Create Date: 2025-06-30 10:12:13.065540
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd49ee8d82364'
down_revision = '27f1b3976f3f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: d54a88422a68
Revises: d7bbffbbc931
Create Date: 2025-06-27 09:24:27.947480
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd54a88422a68'
down_revision = 'd7bbffbbc931'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: d647dd4d3fbd
Revises: b684611b27b1
Create Date: 2025-06-30 08:54:56.276182
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd647dd4d3fbd'
down_revision = 'b684611b27b1'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,32 +0,0 @@
"""auto-migrate
Revision ID: d7bbffbbc931
Revises:
Create Date: 2025-06-27 09:20:35.600333
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd7bbffbbc931'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('grow_logs', schema=None) as batch_op:
batch_op.add_column(sa.Column('event_type', sa.String(length=50), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('grow_logs', schema=None) as batch_op:
batch_op.drop_column('event_type')
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: dcc114909948
Revises: 2d11e31941d9
Create Date: 2025-06-30 07:49:55.919638
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'dcc114909948'
down_revision = '2d11e31941d9'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: dd2492e0ede0
Revises: d647dd4d3fbd
Create Date: 2025-06-30 09:18:20.337888
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'dd2492e0ede0'
down_revision = 'd647dd4d3fbd'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: ee4be515bb55
Revises: b1e37dc718f2
Create Date: 2025-06-30 09:57:22.706206
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ee4be515bb55'
down_revision = 'b1e37dc718f2'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: f00a9585a348
Revises: 209596f02c2a
Create Date: 2025-06-27 09:55:08.249023
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f00a9585a348'
down_revision = '209596f02c2a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: f34b5e058563
Revises: a5cb08298ee4
Create Date: 2025-06-30 09:40:49.692944
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f34b5e058563'
down_revision = 'a5cb08298ee4'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,59 +0,0 @@
"""auto-migrate
Revision ID: f741addef1a1
Revises: 24de4aa78a43
Create Date: 2025-06-29 10:16:35.487343
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'f741addef1a1'
down_revision = '24de4aa78a43'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('update_images')
op.drop_table('plant_updates')
with op.batch_alter_table('grow_logs', schema=None) as batch_op:
batch_op.add_column(sa.Column('media_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key(None, 'media', ['media_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('grow_logs', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_column('media_id')
op.create_table('plant_updates',
sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False),
sa.Column('growlog_id', mysql.INTEGER(), autoincrement=False, nullable=False),
sa.Column('description', mysql.TEXT(), nullable=True),
sa.Column('created_at', mysql.DATETIME(), nullable=False),
sa.ForeignKeyConstraint(['growlog_id'], ['grow_logs.id'], name=op.f('plant_updates_ibfk_1')),
sa.PrimaryKeyConstraint('id'),
mysql_collate='utf8mb4_0900_ai_ci',
mysql_default_charset='utf8mb4',
mysql_engine='InnoDB'
)
op.create_table('update_images',
sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False),
sa.Column('update_id', mysql.INTEGER(), autoincrement=False, nullable=False),
sa.Column('media_id', mysql.INTEGER(), autoincrement=False, nullable=False),
sa.Column('created_at', mysql.DATETIME(), nullable=False),
sa.ForeignKeyConstraint(['media_id'], ['media.id'], name=op.f('update_images_ibfk_2')),
sa.ForeignKeyConstraint(['update_id'], ['plant_updates.id'], name=op.f('update_images_ibfk_1')),
sa.PrimaryKeyConstraint('id'),
mysql_collate='utf8mb4_0900_ai_ci',
mysql_default_charset='utf8mb4',
mysql_engine='InnoDB'
)
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: f7f41136c073
Revises: 807ca973d0cf
Create Date: 2025-06-28 08:50:28.814054
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f7f41136c073'
down_revision = '807ca973d0cf'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: f81a9a44a7fb
Revises: b56cd5e57987
Create Date: 2025-06-28 09:22:10.689435
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f81a9a44a7fb'
down_revision = 'b56cd5e57987'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: fa34eb3f6084
Revises: 06234a515bde
Create Date: 2025-06-30 09:44:53.445644
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fa34eb3f6084'
down_revision = '06234a515bde'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: faeca4f53b04
Revises: 076bfc1a441b
Create Date: 2025-06-30 08:27:15.001657
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'faeca4f53b04'
down_revision = '076bfc1a441b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""auto-migrate
Revision ID: fe0ebdec3255
Revises: f81a9a44a7fb
Create Date: 2025-06-28 09:23:36.801994
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fe0ebdec3255'
down_revision = 'f81a9a44a7fb'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -13,6 +13,7 @@ from app import db
from plugins.auth.models import User from plugins.auth.models import User
from plugins.plant.growlog.models import GrowLog from plugins.plant.growlog.models import GrowLog
from plugins.plant.models import Plant from plugins.plant.models import Plant
from plugins.media.models import Media
from plugins.admin.models import AnalyticsEvent from plugins.admin.models import AnalyticsEvent
from .forms import UserForm from .forms import UserForm
@ -332,3 +333,12 @@ def undelete_user(user_id):
page=request.args.get('page'), page=request.args.get('page'),
show_deleted=request.args.get('show_deleted'), show_deleted=request.args.get('show_deleted'),
q=request.args.get('q'))) q=request.args.get('q')))
@bp.route('/orphaned-media')
@login_required
def orphaned_media_list():
if not current_user.role == 'admin':
abort(403)
items = Media.query.filter_by(status='orphaned').order_by(Media.orphaned_at.desc()).all()
return render_template('admin/orphaned_media_list.html', items=items)

View File

@ -0,0 +1,33 @@
<!-- plugins/admin/templates/admin/orphaned_media_list.html -->
{% extends 'core/base.html' %}
{% block title %}Orphaned Media Admin Nature In Pots{% endblock %}
{% block content %}
<h1>Orphaned Media</h1>
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Original URL</th>
<th>New URL</th>
<th>Orphaned At</th>
</tr>
</thead>
<tbody>
{% for m in items %}
<tr>
<td>{{ m.id }}</td>
<td><a href="{{ m.original_file_url }}" target="_blank">{{ m.original_file_url }}</a></td>
<td><a href="{{ m.file_url }}" target="_blank">{{ m.file_url }}</a></td>
<td>{{ m.orphaned_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center">No orphaned media found.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@ -1,15 +1,62 @@
# plugins/auth/forms.py
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField from wtforms import StringField, PasswordField, BooleanField, SubmitField, IntegerField
from wtforms.validators import DataRequired, Email, Length, EqualTo from wtforms.validators import (
DataRequired, Email, Length, EqualTo, Regexp, NumberRange
)
class RegistrationForm(FlaskForm): class RegistrationForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=25)]) username = StringField(
email = StringField('Email', validators=[DataRequired(), Email()]) 'Username',
password = PasswordField('Password', validators=[DataRequired(), Length(min=6)]) validators=[DataRequired(), Length(min=3, max=25)]
confirm = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')]) )
submit = SubmitField('Register') email = StringField(
'Email',
validators=[DataRequired(), Email(), Length(max=120)]
)
invitation_code = StringField(
'Invitation Code',
validators=[DataRequired(),
Length(min=36, max=36,
message="Invitation code must be 36 characters.")]
)
password = PasswordField(
'Password',
validators=[DataRequired(), Length(min=6)]
)
confirm = PasswordField(
'Confirm Password',
validators=[DataRequired(),
EqualTo('password', message='Passwords must match.')]
)
submit = SubmitField('Register')
class LoginForm(FlaskForm): class LoginForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()]) email = StringField(
password = PasswordField('Password', validators=[DataRequired()]) 'Email',
submit = SubmitField('Login') validators=[DataRequired(), Email(), Length(max=120)]
)
password = PasswordField(
'Password',
validators=[DataRequired()]
)
remember_me = BooleanField('Remember me')
submit = SubmitField('Log In')
class InviteForm(FlaskForm):
email = StringField(
'Recipient Email',
validators=[DataRequired(), Email(), Length(max=120)]
)
submit = SubmitField('Send Invite')
class AdjustInvitesForm(FlaskForm):
delta = IntegerField(
'Adjustment',
validators=[DataRequired(), NumberRange()]
)
submit = SubmitField('Adjust Invites')

View File

@ -1,33 +1,42 @@
# File: plugins/auth/models.py from uuid import uuid4
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin from flask_login import UserMixin
from datetime import datetime from sqlalchemy import event
from sqlalchemy.orm import object_session
from app import db, login_manager from app import db, login_manager
from app.config import Config
class User(db.Model, UserMixin): class User(db.Model, UserMixin):
__tablename__ = 'users' __tablename__ = 'users'
__table_args__ = {'extend_existing': True} __table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), unique=True, nullable=False) email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.Text, nullable=False) password_hash = db.Column(db.Text, nullable=False)
role = db.Column(db.String(50), default='user') role = db.Column(db.String(50), default='user')
is_verified = db.Column(db.Boolean, default=False) is_verified = db.Column(db.Boolean, default=False)
excluded_from_analytics = db.Column(db.Boolean, default=False) excluded_from_analytics = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
is_deleted = db.Column(db.Boolean, nullable=False, default=False)
is_banned = db.Column(db.Boolean, nullable=False, default=False)
suspended_until = db.Column(db.DateTime, nullable=True)
is_deleted = db.Column(db.Boolean, nullable=False, default=False) # ← New: how many invites the user may still send
is_banned = db.Column(db.Boolean, nullable=False, default=False) invites_remaining = db.Column(
suspended_until = db.Column(db.DateTime, nullable=True) db.Integer,
nullable=False,
default=Config.INVITES_PER_USER
)
# relationships (existing)
submitted_submissions = db.relationship( submitted_submissions = db.relationship(
"Submission", "Submission",
foreign_keys="Submission.user_id", foreign_keys="Submission.user_id",
back_populates="submitter", back_populates="submitter",
lazy=True lazy=True
) )
reviewed_submissions = db.relationship( reviewed_submissions = db.relationship(
"Submission", "Submission",
foreign_keys="Submission.reviewed_by", foreign_keys="Submission.reviewed_by",
@ -42,18 +51,77 @@ class User(db.Model, UserMixin):
return check_password_hash(self.password_hash, password) return check_password_hash(self.password_hash, password)
# ─── Flask-Login integration ───────────────────────────────────────────────── class Invitation(db.Model):
__tablename__ = 'invitation'
def _load_user(user_id): id = db.Column(db.Integer, primary_key=True)
"""Return a User by ID, or None.""" code = db.Column(db.String(36), unique=True, nullable=False, default=lambda: str(uuid4()))
recipient_email = db.Column(db.String(120), nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
sender_ip = db.Column(db.String(45), nullable=False)
receiver_ip = db.Column(db.String(45), nullable=True)
is_used = db.Column(db.Boolean, default=False, nullable=False)
used_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
used_at = db.Column(db.DateTime, nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
creator = db.relationship(
'User',
foreign_keys=[created_by],
backref='invitations_sent'
)
user = db.relationship(
'User',
foreign_keys=[used_by],
backref='invitation_received'
)
def mark_used(self, user, ip_addr):
self.is_used = True
self.user = user
self.used_at = datetime.utcnow()
self.receiver_ip = ip_addr
# ─── Autorevoke invites when a user is banned/suspended/deleted ────────────────
@event.listens_for(User, 'after_update')
def revoke_user_invites(mapper, connection, target):
sess = object_session(target)
if not sess:
return
state = db.inspect(target)
banned_changed = state.attrs.is_banned.history.has_changes()
deleted_changed = state.attrs.is_deleted.history.has_changes()
suspended_changed = state.attrs.suspended_until.history.has_changes()
if (banned_changed and target.is_banned) \
or (deleted_changed and target.is_deleted) \
or (suspended_changed and target.suspended_until and target.suspended_until > datetime.utcnow()):
invs = sess.query(Invitation) \
.filter_by(created_by=target.id, is_active=True, is_used=False) \
.all()
refund = len(invs)
for inv in invs:
inv.is_active = False
target.invites_remaining = target.invites_remaining + refund
sess.add(target)
sess.flush()
# ─── Flask-Login user loader ────────────────────────────────────────────────────
@login_manager.user_loader
def load_user(user_id):
if not str(user_id).isdigit(): if not str(user_id).isdigit():
return None return None
return User.query.get(int(user_id)) return User.query.get(int(user_id))
def register_user_loader(app): def register_user_loader(lm):
""" """
Hook into Flask-Login to register the user_loader. Called by the JSONdriven plugin loader to wire FlaskLogin.
Called by our JSON-driven loader if declared in plugin.json.
""" """
login_manager.user_loader(_load_user) lm.user_loader(load_user)

View File

@ -1,30 +1,40 @@
# File: plugins/auth/routes.py # plugins/auth/routes.py
from flask import Blueprint, render_template, redirect, flash, url_for, request from datetime import datetime
from flask_login import login_user, logout_user, login_required from flask import (
from .models import User Blueprint, render_template, redirect, flash, url_for, request
from .forms import LoginForm, RegistrationForm )
from flask_login import (
login_user, logout_user, login_required, current_user
)
from app import db from app import db
from .models import User, Invitation
from .forms import (
LoginForm, RegistrationForm, InviteForm, AdjustInvitesForm
)
bp = Blueprint( bp = Blueprint(
'auth', 'auth',
__name__, __name__,
template_folder='templates/auth', # ← now points at plugins/auth/templates/auth/ template_folder='templates',
url_prefix='/auth' url_prefix='/auth'
) )
@bp.route('/login', methods=['GET', 'POST']) @bp.route('/login', methods=['GET', 'POST'])
def login(): def login():
if current_user.is_authenticated:
return redirect(url_for('home'))
form = LoginForm() form = LoginForm()
if form.validate_on_submit(): if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first() user = User.query.filter_by(email=form.email.data.lower()).first()
if user and user.check_password(form.password.data): if user and user.check_password(form.password.data):
login_user(user) login_user(user, remember=form.remember_me.data)
flash('Logged in successfully.', 'success') flash('Logged in successfully.', 'success')
return redirect(url_for('home')) next_page = request.args.get('next') or url_for('home')
return redirect(next_page)
flash('Invalid email or password.', 'danger') flash('Invalid email or password.', 'danger')
return render_template('login.html', form=form) # resolves to templates/auth/login.html return render_template('auth/login.html', form=form)
@bp.route('/logout') @bp.route('/logout')
@ -37,12 +47,92 @@ def logout():
@bp.route('/register', methods=['GET', 'POST']) @bp.route('/register', methods=['GET', 'POST'])
def register(): def register():
form = RegistrationForm() if current_user.is_authenticated:
return redirect(url_for('home'))
invite_code = request.args.get('invite', '').strip()
form = RegistrationForm(invitation_code=invite_code)
if form.validate_on_submit(): if form.validate_on_submit():
user = User(email=form.email.data) # Validate invitation
invitation = Invitation.query.filter_by(
code=form.invitation_code.data,
is_active=True,
is_used=False
).first()
if not invitation:
flash('Registration is by invitation only. Provide a valid code.', 'warning')
return render_template('auth/register.html', form=form)
if invitation.recipient_email and \
invitation.recipient_email.lower() != form.email.data.lower():
flash('This invitation is not valid for that email address.', 'warning')
return render_template('auth/register.html', form=form)
# Create the user
user = User(email=form.email.data.lower())
user.set_password(form.password.data) user.set_password(form.password.data)
db.session.add(user) db.session.add(user)
db.session.flush()
# Mark invitation used
invitation.mark_used(user, request.remote_addr)
db.session.commit() db.session.commit()
flash('Account created! Please log in.', 'success') flash('Account created! Please log in.', 'success')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
return render_template('register.html', form=form) # resolves to templates/auth/register.html
return render_template('auth/register.html', form=form)
@bp.route('/invite', methods=['GET', 'POST'])
@login_required
def send_invite():
form = InviteForm()
# Block banned/suspended users from sending
if current_user.is_banned or current_user.is_deleted or (
current_user.suspended_until and current_user.suspended_until > datetime.utcnow()
):
flash('You are not permitted to send invitations.', 'warning')
return redirect(url_for('home'))
if form.validate_on_submit():
if current_user.invites_remaining < 1:
flash('No invites remaining. Ask an admin to increase your quota.', 'danger')
else:
recipient = form.email.data.strip().lower()
inv = Invitation(
recipient_email=recipient,
created_by=current_user.id,
sender_ip=request.remote_addr
)
db.session.add(inv)
current_user.invites_remaining -= 1
db.session.commit()
flash(f'Invitation sent to {recipient}.', 'success')
return redirect(url_for('auth.send_invite'))
return render_template(
'auth/invite.html',
form=form,
invites=current_user.invites_remaining
)
@bp.route('/admin/adjust_invites/<int:user_id>', methods=['GET', 'POST'])
@login_required
def adjust_invites(user_id):
# assume current_user has admin rights
user = User.query.get_or_404(user_id)
form = AdjustInvitesForm()
if form.validate_on_submit():
user.invites_remaining = max(0, user.invites_remaining + form.delta.data)
db.session.commit()
flash(f"{user.email}'s invite quota adjusted by {form.delta.data}.", 'info')
return redirect(url_for('admin.user_list'))
return render_template(
'auth/adjust_invites.html',
form=form,
user=user
)

View File

@ -0,0 +1,19 @@
{% extends "core/base.html" %}
{% block title %}Send Invitation{% endblock %}
{% block content %}
<div class="container mt-4" style="max-width: 500px;">
<h2>Send Invitation</h2>
<p>You have <strong>{{ invites }}</strong> invites remaining.</p>
<form method="POST" action="{{ url_for('auth.send_invite') }}">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control", placeholder="recipient@example.com") }}
{% for error in form.email.errors %}
<div class="text-danger small">{{ error }}</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary">{{ form.submit.label.text }}</button>
</form>
</div>
{% endblock %}

View File

@ -1,16 +1,34 @@
{% extends 'core/base.html' %} {% extends "core/base.html" %}
{% block title %}Log In{% endblock %}
{% block content %} {% block content %}
<h2>Login</h2> <div class="container mt-4" style="max-width: 400px;">
<form method="POST" action="{{ url_for('auth.login') }}"> <h2 class="mb-3">Log In</h2>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <form method="POST" action="{{ url_for('auth.login') }}">
<div class="mb-3"> {{ form.hidden_tag() }}
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" required> <div class="mb-3">
</div> {{ form.email.label(class="form-label") }}
<div class="mb-3"> {{ form.email(class="form-control", placeholder="you@example.com") }}
<label for="password" class="form-label">Password</label> {% for e in form.email.errors %}
<input type="password" class="form-control" id="password" name="password" required> <div class="text-danger small">{{ e }}</div>
</div> {% endfor %}
<button type="submit" class="btn btn-primary">Login</button> </div>
</form>
<div class="mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control", placeholder="Password") }}
{% for e in form.password.errors %}
<div class="text-danger small">{{ e }}</div>
{% endfor %}
</div>
<div class="mb-3 form-check">
{{ form.remember_me(class="form-check-input") }}
{{ form.remember_me.label(class="form-check-label") }}
</div>
<button type="submit" class="btn btn-primary w-100">{{ form.submit.label.text }}</button>
</form>
</div>
{% endblock %} {% endblock %}

View File

@ -1,17 +1,53 @@
{% extends 'core/base.html' %} {% extends "core/base.html" %}
{% block title %}Register{% endblock %} {% block title %}Register{% endblock %}
{% block content %} {% block content %}
<h2>Register</h2> <div class="container mt-4" style="max-width: 400px;">
<form method="POST"> <h2 class="mb-3">Register</h2>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <form method="POST" action="{{ url_for('auth.register') }}">
{{ form.hidden_tag() }}
<div class="mb-3"> <div class="mb-3">
<label>Email</label> {{ form.username.label(class="form-label") }}
<input name="email" class="form-control" type="email" required> {{ form.username(class="form-control", placeholder="Username") }}
{% for e in form.username.errors %}
<div class="text-danger small">{{ e }}</div>
{% endfor %}
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label>Password</label> {{ form.email.label(class="form-label") }}
<input name="password" class="form-control" type="password" required> {{ form.email(class="form-control", placeholder="you@example.com") }}
{% for e in form.email.errors %}
<div class="text-danger small">{{ e }}</div>
{% endfor %}
</div> </div>
<button class="btn btn-primary" type="submit">Register</button>
</form> <div class="mb-3">
{{ form.invitation_code.label(class="form-label") }}
{{ form.invitation_code(class="form-control", placeholder="Invitation Code") }}
{% for e in form.invitation_code.errors %}
<div class="text-danger small">{{ e }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control", placeholder="Password") }}
{% for e in form.password.errors %}
<div class="text-danger small">{{ e }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.confirm.label(class="form-label") }}
{{ form.confirm(class="form-control", placeholder="Confirm Password") }}
{% for e in form.confirm.errors %}
<div class="text-danger small">{{ e }}</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary w-100">{{ form.submit.label.text }}</button>
</form>
</div>
{% endblock %} {% endblock %}

View File

@ -1,4 +1,4 @@
# plugins/media/models.py # File: plugins/media/models.py
from datetime import datetime from datetime import datetime
from flask import url_for from flask import url_for
@ -8,17 +8,22 @@ class Media(db.Model):
__tablename__ = "media" __tablename__ = "media"
__table_args__ = {"extend_existing": True} __table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
plugin = db.Column(db.String(50), nullable=False) plugin = db.Column(db.String(50), nullable=False)
related_id = db.Column(db.Integer, nullable=False) related_id = db.Column(db.Integer, nullable=False)
filename = db.Column(db.String(256), nullable=False) filename = db.Column(db.String(256), nullable=False)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
uploader_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) uploader_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
caption = db.Column(db.String(255), nullable=True) caption = db.Column(db.String(255), nullable=True)
plant_id = db.Column(db.Integer, db.ForeignKey("plant.id"), nullable=True) plant_id = db.Column(db.Integer, db.ForeignKey("plant.id"), nullable=True)
growlog_id = db.Column(db.Integer, db.ForeignKey("grow_logs.id"), nullable=True) growlog_id = db.Column(db.Integer, db.ForeignKey("grow_logs.id"), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
file_url = db.Column(db.String(512), nullable=False) file_url = db.Column(db.String(512), nullable=False)
original_file_url = db.Column(db.Text, nullable=True)
# ←─ NEW ── track orphaned state
status = db.Column(db.String(20), nullable=False, default='active')
orphaned_at = db.Column(db.DateTime, nullable=True)
hearts = db.relationship( hearts = db.relationship(
"ImageHeart", "ImageHeart",
@ -33,7 +38,7 @@ class Media(db.Model):
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
# ↔ Media items attached to a Plant # ↔ attached Plant
plant = db.relationship( plant = db.relationship(
"Plant", "Plant",
back_populates="media_items", back_populates="media_items",
@ -41,7 +46,7 @@ class Media(db.Model):
lazy="joined", lazy="joined",
) )
# ↔ Media items attached to a GrowLog # ↔ attached GrowLog
growlog = db.relationship( growlog = db.relationship(
"GrowLog", "GrowLog",
back_populates="media_items", back_populates="media_items",
@ -75,6 +80,15 @@ class Media(db.Model):
for fe in self.featured_entries for fe in self.featured_entries
) )
def mark_orphaned(self, new_url):
"""
Move to orphaned state, recording original URL and timestamp.
"""
self.original_file_url = self.file_url
self.file_url = new_url
self.status = 'orphaned'
self.orphaned_at = datetime.utcnow()
class ZipJob(db.Model): class ZipJob(db.Model):
__tablename__ = 'zip_jobs' __tablename__ = 'zip_jobs'
@ -93,7 +107,7 @@ class ImageHeart(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
media_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=False) media_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
class FeaturedImage(db.Model): class FeaturedImage(db.Model):
@ -106,4 +120,4 @@ class FeaturedImage(db.Model):
context_id = db.Column(db.Integer, nullable=False) context_id = db.Column(db.Integer, nullable=False)
override_text = db.Column(db.String(255), nullable=True) override_text = db.Column(db.String(255), nullable=True)
is_featured = db.Column(db.Boolean, default=True, nullable=False) is_featured = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)

View File

@ -2,7 +2,7 @@
"name": "Media", "name": "Media",
"version": "0.1.0", "version": "0.1.0",
"author": "Bryson Shepard <bryson@natureinpots.com>", "author": "Bryson Shepard <bryson@natureinpots.com>",
"description": "Manages image uploads, storage, and URL generation.", "description": "Upload, serve, and process images & other media.",
"module": "plugins.media", "module": "plugins.media",
"routes": { "routes": {
"module": "plugins.media.routes", "module": "plugins.media.routes",
@ -18,6 +18,15 @@
"callable": "plugins.media.routes.generate_image_url" "callable": "plugins.media.routes.generate_image_url"
} }
], ],
"tasks": [
"plugins.media.tasks"
],
"tasks_init": [
{
"module": "plugins.media.tasks",
"callable": "init_media_tasks"
}
],
"license": "Proprietary", "license": "Proprietary",
"repository": "https://github.com/your-org/your-app" "repository": "https://github.com/your-org/your-app"
} }

View File

@ -1,35 +1,47 @@
# File: plugins/media/tasks.py
import os import os
import shutil
import zipfile import zipfile
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from celery.schedules import crontab
from flask import current_app
from app import db from app import db
from plugins.media.models import ZipJob from app.celery_app import celery
from plugins.media.models import Media, ZipJob
# Reimport your create_app and utility plugin to get Celery # ─── Constants ────────────────────────────────────────────────────────────────
from plugins.utility.celery import celery_app IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
DOC_EXTS = {'.pdf', '.txt', '.csv'}
# Constants
IMAGE_EXTS = {'.jpg','.jpeg','.png','.gif'}
DOC_EXTS = {'.pdf','.txt','.csv'}
MAX_ZIP_FILES = 1000 MAX_ZIP_FILES = 1000
MAX_PIXELS = 8000 * 8000 MAX_PIXELS = 8000 * 8000
def validate_image(path): def validate_image(path):
try: try:
with Image.open(path) as img: with Image.open(path) as img:
img.verify() img.verify()
w, h = Image.open(path).size w, h = Image.open(path).size
return (w*h) <= MAX_PIXELS return (w * h) <= MAX_PIXELS
except (UnidentifiedImageError, IOError): except (UnidentifiedImageError, IOError):
return False return False
@celery_app.task(bind=True)
@celery.task(
bind=True,
name='plugins.media.tasks.process_zip',
queue='media'
)
def process_zip(self, job_id, zip_path): def process_zip(self, job_id, zip_path):
"""
Unpack and validate a useruploaded ZIP batch.
"""
job = ZipJob.query.get(job_id) job = ZipJob.query.get(job_id)
job.status = 'processing' job.status = 'processing'
db.session.commit() db.session.commit()
extract_dir = zip_path + '_contents' extract_dir = f"{zip_path}_contents"
try: try:
with zipfile.ZipFile(zip_path) as zf: with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist() names = zf.namelist()
@ -50,20 +62,84 @@ def process_zip(self, job_id, zip_path):
with zf.open(member) as src, open(target, 'wb') as dst: with zf.open(member) as src, open(target, 'wb') as dst:
dst.write(src.read()) dst.write(src.read())
if ext in IMAGE_EXTS and not validate_image(target): if ext in IMAGE_EXTS:
raise ValueError(f'Bad image: {member}') if not validate_image(target):
raise ValueError(f'Bad image: {member}')
elif ext == '.pdf': elif ext == '.pdf':
if open(target,'rb').read(5)!=b'%PDF-': with open(target, 'rb') as f:
header = f.read(5)
if header != b'%PDF-':
raise ValueError(f'Bad PDF: {member}') raise ValueError(f'Bad PDF: {member}')
else: else:
# txt/csv → simple UTF-8 check with open(target, 'rb') as f:
open(target,'rb').read(1024).decode('utf-8') f.read(1024).decode('utf-8')
job.status = 'done' job.status = 'done'
except Exception as e: except Exception as e:
job.status = 'failed' job.status = 'failed'
job.error = str(e) job.error = str(e)
finally: finally:
db.session.commit() db.session.commit()
if os.path.isdir(extract_dir):
shutil.rmtree(extract_dir)
@celery.on_after_configure.connect
def setup_periodic_tasks(sender, **kwargs):
"""
Schedule periodic media prune job every day at 2am.
"""
sender.add_periodic_task(
crontab(hour=2, minute=0),
prune_orphans.s(),
name='media_prune',
queue='media'
)
@celery.task(
name='plugins.media.tasks.prune_orphans',
queue='media'
)
def prune_orphans():
"""
Mark orphaned Media records, move their files to /static/orphaned/,
and log the change in the DB.
"""
orphan_dir = os.path.join(current_app.root_path, 'static', 'orphaned')
os.makedirs(orphan_dir, exist_ok=True)
candidates = Media.query.filter(
Media.status == 'active',
Media.plant_id.is_(None),
Media.growlog_id.is_(None),
Media.related_id.is_(None)
).all()
for m in candidates:
src_rel = m.file_url.lstrip('/')
src_abs = os.path.join(current_app.root_path, src_rel)
if not os.path.isfile(src_abs):
current_app.logger.warning(f"Orphan prune: file not found {src_abs}")
continue
filename = os.path.basename(src_abs)
dest_abs = os.path.join(orphan_dir, filename)
shutil.move(src_abs, dest_abs)
new_url = f"/static/orphaned/{filename}"
m.mark_orphaned(new_url)
current_app.logger.info(
f"Orphaned media #{m.id}: moved {src_rel}{new_url}"
)
db.session.commit()
def init_media_tasks(celery_app):
"""
Called by the JSONdriven loader so tasks_init no longer errors.
Celery scheduling is handled via on_after_configure.
"""
celery_app.logger.info("[Media] init_media_tasks called (noop)")

View File

@ -1,4 +1,4 @@
# plugins/plant/growlog/routes.py # File: plugins/plant/growlog/routes.py
from uuid import UUID as _UUID from uuid import UUID as _UUID
from flask import ( from flask import (
@ -7,6 +7,7 @@ from flask import (
) )
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db from app import db
from app.celery_app import celery
from .models import GrowLog from .models import GrowLog
from .forms import GrowLogForm from .forms import GrowLogForm
from plugins.plant.models import Plant, PlantCommonName from plugins.plant.models import Plant, PlantCommonName
@ -18,6 +19,7 @@ bp = Blueprint(
template_folder='templates', template_folder='templates',
) )
def _get_plant_by_uuid(uuid_val): def _get_plant_by_uuid(uuid_val):
""" """
Normalize & validate a UUID (may be a uuid.UUID or a string), Normalize & validate a UUID (may be a uuid.UUID or a string),
@ -40,6 +42,7 @@ def _get_plant_by_uuid(uuid_val):
.first_or_404() .first_or_404()
) )
def _user_plant_choices(): def _user_plant_choices():
""" """
Return [(uuid, "Common Name uuid"), ...] for all plants Return [(uuid, "Common Name uuid"), ...] for all plants
@ -103,9 +106,6 @@ def add_log(plant_uuid=None):
@bp.route('/<uuid:plant_uuid>') @bp.route('/<uuid:plant_uuid>')
@login_required @login_required
def list_logs(plant_uuid): def list_logs(plant_uuid):
from plugins.utility.celery import celery_app
celery_app.send_task('plugins.utility.tasks.ping')
limit = request.args.get('limit', default=10, type=int) limit = request.args.get('limit', default=10, type=int)
if plant_uuid: if plant_uuid:

View File

@ -90,6 +90,7 @@ class Plant(db.Model):
price = db.Column(db.Numeric(10, 2), nullable=True) price = db.Column(db.Numeric(10, 2), nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False) is_active = db.Column(db.Boolean, default=True, nullable=False)
is_public = db.Column(db.Boolean, nullable=False, default=False)
featured_media_id = db.Column(db.Integer, db.ForeignKey('media.id'), nullable=True) featured_media_id = db.Column(db.Integer, db.ForeignKey('media.id'), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)

View File

@ -1,13 +1,3 @@
# plugins/utility/__init__.py
def register_cli(app): def register_cli(app):
# no CLI commands for now # no CLI commands for now
pass pass
def init_celery(app):
# Called via plugin.json entry_point
from .celery import init_celery as _init, celery_app
_init(app)
# Attach it if you like: app.celery = celery_app
app.celery = celery_app
return celery_app

Some files were not shown because too many files have changed in this diff Show More