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/
uploads/
static/uploads/
data/mysql_data
data/uploads

View File

@ -21,3 +21,7 @@ NEO4J_PASSWORD=your_secure_password
# Media Settings
STANDARD_IMG_SIZE=300x200
# Celery broker + results (for both worker & flower)
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0

View File

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

View File

@ -4,19 +4,15 @@ import os
import json
import importlib
import time
import logging
from datetime import datetime
from dotenv import load_dotenv, find_dotenv
from flask import Flask, request
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_migrate import Migrate, upgrade, migrate as _migrate, stamp as _stamp
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect
from datetime import datetime
from dotenv import load_dotenv, find_dotenv
# ─── Load .env ────────────────────────────────────────────────────────────────
dotenv_path = find_dotenv()
if dotenv_path:
load_dotenv(dotenv_path, override=True)
# ─── Core extensions ───────────────────────────────────────────────────────────
db = SQLAlchemy()
@ -24,120 +20,206 @@ migrate = Migrate()
login_manager = LoginManager()
csrf = CSRFProtect()
# ─── Template helper (still in core) ──────────────────────────────────────────
from plugins.media.routes import generate_image_url # noqa: E402
def create_app():
# ─── Configure Flask ────────────────────────────────────────────────────────
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
# ─── Load .env ───────────────────────────────────────────────────────────────
dotenv_path = find_dotenv()
if dotenv_path:
load_dotenv(dotenv_path, override=True)
# ─── Flask setup ─────────────────────────────────────────────────────────────
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
migrations_dir = os.path.join(project_root, 'migrations')
app = Flask(
__name__,
static_folder=os.path.join(project_root, 'static'),
static_url_path='/static'
)
app.config.from_object('app.config.Config')
# ─── Init extensions ───────────────────────────────────────────────────────
# install 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)
db.init_app(app)
migrate.init_app(app, db)
migrate.init_app(app, db, directory=migrations_dir)
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
app.logger.info("🔗 Initialized extensions (CSRF, SQLAlchemy, Migrate, LoginManager)")
# ─── Core routes & errors ───────────────────────────────────────────────────
# ─── AUTOMATIC MIGRATIONS ────────────────────────────────────────────────────
with app.app_context():
app.logger.info("🛠️ Checking for schema changes…")
try:
upgrade()
app.logger.info("🛠️ Alembic reports DB is at head")
except Exception:
_stamp(revision='head')
upgrade()
app.logger.info("🛠️ Stamped and upgraded to head")
try:
_migrate(message="autogen", autogenerate=True)
app.logger.info("🛠️ Autogenerated migration revision created")
except Exception:
app.logger.debug("🛠️ No new migrations detected")
upgrade()
app.logger.info("🛠️ Database fully migrated")
# ─── Core routes & errorhandlers ─────────────────────────────────────────────
from .errors import bp as errors_bp # noqa: E402
app.register_blueprint(errors_bp)
from .routes import init_app as register_core_routes # noqa: E402
register_core_routes(app)
app.logger.info("✔️ Registered core routes")
# ─── JSONdriven plugin loader ──────────────────────────────────────────────
plugins_dir = os.path.join(project_root, 'plugins')
# ─── JSONdriven plugin loader with unbuffered prints ─────────────────────────
print("🔌 Discovering plugins…", flush=True)
plugins_dir = os.path.join(project_root, 'plugins')
loaded_plugins = []
for name in sorted(os.listdir(plugins_dir)):
plugin_path = os.path.join(plugins_dir, name)
manifest = os.path.join(plugin_path, 'plugin.json')
manifest = os.path.join(plugins_dir, name, 'plugin.json')
if not os.path.isfile(manifest):
continue
plugin_info = {
'name': name,
'models': [],
'routes': None,
'cli': [],
'template_globals': [],
'tasks': [],
'tasks_init': [],
'subplugins': []
}
errors = []
try:
meta = json.load(open(manifest))
except Exception as e:
print(f"Plugin '{name}' 🛑 manifest load failed: {e}")
print(f" ✖ manifest load error for '{name}': {e}", flush=True)
continue
# 1) Import models
# 1) models
for model_path in meta.get('models', []):
try:
importlib.import_module(model_path)
plugin_info['models'].append(model_path)
except Exception as e:
errors.append(f"model import ({model_path}): {e}")
errors.append(f"model '{model_path}': {e}")
# 1.b) user_loader hook
# 2) user_loader
ul = meta.get('user_loader')
if ul:
try:
mod = importlib.import_module(ul['module'])
fn = getattr(mod, ul['callable'])
fn(app)
m = importlib.import_module(ul['module'])
fn = getattr(m, ul['callable'])
fn(login_manager)
except Exception as e:
errors.append(f"user_loader ({ul['module']}:{ul['callable']}): {e}")
errors.append(f"user_loader '{ul}': {e}")
# 2) Register routes
routes_cfg = meta.get('routes')
if routes_cfg:
# 3) routes
rt = meta.get('routes')
if rt:
try:
mod = importlib.import_module(routes_cfg['module'])
bp_obj = getattr(mod, routes_cfg['blueprint'])
prefix = routes_cfg.get('url_prefix')
m = importlib.import_module(rt['module'])
bp_obj = getattr(m, rt['blueprint'])
prefix = rt.get('url_prefix')
app.register_blueprint(bp_obj, url_prefix=prefix, strict_slashes=False)
plugin_info['routes'] = f"{rt['module']}::{rt['blueprint']}"
except Exception as e:
errors.append(f"routes ({routes_cfg['module']}): {e}")
errors.append(f"routes '{rt}': {e}")
# 3) Register CLI commands
cli_cfg = meta.get('cli')
if cli_cfg:
# 4) CLI
cli = meta.get('cli')
if cli:
try:
mod = importlib.import_module(cli_cfg['module'])
fn = getattr(mod, cli_cfg['callable'])
m = importlib.import_module(cli['module'])
fn = getattr(m, cli['callable'])
app.cli.add_command(fn)
plugin_info['cli'].append(f"{cli['module']}::{cli['callable']}")
except Exception as e:
errors.append(f"cli ({cli_cfg['module']}:{cli_cfg['callable']}): {e}")
errors.append(f"cli '{cli}': {e}")
# 4) Template globals
# 5) template_globals
for tg in meta.get('template_globals', []):
try:
mod_name, fn_name = tg['callable'].rsplit('.', 1)
mod = importlib.import_module(mod_name)
fn = getattr(mod, fn_name)
m = importlib.import_module(mod_name)
fn = getattr(m, fn_name)
app.jinja_env.globals[tg['name']] = fn
plugin_info['template_globals'].append(tg['name'])
except Exception as e:
errors.append(f"template_global ({tg}): {e}")
errors.append(f"template_global '{tg}': {e}")
# 5) Subplugins (models + routes)
# 6) subplugins
for sp in meta.get('subplugins', []):
sub_info = {'name': sp['name'], 'models': [], 'routes': None}
for mp in sp.get('models', []):
try:
importlib.import_module(mp)
sub_info['models'].append(mp)
except Exception as e:
errors.append(f"subplugin model ({mp}): {e}")
sp_rt = sp.get('routes')
if sp_rt:
errors.append(f"subplugin model '{mp}': {e}")
srt = sp.get('routes')
if srt:
try:
mod = importlib.import_module(sp_rt['module'])
bp_obj = getattr(mod, sp_rt['blueprint'])
prefix = sp_rt.get('url_prefix')
app.register_blueprint(bp_obj, url_prefix=prefix, strict_slashes=False)
m = importlib.import_module(srt['module'])
bp_obj = getattr(m, srt['blueprint'])
app.register_blueprint(bp_obj, url_prefix=srt.get('url_prefix'), strict_slashes=False)
sub_info['routes'] = f"{srt['module']}::{srt['blueprint']}"
except Exception as e:
errors.append(f"subplugin routes ({sp_rt['module']}): {e}")
errors.append(f"subplugin routes '{srt}': {e}")
plugin_info['subplugins'].append(sub_info)
# 7) tasks
for task_mod in meta.get('tasks', []):
try:
importlib.import_module(task_mod)
plugin_info['tasks'].append(task_mod)
except Exception as e:
errors.append(f"task '{task_mod}': {e}")
# 8) tasks_init
for hook in meta.get('tasks_init', []):
try:
m = importlib.import_module(hook['module'])
fn = getattr(m, hook['callable'])
fn(app)
plugin_info['tasks_init'].append(f"{hook['module']}::{hook['callable']}")
except Exception as e:
errors.append(f"tasks_init '{hook}': {e}")
# Final status
if errors:
print(f"Plugin '{name}' 🛑 failed to load: {'; '.join(errors)}")
print(f"Plugin '{name}' errors: " + "; ".join(errors), flush=True)
else:
print(f"Plugin '{name}' ✔️ Loaded Successfully.")
print(f"Plugin '{name}' loaded", flush=True)
loaded_plugins.append(plugin_info)
# ─── Context processors, analytics, teardown ───────────────────────────────
# summary
print("🌟 Loaded plugins summary:", flush=True)
for info in loaded_plugins:
print(
f"{info['name']}: "
f"models={info['models']}, "
f"routes={info['routes']}, "
f"cli={info['cli']}, "
f"template_globals={info['template_globals']}, "
f"tasks={info['tasks']}, "
f"tasks_init={info['tasks_init']}, "
f"subplugins={[s['name'] for s in info['subplugins']]}",
flush=True
)
# ─── Context processors, before/after request, teardown ─────────────────────
@app.context_processor
def inject_current_year():
return {'current_year': datetime.now().year}
@ -156,13 +238,13 @@ def create_app():
try:
duration = time.time() - getattr(request, '_start_time', time.time())
ev = AnalyticsEvent(
method = request.method,
path = request.path,
status_code = response.status_code,
response_time = duration,
user_agent = request.headers.get('User-Agent'),
referer = request.headers.get('Referer'),
accept_language=request.headers.get('Accept-Language'),
method = request.method,
path = request.path,
status_code = response.status_code,
response_time = duration,
user_agent = request.headers.get('User-Agent'),
referer = request.headers.get('Referer'),
accept_language = request.headers.get('Accept-Language'),
)
db.session.add(ev)
db.session.commit()
@ -174,7 +256,5 @@ def create_app():
def shutdown_session(exception=None):
db.session.remove()
# ─── Keep the template helper exposed ──────────────────────────────────────
app.jinja_env.globals['generate_image_url'] = generate_image_url
print("✅ create_app() complete; ready to serve", flush=True)
return app

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

