broke
This commit is contained in:
@ -9,3 +9,6 @@ mysql_data/
|
||||
migrations/versions/
|
||||
uploads/
|
||||
static/uploads/
|
||||
|
||||
data/mysql_data
|
||||
data/uploads
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
218
app/__init__.py
218
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
|
||||
|
84
app/celery_app.py
Normal file
84
app/celery_app.py
Normal file
@ -0,0 +1,84 @@
|
||||
# File: app/celery_app.py
|
||||
|
||||
import os
|
||||
import json
|
||||
import importlib
|
||||
from celery import Celery
|
||||
|
||||
# 1) Create the Celery object (broker/backend will be set from our Flask config)
|
||||
celery = Celery('natureinpots')
|
||||
|
||||
|
||||
def init_celery(app):
|
||||
"""
|
||||
Wire up Celery to our Flask app, discover all plugin task modules
|
||||
and run any tasks_init hooks.
|
||||
"""
|
||||
# 1. Configure broker & result backend from Flask config
|
||||
celery.conf.broker_url = app.config['CELERY_BROKER_URL']
|
||||
celery.conf.result_backend = app.config['CELERY_RESULT_BACKEND']
|
||||
|
||||
# 2. Make all tasks run inside the Flask application context
|
||||
TaskBase = celery.Task
|
||||
class ContextTask(TaskBase):
|
||||
def __call__(self, *args, **kwargs):
|
||||
with app.app_context():
|
||||
return TaskBase.__call__(self, *args, **kwargs)
|
||||
celery.Task = ContextTask
|
||||
|
||||
# 3. Discover all plugins by looking for plugin.json in plugins/
|
||||
plugins_dir = os.path.join(app.root_path, '..', 'plugins')
|
||||
task_modules = []
|
||||
for plugin_name in sorted(os.listdir(plugins_dir)):
|
||||
manifest = os.path.join(plugins_dir, plugin_name, 'plugin.json')
|
||||
if not os.path.isfile(manifest):
|
||||
continue
|
||||
|
||||
# Load plugin metadata
|
||||
try:
|
||||
meta = json.load(open(manifest))
|
||||
except Exception:
|
||||
app.logger.error(f"[Celery] Failed to load plugin.json for '{plugin_name}'")
|
||||
continue
|
||||
|
||||
# a) Gather task modules (string or dict or list)
|
||||
tasks_cfg = meta.get('tasks')
|
||||
if isinstance(tasks_cfg, str):
|
||||
task_modules.append(tasks_cfg)
|
||||
elif isinstance(tasks_cfg, dict) and tasks_cfg.get('module'):
|
||||
task_modules.append(tasks_cfg['module'])
|
||||
elif isinstance(tasks_cfg, list):
|
||||
for entry in tasks_cfg:
|
||||
if isinstance(entry, str):
|
||||
task_modules.append(entry)
|
||||
elif isinstance(entry, dict) and entry.get('module'):
|
||||
task_modules.append(entry['module'])
|
||||
|
||||
# b) Run any tasks_init hooks (e.g. to register schedules)
|
||||
for hook in meta.get('tasks_init', []):
|
||||
module_name = hook.get('module')
|
||||
fn_name = hook.get('callable')
|
||||
if not module_name or not fn_name:
|
||||
continue
|
||||
try:
|
||||
m = importlib.import_module(module_name)
|
||||
fn = getattr(m, fn_name)
|
||||
# pass the Celery instance so they can register schedules, etc.
|
||||
fn(celery)
|
||||
except Exception as e:
|
||||
app.logger.error(f"[Celery] Failed tasks_init for '{plugin_name}': {e}")
|
||||
|
||||
# 4. Autodiscover all gathered task modules
|
||||
if task_modules:
|
||||
celery.autodiscover_tasks(task_modules)
|
||||
app.logger.info(f"[Celery] Autodiscovered tasks in: {task_modules}")
|
||||
|
||||
return celery
|
||||
|
||||
|
||||
# 5) Immediately bootstrap Celery with our Flask app so that
|
||||
# any `celery -A app.celery_app:celery worker --beat` invocation
|
||||
# will pick up your plugins’ tasks and schedules.
|
||||
from app import create_app
|
||||
_flask_app = create_app()
|
||||
init_celery(_flask_app)
|
@ -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"
|
||||
|
@ -97,16 +97,33 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Admin link -->
|
||||
<!-- Admin dropdown -->
|
||||
{% if current_user.role == 'admin' %}
|
||||
<a
|
||||
class="btn btn-outline-danger me-3"
|
||||
href="{{ url_for('admin.dashboard') }}"
|
||||
>
|
||||
Admin Dashboard
|
||||
</a>
|
||||
<div class="btn-group me-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-danger dropdown-toggle"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Admin
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('admin.dashboard') }}">
|
||||
Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('admin.orphaned_media_list') }}">
|
||||
Orphaned Media
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<!-- Plugins dropdown -->
|
||||
<div class="dropdown me-3">
|
||||
<a
|
||||
|
BIN
beta-0.1.14.zip
Normal file
BIN
beta-0.1.14.zip
Normal file
Binary file not shown.
BIN
betas/beta-0.1.1.zip
Normal file
BIN
betas/beta-0.1.1.zip
Normal file
Binary file not shown.
BIN
betas/beta-0.1.10.zip
Normal file
BIN
betas/beta-0.1.10.zip
Normal file
Binary file not shown.
BIN
betas/beta-0.1.11.zip
Normal file
BIN
betas/beta-0.1.11.zip
Normal file
Binary file not shown.
BIN
betas/beta-0.1.12.zip
Normal file
BIN
betas/beta-0.1.12.zip
Normal file
Binary file not shown.
BIN
betas/beta-0.1.13.zip
Normal file
BIN
betas/beta-0.1.13.zip
Normal file
Binary file not shown.
BIN
betas/beta-0.1.2.zip
Normal file
BIN
betas/beta-0.1.2.zip
Normal file
Binary file not shown.
BIN
betas/beta-0.1.3.zip
Normal file
BIN
betas/beta-0.1.3.zip
Normal file
Binary file not shown.
BIN
betas/beta-0.1.4.zip
Normal file
BIN
betas/beta-0.1.4.zip
Normal file
Binary file not shown.
BIN
betas/beta-0.1.5.zip
Normal file
BIN
betas/beta-0.1.5.zip
Normal file
Binary file not shown.
BIN
betas/beta-0.1.6.zip
Normal file
BIN
betas/beta-0.1.6.zip
Normal file
Binary file not shown.
BIN
betas/beta-0.1.7.zip
Normal file
BIN
betas/beta-0.1.7.zip
Normal file
Binary file not shown.
BIN
betas/beta-0.1.8.zip
Normal file
BIN
betas/beta-0.1.8.zip
Normal file
Binary file not shown.
BIN
betas/beta-0.1.9.zip
Normal file
BIN
betas/beta-0.1.9.zip
Normal file
Binary file not shown.
@ -10,9 +10,6 @@ services:
|
||||
build: .
|
||||
ports:
|
||||
- "5000:5000"
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./static/uploads:/app/static/uploads
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@ -37,6 +34,9 @@ services:
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
volumes:
|
||||
- .:/app:delegated
|
||||
- ./${UPLOAD_FOLDER}:/app/${UPLOAD_FOLDER}
|
||||
networks:
|
||||
- appnet
|
||||
|
||||
@ -53,7 +53,7 @@ services:
|
||||
ports:
|
||||
- "42000:3306"
|
||||
volumes:
|
||||
- ./mysql_data:/var/lib/mysql
|
||||
- ./${MYSQL_DATA_FOLDER}:/var/lib/mysql
|
||||
entrypoint: >
|
||||
sh -c "mkdir -p /var/lib/mysql &&
|
||||
chown -R 1000:998 /var/lib/mysql &&
|
||||
@ -72,6 +72,8 @@ services:
|
||||
restart: always
|
||||
ports:
|
||||
- "8080:8080"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- ADMINER_DEFAULT_SERVER=db
|
||||
depends_on:
|
||||
@ -86,7 +88,6 @@ services:
|
||||
- "7474:7474"
|
||||
- "7687:7687"
|
||||
environment:
|
||||
# only the one var Neo4j actually needs
|
||||
- NEO4J_AUTH=neo4j/${NEO4J_PASSWORD}
|
||||
volumes:
|
||||
- neo4j_data:/data
|
||||
@ -104,13 +105,35 @@ services:
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||
user: "appuser"
|
||||
command: celery -A plugins.utility.celery:celery_app worker --loglevel=info
|
||||
command: celery -A app.celery_app:celery worker --beat --loglevel=info
|
||||
depends_on:
|
||||
- redis
|
||||
- db
|
||||
networks:
|
||||
- appnet
|
||||
|
||||
flower:
|
||||
build: .
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||
command: >
|
||||
celery
|
||||
--app app.celery_app.celery
|
||||
--broker redis://redis:6379/0
|
||||
flower
|
||||
--port=5555
|
||||
depends_on:
|
||||
- redis
|
||||
- db
|
||||
ports:
|
||||
- "5555:5555"
|
||||
networks:
|
||||
- appnet
|
||||
|
||||
volumes:
|
||||
neo4j_data:
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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:
|
||||
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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)
|
33
plugins/admin/templates/admin/orphaned_media_list.html
Normal file
33
plugins/admin/templates/admin/orphaned_media_list.html
Normal file
@ -0,0 +1,33 @@
|
||||
<!-- plugins/admin/templates/admin/orphaned_media_list.html -->
|
||||
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block title %}Orphaned Media – Admin – Nature In Pots{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Orphaned Media</h1>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Original URL</th>
|
||||
<th>New URL</th>
|
||||
<th>Orphaned At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in items %}
|
||||
<tr>
|
||||
<td>{{ m.id }}</td>
|
||||
<td><a href="{{ m.original_file_url }}" target="_blank">{{ m.original_file_url }}</a></td>
|
||||
<td><a href="{{ m.file_url }}" target="_blank">{{ m.file_url }}</a></td>
|
||||
<td>{{ m.orphaned_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center">No orphaned media found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
@ -1,15 +1,62 @@
|
||||
# plugins/auth/forms.py
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, PasswordField, SubmitField
|
||||
from wtforms.validators import DataRequired, Email, Length, EqualTo
|
||||
from wtforms import StringField, PasswordField, BooleanField, SubmitField, IntegerField
|
||||
from wtforms.validators import (
|
||||
DataRequired, Email, Length, EqualTo, Regexp, NumberRange
|
||||
)
|
||||
|
||||
class RegistrationForm(FlaskForm):
|
||||
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=25)])
|
||||
email = StringField('Email', validators=[DataRequired(), Email()])
|
||||
password = PasswordField('Password', validators=[DataRequired(), Length(min=6)])
|
||||
confirm = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
|
||||
submit = SubmitField('Register')
|
||||
username = StringField(
|
||||
'Username',
|
||||
validators=[DataRequired(), Length(min=3, max=25)]
|
||||
)
|
||||
email = StringField(
|
||||
'Email',
|
||||
validators=[DataRequired(), Email(), Length(max=120)]
|
||||
)
|
||||
invitation_code = StringField(
|
||||
'Invitation Code',
|
||||
validators=[DataRequired(),
|
||||
Length(min=36, max=36,
|
||||
message="Invitation code must be 36 characters.")]
|
||||
)
|
||||
password = PasswordField(
|
||||
'Password',
|
||||
validators=[DataRequired(), Length(min=6)]
|
||||
)
|
||||
confirm = PasswordField(
|
||||
'Confirm Password',
|
||||
validators=[DataRequired(),
|
||||
EqualTo('password', message='Passwords must match.')]
|
||||
)
|
||||
submit = SubmitField('Register')
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
email = StringField('Email', validators=[DataRequired(), Email()])
|
||||
password = PasswordField('Password', validators=[DataRequired()])
|
||||
submit = SubmitField('Login')
|
||||
email = StringField(
|
||||
'Email',
|
||||
validators=[DataRequired(), Email(), Length(max=120)]
|
||||
)
|
||||
password = PasswordField(
|
||||
'Password',
|
||||
validators=[DataRequired()]
|
||||
)
|
||||
remember_me = BooleanField('Remember me')
|
||||
submit = SubmitField('Log In')
|
||||
|
||||
|
||||
class InviteForm(FlaskForm):
|
||||
email = StringField(
|
||||
'Recipient Email',
|
||||
validators=[DataRequired(), Email(), Length(max=120)]
|
||||
)
|
||||
submit = SubmitField('Send Invite')
|
||||
|
||||
|
||||
class AdjustInvitesForm(FlaskForm):
|
||||
delta = IntegerField(
|
||||
'Adjustment',
|
||||
validators=[DataRequired(), NumberRange()]
|
||||
)
|
||||
submit = SubmitField('Adjust Invites')
|
||||
|
@ -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)
|
||||
|
@ -1,30 +1,40 @@
|
||||
# File: plugins/auth/routes.py
|
||||
# plugins/auth/routes.py
|
||||
|
||||
from flask import Blueprint, render_template, redirect, flash, url_for, request
|
||||
from flask_login import login_user, logout_user, login_required
|
||||
from .models import User
|
||||
from .forms import LoginForm, RegistrationForm
|
||||
from datetime import datetime
|
||||
from flask import (
|
||||
Blueprint, render_template, redirect, flash, url_for, request
|
||||
)
|
||||
from flask_login import (
|
||||
login_user, logout_user, login_required, current_user
|
||||
)
|
||||
from app import db
|
||||
from .models import User, Invitation
|
||||
from .forms import (
|
||||
LoginForm, RegistrationForm, InviteForm, AdjustInvitesForm
|
||||
)
|
||||
|
||||
bp = Blueprint(
|
||||
'auth',
|
||||
__name__,
|
||||
template_folder='templates/auth', # ← now points at plugins/auth/templates/auth/
|
||||
template_folder='templates',
|
||||
url_prefix='/auth'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('home'))
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(email=form.email.data).first()
|
||||
user = User.query.filter_by(email=form.email.data.lower()).first()
|
||||
if user and user.check_password(form.password.data):
|
||||
login_user(user)
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
flash('Logged in successfully.', 'success')
|
||||
return redirect(url_for('home'))
|
||||
next_page = request.args.get('next') or url_for('home')
|
||||
return redirect(next_page)
|
||||
flash('Invalid email or password.', 'danger')
|
||||
return render_template('login.html', form=form) # resolves to templates/auth/login.html
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
|
||||
@bp.route('/logout')
|
||||
@ -37,12 +47,92 @@ def logout():
|
||||
|
||||
@bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
form = RegistrationForm()
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('home'))
|
||||
|
||||
invite_code = request.args.get('invite', '').strip()
|
||||
form = RegistrationForm(invitation_code=invite_code)
|
||||
|
||||
if form.validate_on_submit():
|
||||
user = User(email=form.email.data)
|
||||
# Validate invitation
|
||||
invitation = Invitation.query.filter_by(
|
||||
code=form.invitation_code.data,
|
||||
is_active=True,
|
||||
is_used=False
|
||||
).first()
|
||||
if not invitation:
|
||||
flash('Registration is by invitation only. Provide a valid code.', 'warning')
|
||||
return render_template('auth/register.html', form=form)
|
||||
|
||||
if invitation.recipient_email and \
|
||||
invitation.recipient_email.lower() != form.email.data.lower():
|
||||
flash('This invitation is not valid for that email address.', 'warning')
|
||||
return render_template('auth/register.html', form=form)
|
||||
|
||||
# Create the user
|
||||
user = User(email=form.email.data.lower())
|
||||
user.set_password(form.password.data)
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
|
||||
# Mark invitation used
|
||||
invitation.mark_used(user, request.remote_addr)
|
||||
db.session.commit()
|
||||
|
||||
flash('Account created! Please log in.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
return render_template('register.html', form=form) # resolves to templates/auth/register.html
|
||||
|
||||
return render_template('auth/register.html', form=form)
|
||||
|
||||
|
||||
@bp.route('/invite', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def send_invite():
|
||||
form = InviteForm()
|
||||
|
||||
# Block banned/suspended users from sending
|
||||
if current_user.is_banned or current_user.is_deleted or (
|
||||
current_user.suspended_until and current_user.suspended_until > datetime.utcnow()
|
||||
):
|
||||
flash('You are not permitted to send invitations.', 'warning')
|
||||
return redirect(url_for('home'))
|
||||
|
||||
if form.validate_on_submit():
|
||||
if current_user.invites_remaining < 1:
|
||||
flash('No invites remaining. Ask an admin to increase your quota.', 'danger')
|
||||
else:
|
||||
recipient = form.email.data.strip().lower()
|
||||
inv = Invitation(
|
||||
recipient_email=recipient,
|
||||
created_by=current_user.id,
|
||||
sender_ip=request.remote_addr
|
||||
)
|
||||
db.session.add(inv)
|
||||
current_user.invites_remaining -= 1
|
||||
db.session.commit()
|
||||
flash(f'Invitation sent to {recipient}.', 'success')
|
||||
return redirect(url_for('auth.send_invite'))
|
||||
|
||||
return render_template(
|
||||
'auth/invite.html',
|
||||
form=form,
|
||||
invites=current_user.invites_remaining
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/admin/adjust_invites/<int:user_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def adjust_invites(user_id):
|
||||
# assume current_user has admin rights
|
||||
user = User.query.get_or_404(user_id)
|
||||
form = AdjustInvitesForm()
|
||||
if form.validate_on_submit():
|
||||
user.invites_remaining = max(0, user.invites_remaining + form.delta.data)
|
||||
db.session.commit()
|
||||
flash(f"{user.email}'s invite quota adjusted by {form.delta.data}.", 'info')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
return render_template(
|
||||
'auth/adjust_invites.html',
|
||||
form=form,
|
||||
user=user
|
||||
)
|
||||
|
19
plugins/auth/templates/auth/invite.html
Normal file
19
plugins/auth/templates/auth/invite.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% block title %}Send Invitation{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container mt-4" style="max-width: 500px;">
|
||||
<h2>Send Invitation</h2>
|
||||
<p>You have <strong>{{ invites }}</strong> invites remaining.</p>
|
||||
<form method="POST" action="{{ url_for('auth.send_invite') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="mb-3">
|
||||
{{ form.email.label(class="form-label") }}
|
||||
{{ form.email(class="form-control", placeholder="recipient@example.com") }}
|
||||
{% for error in form.email.errors %}
|
||||
<div class="text-danger small">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">{{ form.submit.label.text }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,16 +1,34 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% extends "core/base.html" %}
|
||||
{% block title %}Log In{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Login</h2>
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</form>
|
||||
<div class="container mt-4" style="max-width: 400px;">
|
||||
<h2 class="mb-3">Log In</h2>
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.email.label(class="form-label") }}
|
||||
{{ form.email(class="form-control", placeholder="you@example.com") }}
|
||||
{% for e in form.email.errors %}
|
||||
<div class="text-danger small">{{ e }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control", placeholder="Password") }}
|
||||
{% for e in form.password.errors %}
|
||||
<div class="text-danger small">{{ e }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
{{ form.remember_me(class="form-check-input") }}
|
||||
{{ form.remember_me.label(class="form-check-label") }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">{{ form.submit.label.text }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -1,17 +1,53 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% extends "core/base.html" %}
|
||||
{% block title %}Register{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Register</h2>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="container mt-4" style="max-width: 400px;">
|
||||
<h2 class="mb-3">Register</h2>
|
||||
<form method="POST" action="{{ url_for('auth.register') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label>Email</label>
|
||||
<input name="email" class="form-control" type="email" required>
|
||||
{{ form.username.label(class="form-label") }}
|
||||
{{ form.username(class="form-control", placeholder="Username") }}
|
||||
{% for e in form.username.errors %}
|
||||
<div class="text-danger small">{{ e }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label>Password</label>
|
||||
<input name="password" class="form-control" type="password" required>
|
||||
{{ form.email.label(class="form-label") }}
|
||||
{{ form.email(class="form-control", placeholder="you@example.com") }}
|
||||
{% for e in form.email.errors %}
|
||||
<div class="text-danger small">{{ e }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Register</button>
|
||||
</form>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.invitation_code.label(class="form-label") }}
|
||||
{{ form.invitation_code(class="form-control", placeholder="Invitation Code") }}
|
||||
{% for e in form.invitation_code.errors %}
|
||||
<div class="text-danger small">{{ e }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control", placeholder="Password") }}
|
||||
{% for e in form.password.errors %}
|
||||
<div class="text-danger small">{{ e }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.confirm.label(class="form-label") }}
|
||||
{{ form.confirm(class="form-control", placeholder="Confirm Password") }}
|
||||
{% for e in form.confirm.errors %}
|
||||
<div class="text-danger small">{{ e }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">{{ form.submit.label.text }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -1,4 +1,4 @@
|
||||
# plugins/media/models.py
|
||||
# File: plugins/media/models.py
|
||||
|
||||
from datetime import datetime
|
||||
from flask import url_for
|
||||
@ -8,17 +8,22 @@ class Media(db.Model):
|
||||
__tablename__ = "media"
|
||||
__table_args__ = {"extend_existing": True}
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
plugin = db.Column(db.String(50), nullable=False)
|
||||
related_id = db.Column(db.Integer, nullable=False)
|
||||
filename = db.Column(db.String(256), nullable=False)
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
uploader_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||||
caption = db.Column(db.String(255), nullable=True)
|
||||
plant_id = db.Column(db.Integer, db.ForeignKey("plant.id"), nullable=True)
|
||||
growlog_id = db.Column(db.Integer, db.ForeignKey("grow_logs.id"), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
file_url = db.Column(db.String(512), nullable=False)
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
plugin = db.Column(db.String(50), nullable=False)
|
||||
related_id = db.Column(db.Integer, nullable=False)
|
||||
filename = db.Column(db.String(256), nullable=False)
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
uploader_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||||
caption = db.Column(db.String(255), nullable=True)
|
||||
plant_id = db.Column(db.Integer, db.ForeignKey("plant.id"), nullable=True)
|
||||
growlog_id = db.Column(db.Integer, db.ForeignKey("grow_logs.id"), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
file_url = db.Column(db.String(512), nullable=False)
|
||||
original_file_url = db.Column(db.Text, nullable=True)
|
||||
|
||||
# ←─ NEW ── track orphaned state
|
||||
status = db.Column(db.String(20), nullable=False, default='active')
|
||||
orphaned_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
hearts = db.relationship(
|
||||
"ImageHeart",
|
||||
@ -33,7 +38,7 @@ class Media(db.Model):
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# ↔ Media items attached to a Plant
|
||||
# ↔ attached Plant
|
||||
plant = db.relationship(
|
||||
"Plant",
|
||||
back_populates="media_items",
|
||||
@ -41,7 +46,7 @@ class Media(db.Model):
|
||||
lazy="joined",
|
||||
)
|
||||
|
||||
# ↔ Media items attached to a GrowLog
|
||||
# ↔ attached GrowLog
|
||||
growlog = db.relationship(
|
||||
"GrowLog",
|
||||
back_populates="media_items",
|
||||
@ -75,6 +80,15 @@ class Media(db.Model):
|
||||
for fe in self.featured_entries
|
||||
)
|
||||
|
||||
def mark_orphaned(self, new_url):
|
||||
"""
|
||||
Move to orphaned state, recording original URL and timestamp.
|
||||
"""
|
||||
self.original_file_url = self.file_url
|
||||
self.file_url = new_url
|
||||
self.status = 'orphaned'
|
||||
self.orphaned_at = datetime.utcnow()
|
||||
|
||||
|
||||
class ZipJob(db.Model):
|
||||
__tablename__ = 'zip_jobs'
|
||||
@ -93,7 +107,7 @@ class ImageHeart(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||||
media_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
|
||||
class FeaturedImage(db.Model):
|
||||
@ -106,4 +120,4 @@ class FeaturedImage(db.Model):
|
||||
context_id = db.Column(db.Integer, nullable=False)
|
||||
override_text = db.Column(db.String(255), nullable=True)
|
||||
is_featured = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
@ -2,7 +2,7 @@
|
||||
"name": "Media",
|
||||
"version": "0.1.0",
|
||||
"author": "Bryson Shepard <bryson@natureinpots.com>",
|
||||
"description": "Manages image uploads, storage, and URL generation.",
|
||||
"description": "Upload, serve, and process images & other media.",
|
||||
"module": "plugins.media",
|
||||
"routes": {
|
||||
"module": "plugins.media.routes",
|
||||
@ -18,6 +18,15 @@
|
||||
"callable": "plugins.media.routes.generate_image_url"
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
"plugins.media.tasks"
|
||||
],
|
||||
"tasks_init": [
|
||||
{
|
||||
"module": "plugins.media.tasks",
|
||||
"callable": "init_media_tasks"
|
||||
}
|
||||
],
|
||||
"license": "Proprietary",
|
||||
"repository": "https://github.com/your-org/your-app"
|
||||
}
|
@ -1,35 +1,47 @@
|
||||
# File: plugins/media/tasks.py
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from werkzeug.utils import secure_filename
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from celery.schedules import crontab
|
||||
from flask import current_app
|
||||
from app import db
|
||||
from plugins.media.models import ZipJob
|
||||
from app.celery_app import celery
|
||||
from plugins.media.models import Media, ZipJob
|
||||
|
||||
# Re‐import your create_app and utility plugin to get Celery
|
||||
from plugins.utility.celery import celery_app
|
||||
|
||||
# Constants
|
||||
IMAGE_EXTS = {'.jpg','.jpeg','.png','.gif'}
|
||||
DOC_EXTS = {'.pdf','.txt','.csv'}
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
|
||||
DOC_EXTS = {'.pdf', '.txt', '.csv'}
|
||||
MAX_ZIP_FILES = 1000
|
||||
MAX_PIXELS = 8000 * 8000
|
||||
|
||||
|
||||
def validate_image(path):
|
||||
try:
|
||||
with Image.open(path) as img:
|
||||
img.verify()
|
||||
w, h = Image.open(path).size
|
||||
return (w*h) <= MAX_PIXELS
|
||||
return (w * h) <= MAX_PIXELS
|
||||
except (UnidentifiedImageError, IOError):
|
||||
return False
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
|
||||
@celery.task(
|
||||
bind=True,
|
||||
name='plugins.media.tasks.process_zip',
|
||||
queue='media'
|
||||
)
|
||||
def process_zip(self, job_id, zip_path):
|
||||
"""
|
||||
Unpack and validate a user‐uploaded ZIP batch.
|
||||
"""
|
||||
job = ZipJob.query.get(job_id)
|
||||
job.status = 'processing'
|
||||
db.session.commit()
|
||||
|
||||
extract_dir = zip_path + '_contents'
|
||||
extract_dir = f"{zip_path}_contents"
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = zf.namelist()
|
||||
@ -50,20 +62,84 @@ def process_zip(self, job_id, zip_path):
|
||||
with zf.open(member) as src, open(target, 'wb') as dst:
|
||||
dst.write(src.read())
|
||||
|
||||
if ext in IMAGE_EXTS and not validate_image(target):
|
||||
raise ValueError(f'Bad image: {member}')
|
||||
if ext in IMAGE_EXTS:
|
||||
if not validate_image(target):
|
||||
raise ValueError(f'Bad image: {member}')
|
||||
elif ext == '.pdf':
|
||||
if open(target,'rb').read(5)!=b'%PDF-':
|
||||
with open(target, 'rb') as f:
|
||||
header = f.read(5)
|
||||
if header != b'%PDF-':
|
||||
raise ValueError(f'Bad PDF: {member}')
|
||||
else:
|
||||
# txt/csv → simple UTF-8 check
|
||||
open(target,'rb').read(1024).decode('utf-8')
|
||||
with open(target, 'rb') as f:
|
||||
f.read(1024).decode('utf-8')
|
||||
|
||||
job.status = 'done'
|
||||
|
||||
except Exception as e:
|
||||
job.status = 'failed'
|
||||
job.error = str(e)
|
||||
|
||||
job.error = str(e)
|
||||
finally:
|
||||
db.session.commit()
|
||||
if os.path.isdir(extract_dir):
|
||||
shutil.rmtree(extract_dir)
|
||||
|
||||
|
||||
@celery.on_after_configure.connect
|
||||
def setup_periodic_tasks(sender, **kwargs):
|
||||
"""
|
||||
Schedule periodic media prune job every day at 2am.
|
||||
"""
|
||||
sender.add_periodic_task(
|
||||
crontab(hour=2, minute=0),
|
||||
prune_orphans.s(),
|
||||
name='media_prune',
|
||||
queue='media'
|
||||
)
|
||||
|
||||
|
||||
@celery.task(
|
||||
name='plugins.media.tasks.prune_orphans',
|
||||
queue='media'
|
||||
)
|
||||
def prune_orphans():
|
||||
"""
|
||||
Mark orphaned Media records, move their files to /static/orphaned/,
|
||||
and log the change in the DB.
|
||||
"""
|
||||
orphan_dir = os.path.join(current_app.root_path, 'static', 'orphaned')
|
||||
os.makedirs(orphan_dir, exist_ok=True)
|
||||
|
||||
candidates = Media.query.filter(
|
||||
Media.status == 'active',
|
||||
Media.plant_id.is_(None),
|
||||
Media.growlog_id.is_(None),
|
||||
Media.related_id.is_(None)
|
||||
).all()
|
||||
|
||||
for m in candidates:
|
||||
src_rel = m.file_url.lstrip('/')
|
||||
src_abs = os.path.join(current_app.root_path, src_rel)
|
||||
if not os.path.isfile(src_abs):
|
||||
current_app.logger.warning(f"Orphan prune: file not found {src_abs}")
|
||||
continue
|
||||
|
||||
filename = os.path.basename(src_abs)
|
||||
dest_abs = os.path.join(orphan_dir, filename)
|
||||
shutil.move(src_abs, dest_abs)
|
||||
|
||||
new_url = f"/static/orphaned/{filename}"
|
||||
m.mark_orphaned(new_url)
|
||||
|
||||
current_app.logger.info(
|
||||
f"Orphaned media #{m.id}: moved {src_rel} → {new_url}"
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def init_media_tasks(celery_app):
|
||||
"""
|
||||
Called by the JSON‐driven loader so tasks_init no longer errors.
|
||||
Celery scheduling is handled via on_after_configure.
|
||||
"""
|
||||
celery_app.logger.info("[Media] init_media_tasks called (no‐op)")
|
||||
|
@ -1,4 +1,4 @@
|
||||
# plugins/plant/growlog/routes.py
|
||||
# File: plugins/plant/growlog/routes.py
|
||||
|
||||
from uuid import UUID as _UUID
|
||||
from flask import (
|
||||
@ -7,6 +7,7 @@ from flask import (
|
||||
)
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.celery_app import celery
|
||||
from .models import GrowLog
|
||||
from .forms import GrowLogForm
|
||||
from plugins.plant.models import Plant, PlantCommonName
|
||||
@ -18,6 +19,7 @@ bp = Blueprint(
|
||||
template_folder='templates',
|
||||
)
|
||||
|
||||
|
||||
def _get_plant_by_uuid(uuid_val):
|
||||
"""
|
||||
Normalize & validate a UUID (may be a uuid.UUID or a string),
|
||||
@ -40,6 +42,7 @@ def _get_plant_by_uuid(uuid_val):
|
||||
.first_or_404()
|
||||
)
|
||||
|
||||
|
||||
def _user_plant_choices():
|
||||
"""
|
||||
Return [(uuid, "Common Name – uuid"), ...] for all plants
|
||||
@ -103,9 +106,6 @@ def add_log(plant_uuid=None):
|
||||
@bp.route('/<uuid:plant_uuid>')
|
||||
@login_required
|
||||
def list_logs(plant_uuid):
|
||||
from plugins.utility.celery import celery_app
|
||||
celery_app.send_task('plugins.utility.tasks.ping')
|
||||
|
||||
limit = request.args.get('limit', default=10, type=int)
|
||||
|
||||
if plant_uuid:
|
||||
|
@ -90,6 +90,7 @@ class Plant(db.Model):
|
||||
price = db.Column(db.Numeric(10, 2), nullable=True)
|
||||
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
is_public = db.Column(db.Boolean, nullable=False, default=False)
|
||||
featured_media_id = db.Column(db.Integer, db.ForeignKey('media.id'), nullable=True)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
@ -1,13 +1,3 @@
|
||||
# plugins/utility/__init__.py
|
||||
|
||||
def register_cli(app):
|
||||
# no CLI commands for now
|
||||
pass
|
||||
|
||||
def init_celery(app):
|
||||
# Called via plugin.json entry_point
|
||||
from .celery import init_celery as _init, celery_app
|
||||
_init(app)
|
||||
# Attach it if you like: app.celery = celery_app
|
||||
app.celery = celery_app
|
||||
return celery_app
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user