diff --git a/.dockerignore b/.dockerignore
index 8cc8923..b31d37d 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -9,3 +9,6 @@ mysql_data/
migrations/versions/
uploads/
static/uploads/
+
+data/mysql_data
+data/uploads
diff --git a/.env.example b/.env.example
index 8d78f4e..358d551 100644
--- a/.env.example
+++ b/.env.example
@@ -21,3 +21,7 @@ NEO4J_PASSWORD=your_secure_password
# Media Settings
STANDARD_IMG_SIZE=300x200
+
+# Celery broker + results (for both worker & flower)
+CELERY_BROKER_URL=redis://redis:6379/0
+CELERY_RESULT_BACKEND=redis://redis:6379/0
diff --git a/Dockerfile b/Dockerfile
index ae6c8c4..d3bbe74 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,7 @@
FROM python:3.11-slim
+ENV PYTHONUNBUFFERED=1
+
# Install build deps and netcat for the DB-wait
RUN apt-get update && apt-get install -y \
gcc \
@@ -9,23 +11,21 @@ RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
-# Upgrade pip and install Python requirements
WORKDIR /app
COPY requirements.txt .
RUN pip install --upgrade pip \
&& pip install -r requirements.txt
-# Copy the app code
COPY . .
# Create a non-root user and give it ownership of /app
RUN useradd -ms /bin/bash appuser \
&& chown -R appuser:appuser /app
-# Switch to appuser for all subsequent commands
+# Switch to appuser for everything below
USER appuser
-# Make the entrypoint script executable
+# Prepare entrypoint
COPY --chown=appuser:appuser entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
diff --git a/app/__init__.py b/app/__init__.py
index 7cc9d6b..d70e83c 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -4,19 +4,15 @@ import os
import json
import importlib
import time
+import logging
+from datetime import datetime
+from dotenv import load_dotenv, find_dotenv
from flask import Flask, request
from flask_sqlalchemy import SQLAlchemy
-from flask_migrate import Migrate
+from flask_migrate import Migrate, upgrade, migrate as _migrate, stamp as _stamp
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect
-from datetime import datetime
-from dotenv import load_dotenv, find_dotenv
-
-# ─── Load .env ────────────────────────────────────────────────────────────────
-dotenv_path = find_dotenv()
-if dotenv_path:
- load_dotenv(dotenv_path, override=True)
# ─── Core extensions ───────────────────────────────────────────────────────────
db = SQLAlchemy()
@@ -24,120 +20,206 @@ migrate = Migrate()
login_manager = LoginManager()
csrf = CSRFProtect()
-# ─── Template helper (still in core) ──────────────────────────────────────────
-from plugins.media.routes import generate_image_url # noqa: E402
def create_app():
- # ─── Configure Flask ────────────────────────────────────────────────────────
- project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
+ # ─── Load .env ───────────────────────────────────────────────────────────────
+ dotenv_path = find_dotenv()
+ if dotenv_path:
+ load_dotenv(dotenv_path, override=True)
+
+ # ─── Flask setup ─────────────────────────────────────────────────────────────
+ project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
+ migrations_dir = os.path.join(project_root, 'migrations')
+
app = Flask(
__name__,
static_folder=os.path.join(project_root, 'static'),
static_url_path='/static'
)
- app.config.from_object('app.config.Config')
- # ─── Init extensions ───────────────────────────────────────────────────────
+ # install INFO‐level logging handler
+ handler = logging.StreamHandler()
+ handler.setLevel(logging.INFO)
+ app.logger.setLevel(logging.INFO)
+ app.logger.addHandler(handler)
+ app.logger.info("🚀 Starting create_app()")
+
+ # main config
+ app.config.from_object('app.config.Config')
+ app.logger.info(f"🔧 Loaded config from {app.config.__class__.__name__}")
+
+ # ─── Init extensions ─────────────────────────────────────────────────────────
csrf.init_app(app)
db.init_app(app)
- migrate.init_app(app, db)
+ migrate.init_app(app, db, directory=migrations_dir)
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
+ app.logger.info("🔗 Initialized extensions (CSRF, SQLAlchemy, Migrate, LoginManager)")
- # ─── Core routes & errors ───────────────────────────────────────────────────
+ # ─── AUTOMATIC MIGRATIONS ────────────────────────────────────────────────────
+ with app.app_context():
+ app.logger.info("🛠️ Checking for schema changes…")
+ try:
+ upgrade()
+ app.logger.info("🛠️ Alembic reports DB is at head")
+ except Exception:
+ _stamp(revision='head')
+ upgrade()
+ app.logger.info("🛠️ Stamped and upgraded to head")
+ try:
+ _migrate(message="autogen", autogenerate=True)
+ app.logger.info("🛠️ Autogenerated migration revision created")
+ except Exception:
+ app.logger.debug("🛠️ No new migrations detected")
+ upgrade()
+ app.logger.info("🛠️ Database fully migrated")
+
+ # ─── Core routes & error‐handlers ─────────────────────────────────────────────
from .errors import bp as errors_bp # noqa: E402
app.register_blueprint(errors_bp)
from .routes import init_app as register_core_routes # noqa: E402
register_core_routes(app)
- app.logger.info("✔️ Registered core routes")
- # ─── JSON‐driven plugin loader ──────────────────────────────────────────────
- plugins_dir = os.path.join(project_root, 'plugins')
+ # ─── JSON‐driven plugin loader with unbuffered prints ─────────────────────────
+ print("🔌 Discovering plugins…", flush=True)
+ plugins_dir = os.path.join(project_root, 'plugins')
+ loaded_plugins = []
+
for name in sorted(os.listdir(plugins_dir)):
- plugin_path = os.path.join(plugins_dir, name)
- manifest = os.path.join(plugin_path, 'plugin.json')
+ manifest = os.path.join(plugins_dir, name, 'plugin.json')
if not os.path.isfile(manifest):
continue
+ plugin_info = {
+ 'name': name,
+ 'models': [],
+ 'routes': None,
+ 'cli': [],
+ 'template_globals': [],
+ 'tasks': [],
+ 'tasks_init': [],
+ 'subplugins': []
+ }
errors = []
+
try:
meta = json.load(open(manifest))
except Exception as e:
- print(f"Plugin '{name}' 🛑 manifest load failed: {e}")
+ print(f" ✖ manifest load error for '{name}': {e}", flush=True)
continue
- # 1) Import models
+ # 1) models
for model_path in meta.get('models', []):
try:
importlib.import_module(model_path)
+ plugin_info['models'].append(model_path)
except Exception as e:
- errors.append(f"model import ({model_path}): {e}")
+ errors.append(f"model '{model_path}': {e}")
- # 1.b) user_loader hook
+ # 2) user_loader
ul = meta.get('user_loader')
if ul:
try:
- mod = importlib.import_module(ul['module'])
- fn = getattr(mod, ul['callable'])
- fn(app)
+ m = importlib.import_module(ul['module'])
+ fn = getattr(m, ul['callable'])
+ fn(login_manager)
except Exception as e:
- errors.append(f"user_loader ({ul['module']}:{ul['callable']}): {e}")
+ errors.append(f"user_loader '{ul}': {e}")
- # 2) Register routes
- routes_cfg = meta.get('routes')
- if routes_cfg:
+ # 3) routes
+ rt = meta.get('routes')
+ if rt:
try:
- mod = importlib.import_module(routes_cfg['module'])
- bp_obj = getattr(mod, routes_cfg['blueprint'])
- prefix = routes_cfg.get('url_prefix')
+ m = importlib.import_module(rt['module'])
+ bp_obj = getattr(m, rt['blueprint'])
+ prefix = rt.get('url_prefix')
app.register_blueprint(bp_obj, url_prefix=prefix, strict_slashes=False)
+ plugin_info['routes'] = f"{rt['module']}::{rt['blueprint']}"
except Exception as e:
- errors.append(f"routes ({routes_cfg['module']}): {e}")
+ errors.append(f"routes '{rt}': {e}")
- # 3) Register CLI commands
- cli_cfg = meta.get('cli')
- if cli_cfg:
+ # 4) CLI
+ cli = meta.get('cli')
+ if cli:
try:
- mod = importlib.import_module(cli_cfg['module'])
- fn = getattr(mod, cli_cfg['callable'])
+ m = importlib.import_module(cli['module'])
+ fn = getattr(m, cli['callable'])
app.cli.add_command(fn)
+ plugin_info['cli'].append(f"{cli['module']}::{cli['callable']}")
except Exception as e:
- errors.append(f"cli ({cli_cfg['module']}:{cli_cfg['callable']}): {e}")
+ errors.append(f"cli '{cli}': {e}")
- # 4) Template globals
+ # 5) template_globals
for tg in meta.get('template_globals', []):
try:
mod_name, fn_name = tg['callable'].rsplit('.', 1)
- mod = importlib.import_module(mod_name)
- fn = getattr(mod, fn_name)
+ m = importlib.import_module(mod_name)
+ fn = getattr(m, fn_name)
app.jinja_env.globals[tg['name']] = fn
+ plugin_info['template_globals'].append(tg['name'])
except Exception as e:
- errors.append(f"template_global ({tg}): {e}")
+ errors.append(f"template_global '{tg}': {e}")
- # 5) Subplugins (models + routes)
+ # 6) subplugins
for sp in meta.get('subplugins', []):
+ sub_info = {'name': sp['name'], 'models': [], 'routes': None}
for mp in sp.get('models', []):
try:
importlib.import_module(mp)
+ sub_info['models'].append(mp)
except Exception as e:
- errors.append(f"subplugin model ({mp}): {e}")
- sp_rt = sp.get('routes')
- if sp_rt:
+ errors.append(f"subplugin model '{mp}': {e}")
+ srt = sp.get('routes')
+ if srt:
try:
- mod = importlib.import_module(sp_rt['module'])
- bp_obj = getattr(mod, sp_rt['blueprint'])
- prefix = sp_rt.get('url_prefix')
- app.register_blueprint(bp_obj, url_prefix=prefix, strict_slashes=False)
+ m = importlib.import_module(srt['module'])
+ bp_obj = getattr(m, srt['blueprint'])
+ app.register_blueprint(bp_obj, url_prefix=srt.get('url_prefix'), strict_slashes=False)
+ sub_info['routes'] = f"{srt['module']}::{srt['blueprint']}"
except Exception as e:
- errors.append(f"subplugin routes ({sp_rt['module']}): {e}")
+ errors.append(f"subplugin routes '{srt}': {e}")
+ plugin_info['subplugins'].append(sub_info)
+
+ # 7) tasks
+ for task_mod in meta.get('tasks', []):
+ try:
+ importlib.import_module(task_mod)
+ plugin_info['tasks'].append(task_mod)
+ except Exception as e:
+ errors.append(f"task '{task_mod}': {e}")
+
+ # 8) tasks_init
+ for hook in meta.get('tasks_init', []):
+ try:
+ m = importlib.import_module(hook['module'])
+ fn = getattr(m, hook['callable'])
+ fn(app)
+ plugin_info['tasks_init'].append(f"{hook['module']}::{hook['callable']}")
+ except Exception as e:
+ errors.append(f"tasks_init '{hook}': {e}")
- # Final status
if errors:
- print(f"Plugin '{name}' 🛑 failed to load: {'; '.join(errors)}")
+ print(f" ✖ Plugin '{name}' errors: " + "; ".join(errors), flush=True)
else:
- print(f"Plugin '{name}' ✔️ Loaded Successfully.")
+ print(f" ✔ Plugin '{name}' loaded", flush=True)
+ loaded_plugins.append(plugin_info)
- # ─── Context processors, analytics, teardown ───────────────────────────────
+ # summary
+ print("🌟 Loaded plugins summary:", flush=True)
+ for info in loaded_plugins:
+ print(
+ f" • {info['name']}: "
+ f"models={info['models']}, "
+ f"routes={info['routes']}, "
+ f"cli={info['cli']}, "
+ f"template_globals={info['template_globals']}, "
+ f"tasks={info['tasks']}, "
+ f"tasks_init={info['tasks_init']}, "
+ f"subplugins={[s['name'] for s in info['subplugins']]}",
+ flush=True
+ )
+
+ # ─── Context processors, before/after request, teardown ─────────────────────
@app.context_processor
def inject_current_year():
return {'current_year': datetime.now().year}
@@ -156,13 +238,13 @@ def create_app():
try:
duration = time.time() - getattr(request, '_start_time', time.time())
ev = AnalyticsEvent(
- method = request.method,
- path = request.path,
- status_code = response.status_code,
- response_time = duration,
- user_agent = request.headers.get('User-Agent'),
- referer = request.headers.get('Referer'),
- accept_language=request.headers.get('Accept-Language'),
+ method = request.method,
+ path = request.path,
+ status_code = response.status_code,
+ response_time = duration,
+ user_agent = request.headers.get('User-Agent'),
+ referer = request.headers.get('Referer'),
+ accept_language = request.headers.get('Accept-Language'),
)
db.session.add(ev)
db.session.commit()
@@ -174,7 +256,5 @@ def create_app():
def shutdown_session(exception=None):
db.session.remove()
- # ─── Keep the template helper exposed ──────────────────────────────────────
- app.jinja_env.globals['generate_image_url'] = generate_image_url
-
+ print("✅ create_app() complete; ready to serve", flush=True)
return app
diff --git a/app/celery_app.py b/app/celery_app.py
new file mode 100644
index 0000000..9bd0816
--- /dev/null
+++ b/app/celery_app.py
@@ -0,0 +1,84 @@
+# File: app/celery_app.py
+
+import os
+import json
+import importlib
+from celery import Celery
+
+# 1) Create the Celery object (broker/backend will be set from our Flask config)
+celery = Celery('natureinpots')
+
+
+def init_celery(app):
+ """
+ Wire up Celery to our Flask app, discover all plugin task modules
+ and run any tasks_init hooks.
+ """
+ # 1. Configure broker & result backend from Flask config
+ celery.conf.broker_url = app.config['CELERY_BROKER_URL']
+ celery.conf.result_backend = app.config['CELERY_RESULT_BACKEND']
+
+ # 2. Make all tasks run inside the Flask application context
+ TaskBase = celery.Task
+ class ContextTask(TaskBase):
+ def __call__(self, *args, **kwargs):
+ with app.app_context():
+ return TaskBase.__call__(self, *args, **kwargs)
+ celery.Task = ContextTask
+
+ # 3. Discover all plugins by looking for plugin.json in plugins/
+ plugins_dir = os.path.join(app.root_path, '..', 'plugins')
+ task_modules = []
+ for plugin_name in sorted(os.listdir(plugins_dir)):
+ manifest = os.path.join(plugins_dir, plugin_name, 'plugin.json')
+ if not os.path.isfile(manifest):
+ continue
+
+ # Load plugin metadata
+ try:
+ meta = json.load(open(manifest))
+ except Exception:
+ app.logger.error(f"[Celery] Failed to load plugin.json for '{plugin_name}'")
+ continue
+
+ # a) Gather task modules (string or dict or list)
+ tasks_cfg = meta.get('tasks')
+ if isinstance(tasks_cfg, str):
+ task_modules.append(tasks_cfg)
+ elif isinstance(tasks_cfg, dict) and tasks_cfg.get('module'):
+ task_modules.append(tasks_cfg['module'])
+ elif isinstance(tasks_cfg, list):
+ for entry in tasks_cfg:
+ if isinstance(entry, str):
+ task_modules.append(entry)
+ elif isinstance(entry, dict) and entry.get('module'):
+ task_modules.append(entry['module'])
+
+ # b) Run any tasks_init hooks (e.g. to register schedules)
+ for hook in meta.get('tasks_init', []):
+ module_name = hook.get('module')
+ fn_name = hook.get('callable')
+ if not module_name or not fn_name:
+ continue
+ try:
+ m = importlib.import_module(module_name)
+ fn = getattr(m, fn_name)
+ # pass the Celery instance so they can register schedules, etc.
+ fn(celery)
+ except Exception as e:
+ app.logger.error(f"[Celery] Failed tasks_init for '{plugin_name}': {e}")
+
+ # 4. Autodiscover all gathered task modules
+ if task_modules:
+ celery.autodiscover_tasks(task_modules)
+ app.logger.info(f"[Celery] Autodiscovered tasks in: {task_modules}")
+
+ return celery
+
+
+# 5) Immediately bootstrap Celery with our Flask app so that
+# any `celery -A app.celery_app:celery worker --beat` invocation
+# will pick up your plugins’ tasks and schedules.
+from app import create_app
+_flask_app = create_app()
+init_celery(_flask_app)
diff --git a/app/config.py b/app/config.py
index 5d8f0d6..96a19ed 100644
--- a/app/config.py
+++ b/app/config.py
@@ -1,41 +1,47 @@
import os
from dotenv import load_dotenv, find_dotenv
-# ─── Load .env from project root or any parent ────────────────────────────────
+# ─── Load .env from project root or parents ──────────────────────────────────
dotenv_path = find_dotenv()
if dotenv_path:
load_dotenv(dotenv_path, override=True)
-# ─── Paths ────────────────────────────────────────────────────────────────────
+# ─── Paths ───────────────────────────────────────────────────────────────────
CONFIG_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(CONFIG_DIR)
class Config:
- # ─── Environment ─────────────────────────────────────────────────────────────
+ # ─── Environment ─────────────────────────────────────────────────────────
ENV = (
os.getenv('FLASK_ENV')
or os.getenv('DOCKER_ENV')
or 'production'
).lower()
- # ─── Secret Key ──────────────────────────────────────────────────────────────
+ # ─── Debug / Exceptions ───────────────────────────────────────────────────
+ DEBUG = ENV != 'production'
+ PROPAGATE_EXCEPTIONS = True
+ TRAP_HTTP_EXCEPTIONS = True
+
+ # ─── Secret Key ───────────────────────────────────────────────────────────
if ENV == 'production':
SECRET_KEY = os.getenv('SECRET_KEY')
if not SECRET_KEY:
raise RuntimeError(
"SECRET_KEY environment variable not set! "
- "Generate one with `openssl rand -hex 32` and export it."
+ "Generate one with `openssl rand -hex 32`."
)
else:
- # dev/test: fall back to env or a random one
SECRET_KEY = os.getenv('SECRET_KEY') or os.urandom(24).hex()
- # ─── Uploads ────────────────────────────────────────────────────────────────
- # Default to PROJECT_ROOT/static/uploads; if UPLOAD_FOLDER env is set, resolve relative to PROJECT_ROOT
+ # ─── Uploads ───────────────────────────────────────────────────────────────
_env_upload = os.getenv('UPLOAD_FOLDER', '')
if _env_upload:
- # if absolute, use directly; otherwise join to project root
- UPLOAD_FOLDER = _env_upload if os.path.isabs(_env_upload) else os.path.join(PROJECT_ROOT, _env_upload)
+ UPLOAD_FOLDER = (
+ _env_upload
+ if os.path.isabs(_env_upload)
+ else os.path.join(PROJECT_ROOT, _env_upload)
+ )
else:
UPLOAD_FOLDER = os.path.join(PROJECT_ROOT, "static", "uploads")
@@ -48,7 +54,7 @@ class Config:
raise RuntimeError("CELERY_BROKER_URL environment variable not set!")
CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND', CELERY_BROKER_URL)
- # ─── MySQL ──────────────────────────────────────────────────────────────────
+ # ─── MySQL ─────────────────────────────────────────────────────────────────
MYSQL_USER = os.getenv('MYSQL_USER')
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD')
if not MYSQL_PASSWORD:
@@ -65,11 +71,17 @@ class Config:
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
- # ─── Cookies / Session ──────────────────────────────────────────────────────
- SESSION_COOKIE_SECURE = True
+ # ─── Cookies / Session ─────────────────────────────────────────────────────
+ # only mark cookies Secure in production; in dev we need them over HTTP
+ if ENV == 'production':
+ SESSION_COOKIE_SECURE = True
+ REMEMBER_COOKIE_SECURE = True
+ else:
+ SESSION_COOKIE_SECURE = False
+ REMEMBER_COOKIE_SECURE = False
+
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
- REMEMBER_COOKIE_SECURE = True
REMEMBER_COOKIE_HTTPONLY = True
REMEMBER_COOKIE_SAMESITE = 'Lax'
PREFERRED_URL_SCHEME = 'https'
@@ -77,6 +89,7 @@ class Config:
# ─── Toggles ────────────────────────────────────────────────────────────────
ENABLE_DB_SEEDING = os.getenv('ENABLE_DB_SEEDING', '0') == '1'
DOCKER_ENV = os.getenv('DOCKER_ENV', 'production')
+ INVITES_PER_USER = int(os.getenv('INVITES_PER_USER', 5))
# ─── Neo4j ──────────────────────────────────────────────────────────────────
NEO4J_URI = os.getenv('NEO4J_URI', 'bolt://neo4j:7687')
@@ -85,8 +98,8 @@ class Config:
if not NEO4J_PASSWORD:
raise RuntimeError("NEO4J_PASSWORD environment variable not set!")
- # ─── Misc ──────────────────────────────────────────────────────────────────
- STANDARD_IMG_SIZE = tuple(
+ # ─── Misc ───────────────────────────────────────────────────────────────────
+ STANDARD_IMG_SIZE = tuple(
map(int, os.getenv('STANDARD_IMG_SIZE', '300x200').split('x'))
)
PLANT_CARDS_BASE_URL = "https://plant.cards"
diff --git a/app/templates/core/base.html b/app/templates/core/base.html
index 7f3d24a..e8a5628 100644
--- a/app/templates/core/base.html
+++ b/app/templates/core/base.html
@@ -97,16 +97,33 @@
-
+
{% if current_user.role == 'admin' %}
-
- Admin Dashboard
-
+
+
+
+
{% endif %}
+
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:
diff --git a/entrypoint.sh b/entrypoint.sh
index 195a355..7a268b2 100644
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -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"
diff --git a/migrations/env.py b/migrations/env.py
index 18de0f0..d72b142 100644
--- a/migrations/env.py
+++ b/migrations/env.py
@@ -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 app’s metadata for 'autogenerate' support ─────────────────
-from app import db
-target_metadata = db.metadata
-
-# ─── Dynamically import all plugin models listed in plugin.json ─────────────
+# ─── Dynamically import every plugin’s 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 application’s 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:
diff --git a/migrations/versions/01876f89899b_auto_migrate.py b/migrations/versions/01876f89899b_auto_migrate.py
deleted file mode 100644
index fbd7c57..0000000
--- a/migrations/versions/01876f89899b_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/03650b9a0f3a_auto_migrate.py b/migrations/versions/03650b9a0f3a_auto_migrate.py
deleted file mode 100644
index 354750f..0000000
--- a/migrations/versions/03650b9a0f3a_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/0514fb24a61e_auto_migrate.py b/migrations/versions/0514fb24a61e_auto_migrate.py
deleted file mode 100644
index 86a22cf..0000000
--- a/migrations/versions/0514fb24a61e_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/06234a515bde_auto_migrate.py b/migrations/versions/06234a515bde_auto_migrate.py
deleted file mode 100644
index d7678e7..0000000
--- a/migrations/versions/06234a515bde_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/076bfc1a441b_auto_migrate.py b/migrations/versions/076bfc1a441b_auto_migrate.py
deleted file mode 100644
index 698c8af..0000000
--- a/migrations/versions/076bfc1a441b_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/08ebb5577232_auto_migrate.py b/migrations/versions/08ebb5577232_auto_migrate.py
deleted file mode 100644
index 1754483..0000000
--- a/migrations/versions/08ebb5577232_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/0964777a3294_auto_migrate.py b/migrations/versions/0964777a3294_auto_migrate.py
deleted file mode 100644
index e5cb4bb..0000000
--- a/migrations/versions/0964777a3294_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/0efc1a18285f_auto_migrate.py b/migrations/versions/0efc1a18285f_auto_migrate.py
deleted file mode 100644
index 0462bd3..0000000
--- a/migrations/versions/0efc1a18285f_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/10e39b33d4e7_auto_migrate.py b/migrations/versions/10e39b33d4e7_auto_migrate.py
deleted file mode 100644
index d0153b0..0000000
--- a/migrations/versions/10e39b33d4e7_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/12cc29f97b11_auto_migrate.py b/migrations/versions/12cc29f97b11_auto_migrate.py
deleted file mode 100644
index e7cb02e..0000000
--- a/migrations/versions/12cc29f97b11_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/12ef820b5618_auto_migrate.py b/migrations/versions/12ef820b5618_auto_migrate.py
deleted file mode 100644
index 41e1932..0000000
--- a/migrations/versions/12ef820b5618_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/19e2a1b15b5e_auto_migrate.py b/migrations/versions/19e2a1b15b5e_auto_migrate.py
deleted file mode 100644
index 8c1b8e0..0000000
--- a/migrations/versions/19e2a1b15b5e_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/209596f02c2a_auto_migrate.py b/migrations/versions/209596f02c2a_auto_migrate.py
deleted file mode 100644
index eca65e2..0000000
--- a/migrations/versions/209596f02c2a_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/228e71f1a33b_auto_migrate.py b/migrations/versions/228e71f1a33b_auto_migrate.py
deleted file mode 100644
index db9bf8f..0000000
--- a/migrations/versions/228e71f1a33b_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/24de4aa78a43_auto_migrate.py b/migrations/versions/24de4aa78a43_auto_migrate.py
deleted file mode 100644
index 1e6ae26..0000000
--- a/migrations/versions/24de4aa78a43_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/27f1b3976f3f_auto_migrate.py b/migrations/versions/27f1b3976f3f_auto_migrate.py
deleted file mode 100644
index 1352319..0000000
--- a/migrations/versions/27f1b3976f3f_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/2d11e31941d9_auto_migrate.py b/migrations/versions/2d11e31941d9_auto_migrate.py
deleted file mode 100644
index 5db94ae..0000000
--- a/migrations/versions/2d11e31941d9_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/30376c514135_auto_migrate.py b/migrations/versions/30376c514135_auto_migrate.py
deleted file mode 100644
index 2250508..0000000
--- a/migrations/versions/30376c514135_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/310f500a3d2f_auto_migrate.py b/migrations/versions/310f500a3d2f_auto_migrate.py
deleted file mode 100644
index 40bfefd..0000000
--- a/migrations/versions/310f500a3d2f_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/390c977fe679_auto_migrate.py b/migrations/versions/390c977fe679_auto_migrate.py
deleted file mode 100644
index e88b619..0000000
--- a/migrations/versions/390c977fe679_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/4082065b932b_auto_migrate.py b/migrations/versions/4082065b932b_auto_migrate.py
deleted file mode 100644
index 974abd6..0000000
--- a/migrations/versions/4082065b932b_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/42ce181f4eab_auto_migrate.py b/migrations/versions/42ce181f4eab_auto_migrate.py
deleted file mode 100644
index d043d9c..0000000
--- a/migrations/versions/42ce181f4eab_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/493fbb46e881_auto_migrate.py b/migrations/versions/493fbb46e881_auto_migrate.py
deleted file mode 100644
index 0b5f339..0000000
--- a/migrations/versions/493fbb46e881_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/51640ecd70ee_auto_migrate.py b/migrations/versions/51640ecd70ee_auto_migrate.py
deleted file mode 100644
index 44615c0..0000000
--- a/migrations/versions/51640ecd70ee_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/53d0e3d0cd47_auto_migrate.py b/migrations/versions/53d0e3d0cd47_auto_migrate.py
deleted file mode 100644
index 25bd662..0000000
--- a/migrations/versions/53d0e3d0cd47_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/7229fe50de09_auto_migrate.py b/migrations/versions/7229fe50de09_auto_migrate.py
deleted file mode 100644
index 45c160b..0000000
--- a/migrations/versions/7229fe50de09_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/807ca973d0cf_auto_migrate.py b/migrations/versions/807ca973d0cf_auto_migrate.py
deleted file mode 100644
index 2ae5fe0..0000000
--- a/migrations/versions/807ca973d0cf_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/85b7ca21ec19_auto_migrate.py b/migrations/versions/85b7ca21ec19_auto_migrate.py
deleted file mode 100644
index a9405a6..0000000
--- a/migrations/versions/85b7ca21ec19_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/87c6df96bef3_auto_migrate.py b/migrations/versions/87c6df96bef3_auto_migrate.py
deleted file mode 100644
index 60ba994..0000000
--- a/migrations/versions/87c6df96bef3_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/886aa234b3b7_auto_migrate.py b/migrations/versions/886aa234b3b7_auto_migrate.py
deleted file mode 100644
index c9c3564..0000000
--- a/migrations/versions/886aa234b3b7_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/892da654697c_auto_migrate.py b/migrations/versions/892da654697c_auto_migrate.py
deleted file mode 100644
index c266a01..0000000
--- a/migrations/versions/892da654697c_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/8c1e8db7b3cb_auto_migrate.py b/migrations/versions/8c1e8db7b3cb_auto_migrate.py
deleted file mode 100644
index 3b8d215..0000000
--- a/migrations/versions/8c1e8db7b3cb_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/93f8a5cbc643_auto_migrate.py b/migrations/versions/93f8a5cbc643_auto_migrate.py
deleted file mode 100644
index 4ba265e..0000000
--- a/migrations/versions/93f8a5cbc643_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/9cc2626a6e79_auto_migrate.py b/migrations/versions/9cc2626a6e79_auto_migrate.py
deleted file mode 100644
index b456be3..0000000
--- a/migrations/versions/9cc2626a6e79_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/a5cb08298ee4_auto_migrate.py b/migrations/versions/a5cb08298ee4_auto_migrate.py
deleted file mode 100644
index 6d7a80b..0000000
--- a/migrations/versions/a5cb08298ee4_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/a69f613f9cd5_auto_migrate.py b/migrations/versions/a69f613f9cd5_auto_migrate.py
deleted file mode 100644
index f879130..0000000
--- a/migrations/versions/a69f613f9cd5_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/a79453aefa45_auto_migrate.py b/migrations/versions/a79453aefa45_auto_migrate.py
deleted file mode 100644
index 7a251b6..0000000
--- a/migrations/versions/a79453aefa45_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/a87d4c1df4e5_auto_migrate.py b/migrations/versions/a87d4c1df4e5_auto_migrate.py
deleted file mode 100644
index 39b52cc..0000000
--- a/migrations/versions/a87d4c1df4e5_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/acd3093204e7_auto_migrate.py b/migrations/versions/acd3093204e7_auto_migrate.py
deleted file mode 100644
index e0c11e9..0000000
--- a/migrations/versions/acd3093204e7_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/b1e37dc718f2_auto_migrate.py b/migrations/versions/b1e37dc718f2_auto_migrate.py
deleted file mode 100644
index 0a829b1..0000000
--- a/migrations/versions/b1e37dc718f2_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/b56cd5e57987_auto_migrate.py b/migrations/versions/b56cd5e57987_auto_migrate.py
deleted file mode 100644
index 1faa786..0000000
--- a/migrations/versions/b56cd5e57987_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/b57c767ad0d6_auto_migrate.py b/migrations/versions/b57c767ad0d6_auto_migrate.py
deleted file mode 100644
index 5ff2847..0000000
--- a/migrations/versions/b57c767ad0d6_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/b684611b27b1_auto_migrate.py b/migrations/versions/b684611b27b1_auto_migrate.py
deleted file mode 100644
index 1745872..0000000
--- a/migrations/versions/b684611b27b1_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/c23c31ae3a1d_auto_migrate.py b/migrations/versions/c23c31ae3a1d_auto_migrate.py
deleted file mode 100644
index 3f81382..0000000
--- a/migrations/versions/c23c31ae3a1d_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/c6fad4522e3c_auto_migrate.py b/migrations/versions/c6fad4522e3c_auto_migrate.py
deleted file mode 100644
index 507f98f..0000000
--- a/migrations/versions/c6fad4522e3c_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/c92477263320_auto_migrate.py b/migrations/versions/c92477263320_auto_migrate.py
deleted file mode 100644
index b240749..0000000
--- a/migrations/versions/c92477263320_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/d49ee8d82364_auto_migrate.py b/migrations/versions/d49ee8d82364_auto_migrate.py
deleted file mode 100644
index 4f8ae1f..0000000
--- a/migrations/versions/d49ee8d82364_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/d54a88422a68_auto_migrate.py b/migrations/versions/d54a88422a68_auto_migrate.py
deleted file mode 100644
index fb6ac3f..0000000
--- a/migrations/versions/d54a88422a68_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/d647dd4d3fbd_auto_migrate.py b/migrations/versions/d647dd4d3fbd_auto_migrate.py
deleted file mode 100644
index 3ed31ef..0000000
--- a/migrations/versions/d647dd4d3fbd_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/d7bbffbbc931_auto_migrate.py b/migrations/versions/d7bbffbbc931_auto_migrate.py
deleted file mode 100644
index ead1201..0000000
--- a/migrations/versions/d7bbffbbc931_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/dcc114909948_auto_migrate.py b/migrations/versions/dcc114909948_auto_migrate.py
deleted file mode 100644
index 39ff378..0000000
--- a/migrations/versions/dcc114909948_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/dd2492e0ede0_auto_migrate.py b/migrations/versions/dd2492e0ede0_auto_migrate.py
deleted file mode 100644
index a2d7c3b..0000000
--- a/migrations/versions/dd2492e0ede0_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/ee4be515bb55_auto_migrate.py b/migrations/versions/ee4be515bb55_auto_migrate.py
deleted file mode 100644
index 1616571..0000000
--- a/migrations/versions/ee4be515bb55_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/f00a9585a348_auto_migrate.py b/migrations/versions/f00a9585a348_auto_migrate.py
deleted file mode 100644
index 5fd857f..0000000
--- a/migrations/versions/f00a9585a348_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/f34b5e058563_auto_migrate.py b/migrations/versions/f34b5e058563_auto_migrate.py
deleted file mode 100644
index a633db1..0000000
--- a/migrations/versions/f34b5e058563_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/f741addef1a1_auto_migrate.py b/migrations/versions/f741addef1a1_auto_migrate.py
deleted file mode 100644
index 6aceae0..0000000
--- a/migrations/versions/f741addef1a1_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/f7f41136c073_auto_migrate.py b/migrations/versions/f7f41136c073_auto_migrate.py
deleted file mode 100644
index cea8e3b..0000000
--- a/migrations/versions/f7f41136c073_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/f81a9a44a7fb_auto_migrate.py b/migrations/versions/f81a9a44a7fb_auto_migrate.py
deleted file mode 100644
index 460a5f6..0000000
--- a/migrations/versions/f81a9a44a7fb_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/fa34eb3f6084_auto_migrate.py b/migrations/versions/fa34eb3f6084_auto_migrate.py
deleted file mode 100644
index ab8dc2b..0000000
--- a/migrations/versions/fa34eb3f6084_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/faeca4f53b04_auto_migrate.py b/migrations/versions/faeca4f53b04_auto_migrate.py
deleted file mode 100644
index 55416c1..0000000
--- a/migrations/versions/faeca4f53b04_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/migrations/versions/fe0ebdec3255_auto_migrate.py b/migrations/versions/fe0ebdec3255_auto_migrate.py
deleted file mode 100644
index 937c2ff..0000000
--- a/migrations/versions/fe0ebdec3255_auto_migrate.py
+++ /dev/null
@@ -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 ###
diff --git a/plugins/admin/routes.py b/plugins/admin/routes.py
index 0b15306..824448d 100644
--- a/plugins/admin/routes.py
+++ b/plugins/admin/routes.py
@@ -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)
\ No newline at end of file
diff --git a/plugins/admin/templates/admin/orphaned_media_list.html b/plugins/admin/templates/admin/orphaned_media_list.html
new file mode 100644
index 0000000..26f7877
--- /dev/null
+++ b/plugins/admin/templates/admin/orphaned_media_list.html
@@ -0,0 +1,33 @@
+
+
+{% extends 'core/base.html' %}
+
+{% block title %}Orphaned Media – Admin – Nature In Pots{% endblock %}
+
+{% block content %}
+ Orphaned Media
+
+
+
+ ID |
+ Original URL |
+ New URL |
+ Orphaned At |
+
+
+
+ {% for m in items %}
+
+ {{ m.id }} |
+ {{ m.original_file_url }} |
+ {{ m.file_url }} |
+ {{ m.orphaned_at.strftime('%Y-%m-%d %H:%M:%S') }} |
+
+ {% else %}
+
+ No orphaned media found. |
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/plugins/auth/forms.py b/plugins/auth/forms.py
index 17cc640..523119d 100644
--- a/plugins/auth/forms.py
+++ b/plugins/auth/forms.py
@@ -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')
\ No newline at end of file
+ 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')
diff --git a/plugins/auth/models.py b/plugins/auth/models.py
index 5f4543e..8bbb732 100644
--- a/plugins/auth/models.py
+++ b/plugins/auth/models.py
@@ -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
+
+
+# ─── Auto‐revoke 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 JSON‐driven plugin loader to wire Flask‐Login.
"""
- login_manager.user_loader(_load_user)
+ lm.user_loader(load_user)
diff --git a/plugins/auth/routes.py b/plugins/auth/routes.py
index 3ffe20f..d8a18ad 100644
--- a/plugins/auth/routes.py
+++ b/plugins/auth/routes.py
@@ -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/
', 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
+ )
diff --git a/plugins/auth/templates/auth/invite.html b/plugins/auth/templates/auth/invite.html
new file mode 100644
index 0000000..53b191c
--- /dev/null
+++ b/plugins/auth/templates/auth/invite.html
@@ -0,0 +1,19 @@
+{% extends "core/base.html" %}
+{% block title %}Send Invitation{% endblock %}
+{% block content %}
+
+
Send Invitation
+
You have {{ invites }} invites remaining.
+
+
+{% endblock %}
diff --git a/plugins/auth/templates/auth/login.html b/plugins/auth/templates/auth/login.html
index 369cf67..e0ed5c6 100644
--- a/plugins/auth/templates/auth/login.html
+++ b/plugins/auth/templates/auth/login.html
@@ -1,16 +1,34 @@
-{% extends 'core/base.html' %}
+{% extends "core/base.html" %}
+{% block title %}Log In{% endblock %}
+
{% block content %}
-Login
-
+
{% endblock %}
diff --git a/plugins/auth/templates/auth/register.html b/plugins/auth/templates/auth/register.html
index 38dec24..7775390 100644
--- a/plugins/auth/templates/auth/register.html
+++ b/plugins/auth/templates/auth/register.html
@@ -1,17 +1,53 @@
-{% extends 'core/base.html' %}
+{% extends "core/base.html" %}
{% block title %}Register{% endblock %}
+
{% block content %}
-Register
-
+{% endblock %}
diff --git a/plugins/utility/templates/utility/imports.html b/plugins/utility/templates/utility/imports.html
new file mode 100644
index 0000000..abfd4fd
--- /dev/null
+++ b/plugins/utility/templates/utility/imports.html
@@ -0,0 +1,20 @@
+{% extends "core/base.html" %}
+{% block content %}
+ Your Recent Imports
+
+
+ # | Export ID | Uploaded | Status | Error |
+
+
+ {% for b in batches %}
+
+ {{ b.id }} |
+ {{ b.export_id }} |
+ {{ b.imported_at }} |
+ {{ b.status }} |
+ {{ b.error or "" }} |
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/requirements.txt b/requirements.txt
index 4964291..b0c035a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -14,3 +14,4 @@ qrcode
celery
redis>=4.5.0
email-validator>=1.2.0
+flower>=1.0.0