View File

@ -97,16 +97,33 @@
</ul>
</div>
<!-- Admin link -->
<!-- Admin dropdown -->
{% if current_user.role == 'admin' %}
<a
class="btn btn-outline-danger me-3"
href="{{ url_for('admin.dashboard') }}"
>
Admin Dashboard
</a>
<div class="btn-group me-3">
<button
type="button"
class="btn btn-outline-danger dropdown-toggle"
data-bs-toggle="dropdown"
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 %}
<!-- Plugins dropdown -->
<div class="dropdown me-3">
<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: .
ports:
- "5000:5000"
volumes:
- .:/app
- ./static/uploads:/app/static/uploads
env_file:
- .env
environment:
@ -37,6 +34,9 @@ services:
timeout: 3s
retries: 3
start_period: 30s
volumes:
- .:/app:delegated
- ./${UPLOAD_FOLDER}:/app/${UPLOAD_FOLDER}
networks:
- appnet
@ -53,7 +53,7 @@ services:
ports:
- "42000:3306"
volumes:
- ./mysql_data:/var/lib/mysql
- ./${MYSQL_DATA_FOLDER}:/var/lib/mysql
entrypoint: >
sh -c "mkdir -p /var/lib/mysql &&
chown -R 1000:998 /var/lib/mysql &&
@ -72,6 +72,8 @@ services:
restart: always
ports:
- "8080:8080"
env_file:
- .env
environment:
- ADMINER_DEFAULT_SERVER=db
depends_on:
@ -86,7 +88,6 @@ services:
- "7474:7474"
- "7687:7687"
environment:
# only the one var Neo4j actually needs
- NEO4J_AUTH=neo4j/${NEO4J_PASSWORD}
volumes:
- neo4j_data:/data
@ -104,13 +105,35 @@ services:
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
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:
- redis
- db
networks:
- 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:
neo4j_data:

View File

@ -14,11 +14,10 @@ echo "[✔] Database is up"
# Only the "flask" entrypoint needs uploads + migrations
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}"
mkdir -p "$UPLOAD_DIR"
chown -R 1000:998 "$UPLOAD_DIR"
chmod -R 775 "$UPLOAD_DIR"
echo "⏺️ Ensured upload directory exists: $UPLOAD_DIR"
# Run DB migrations
echo "[🛠️] Applying database migrations"

View File

@ -2,32 +2,30 @@
import os
import sys
import warnings
import json
import importlib
from logging.config import fileConfig
from sqlalchemy import create_engine, pool
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__), ".."))
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
dotenv_path = find_dotenv() # looks in project root or parents
if dotenv_path:
load_dotenv(dotenv_path, override=True)
dotenv = find_dotenv()
if dotenv:
load_dotenv(dotenv, override=True)
# ─── Alembic Config & Logging ────────────────────────────────────────────────
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 ─────────────
# ─── Dynamically import every plugins models *before* capturing metadata ────
plugins_dir = os.path.join(project_root, "plugins")
for plugin in sorted(os.listdir(plugins_dir)):
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))
except Exception:
continue
for model_mod in meta.get("models", []):
try:
importlib.import_module(model_mod)
@ -50,59 +47,70 @@ for plugin in sorted(os.listdir(plugins_dir)):
except ImportError:
pass
# ─── Build or retrieve the database URL ──────────────────────────────────────
def get_database_url():
# 1) alembic.ini setting
# ─── Alembic config & logging ────────────────────────────────────────────────
config = context.config
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")
if url:
return url
# 2) Generic DATABASE_URL env var
return url.strip()
url = os.environ.get("DATABASE_URL")
if 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)
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 ───────────────────────────────────────────────────────
# ─── Offline migrations ──────────────────────────────────────────────────────
def run_migrations_offline():
url = get_database_url()
context.configure(
url=url,
url=get_url(),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
compare_server_default=True,
include_object=include_object,
)
with context.begin_transaction():
context.run_migrations()
# ─── Online migration ───────────────────────────────────────────────────────
# ─── Online migrations ───────────────────────────────────────────────────────
def run_migrations_online():
url = get_database_url()
connectable = create_engine(url, poolclass=pool.NullPool)
with connectable.connect() as connection:
engine = create_engine(get_url(), poolclass=pool.NullPool)
with engine.connect() as conn:
context.configure(
connection=connection,
connection=conn,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
include_object=include_object,
)
with context.begin_transaction():
context.run_migrations()
# ─── Entrypoint ─────────────────────────────────────────────────────────────
if context.is_offline_mode():
run_migrations_offline()
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.plant.growlog.models import GrowLog
from plugins.plant.models import Plant
from plugins.media.models import Media
from plugins.admin.models import AnalyticsEvent
from .forms import UserForm
@ -332,3 +333,12 @@ def undelete_user(user_id):
page=request.args.get('page'),
show_deleted=request.args.get('show_deleted'),
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 wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, Length, EqualTo
from wtforms import StringField, PasswordField, BooleanField, SubmitField, IntegerField
from wtforms.validators import (
DataRequired, Email, Length, EqualTo, Regexp, NumberRange
)
class RegistrationForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=25)])
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired(), Length(min=6)])
confirm = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Register')
username = StringField(
'Username',
validators=[DataRequired(), Length(min=3, max=25)]
)
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):
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired()])
submit = SubmitField('Login')
email = StringField(
'Email',
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 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.config import Config
class User(db.Model, UserMixin):
__tablename__ = 'users'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.Text, nullable=False)
role = db.Column(db.String(50), default='user')
is_verified = db.Column(db.Boolean, default=False)
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.Text, nullable=False)
role = db.Column(db.String(50), default='user')
is_verified = 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)
is_banned = db.Column(db.Boolean, nullable=False, default=False)
suspended_until = db.Column(db.DateTime, nullable=True)
# ← New: how many invites the user may still send
invites_remaining = db.Column(
db.Integer,
nullable=False,
default=Config.INVITES_PER_USER
)
# relationships (existing)
submitted_submissions = db.relationship(
"Submission",
foreign_keys="Submission.user_id",
back_populates="submitter",
lazy=True
)
reviewed_submissions = db.relationship(
"Submission",
foreign_keys="Submission.reviewed_by",
@ -42,18 +51,77 @@ class User(db.Model, UserMixin):
return check_password_hash(self.password_hash, password)
# ─── Flask-Login integration ─────────────────────────────────────────────────
class Invitation(db.Model):
__tablename__ = 'invitation'
def _load_user(user_id):
"""Return a User by ID, or None."""
id = db.Column(db.Integer, primary_key=True)
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():
return None
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 our JSON-driven loader if declared in plugin.json.
Called by the JSONdriven plugin loader to wire FlaskLogin.
"""
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 flask_login import login_user, logout_user, login_required
from .models import User
from .forms import LoginForm, RegistrationForm
from datetime import datetime
from flask import (
Blueprint, render_template, redirect, flash, url_for, request
)
from flask_login import (
login_user, logout_user, login_required, current_user
)
from app import db
from .models import User, Invitation
from .forms import (
LoginForm, RegistrationForm, InviteForm, AdjustInvitesForm
)
bp = Blueprint(
'auth',
__name__,
template_folder='templates/auth', # ← now points at plugins/auth/templates/auth/
template_folder='templates',
url_prefix='/auth'
)
@bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('home'))
form = LoginForm()
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):
login_user(user)
login_user(user, remember=form.remember_me.data)
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')
return render_template('login.html', form=form) # resolves to templates/auth/login.html
return render_template('auth/login.html', form=form)
@bp.route('/logout')
@ -37,12 +47,92 @@ def logout():
@bp.route('/register', methods=['GET', 'POST'])
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():
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)
db.session.add(user)
db.session.flush()
# Mark invitation used
invitation.mark_used(user, request.remote_addr)
db.session.commit()
flash('Account created! Please log in.', 'success')
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 %}
<h2>Login</h2>
<form method="POST" action="{{ url_for('auth.login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">Login</button>
</form>
<div class="container mt-4" style="max-width: 400px;">
<h2 class="mb-3">Log In</h2>
<form method="POST" action="{{ url_for('auth.login') }}">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ 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 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 %}

View File

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

View File

@ -1,4 +1,4 @@
# plugins/media/models.py
# File: plugins/media/models.py
from datetime import datetime
from flask import url_for
@ -8,17 +8,22 @@ class Media(db.Model):
__tablename__ = "media"
__table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True)
plugin = db.Column(db.String(50), nullable=False)
related_id = db.Column(db.Integer, nullable=False)
filename = db.Column(db.String(256), 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)
caption = db.Column(db.String(255), 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)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
file_url = db.Column(db.String(512), nullable=False)
id = db.Column(db.Integer, primary_key=True)
plugin = db.Column(db.String(50), nullable=False)
related_id = db.Column(db.Integer, nullable=False)
filename = db.Column(db.String(256), 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)
caption = db.Column(db.String(255), 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)
created_at = db.Column(db.DateTime, default=datetime.utcnow, 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(
"ImageHeart",
@ -33,7 +38,7 @@ class Media(db.Model):
cascade="all, delete-orphan",
)
# ↔ Media items attached to a Plant
# ↔ attached Plant
plant = db.relationship(
"Plant",
back_populates="media_items",
@ -41,7 +46,7 @@ class Media(db.Model):
lazy="joined",
)
# ↔ Media items attached to a GrowLog
# ↔ attached GrowLog
growlog = db.relationship(
"GrowLog",
back_populates="media_items",
@ -75,6 +80,15 @@ class Media(db.Model):
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):
__tablename__ = 'zip_jobs'
@ -93,7 +107,7 @@ class ImageHeart(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.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):
@ -106,4 +120,4 @@ class FeaturedImage(db.Model):
context_id = db.Column(db.Integer, nullable=False)
override_text = db.Column(db.String(255), nullable=True)
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",
"version": "0.1.0",
"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",
"routes": {
"module": "plugins.media.routes",
@ -18,6 +18,15 @@
"callable": "plugins.media.routes.generate_image_url"
}
],
"tasks": [
"plugins.media.tasks"
],
"tasks_init": [
{
"module": "plugins.media.tasks",
"callable": "init_media_tasks"
}
],
"license": "Proprietary",
"repository": "https://github.com/your-org/your-app"
}

View File

@ -1,35 +1,47 @@
# File: plugins/media/tasks.py
import os
import shutil
import zipfile
from werkzeug.utils import secure_filename
from PIL import Image, UnidentifiedImageError
from celery.schedules import crontab
from flask import current_app
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
from plugins.utility.celery import celery_app
# Constants
IMAGE_EXTS = {'.jpg','.jpeg','.png','.gif'}
DOC_EXTS = {'.pdf','.txt','.csv'}
# ─── Constants ────────────────────────────────────────────────────────────────
IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
DOC_EXTS = {'.pdf', '.txt', '.csv'}
MAX_ZIP_FILES = 1000
MAX_PIXELS = 8000 * 8000
def validate_image(path):
try:
with Image.open(path) as img:
img.verify()
w, h = Image.open(path).size
return (w*h) <= MAX_PIXELS
return (w * h) <= MAX_PIXELS
except (UnidentifiedImageError, IOError):
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):
"""
Unpack and validate a useruploaded ZIP batch.
"""
job = ZipJob.query.get(job_id)
job.status = 'processing'
db.session.commit()
extract_dir = zip_path + '_contents'
extract_dir = f"{zip_path}_contents"
try:
with zipfile.ZipFile(zip_path) as zf:
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:
dst.write(src.read())
if ext in IMAGE_EXTS and not validate_image(target):
raise ValueError(f'Bad image: {member}')
if ext in IMAGE_EXTS:
if not validate_image(target):
raise ValueError(f'Bad image: {member}')
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}')
else:
# txt/csv → simple UTF-8 check
open(target,'rb').read(1024).decode('utf-8')
with open(target, 'rb') as f:
f.read(1024).decode('utf-8')
job.status = 'done'
except Exception as e:
job.status = 'failed'
job.error = str(e)
job.error = str(e)
finally:
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 flask import (
@ -7,6 +7,7 @@ from flask import (
)
from flask_login import login_required, current_user
from app import db
from app.celery_app import celery
from .models import GrowLog
from .forms import GrowLogForm
from plugins.plant.models import Plant, PlantCommonName
@ -18,6 +19,7 @@ bp = Blueprint(
template_folder='templates',
)
def _get_plant_by_uuid(uuid_val):
"""
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()
)
def _user_plant_choices():
"""
Return [(uuid, "Common Name uuid"), ...] for all plants
@ -103,9 +106,6 @@ def add_log(plant_uuid=None):
@bp.route('/<uuid:plant_uuid>')
@login_required
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)
if plant_uuid:

View File

@ -90,6 +90,7 @@ class Plant(db.Model):
price = db.Column(db.Numeric(10, 2), nullable=True)
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)
created_at = db.Column(db.DateTime, default=datetime.utcnow)

View File

@ -1,13 +1,3 @@
# plugins/utility/__init__.py
def register_cli(app):
# no CLI commands for now
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