a ton of fun happened, refactored alot

This commit is contained in:
2025-07-03 04:29:43 -05:00
parent 72e060d783
commit 1bbe6e2743
121 changed files with 2315 additions and 900 deletions

View File

@ -2,8 +2,9 @@
USE_REMOTE_MYSQL=0 USE_REMOTE_MYSQL=0
ENABLE_DB_SEEDING=1 ENABLE_DB_SEEDING=1
DOCKER_ENV=development DOCKER_ENV=development
UPLOAD_FOLDER=app/static/uploads FLASK_ENV=development
SECRET_KEY=supersecretplantappkey UPLOAD_FOLDER=static/uploads
SECRET_KEY=37f765030a6986ce47922ea1248d1e8dc24c1bc0638e4cd0d09382d1634a8e2a
# MySQL configuration # MySQL configuration
MYSQL_HOST=db MYSQL_HOST=db
@ -13,9 +14,10 @@ MYSQL_USER=plant_user
MYSQL_PASSWORD=plant_pass MYSQL_PASSWORD=plant_pass
MYSQL_ROOT_PASSWORD=supersecret MYSQL_ROOT_PASSWORD=supersecret
# Neo4j Settings
NEO4J_URI=bolt://neo4j:7687 NEO4J_URI=bolt://neo4j:7687
NEO4J_USER=neo4j NEO4J_USER=neo4j
NEO4J_PASSWORD=your_secure_password NEO4J_PASSWORD=your_secure_password
# Media Settings
STANDARD_IMG_SIZE=300x200 STANDARD_IMG_SIZE=300x200

1
.gitignore vendored
View File

@ -11,7 +11,6 @@ __pycache__/
instance/ instance/
mysql_data/ mysql_data/
.env .env
#.env.*
# VS Code # VS Code
.vscode/ .vscode/

View File

@ -1,16 +1,32 @@
FROM python:3.11-slim FROM python:3.11-slim
# Install build deps and netcat for the DB-wait
RUN apt-get update && apt-get install -y \
gcc \
default-libmysqlclient-dev \
pkg-config \
netcat-openbsd \
curl \
&& rm -rf /var/lib/apt/lists/*
# Upgrade pip and install Python requirements
WORKDIR /app WORKDIR /app
COPY requirements.txt .
RUN pip install --upgrade pip \
&& pip install -r requirements.txt
# Copy the app code
COPY . . COPY . .
# Required for mysqlclient + netcat wait # Create a non-root user and give it ownership of /app
RUN apt-get update && apt-get install -y gcc default-libmysqlclient-dev pkg-config netcat-openbsd curl && rm -rf /var/lib/apt/lists/* RUN useradd -ms /bin/bash appuser \
&& chown -R appuser:appuser /app
RUN pip install --upgrade pip # Switch to appuser for all subsequent commands
RUN pip install -r requirements.txt USER appuser
# Add entrypoint script # Make the entrypoint script executable
COPY entrypoint.sh /entrypoint.sh COPY --chown=appuser:appuser entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]

View File

@ -1,120 +1,145 @@
# app/__init__.py # File: app/__init__.py
import os import os
import json import json
import glob
import importlib import importlib
import importlib.util
import time import time
from flask import Flask,request from flask import Flask, request
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate from flask_migrate import Migrate
from flask_login import LoginManager from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect from flask_wtf.csrf import CSRFProtect
from dotenv import load_dotenv
from datetime import datetime 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)
# Load environment variables from .env or system # ─── Core extensions ───────────────────────────────────────────────────────────
load_dotenv()
# ─── Initialize core extensions ─────────────────────────────────────────────────
db = SQLAlchemy() db = SQLAlchemy()
migrate = Migrate() migrate = Migrate()
login_manager = LoginManager() login_manager = LoginManager()
csrf = CSRFProtect() csrf = CSRFProtect()
from plugins.media.routes import generate_image_url # Import it here # ─── Template helper (still in core) ──────────────────────────────────────────
from plugins.media.routes import generate_image_url # noqa: E402
def create_app(): def create_app():
app = Flask(__name__) # ─── Configure Flask ────────────────────────────────────────────────────────
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
app = Flask(
__name__,
static_folder=os.path.join(project_root, 'static'),
static_url_path='/static'
)
app.config.from_object('app.config.Config') app.config.from_object('app.config.Config')
# ─── Initialize extensions with the app ─────────────────────────────────────── # ─── Init extensions ───────────────────────────────────────────────────────
csrf.init_app(app) csrf.init_app(app)
db.init_app(app) db.init_app(app)
migrate.init_app(app, db) migrate.init_app(app, db)
login_manager.init_app(app) login_manager.init_app(app)
login_manager.login_view = 'auth.login' login_manager.login_view = 'auth.login'
# ─── Register user_loader for Flask-Login ─────────────────────────────────── # ─── Core routes & errors ───────────────────────────────────────────────────
from plugins.auth.models import User from .errors import bp as errors_bp # noqa: E402
@login_manager.user_loader
def load_user(user_id):
try:
return User.query.get(int(user_id))
except Exception:
return None
# ─── Register error handlers ─────────────────────────────────────────────────
from .errors import bp as errors_bp
app.register_blueprint(errors_bp) 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")
# ─── 1) Autoimport plugin models by their package names ───────────────────── # ─── JSONdriven plugin loader ──────────────────────────────────────────────
# This ensures that every plugins/<plugin>/models.py is imported exactly once plugins_dir = os.path.join(project_root, 'plugins')
plugin_model_paths = glob.glob( for name in sorted(os.listdir(plugins_dir)):
os.path.join(os.path.dirname(__file__), '..', 'plugins', '*', 'models.py') plugin_path = os.path.join(plugins_dir, name)
) manifest = os.path.join(plugin_path, 'plugin.json')
for path in plugin_model_paths: if not os.path.isfile(manifest):
# path looks like ".../plugins/plant/models.py"
rel = path.split(os.sep)[-2] # e.g. "plant"
pkg = f"plugins.{rel}.models" # e.g. "plugins.plant.models"
try:
importlib.import_module(pkg)
print(f"✅ (Startup) Loaded: {pkg}")
except Exception as e:
print(f"❌ (Startup) Failed to load {pkg}: {e}")
# ─── 2) Autodiscover & register plugin routes, CLI, entrypoints ────────────
plugin_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'plugins'))
for plugin in os.listdir(plugin_path):
if plugin.endswith('.noload'):
print(f"[⏭] Skipping plugin '{plugin}' (marked as .noload)")
continue continue
plugin_dir = os.path.join(plugin_path, plugin) errors = []
if not os.path.isdir(plugin_dir): try:
meta = json.load(open(manifest))
except Exception as e:
print(f"Plugin '{name}' 🛑 manifest load failed: {e}")
continue continue
# (a) Register routes.py # 1) Import models
route_file = os.path.join(plugin_dir, 'routes.py') for model_path in meta.get('models', []):
if os.path.isfile(route_file):
try: try:
spec = importlib.util.spec_from_file_location(f"plugins.{plugin}.routes", route_file) importlib.import_module(model_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
if hasattr(mod, 'bp'):
app.register_blueprint(mod.bp, strict_slashes=False)
print(f"✔️ Registered routes for plugin '{plugin}'")
except Exception as e: except Exception as e:
print(f"❌ Failed to load routes from plugin '{plugin}': {e}") errors.append(f"model import ({model_path}): {e}")
# (b) Register CLI and entrypoint # 1.b) user_loader hook
init_file = os.path.join(plugin_dir, '__init__.py') ul = meta.get('user_loader')
plugin_json = os.path.join(plugin_dir, 'plugin.json') if ul:
if os.path.isfile(init_file):
try: try:
cli_module = importlib.import_module(f"plugins.{plugin}") mod = importlib.import_module(ul['module'])
if hasattr(cli_module, 'register_cli'): fn = getattr(mod, ul['callable'])
cli_module.register_cli(app) fn(app)
print(f"✔️ Registered CLI for plugin '{plugin}'")
if os.path.isfile(plugin_json):
with open(plugin_json, 'r') as f:
meta = json.load(f)
entry = meta.get('entry_point')
if entry and hasattr(cli_module, entry):
getattr(cli_module, entry)(app)
print(f"✔️ Ran entry point '{entry}' for plugin '{plugin}'")
except Exception as e: except Exception as e:
print(f"❌ Failed to load CLI for plugin '{plugin}': {e}") errors.append(f"user_loader ({ul['module']}:{ul['callable']}): {e}")
# ─── Inject current year into templates ──────────────────────────────────────── # 2) Register routes
routes_cfg = meta.get('routes')
if routes_cfg:
try:
mod = importlib.import_module(routes_cfg['module'])
bp_obj = getattr(mod, routes_cfg['blueprint'])
prefix = routes_cfg.get('url_prefix')
app.register_blueprint(bp_obj, url_prefix=prefix, strict_slashes=False)
except Exception as e:
errors.append(f"routes ({routes_cfg['module']}): {e}")
# 3) Register CLI commands
cli_cfg = meta.get('cli')
if cli_cfg:
try:
mod = importlib.import_module(cli_cfg['module'])
fn = getattr(mod, cli_cfg['callable'])
app.cli.add_command(fn)
except Exception as e:
errors.append(f"cli ({cli_cfg['module']}:{cli_cfg['callable']}): {e}")
# 4) 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)
app.jinja_env.globals[tg['name']] = fn
except Exception as e:
errors.append(f"template_global ({tg}): {e}")
# 5) Subplugins (models + routes)
for sp in meta.get('subplugins', []):
for mp in sp.get('models', []):
try:
importlib.import_module(mp)
except Exception as e:
errors.append(f"subplugin model ({mp}): {e}")
sp_rt = sp.get('routes')
if sp_rt:
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)
except Exception as e:
errors.append(f"subplugin routes ({sp_rt['module']}): {e}")
# Final status
if errors:
print(f"Plugin '{name}' 🛑 failed to load: {'; '.join(errors)}")
else:
print(f"Plugin '{name}' ✔️ Loaded Successfully.")
# ─── Context processors, analytics, teardown ───────────────────────────────
@app.context_processor @app.context_processor
def inject_current_year(): def inject_current_year():
from datetime import datetime
return {'current_year': datetime.now().year} return {'current_year': datetime.now().year}
@app.context_processor @app.context_processor
@ -127,17 +152,16 @@ def create_app():
@app.after_request @app.after_request
def log_analytics(response): def log_analytics(response):
# import here to avoid circular at moduleload time from plugins.admin.models import AnalyticsEvent # noqa: E402
from plugins.admin.models import AnalyticsEvent
try: try:
duration = time.time() - getattr(request, '_start_time', time.time()) duration = time.time() - getattr(request, '_start_time', time.time())
ev = AnalyticsEvent( ev = AnalyticsEvent(
method=request.method, method = request.method,
path=request.path, path = request.path,
status_code=response.status_code, status_code = response.status_code,
response_time=duration, response_time = duration,
user_agent=request.headers.get('User-Agent'), user_agent = request.headers.get('User-Agent'),
referer=request.headers.get('Referer'), referer = request.headers.get('Referer'),
accept_language=request.headers.get('Accept-Language'), accept_language=request.headers.get('Accept-Language'),
) )
db.session.add(ev) db.session.add(ev)
@ -146,6 +170,11 @@ def create_app():
db.session.rollback() db.session.rollback()
return response return response
@app.teardown_appcontext
def shutdown_session(exception=None):
db.session.remove()
# ─── Keep the template helper exposed ──────────────────────────────────────
app.jinja_env.globals['generate_image_url'] = generate_image_url app.jinja_env.globals['generate_image_url'] = generate_image_url
return app return app

View File

@ -1,45 +1,93 @@
import os import os
from dotenv import load_dotenv, find_dotenv
# CONFIG_DIR is your app package; go up one to the project root # ─── Load .env from project root or any parent ────────────────────────────────
dotenv_path = find_dotenv()
if dotenv_path:
load_dotenv(dotenv_path, override=True)
# ─── Paths ────────────────────────────────────────────────────────────────────
CONFIG_DIR = os.path.dirname(os.path.abspath(__file__)) CONFIG_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(CONFIG_DIR) PROJECT_ROOT = os.path.dirname(CONFIG_DIR)
class Config: class Config:
SECRET_KEY = os.environ['SECRET_KEY'] # ─── Environment ─────────────────────────────────────────────────────────────
MAX_CONTENT_LENGTH = int( ENV = (
os.environ.get('MAX_CONTENT_LENGTH', 20 * 1024 * 1024 * 1024) os.getenv('FLASK_ENV')
) or os.getenv('DOCKER_ENV')
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} or 'production'
).lower()
# ─── 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."
)
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
_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)
else:
UPLOAD_FOLDER = os.path.join(PROJECT_ROOT, "static", "uploads") UPLOAD_FOLDER = os.path.join(PROJECT_ROOT, "static", "uploads")
# MySQL connection parameters MAX_CONTENT_LENGTH = int(os.getenv('MAX_CONTENT_LENGTH', 20 * 1024**3))
MYSQL_USER = os.environ['MYSQL_USER'] ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
MYSQL_PASSWORD = os.environ['MYSQL_PASSWORD']
MYSQL_HOST = os.environ['MYSQL_HOST'] # ─── Celery ────────────────────────────────────────────────────────────────
MYSQL_PORT = int(os.environ.get('MYSQL_PORT', 3306)) CELERY_BROKER_URL = os.getenv('CELERY_BROKER_URL')
MYSQL_DATABASE = os.environ['MYSQL_DATABASE'] if not CELERY_BROKER_URL:
raise RuntimeError("CELERY_BROKER_URL environment variable not set!")
CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND', CELERY_BROKER_URL)
# ─── MySQL ──────────────────────────────────────────────────────────────────
MYSQL_USER = os.getenv('MYSQL_USER')
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD')
if not MYSQL_PASSWORD:
raise RuntimeError("MYSQL_PASSWORD environment variable not set!")
MYSQL_HOST = os.getenv('MYSQL_HOST', 'db')
MYSQL_PORT = int(os.getenv('MYSQL_PORT', 3306))
MYSQL_DATABASE = os.getenv('MYSQL_DATABASE')
if not MYSQL_DATABASE:
raise RuntimeError("MYSQL_DATABASE environment variable not set!")
# Build the SQLAlchemy database URI
SQLALCHEMY_DATABASE_URI = ( SQLALCHEMY_DATABASE_URI = (
f"mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}" f"mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}"
f"@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DATABASE}" f"@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DATABASE}"
) )
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
# Optional toggles # ─── Cookies / Session ──────────────────────────────────────────────────────
ENABLE_DB_SEEDING = os.environ.get('ENABLE_DB_SEEDING', '0') == '1' SESSION_COOKIE_SECURE = True
DOCKER_ENV = os.environ.get('FLASK_ENV', 'production') SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
REMEMBER_COOKIE_SECURE = True
REMEMBER_COOKIE_HTTPONLY = True
REMEMBER_COOKIE_SAMESITE = 'Lax'
PREFERRED_URL_SCHEME = 'https'
# Neo4j configuration # ─── Toggles ────────────────────────────────────────────────────────────────
ENABLE_DB_SEEDING = os.getenv('ENABLE_DB_SEEDING', '0') == '1'
DOCKER_ENV = os.getenv('DOCKER_ENV', 'production')
# ─── Neo4j ──────────────────────────────────────────────────────────────────
NEO4J_URI = os.getenv('NEO4J_URI', 'bolt://neo4j:7687') NEO4J_URI = os.getenv('NEO4J_URI', 'bolt://neo4j:7687')
NEO4J_USER = os.getenv('NEO4J_USER', 'neo4j') NEO4J_USER = os.getenv('NEO4J_USER', 'neo4j')
NEO4J_PASSWORD = os.getenv('NEO4J_PASSWORD', 'your_secure_password') NEO4J_PASSWORD = os.getenv('NEO4J_PASSWORD')
if not NEO4J_PASSWORD:
raise RuntimeError("NEO4J_PASSWORD environment variable not set!")
# Standard image size (for placeholders, etc.) # ─── Misc ──────────────────────────────────────────────────────────────────
STANDARD_IMG_SIZE = tuple( STANDARD_IMG_SIZE = tuple(
map(int, os.getenv('STANDARD_IMG_SIZE', '300x200').split('x')) map(int, os.getenv('STANDARD_IMG_SIZE', '300x200').split('x'))
) )
PLANT_CARDS_BASE_URL = "https://plant.cards" PLANT_CARDS_BASE_URL = "https://plant.cards"
ALLOW_REGISTRATION = False ALLOW_REGISTRATION = False

15
app/routes.py Normal file
View File

@ -0,0 +1,15 @@
from flask import render_template
def init_app(app):
"""
Register core application routes directly on the Flask app:
- GET / → home page
- GET /health → health check
"""
@app.route('/')
def home():
return render_template('core/home.html')
@app.route('/health')
def health():
return 'OK', 200

View File

@ -7,6 +7,6 @@
<body> <body>
<h1>400 Bad Request</h1> <h1>400 Bad Request</h1>
<p>{{ error.description or "Sorry, we couldnt understand that request." }}</p> <p>{{ error.description or "Sorry, we couldnt understand that request." }}</p>
<a href="{{ url_for('main.index') }}">Return home</a> <a href="{{ url_for('home') }}">Return home</a>
</body> </body>
</html> </html>

View File

@ -1,4 +1,3 @@
{# plugins/core_ui/templates/core_ui/_media_macros.html #}
{% macro render_media_list(media_list, thumb_width=150, current_user=None) -%} {% macro render_media_list(media_list, thumb_width=150, current_user=None) -%}
{% if media_list %} {% if media_list %}
<div class="row"> <div class="row">

View File

@ -22,7 +22,7 @@
<body> <body>
<nav class="navbar navbar-expand-lg navbar-light bg-white shadow-sm mb-4"> <nav class="navbar navbar-expand-lg navbar-light bg-white shadow-sm mb-4">
<div class="container"> <div class="container">
<a class="navbar-brand fw-bold" href="{{ url_for('core_ui.home') }}"> <a class="navbar-brand fw-bold" href="{{ url_for('home') }}">
Nature In Pots Nature In Pots
</a> </a>
<button <button
@ -38,7 +38,7 @@
<!-- Left links --> <!-- Left links -->
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item me-2"> <li class="nav-item me-2">
<a class="nav-link" href="{{ url_for('core_ui.home') }}">Home</a> <a class="nav-link" href="{{ url_for('home') }}">Home</a>
</li> </li>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<li class="nav-item me-2"> <li class="nav-item me-2">

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %} {% extends 'core/base.html' %}
{% block title %}Home | Nature In Pots{% endblock %} {% block title %}Home | Nature In Pots{% endblock %}
{% block content %} {% block content %}

BIN
beta-0.1.0.zip Normal file

Binary file not shown.

BIN
betas/beta-0.0.10.zip Normal file

Binary file not shown.

BIN
betas/beta-0.0.11.zip Normal file

Binary file not shown.

BIN
betas/beta-0.0.12.zip Normal file

Binary file not shown.

BIN
betas/beta-0.0.13.zip Normal file

Binary file not shown.

BIN
betas/beta-0.0.2.zip Normal file

Binary file not shown.

BIN
betas/beta-0.0.3.zip Normal file

Binary file not shown.

BIN
betas/beta-0.0.4.zip Normal file

Binary file not shown.

BIN
betas/beta-0.0.5.zip Normal file

Binary file not shown.

BIN
betas/beta-0.0.6.zip Normal file

Binary file not shown.

BIN
betas/beta-0.0.7.zip Normal file

Binary file not shown.

BIN
betas/beta-0.0.8.zip Normal file

Binary file not shown.

BIN
betas/beta-0.0.9.zip Normal file

Binary file not shown.

View File

@ -1,6 +1,11 @@
version: '3.8' version: '3.8'
services: services:
redis:
image: redis:7-alpine
networks:
- appnet
web: web:
build: . build: .
ports: ports:
@ -8,17 +13,23 @@ services:
volumes: volumes:
- .:/app - .:/app
- ./static/uploads:/app/static/uploads - ./static/uploads:/app/static/uploads
env_file:
- .env
environment: environment:
- FLASK_APP=app - FLASK_APP=app:create_app
- FLASK_ENV=development - FLASK_ENV=${FLASK_ENV}
- SECRET_KEY=${SECRET_KEY}
- USE_REMOTE_MYSQL=${USE_REMOTE_MYSQL} - USE_REMOTE_MYSQL=${USE_REMOTE_MYSQL}
- ENABLE_DB_SEEDING=${ENABLE_DB_SEEDING} - ENABLE_DB_SEEDING=${ENABLE_DB_SEEDING}
- MYSQL_HOST=${MYSQL_HOST}
- MYSQL_PORT=${MYSQL_PORT}
- MYSQL_DATABASE=${MYSQL_DATABASE} - MYSQL_DATABASE=${MYSQL_DATABASE}
- MYSQL_USER=${MYSQL_USER} - MYSQL_USER=${MYSQL_USER}
- MYSQL_PASSWORD=${MYSQL_PASSWORD} - MYSQL_PASSWORD=${MYSQL_PASSWORD}
- MYSQL_HOST=${MYSQL_HOST} - CELERY_BROKER_URL=redis://redis:6379/0
- MYSQL_PORT=${MYSQL_PORT} - CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on: depends_on:
- redis
- db - db
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -fs http://127.0.0.1:5000/health || exit 1"] test: ["CMD-SHELL", "curl -fs http://127.0.0.1:5000/health || exit 1"]
@ -32,6 +43,8 @@ services:
db: db:
image: mysql:8 image: mysql:8
restart: unless-stopped restart: unless-stopped
env_file:
- .env
environment: environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=${MYSQL_DATABASE} - MYSQL_DATABASE=${MYSQL_DATABASE}
@ -73,12 +86,31 @@ services:
- "7474:7474" - "7474:7474"
- "7687:7687" - "7687:7687"
environment: environment:
- NEO4J_AUTH=neo4j/your_secure_password # only the one var Neo4j actually needs
- NEO4J_AUTH=neo4j/${NEO4J_PASSWORD}
volumes: volumes:
- neo4j_data:/data - neo4j_data:/data
networks: networks:
- appnet - appnet
worker:
build: .
env_file:
- .env
environment:
- SECRET_KEY=${SECRET_KEY}
- MYSQL_HOST=${MYSQL_HOST}
- MYSQL_PORT=${MYSQL_PORT}
- 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
depends_on:
- redis
- db
networks:
- appnet
volumes: volumes:
neo4j_data: neo4j_data:

View File

@ -1,49 +1,46 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -e set -e
UPLOAD_DIR="/app/static/uploads" # Resolve DB host/port from vars or defaults
mkdir -p "$UPLOAD_DIR" DB_HOST="${DB_HOST:-${MYSQL_HOST:-db}}"
chown -R 1000:998 "$UPLOAD_DIR" DB_PORT="${DB_PORT:-${MYSQL_PORT:-3306}}"
chmod -R 775 "$UPLOAD_DIR"
DB_HOST=${DB_HOST:-db}
DB_PORT=${DB_PORT:-3306}
echo "[⏳] Waiting for database at $DB_HOST:$DB_PORT..." echo "[⏳] Waiting for database at $DB_HOST:$DB_PORT..."
until nc -z "$DB_HOST" "$DB_PORT"; do until nc -z "$DB_HOST" "$DB_PORT"; do
sleep 1 sleep 1
done done
echo "[✔] Database is up" echo "[✔] Database is up"
# Initialize Alembic if not present # Only the "flask" entrypoint needs uploads + migrations
if [ ! -d "./migrations" ]; then if [ "$1" = "flask" ]; then
echo "[🆕] No migrations directory found; initializing Alembic"
flask db init
echo "[🆕] Generating initial migration"
flask db migrate -m "initial" || echo "[] Nothing to migrate"
fi
# Autogenerate new migration if needed # Prepare upload dir (web only)
echo "[🛠️] Checking for new schema changes" UPLOAD_DIR="/app/${UPLOAD_FOLDER:-static/uploads}"
flask db migrate -m "auto-migrate" || echo "[] No schema changes detected" mkdir -p "$UPLOAD_DIR"
chown -R 1000:998 "$UPLOAD_DIR"
chmod -R 775 "$UPLOAD_DIR"
# Apply migrations # Run DB migrations
echo "[] Applying database migrations" echo "[🛠] Applying database migrations"
flask db upgrade flask db upgrade
# Create any missing tables (edge case fallback) # Ensure any missing tables
echo "[🔧] Running db.create_all() to ensure full sync" echo "[🛠️] Ensuring tables exist"
python <<EOF python <<EOF
from app import create_app, db from app import create_app, db
app = create_app() app = create_app()
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
EOF EOF
# Optional seeding # Optional seeding
if [ "$ENABLE_DB_SEEDING" = "true" ] || [ "$ENABLE_DB_SEEDING" = "1" ]; then if [ "${ENABLE_DB_SEEDING,,}" = "true" ] || [ "${ENABLE_DB_SEEDING}" = "1" ]; then
echo "[🌱] Seeding Data" echo "[🌱] Seeding Data"
flask preload-data flask preload-data
fi
echo "[🚀] Starting Flask"
fi fi
echo "[🚀] Starting Flask" # Finally hand off to whatever service was requested (flask or celery)
exec "$@" exec "$@"

View File

@ -1,66 +1,108 @@
from __future__ import with_statement # File: migrations/env.py
import os import os
import logging import sys
import glob import json
import importlib import importlib
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig from logging.config import fileConfig
from sqlalchemy import create_engine, pool
from alembic import context
from flask import current_app # ─── Ensure we can load .env and app code ────────────────────────────────────
from app import db 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) ─────────────────────
# 🔍 Automatically import all plugin models under their real package name from dotenv import load_dotenv, find_dotenv
# ----------------------------- dotenv_path = find_dotenv() # looks in project root or parents
plugin_model_paths = glob.glob(os.path.join("plugins", "*", "models.py")) if dotenv_path:
load_dotenv(dotenv_path, override=True)
for path in plugin_model_paths:
rel = path[len("plugins/") : -len("/models.py")]
pkg = f"plugins.{rel}.models"
try:
importlib.import_module(pkg)
print(f"✅ Loaded: {pkg}")
except Exception as e:
print(f"❌ Failed to load {pkg}: {e}")
# -----------------------------
# ─── Alembic Config & Logging ────────────────────────────────────────────────
config = context.config config = context.config
fileConfig(config.config_file_name) fileConfig(config.config_file_name)
logger = logging.getLogger("alembic.env")
logger.setLevel(logging.WARN) # optional: silence alembic spam
# ─── Import your apps metadata for 'autogenerate' support ─────────────────
from app import db
target_metadata = db.metadata target_metadata = db.metadata
# ─── Dynamically import all plugin models listed in plugin.json ─────────────
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")
if not os.path.isfile(manifest):
continue
try:
meta = json.load(open(manifest))
except Exception:
continue
for model_mod in meta.get("models", []):
try:
importlib.import_module(model_mod)
except ImportError:
pass
for sp in meta.get("subplugins", []):
for model_mod in sp.get("models", []):
try:
importlib.import_module(model_mod)
except ImportError:
pass
# ─── Build or retrieve the database URL ──────────────────────────────────────
def get_database_url():
# 1) alembic.ini setting
url = config.get_main_option("sqlalchemy.url")
if url:
return url
# 2) Generic DATABASE_URL env var
url = os.environ.get("DATABASE_URL")
if url:
return url
# 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 ───────────────────────────────────────────────────────
def run_migrations_offline(): def run_migrations_offline():
url = get_database_url()
context.configure( context.configure(
url=current_app.config.get("SQLALCHEMY_DATABASE_URI"), url=url,
target_metadata=target_metadata, target_metadata=target_metadata,
literal_binds=True, literal_binds=True,
dialect_opts={"paramstyle": "named"}, dialect_opts={"paramstyle": "named"},
sort_tables=True,
render_as_batch=True, # ✅ important!
) )
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
# ─── Online migration ────────────────────────────────────────────────────────
def run_migrations_online(): def run_migrations_online():
connectable = db.engine url = get_database_url()
connectable = create_engine(url, poolclass=pool.NullPool)
with connectable.connect() as connection: with connectable.connect() as connection:
context.configure( context.configure(
connection=connection, connection=connection,
target_metadata=target_metadata, target_metadata=target_metadata,
compare_type=True, compare_type=True,
sort_tables=True, compare_server_default=True,
render_as_batch=True,
) )
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
print("🧠 Alembic sees these tables:")
print(sorted(db.metadata.tables.keys()))
if context.is_offline_mode(): if context.is_offline_mode():
run_migrations_offline() run_migrations_offline()
else: else:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,17 @@
{ {
"name": "admin", "name": "Admin",
"version": "0.1.1", "version": "0.1.0",
"description": "Admin panel plugin for Nature In Pots", "author": "Bryson Shepard <bryson@natureinpots.com>",
"entry_point": "register_cli" "description": "Provides the administrative UI and analytics hooks.",
"module": "plugins.admin",
"routes": {
"module": "plugins.admin.routes",
"blueprint": "bp",
"url_prefix": "/admin"
},
"models": [
"plugins.admin.models"
],
"license": "Proprietary",
"repository": "https://github.com/your-org/your-app"
} }

View File

@ -11,7 +11,7 @@ from datetime import datetime, timedelta
from app import db from app import db
from plugins.auth.models import User from plugins.auth.models import User
from plugins.growlog.models import GrowLog from plugins.plant.growlog.models import GrowLog
from plugins.plant.models import Plant from plugins.plant.models import Plant
from plugins.admin.models import AnalyticsEvent from plugins.admin.models import AnalyticsEvent
from .forms import UserForm from .forms import UserForm

View File

@ -1,5 +1,5 @@
{# plugins/admin/templates/admin/dashboard.html #} {# plugins/admin/templates/admin/dashboard.html #}
{% extends 'core_ui/base.html' %} {% extends 'core/base.html' %}
{% block title %}Admin Dashboard Nature In Pots{% endblock %} {% block title %}Admin Dashboard Nature In Pots{% endblock %}
{% block styles %} {% block styles %}

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %} {% extends 'core/base.html' %}
{% block title %}{{ action }} User Admin Nature In Pots{% endblock %} {% block title %}{{ action }} User Admin Nature In Pots{% endblock %}
{% block content %} {% block content %}

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %} {% extends 'core/base.html' %}
{% block title %}Users Admin Nature In Pots{% endblock %} {% block title %}Users Admin Nature In Pots{% endblock %}
{% block content %} {% block content %}
<h1>Users</h1> <h1>Users</h1>

15
plugins/auth/forms.py Normal file
View File

@ -0,0 +1,15 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, Length, EqualTo
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')
class LoginForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired()])
submit = SubmitField('Login')

View File

@ -1,7 +1,9 @@
# File: plugins/auth/models.py
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin from flask_login import UserMixin
from datetime import datetime from datetime import datetime
from app import db from app import db, login_manager
class User(db.Model, UserMixin): class User(db.Model, UserMixin):
__tablename__ = 'users' __tablename__ = 'users'
@ -15,14 +17,10 @@ class User(db.Model, UserMixin):
excluded_from_analytics = db.Column(db.Boolean, default=False) excluded_from_analytics = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Soft-delete flag
is_deleted = db.Column(db.Boolean, nullable=False, default=False) is_deleted = db.Column(db.Boolean, nullable=False, default=False)
# Permanent ban flag
is_banned = db.Column(db.Boolean, nullable=False, default=False) is_banned = db.Column(db.Boolean, nullable=False, default=False)
# Temporary suspension until this UTC datetime
suspended_until = db.Column(db.DateTime, nullable=True) suspended_until = db.Column(db.DateTime, nullable=True)
# Use back_populates, not backref
submitted_submissions = db.relationship( submitted_submissions = db.relationship(
"Submission", "Submission",
foreign_keys="Submission.user_id", foreign_keys="Submission.user_id",
@ -42,3 +40,20 @@ class User(db.Model, UserMixin):
def check_password(self, password): def check_password(self, password):
return check_password_hash(self.password_hash, password) return check_password_hash(self.password_hash, password)
# ─── Flask-Login integration ─────────────────────────────────────────────────
def _load_user(user_id):
"""Return a User by ID, or None."""
if not str(user_id).isdigit():
return None
return User.query.get(int(user_id))
def register_user_loader(app):
"""
Hook into Flask-Login to register the user_loader.
Called by our JSON-driven loader if declared in plugin.json.
"""
login_manager.user_loader(_load_user)

View File

@ -1,6 +1,27 @@
{ {
"name": "auth", "name": "Auth",
"version": "1.0.0", "version": "0.1.0",
"description": "User authentication and authorization plugin", "author": "Bryson Shepard <bryson@natureinpots.com>",
"entry_point": null "description": "Handles user registration, login, logout, and invitation flows.",
"module": "plugins.auth",
"routes": {
"module": "plugins.auth.routes",
"blueprint": "bp",
"url_prefix": "/auth"
},
"models": [
"plugins.auth.models"
],
"template_globals": [
{
"name": "current_user",
"callable": "flask_login.current_user"
}
],
"user_loader": {
"module": "plugins.auth.models",
"callable": "register_user_loader"
},
"license": "Proprietary",
"repository": "https://github.com/your-org/your-app"
} }

View File

@ -1,51 +1,48 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app # File: plugins/auth/routes.py
from flask_login import login_user, logout_user, login_required
from werkzeug.security import check_password_hash from flask import Blueprint, render_template, redirect, flash, url_for, request
from app import db from flask_login import login_user, logout_user, login_required
from .models import User from .models import User
from .forms import LoginForm, RegistrationForm
from app import db
bp = Blueprint(
'auth',
__name__,
template_folder='templates/auth', # ← now points at plugins/auth/templates/auth/
url_prefix='/auth'
)
bp = Blueprint('auth', __name__, template_folder='templates')
@bp.route('/login', methods=['GET', 'POST']) @bp.route('/login', methods=['GET', 'POST'])
def login(): def login():
if request.method == 'POST': form = LoginForm()
email = request.form['email'] if form.validate_on_submit():
password = request.form['password'] user = User.query.filter_by(email=form.email.data).first()
user = User.query.filter_by(email=email).first() if user and user.check_password(form.password.data):
if user and check_password_hash(user.password_hash, password):
login_user(user) login_user(user)
flash('Logged in successfully.', 'success') flash('Logged in successfully.', 'success')
return redirect(url_for('core_ui.home')) return redirect(url_for('home'))
else: flash('Invalid email or password.', 'danger')
flash('Invalid credentials.', 'danger') return render_template('login.html', form=form) # resolves to templates/auth/login.html
return render_template('auth/login.html')
@bp.route('/logout') @bp.route('/logout')
@login_required @login_required
def logout(): def logout():
logout_user() logout_user()
flash('Logged out.', 'info') flash('Logged out.', 'info')
return redirect(url_for('core_ui.home')) return redirect(url_for('home'))
@bp.route('/register', methods=['GET', 'POST']) @bp.route('/register', methods=['GET', 'POST'])
def register(): def register():
if not current_app.config.get('ALLOW_REGISTRATION', True): form = RegistrationForm()
flash('Registration is currently closed.', 'warning') if form.validate_on_submit():
return redirect(url_for('auth.login')) user = User(email=form.email.data)
user.set_password(form.password.data)
if request.method == 'POST':
email = request.form['email']
password = request.form['password']
existing_user = User.query.filter_by(email=email).first()
if existing_user:
flash('Email already registered.', 'warning')
else:
user = User(email=email)
user.set_password(password)
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
flash('Account created. You can now log in.', 'success') flash('Account created! Please log in.', 'success')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
return render_template('register.html', form=form) # resolves to templates/auth/register.html
return render_template('auth/register.html')

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %} {% extends 'core/base.html' %}
{% block content %} {% block content %}
<h2>Login</h2> <h2>Login</h2>
<form method="POST" action="{{ url_for('auth.login') }}"> <form method="POST" action="{{ url_for('auth.login') }}">

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %} {% extends 'core/base.html' %}
{% block title %}Register{% endblock %} {% block title %}Register{% endblock %}
{% block content %} {% block content %}
<h2>Register</h2> <h2>Register</h2>

View File

@ -1,6 +1,12 @@
{ {
"name": "cli", "name": "CLI",
"version": "1.0.0", "version": "0.1.0",
"description": "Command-line interface plugin", "author": "Bryson Shepard <bryson@natureinpots.com>",
"entry_point": null "description": "Adds custom Flask CLI commands for seeding and maintenance.",
"module": "plugins.cli",
"cli": {
"module": "plugins.cli.seed",
"callable": "preload_data_cli"
},
"license": "Proprietary"
} }

View File

@ -1,6 +0,0 @@
{
"name": "core_ui",
"version": "1.1.0",
"description": "Media rendering macros and styling helpers",
"entry_point": null
}

View File

@ -1,12 +0,0 @@
from flask import Blueprint, render_template
from flask_login import login_required, current_user
bp = Blueprint('core_ui', __name__, template_folder='templates')
@bp.route('/')
def home():
return render_template('core_ui/home.html')
@bp.route('/health')
def health():
return 'OK', 200

View File

@ -1,25 +0,0 @@
# plugins/growlog/forms.py
from flask_wtf import FlaskForm
from wtforms import SelectField, StringField, TextAreaField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length
class GrowLogForm(FlaskForm):
plant_uuid = SelectField(
'Plant',
choices=[], # injected in view
validators=[DataRequired()]
)
event_type = SelectField('Event Type', choices=[
('water', 'Watered'),
('fertilizer', 'Fertilized'),
('repot', 'Repotted'),
('note', 'Note'),
('pest', 'Pest Observed')
], validators=[DataRequired()])
title = StringField('Title', validators=[Length(max=255)])
notes = TextAreaField('Notes', validators=[Length(max=1000)])
is_public = BooleanField('Public?')
submit = SubmitField('Add Log')

View File

@ -1,91 +0,0 @@
from datetime import datetime
from app import db
class GrowLog(db.Model):
__tablename__ = "grow_logs"
__table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True)
plant_id = db.Column(db.Integer, db.ForeignKey("plant.id"), nullable=False)
event_type = db.Column(db.String(50), nullable=False)
title = db.Column(db.String(255), nullable=True)
notes = db.Column(db.Text, nullable=True)
is_public = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(
db.DateTime,
default=datetime.utcnow,
onupdate=datetime.utcnow,
nullable=False
)
# ↔ images uploaded directly to this GrowLog
media_items = db.relationship(
"plugins.media.models.Media",
back_populates="growlog",
foreign_keys="plugins.media.models.Media.growlog_id",
lazy="dynamic",
cascade="all, delete-orphan",
)
# ↔ child updates
updates = db.relationship(
"plugins.growlog.models.PlantUpdate",
back_populates="growlog",
foreign_keys="plugins.growlog.models.PlantUpdate.growlog_id",
lazy="dynamic",
cascade="all, delete-orphan",
)
class PlantUpdate(db.Model):
__tablename__ = "plant_updates"
__table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True)
growlog_id = db.Column(db.Integer, db.ForeignKey("grow_logs.id"), nullable=False)
description = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# ↔ parent GrowLog.updates
growlog = db.relationship(
"plugins.growlog.models.GrowLog",
back_populates="updates",
foreign_keys=[growlog_id],
lazy="joined",
)
# ↔ images attached via UpdateImage join table
media_items = db.relationship(
"plugins.growlog.models.UpdateImage",
back_populates="update",
foreign_keys="plugins.growlog.models.UpdateImage.update_id",
lazy="dynamic",
cascade="all, delete-orphan",
)
class UpdateImage(db.Model):
__tablename__ = "update_images"
__table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True)
update_id = db.Column(db.Integer, db.ForeignKey("plant_updates.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)
# ↔ PlantUpdate.media_items
update = db.relationship(
"plugins.growlog.models.PlantUpdate",
back_populates="media_items",
foreign_keys=[update_id],
lazy="joined",
)
# ↔ the actual Media record
media = db.relationship(
"plugins.media.models.Media",
backref=db.backref("update_images", lazy="dynamic"),
foreign_keys=[media_id],
lazy="joined",
)

View File

@ -1,6 +0,0 @@
{
"name": "growlog",
"version": "1.0.0",
"description": "Tracks time-based plant care logs",
"entry_point": null
}

View File

@ -1,4 +1,5 @@
# plugins/media/models.py # plugins/media/models.py
from datetime import datetime from datetime import datetime
from flask import url_for from flask import url_for
from app import db from app import db
@ -17,45 +18,40 @@ class Media(db.Model):
plant_id = db.Column(db.Integer, db.ForeignKey("plant.id"), nullable=True) plant_id = db.Column(db.Integer, db.ForeignKey("plant.id"), nullable=True)
growlog_id = db.Column(db.Integer, db.ForeignKey("grow_logs.id"), nullable=True) growlog_id = db.Column(db.Integer, db.ForeignKey("grow_logs.id"), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# You already have a file_url column in your DB
file_url = db.Column(db.String(512), nullable=False) file_url = db.Column(db.String(512), nullable=False)
hearts = db.relationship( hearts = db.relationship(
"plugins.media.models.ImageHeart", "ImageHeart",
backref="media", backref="media",
lazy="dynamic", lazy="dynamic",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
featured_entries = db.relationship( featured_entries = db.relationship(
"plugins.media.models.FeaturedImage", "FeaturedImage",
backref="media", backref="media",
lazy="dynamic", lazy="dynamic",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
# ↔ Media items attached to a Plant
plant = db.relationship( plant = db.relationship(
"plugins.plant.models.Plant", "Plant",
back_populates="media_items", back_populates="media_items",
foreign_keys=[plant_id], foreign_keys=[plant_id],
lazy="joined", lazy="joined",
) )
# ↔ Media items attached to a GrowLog
growlog = db.relationship( growlog = db.relationship(
"plugins.growlog.models.GrowLog", "GrowLog",
back_populates="media_items", back_populates="media_items",
foreign_keys=[growlog_id], foreign_keys=[growlog_id],
lazy="joined", lazy="joined",
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""
Infer plugin & related_id from whichever FK is set,
and build the file_url path immediately so that INSERT
never tries to write plugin=None or related_id=None.
"""
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# If they passed plant_id or growlog_id in kwargs, pick one:
if self.plant_id: if self.plant_id:
self.plugin = "plant" self.plugin = "plant"
self.related_id = self.plant_id self.related_id = self.plant_id
@ -63,7 +59,6 @@ class Media(db.Model):
self.plugin = "growlog" self.plugin = "growlog"
self.related_id = self.growlog_id self.related_id = self.growlog_id
else: else:
# fallback (you might choose to raise instead)
self.plugin = kwargs.get("plugin", "") self.plugin = kwargs.get("plugin", "")
self.related_id = kwargs.get("related_id", 0) self.related_id = kwargs.get("related_id", 0)
@ -81,6 +76,16 @@ class Media(db.Model):
) )
class ZipJob(db.Model):
__tablename__ = 'zip_jobs'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, nullable=False)
filename = db.Column(db.String(255), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
status = db.Column(db.String(20), default='queued') # queued|processing|done|failed
error = db.Column(db.Text, nullable=True)
class ImageHeart(db.Model): class ImageHeart(db.Model):
__tablename__ = "image_hearts" __tablename__ = "image_hearts"
__table_args__ = {"extend_existing": True} __table_args__ = {"extend_existing": True}

View File

@ -1,6 +1,23 @@
{ {
"name": "media", "name": "Media",
"version": "1.0.0", "version": "0.1.0",
"description": "Upload and attach media to plants and grow logs", "author": "Bryson Shepard <bryson@natureinpots.com>",
"entry_point": null "description": "Manages image uploads, storage, and URL generation.",
"module": "plugins.media",
"routes": {
"module": "plugins.media.routes",
"blueprint": "bp",
"url_prefix": "/media"
},
"models": [
"plugins.media.models"
],
"template_globals": [
{
"name": "generate_image_url",
"callable": "plugins.media.routes.generate_image_url"
}
],
"license": "Proprietary",
"repository": "https://github.com/your-org/your-app"
} }

View File

@ -1,9 +1,6 @@
import os import os
import zipfile
import uuid import uuid
import io
import traceback
import tempfile
import logging
from datetime import datetime from datetime import datetime
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from werkzeug.datastructures import FileStorage from werkzeug.datastructures import FileStorage
@ -13,10 +10,11 @@ from flask import (
jsonify, abort jsonify, abort
) )
from flask_login import login_required, current_user from flask_login import login_required, current_user
from PIL import Image, ExifTags from PIL import Image, UnidentifiedImageError
from app import db from app import db
from .models import Media, ImageHeart, FeaturedImage from .models import Media, ZipJob, ImageHeart, FeaturedImage
from .tasks import process_zip
bp = Blueprint( bp = Blueprint(
"media", "media",
@ -25,43 +23,91 @@ bp = Blueprint(
template_folder="templates" template_folder="templates"
) )
# ─── Constants ──────────────────────────────────────────────────────────────
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
DOC_EXTS = {".pdf", ".txt", ".csv"}
ZIP_EXT = ".zip"
MAX_ZIP_FILES = 1000
MAX_IMAGE_PIXELS = 8000 * 8000 # ~64M pixels
# ─── Context Processor ──────────────────────────────────────────────────────
# ─── Context Processor ─────────────────────────────────────────────────────────
@bp.app_context_processor @bp.app_context_processor
def utility_processor(): def inject_helpers():
"""Expose generate_image_url in all media templates."""
return dict(generate_image_url=generate_image_url) return dict(generate_image_url=generate_image_url)
# ─── Helper Functions ───────────────────────────────────────────────────────
# ─── Helpers & Config ───────────────────────────────────────────────────────── def allowed_file(filename: str) -> bool:
def allowed_file(filename):
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
return ext in current_app.config.get(
"ALLOWED_EXTENSIONS",
{"png", "jpg", "jpeg", "gif", "webp"}
)
def get_upload_path(plugin: str, related_id: int):
""" """
Return (absolute_dir, subdir) where uploads are stored: Return True if the file extension is allowed.
<UPLOAD_FOLDER>/<plugin>/<related_id>/ """
ext = os.path.splitext(filename)[1].lower()
allowed = current_app.config.get(
"ALLOWED_EXTENSIONS",
IMAGE_EXTS | DOC_EXTS | {ZIP_EXT}
)
return ext in allowed
def get_upload_path(plugin: str, related_id: int) -> (str, str):
"""
Build and return (absolute_dir, relative_subdir) under UPLOAD_FOLDER.
""" """
base = current_app.config["UPLOAD_FOLDER"] base = current_app.config["UPLOAD_FOLDER"]
subdir = os.path.join(plugin, str(related_id)) subdir = os.path.join(plugin, str(related_id))
abs_dir = os.path.join(base, subdir) abs_dir = os.path.abspath(os.path.join(base, subdir))
if not abs_dir.startswith(os.path.abspath(base) + os.sep):
raise RuntimeError("Upload path escapes base directory")
os.makedirs(abs_dir, exist_ok=True) os.makedirs(abs_dir, exist_ok=True)
return abs_dir, subdir return abs_dir, subdir
def validate_image(path: str) -> bool:
"""
Verify image integrity and enforce pixel-size limit.
"""
try:
with Image.open(path) as img:
img.verify()
w, h = Image.open(path).size
return w * h <= MAX_IMAGE_PIXELS
except (UnidentifiedImageError, IOError):
return False
def _strip_exif(image: Image.Image) -> Image.Image: def validate_pdf(path: str) -> bool:
"""
Quick header check for PDF files.
"""
try:
with open(path, "rb") as f:
return f.read(5) == b"%PDF-"
except IOError:
return False
def validate_text(path: str) -> bool:
"""
Ensure the file is valid UTF-8 text/CSV.
"""
try:
with open(path, "rb") as f:
f.read(1024).decode("utf-8")
return True
except Exception:
return False
def strip_exif(image: Image.Image) -> Image.Image:
"""
Rotate per EXIF orientation and strip metadata.
"""
try: try:
exif = image._getexif() exif = image._getexif()
orient_key = next( if exif:
(k for k, v in ExifTags.TAGS.items() if v == "Orientation"), orientation_key = next(
None (k for k, v in Image.ExifTags.TAGS.items()
if v == "Orientation"), None
) )
if exif and orient_key in exif: o = exif.get(orientation_key)
o = exif[orient_key]
if o == 3: if o == 3:
image = image.rotate(180, expand=True) image = image.rotate(180, expand=True)
elif o == 6: elif o == 6:
@ -70,8 +116,259 @@ def _strip_exif(image: Image.Image) -> Image.Image:
image = image.rotate(90, expand=True) image = image.rotate(90, expand=True)
except Exception: except Exception:
pass pass
return image data = list(image.getdata())
clean = Image.new(image.mode, image.size)
clean.putdata(data)
return clean
def generate_image_url(media: Media):
"""
Given a Media instance, return its public URL or a placeholder.
"""
if media and media.file_url:
return url_for(
"media.serve_context_media",
context=media.plugin,
context_id=media.related_id,
filename=media.filename
)
# fallback placeholder
w, h = current_app.config.get("STANDARD_IMG_SIZE", (300, 200))
return f"https://placehold.co/{w}x{h}"
# ─── Core Media Routes ──────────────────────────────────────────────────────
@bp.route("/upload", methods=["POST"])
@login_required
def upload_media():
"""
Accept images, PDFs, text/CSV inline; enqueue ZIPs for async processing.
"""
uploaded: FileStorage = request.files.get("media")
if not uploaded or uploaded.filename == "":
flash("No file selected.", "warning")
return redirect(request.referrer or url_for("home"))
filename = secure_filename(uploaded.filename)
ext = os.path.splitext(filename)[1].lower()
if not allowed_file(filename):
flash("Unsupported file type.", "danger")
return redirect(request.referrer)
# Determine plugin & ID
plugin = request.form.get("plugin", "user")
related_id = int(request.form.get("related_id", current_user.id))
# Save location
abs_dir, subdir = get_upload_path(plugin, related_id)
save_path = os.path.join(abs_dir, filename)
uploaded.save(save_path)
# Validate & post-process
if ext in IMAGE_EXTS:
if not validate_image(save_path):
os.remove(save_path)
flash("Invalid or oversized image.", "danger")
return redirect(request.referrer)
with Image.open(save_path) as img:
clean = strip_exif(img)
clean.save(save_path)
elif ext == ".pdf":
if not validate_pdf(save_path):
os.remove(save_path)
flash("Invalid PDF.", "danger")
return redirect(request.referrer)
elif ext in {".txt", ".csv"}:
if not validate_text(save_path):
os.remove(save_path)
flash("Invalid text/CSV.", "danger")
return redirect(request.referrer)
elif ext == ZIP_EXT:
# Create and enqueue a ZipJob
job = ZipJob(user_id=current_user.id, filename=filename)
db.session.add(job)
db.session.commit()
process_zip.delay(job.id, save_path)
flash("ZIP received; processing in background.", "info")
return redirect(url_for("media.upload_status", job_id=job.id))
# Record small-file upload in DB
media = Media(
plugin = plugin,
related_id = related_id,
filename = filename,
file_url = f"{subdir}/{filename}",
uploader_id = current_user.id,
uploaded_at = datetime.utcnow()
)
db.session.add(media)
db.session.commit()
flash("File uploaded successfully.", "success")
return redirect(request.referrer or url_for("home"))
@bp.route("/upload/<int:job_id>/status", methods=["GET"])
@login_required
def upload_status(job_id: int):
"""
Return JSON status for a background ZIP processing job.
"""
job = ZipJob.query.get_or_404(job_id)
if job.user_id != current_user.id:
abort(403)
return jsonify({
"job_id": job.id,
"status": job.status,
"error": job.error
})
@bp.route("/<context>/<int:context_id>/<filename>")
def serve_context_media(context: str, context_id: int, filename: str):
"""
Serve a file from UPLOAD_FOLDER/<plugin>/<id>/<filename>,
with pathtraversal guard and DB check.
"""
# Normalize plugin name
valid = {"user", "plant", "growlog", "vendor"}
if context in valid:
plugin_name = context
elif context.endswith("s") and context[:-1] in valid:
plugin_name = context[:-1]
else:
abort(404)
# Sanitize filename
safe_filename = secure_filename(filename)
if safe_filename != filename:
abort(404)
# Build and verify path
base_dir = current_app.config["UPLOAD_FOLDER"]
dir_path = os.path.join(base_dir, plugin_name, str(context_id))
full_path = os.path.abspath(os.path.join(dir_path, safe_filename))
if not full_path.startswith(os.path.abspath(base_dir) + os.sep):
abort(404)
# Confirm DB row
Media.query.filter_by(
plugin = plugin_name,
related_id = context_id,
filename = filename
).first_or_404()
return send_from_directory(dir_path, filename)
# ─── Utility Routes ─────────────────────────────────────────────────────────
@bp.route("/heart/<int:media_id>", methods=["POST"])
@login_required
def toggle_heart(media_id: int):
"""
Toggle a “heart” (like) on an image for the current user.
"""
existing = ImageHeart.query.filter_by(
user_id = current_user.id,
media_id = media_id
).first()
if existing:
db.session.delete(existing)
db.session.commit()
return jsonify(status="unhearted")
heart = ImageHeart(user_id=current_user.id, media_id=media_id)
db.session.add(heart)
db.session.commit()
return jsonify(status="hearted")
@bp.route("/featured/<context>/<int:context_id>/<int:media_id>", methods=["POST"])
@login_required
def set_featured_image(context: str, context_id: int, media_id: int):
"""
Mark a single image as featured for a given context.
"""
valid = {"plant", "growlog", "user", "vendor"}
if context in valid:
plugin_name = context
elif context.endswith("s") and context[:-1] in valid:
plugin_name = context[:-1]
else:
abort(404)
media = Media.query.filter_by(
plugin = plugin_name,
related_id = context_id,
id = media_id
).first_or_404()
if media.uploader_id != current_user.id and current_user.role != "admin":
abort(403)
FeaturedImage.query.filter_by(
context = plugin_name,
context_id = context_id
).delete()
fi = FeaturedImage(
media_id = media.id,
context = plugin_name,
context_id = context_id,
is_featured = True
)
db.session.add(fi)
db.session.commit()
flash("Featured image updated.", "success")
return redirect(request.referrer or url_for("home"))
@bp.route("/delete/<int:media_id>", methods=["POST"])
@login_required
def delete_media(media_id: int):
"""
Delete a media file and its DB record (softdelete by permission).
"""
media = Media.query.get_or_404(media_id)
if media.uploader_id != current_user.id and current_user.role != "admin":
flash("Not authorized to delete this media.", "danger")
return redirect(request.referrer or url_for("home"))
# Remove file on disk
base = current_app.config["UPLOAD_FOLDER"]
full = os.path.abspath(os.path.join(base, media.file_url))
try:
os.remove(full)
except OSError:
current_app.logger.error(f"Failed to delete file {full}")
# Remove DB record
db.session.delete(media)
db.session.commit()
flash("Media deleted.", "success")
return redirect(request.referrer or url_for("home"))
@bp.route("/rotate/<int:media_id>", methods=["POST"])
@login_required
def rotate_media(media_id: int):
"""
Rotate an image 90° and strip its EXIF metadata.
"""
media = Media.query.get_or_404(media_id)
if media.uploader_id != current_user.id and current_user.role != "admin":
abort(403)
base = current_app.config["UPLOAD_FOLDER"]
full = os.path.abspath(os.path.join(base, media.file_url))
try:
with Image.open(full) as img:
rotated = img.rotate(-90, expand=True)
clean = strip_exif(rotated)
clean.save(full)
flash("Image rotated successfully.", "success")
except Exception as e:
current_app.logger.error(f"Rotation failed for {full}: {e}")
flash("Failed to rotate image.", "danger")
return redirect(request.referrer or url_for("home"))
# ─── Legacy Helpers for Other Plugins ───────────────────────────────────────
def _process_upload_file( def _process_upload_file(
file: FileStorage, file: FileStorage,
@ -105,26 +402,29 @@ def _process_upload_file(
# 5) Build the Media row # 5) Build the Media row
now = datetime.utcnow() now = datetime.utcnow()
media = Media( media = Media(
plugin=plugin, plugin = plugin,
related_id=related_id, related_id = related_id,
filename=filename, filename = filename,
uploaded_at=now, uploaded_at = now,
uploader_id=uploader_id, uploader_id = uploader_id,
caption=caption, caption = caption,
plant_id=plant_id, plant_id = plant_id,
growlog_id=growlog_id, growlog_id = growlog_id,
created_at=now, created_at = now,
file_url=file_url file_url = file_url
) )
return media return media
def save_media_file(file: FileStorage, user_id: int, **ctx) -> Media:
# ─── Exposed Utilities ───────────────────────────────────────────────────────── """
def save_media_file(file, user_id, **ctx): Simple wrapper for other plugins to save an upload via the same logic.
"""
return _process_upload_file(file, user_id, **ctx) return _process_upload_file(file, user_id, **ctx)
def delete_media_file(media: Media): def delete_media_file(media: Media):
"""
Remove a Media record and its file from disk, commit immediately.
"""
base = current_app.config["UPLOAD_FOLDER"] base = current_app.config["UPLOAD_FOLDER"]
full = os.path.normpath(os.path.join(base, media.file_url)) full = os.path.normpath(os.path.join(base, media.file_url))
if os.path.exists(full): if os.path.exists(full):
@ -132,256 +432,12 @@ def delete_media_file(media: Media):
db.session.delete(media) db.session.delete(media)
db.session.commit() db.session.commit()
def rotate_media_file(media: Media): def rotate_media_file(media: Media):
"""
Rotate a Media file 90° in place and commit metadata-only change.
"""
base = current_app.config["UPLOAD_FOLDER"] base = current_app.config["UPLOAD_FOLDER"]
full = os.path.normpath(os.path.join(base, media.file_url)) full = os.path.normpath(os.path.join(base, media.file_url))
with Image.open(full) as img: with Image.open(full) as img:
img.rotate(-90, expand=True).save(full) img.rotate(-90, expand=True).save(full)
db.session.commit() db.session.commit()
def generate_image_url(media: Media):
"""
Given a Media instance (or None), return its public URL
under our new schema, or a placeholder if no media.
"""
if media and media.file_url:
# use singular context
return url_for(
"media.serve_context_media",
context=media.plugin,
context_id=media.related_id,
filename=media.filename
)
# fallback
w, h = current_app.config.get("STANDARD_IMG_SIZE", (300, 200))
return f"https://placehold.co/{w}x{h}"
@bp.route("/<context>/<int:context_id>/<filename>")
def serve_context_media(context, context_id, filename):
"""
Serve files saved under:
<UPLOAD_FOLDER>/<plugin>/<context_id>/<filename>
Accepts both singular and trailing-'s' contexts:
/media/plant/1/foo.jpg OR /media/plants/1/foo.jpg
"""
# — determine plugin name (always singular) —
valid = {"user", "plant", "growlog", "vendor"}
if context in valid:
plugin = context
elif context.endswith("s") and context[:-1] in valid:
plugin = context[:-1]
else:
logging.debug(f"Invalid context '{context}' in URL")
abort(404)
# — build filesystem path —
base = current_app.config["UPLOAD_FOLDER"]
directory = os.path.join(base, plugin, str(context_id))
full_path = os.path.join(directory, filename)
# — Debug log what were about to do —
logging.debug(f"[serve_context_media] plugin={plugin!r}, "
f"context_id={context_id!r}, filename={filename!r}")
logging.debug(f"[serve_context_media] checking DB for media row…")
logging.debug(f"[serve_context_media] filesystem path = {full_path!r}, exists? {os.path.exists(full_path)}")
# — Check the DB row (but dont abort if missing) —
media = Media.query.filter_by(
plugin=plugin,
related_id=context_id,
filename=filename
).first()
if not media:
logging.warning(f"[serve_context_media] no Media DB row for "
f"{plugin}/{context_id}/{filename!r}, "
"will try serving from disk anyway")
# — If the file exists on disk, serve it — otherwise 404 —
if os.path.exists(full_path):
return send_from_directory(directory, filename)
logging.error(f"[serve_context_media] file not found on disk: {full_path!r}")
abort(404)
# ─── Legacy / Other Routes (you can leave these for backward compatibility) ────
@bp.route("/", methods=["GET"])
def media_index():
return redirect(url_for("core_ui.home"))
@bp.route("/<plugin>/<filename>")
def serve(plugin, filename):
# optional legacy support
m = Media.query.filter_by(file_url=f"{plugin}s/%/{filename}").first_or_404()
date_path = m.uploaded_at.strftime("%Y/%m/%d")
disk_dir = os.path.join(
current_app.config["UPLOAD_FOLDER"],
f"{plugin}s",
str(m.plant_id or m.growlog_id),
date_path
)
return send_from_directory(disk_dir, filename)
@bp.route("/<filename>")
def media_public(filename):
base = current_app.config["UPLOAD_FOLDER"]
m = Media.query.filter(Media.file_url.endswith(filename)).first_or_404()
full = os.path.normpath(os.path.join(base, m.file_url))
if not full.startswith(os.path.abspath(base)):
abort(404)
return send_from_directory(base, m.file_url)
@bp.route("/heart/<int:media_id>", methods=["POST"])
@login_required
def toggle_heart(media_id):
existing = ImageHeart.query.filter_by(
user_id=current_user.id, media_id=media_id
).first()
if existing:
db.session.delete(existing)
db.session.commit()
return jsonify({"status": "unhearted"})
heart = ImageHeart(user_id=current_user.id, media_id=media_id)
db.session.add(heart)
db.session.commit()
return jsonify({"status": "hearted"})
@bp.route("/add/<string:plant_uuid>", methods=["POST"])
@login_required
def add_media(plant_uuid):
plant = Plant.query.filter_by(uuid=plant_uuid).first_or_404()
file = request.files.get("file")
if not file or not allowed_file(file.filename):
flash("Invalid or missing file.", "danger")
return redirect(request.referrer or url_for("plant.edit", uuid_val=plant_uuid))
_process_upload_file(
file=file,
uploader_id=current_user.id,
plugin="plant",
related_id=plant.id
)
flash("Media uploaded successfully.", "success")
return redirect(request.referrer or url_for("plant.edit", uuid_val=plant_uuid))
@bp.route("/<context>/<int:context_id>/<filename>")
def media_file(context, context_id, filename):
# your existing serve_context_media logic here
# (unchanged)
from flask import current_app, send_from_directory
import os
valid = {"user", "plant", "growlog", "vendor"}
if context in valid:
plugin = context
elif context.endswith("s") and context[:-1] in valid:
plugin = context[:-1]
else:
abort(404)
media = Media.query.filter_by(
plugin=plugin,
related_id=context_id,
filename=filename
).first_or_404()
base = current_app.config["UPLOAD_FOLDER"]
directory = os.path.join(base, plugin, str(context_id))
return send_from_directory(directory, filename)
@bp.route('/featured/<context>/<int:context_id>/<int:media_id>', methods=['POST'])
def set_featured_image(context, context_id, media_id):
"""
Singleselect “featured” toggle for any plugin (plants, grow_logs, etc).
"""
# normalize to singular plugin name (matches Media.plugin & FeaturedImage.context)
valid = {'plant', 'growlog', 'user', 'vendor'}
if context in valid:
plugin_name = context
elif context.endswith('s') and context[:-1] in valid:
plugin_name = context[:-1]
else:
abort(404)
# must own that media row
media = Media.query.filter_by(
plugin=plugin_name,
related_id=context_id,
id=media_id
).first_or_404()
# clear out any existing featured rows
FeaturedImage.query.filter_by(
context=plugin_name,
context_id=context_id
).delete()
# insert new featured row
fi = FeaturedImage(
media_id=media.id,
context=plugin_name,
context_id=context_id,
is_featured=True
)
db.session.add(fi)
db.session.commit()
# Redirect back with a flash instead of JSON
flash("Featured image updated.", "success")
return redirect(request.referrer or url_for("core_ui.home"))
@bp.route("/delete/<int:media_id>", methods=["POST"])
@login_required
def delete_media(media_id):
media = Media.query.get_or_404(media_id)
if media.uploader_id != current_user.id and current_user.role != "admin":
flash("Not authorized to delete this media.", "danger")
return redirect(request.referrer or url_for("core_ui.home"))
delete_media_file(media)
flash("Media deleted.", "success")
return redirect(request.referrer or url_for("plant.edit", uuid_val=media.plant.uuid))
@bp.route("/bulk_delete/<string:plant_uuid>", methods=["POST"])
@login_required
def bulk_delete_media(plant_uuid):
plant = Plant.query.filter_by(uuid=plant_uuid).first_or_404()
media_ids = request.form.getlist("delete_ids")
deleted = 0
for mid in media_ids:
m = Media.query.filter_by(id=mid, plant_id=plant.id).first()
if m and (m.uploader_id == current_user.id or current_user.role == "admin"):
delete_media_file(m)
deleted += 1
flash(f"{deleted} image(s) deleted.", "success")
return redirect(request.referrer or url_for("plant.edit", uuid_val=plant_uuid))
@bp.route("/rotate/<int:media_id>", methods=["POST"])
@login_required
def rotate_media(media_id):
media = Media.query.get_or_404(media_id)
if media.uploader_id != current_user.id and current_user.role != "admin":
flash("Not authorized to rotate this media.", "danger")
return redirect(request.referrer or url_for("core_ui.home"))
try:
rotate_media_file(media)
flash("Image rotated successfully.", "success")
except Exception as e:
flash(f"Failed to rotate image: {e}", "danger")
return redirect(request.referrer or url_for("plant.edit", uuid_val=media.plant.uuid))

69
plugins/media/tasks.py Normal file
View File

@ -0,0 +1,69 @@
import os
import zipfile
from werkzeug.utils import secure_filename
from PIL import Image, UnidentifiedImageError
from app import db
from plugins.media.models import ZipJob
# Reimport your create_app and utility plugin to get Celery
from plugins.utility.celery import celery_app
# Constants
IMAGE_EXTS = {'.jpg','.jpeg','.png','.gif'}
DOC_EXTS = {'.pdf','.txt','.csv'}
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
except (UnidentifiedImageError, IOError):
return False
@celery_app.task(bind=True)
def process_zip(self, job_id, zip_path):
job = ZipJob.query.get(job_id)
job.status = 'processing'
db.session.commit()
extract_dir = zip_path + '_contents'
try:
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
if len(names) > MAX_ZIP_FILES:
raise ValueError('ZIP contains too many files.')
os.makedirs(extract_dir, exist_ok=True)
for member in names:
safe = secure_filename(member)
if safe != member:
raise ValueError(f'Illegal filename {member}')
_, ext = os.path.splitext(safe.lower())
if ext not in IMAGE_EXTS | DOC_EXTS:
raise ValueError(f'Unsupported type {ext}')
target = os.path.join(extract_dir, safe)
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}')
elif ext == '.pdf':
if open(target,'rb').read(5)!=b'%PDF-':
raise ValueError(f'Bad PDF: {member}')
else:
# txt/csv → simple UTF-8 check
open(target,'rb').read(1024).decode('utf-8')
job.status = 'done'
except Exception as e:
job.status = 'failed'
job.error = str(e)
finally:
db.session.commit()

View File

@ -1,5 +1,5 @@
{# plugins/media/templates/media/list.html #} {# plugins/media/templates/media/list.html #}
{% extends 'core_ui/base.html' %} {% extends 'core/base.html' %}
{% block content %} {% block content %}
<h2>All Uploaded Media</h2> <h2>All Uploaded Media</h2>

View File

@ -0,0 +1,14 @@
{
"name": "Ownership",
"version": "0.1.0",
"author": "Bryson Shepard <bryson@natureinpots.com>",
"description": "Tracks plant ownership transfers and history.",
"module": "plugins.ownership",
"routes": {
"module": "plugins.ownership.routes",
"blueprint": "bp",
"url_prefix": "/ownership"
},
"license": "Proprietary",
"repository": "https://github.com/your-org/your-app"
}

View File

@ -0,0 +1,29 @@
# plugins/plant/growlog/forms.py
from flask_wtf import FlaskForm
from wtforms import SelectField, StringField, TextAreaField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length
class GrowLogForm(FlaskForm):
plant_uuid = SelectField(
'Plant',
choices=[], # injected in view
validators=[DataRequired()]
)
event_type = SelectField(
'Event Type',
choices=[
('water', 'Watered'),
('fertilizer', 'Fertilized'),
('repot', 'Repotted'),
('note', 'Note'),
('pest', 'Pest Observed'),
],
validators=[DataRequired()]
)
title = StringField('Title', validators=[Length(max=255)])
notes = TextAreaField('Notes', validators=[Length(max=1000)])
is_public = BooleanField('Public?')
submit = SubmitField('Save Log')

View File

@ -0,0 +1,40 @@
# plugins/plant/growlog/models.py
from datetime import datetime
from app import db
class GrowLog(db.Model):
__tablename__ = "grow_logs"
__table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True)
plant_id = db.Column(db.Integer, db.ForeignKey("plant.id"), nullable=False)
event_type = db.Column(db.String(50), nullable=False)
title = db.Column(db.String(255), nullable=True)
notes = db.Column(db.Text, nullable=True)
is_public = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(
db.DateTime,
default=datetime.utcnow,
onupdate=datetime.utcnow,
nullable=False
)
# ─── Single “primary” media for this log ───────────────────────────────────
media_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=True)
media = db.relationship(
"plugins.media.models.Media",
backref=db.backref("update_images", lazy="dynamic"),
foreign_keys=[media_id],
lazy="joined",
)
# ─── All Media items whose growlog_id points here ─────────────────────────
media_items = db.relationship(
"plugins.media.models.Media",
back_populates="growlog",
foreign_keys="plugins.media.models.Media.growlog_id",
lazy="dynamic",
cascade="all, delete-orphan"
)

View File

@ -1,7 +1,9 @@
# plugins/plant/growlog/routes.py
from uuid import UUID as _UUID from uuid import UUID as _UUID
from werkzeug.exceptions import NotFound
from flask import ( from flask import (
Blueprint, render_template, abort, redirect, url_for, request, flash Blueprint, render_template, abort, redirect,
url_for, request, flash
) )
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db from app import db
@ -9,7 +11,6 @@ from .models import GrowLog
from .forms import GrowLogForm from .forms import GrowLogForm
from plugins.plant.models import Plant, PlantCommonName from plugins.plant.models import Plant, PlantCommonName
bp = Blueprint( bp = Blueprint(
'growlog', 'growlog',
__name__, __name__,
@ -17,33 +18,33 @@ bp = Blueprint(
template_folder='templates', template_folder='templates',
) )
def _get_plant_by_uuid(uuid_val): def _get_plant_by_uuid(uuid_val):
""" """
uuid_val may already be a uuid.UUID (from a <uuid:> route converter) Normalize & validate a UUID (may be a uuid.UUID or a string),
or a string (from form POST). Normalize & validate it, then lookup. then return the Plant owned by current_user or 404.
""" """
# 1) If Flask route gave us a UUID instance, just stringify it # 1) If Flask gave us a real UUID, stringify it
if isinstance(uuid_val, _UUID): if isinstance(uuid_val, _UUID):
val = str(uuid_val) val = str(uuid_val)
else: else:
# 2) Otherwise try to parse it as a hex string # 2) Otherwise try to parse it
try: try:
val = str(_UUID(uuid_val)) val = str(_UUID(uuid_val))
except (ValueError, TypeError): except (ValueError, TypeError):
# invalid format → 404
abort(404) abort(404)
# 3) Only return plants owned by current_user # 3) Only return plants owned by this user
return ( return (
Plant.query Plant.query
.filter_by(uuid=val, owner_id=current_user.id) .filter_by(uuid=val, owner_id=current_user.id)
.first_or_404() .first_or_404()
) )
def _user_plant_choices(): def _user_plant_choices():
# join to the commonname table and sort by its name """
Return [(uuid, "Common Name uuid"), ...] for all plants
owned by current_user, sorted by common name.
"""
plants = ( plants = (
Plant.query Plant.query
.filter_by(owner_id=current_user.id) .filter_by(owner_id=current_user.id)
@ -62,20 +63,19 @@ def _user_plant_choices():
@login_required @login_required
def add_log(plant_uuid=None): def add_log(plant_uuid=None):
form = GrowLogForm() form = GrowLogForm()
# 1) always populate the dropdown behind the scenes # always populate the select behind the scenes
form.plant_uuid.choices = _user_plant_choices() form.plant_uuid.choices = _user_plant_choices()
plant = None plant = None
hide_select = False hide_select = False
# 2) if URL had a plant_uuid, load & pre-select it, hide dropdown # if URL gave us a plant_uuid, lock to that one
if plant_uuid: if plant_uuid:
plant = _get_plant_by_uuid(plant_uuid) plant = _get_plant_by_uuid(plant_uuid)
form.plant_uuid.data = str(plant_uuid) form.plant_uuid.data = str(plant_uuid)
hide_select = True hide_select = True
if form.validate_on_submit(): if form.validate_on_submit():
# 3) on POST, resolve via form.plant_uuid
plant = _get_plant_by_uuid(form.plant_uuid.data) plant = _get_plant_by_uuid(form.plant_uuid.data)
log = GrowLog( log = GrowLog(
plant_id = plant.id, plant_id = plant.id,
@ -95,7 +95,7 @@ def add_log(plant_uuid=None):
'growlog/log_form.html', 'growlog/log_form.html',
form = form, form = form,
plant = plant, plant = plant,
hide_plant_select = hide_select hide_plant_select = hide_select,
) )
@ -103,15 +103,17 @@ def add_log(plant_uuid=None):
@bp.route('/<uuid:plant_uuid>') @bp.route('/<uuid:plant_uuid>')
@login_required @login_required
def list_logs(plant_uuid): def list_logs(plant_uuid):
# how many to show? from plugins.utility.celery import celery_app
celery_app.send_task('plugins.utility.tasks.ping')
limit = request.args.get('limit', default=10, type=int) limit = request.args.get('limit', default=10, type=int)
if plant_uuid: if plant_uuid:
# logs for a single plant # logs for one plant
plant = _get_plant_by_uuid(plant_uuid) plant = _get_plant_by_uuid(plant_uuid)
query = GrowLog.query.filter_by(plant_id=plant.id) query = GrowLog.query.filter_by(plant_id=plant.id)
else: else:
# logs for all your plants # logs across all of this users plants
plant = None plant = None
query = ( query = (
GrowLog.query GrowLog.query
@ -128,20 +130,20 @@ def list_logs(plant_uuid):
return render_template( return render_template(
'growlog/log_list.html', 'growlog/log_list.html',
plant=plant, plant = plant,
logs=logs, logs = logs,
limit=limit limit = limit,
) )
@bp.route('/<uuid:plant_uuid>/edit/<int:log_id>', methods=['GET', 'POST']) @bp.route('/<uuid:plant_uuid>/edit/<int:log_id>', methods=['GET','POST'])
@login_required @login_required
def edit_log(plant_uuid, log_id): def edit_log(plant_uuid, log_id):
plant = _get_plant_by_uuid(plant_uuid) plant = _get_plant_by_uuid(plant_uuid)
log = GrowLog.query.filter_by(id=log_id, plant_id=plant.id).first_or_404() log = GrowLog.query.filter_by(id=log_id, plant_id=plant.id).first_or_404()
form = GrowLogForm(obj=log) form = GrowLogForm(obj=log)
# Lock the dropdown to this one plant # lock the dropdown to this plant
form.plant_uuid.choices = [(plant.uuid, plant.common_name.name)] form.plant_uuid.choices = [(plant.uuid, plant.common_name.name)]
form.plant_uuid.data = plant.uuid form.plant_uuid.data = plant.uuid
@ -151,16 +153,15 @@ def edit_log(plant_uuid, log_id):
log.notes = form.notes.data log.notes = form.notes.data
log.is_public = form.is_public.data log.is_public = form.is_public.data
db.session.commit() db.session.commit()
flash('Grow log updated.', 'success') flash('Grow log updated.', 'success')
return redirect(url_for('growlog.list_logs', plant_uuid=plant_uuid)) return redirect(url_for('growlog.list_logs', plant_uuid=plant_uuid))
return render_template( return render_template(
'growlog/log_form.html', 'growlog/log_form.html',
form=form, form = form,
plant_uuid=plant_uuid, plant_uuid = plant_uuid,
plant=plant, plant = plant,
log=log log = log,
) )
@ -171,6 +172,5 @@ def delete_log(plant_uuid, log_id):
log = GrowLog.query.filter_by(id=log_id, plant_id=plant.id).first_or_404() log = GrowLog.query.filter_by(id=log_id, plant_id=plant.id).first_or_404()
db.session.delete(log) db.session.delete(log)
db.session.commit() db.session.commit()
flash('Grow log deleted.', 'warning') flash('Grow log deleted.', 'warning')
return redirect(url_for('growlog.list_logs', plant_uuid=plant_uuid)) return redirect(url_for('growlog.list_logs', plant_uuid=plant_uuid))

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %} {% extends 'core/base.html' %}
{% block title %}Add Grow Log{% endblock %} {% block title %}Add Grow Log{% endblock %}
{% block content %} {% block content %}

View File

@ -1,5 +1,5 @@
{# plugins/growlog/templates/growlog/log_list.html #} {# plugins/plant/growlog/templates/growlog/log_list.html #}
{% extends 'core_ui/base.html' %} {% extends 'core/base.html' %}
{% block title %} {% block title %}
{% if plant %} {% if plant %}
@ -18,9 +18,12 @@
Recent Grow Logs Recent Grow Logs
{% endif %} {% endif %}
</h2> </h2>
{# “Add” button: carry plant_uuid when in single-plant view #}
<a <a
href="{% if plant %}{{ url_for('growlog.add_log', plant_uuid=plant.uuid) }}{% else %}{{ url_for('growlog.add_log') }}{% endif %}" href="{% if plant %}
{{ url_for('growlog.add_log', plant_uuid=plant.uuid) }}
{% else %}
{{ url_for('growlog.add_log') }}
{% endif %}"
class="btn btn-success"> class="btn btn-success">
<i class="bi bi-plus-lg"></i> Add Log <i class="bi bi-plus-lg"></i> Add Log
</a> </a>
@ -38,7 +41,6 @@
</small> </small>
</div> </div>
{% if not plant %} {% if not plant %}
{# Show which plant this log belongs to when listing across all plants #}
<div class="ms-auto text-end"> <div class="ms-auto text-end">
<small class="text-secondary">Plant:</small><br> <small class="text-secondary">Plant:</small><br>
<a href="{{ url_for('growlog.list_logs', plant_uuid=log.plant.uuid) }}"> <a href="{{ url_for('growlog.list_logs', plant_uuid=log.plant.uuid) }}">
@ -60,7 +62,7 @@
<img <img
src="{{ generate_image_url(media) }}" src="{{ generate_image_url(media) }}"
class="img-thumbnail" class="img-thumbnail"
style="max-width:100px;" style="max-width: 100px;"
alt="{{ media.caption or '' }}" alt="{{ media.caption or '' }}"
> >
{% endfor %} {% endfor %}
@ -96,7 +98,11 @@
{% else %} {% else %}
<p class="text-muted"> <p class="text-muted">
No grow logs found{% if plant %} for {{ plant.common_name.name }}{% endif %}. No grow logs found{% if plant %} for {{ plant.common_name.name }}{% endif %}.
<a href="{% if plant %}{{ url_for('growlog.add_log', plant_uuid=plant.uuid) }}{% else %}{{ url_for('growlog.add_log') }}{% endif %}"> <a href="{% if plant %}
{{ url_for('growlog.add_log', plant_uuid=plant.uuid) }}
{% else %}
{{ url_for('growlog.add_log') }}
{% endif %}">
Add one now Add one now
</a>. </a>.
</p> </p>

View File

@ -1,8 +1,10 @@
# plugins/plant/models.py # plugins/plant/models.py
from datetime import datetime from datetime import datetime
import uuid as uuid_lib import uuid as uuid_lib
import string, random # for generate_short_id
from app import db from app import db
from plugins.plant.growlog.models import GrowLog
# Association table for Plant ↔ Tag # Association table for Plant ↔ Tag
plant_tags = db.Table( plant_tags = db.Table(
@ -102,14 +104,14 @@ class Plant(db.Model):
media_items = db.relationship( media_items = db.relationship(
'plugins.media.models.Media', 'plugins.media.models.Media',
back_populates='plant', back_populates='plant',
lazy='select', # ← this is the fix lazy='select',
cascade='all, delete-orphan', cascade='all, delete-orphan',
foreign_keys='plugins.media.models.Media.plant_id' foreign_keys='plugins.media.models.Media.plant_id'
) )
@property @property
def media(self): def media(self):
return self.media_items # already a list when lazy='select' return self.media_items
# the one you see on the detail page # the one you see on the detail page
featured_media = db.relationship( featured_media = db.relationship(
@ -120,7 +122,7 @@ class Plant(db.Model):
# ↔ GrowLog instances for this plant # ↔ GrowLog instances for this plant
updates = db.relationship( updates = db.relationship(
'plugins.growlog.models.GrowLog', GrowLog,
backref='plant', backref='plant',
lazy=True, lazy=True,
cascade='all, delete-orphan' cascade='all, delete-orphan'
@ -162,6 +164,5 @@ class Plant(db.Model):
alphabet = string.ascii_lowercase + string.digits alphabet = string.ascii_lowercase + string.digits
while True: while True:
candidate = ''.join(random.choices(alphabet, k=length)) candidate = ''.join(random.choices(alphabet, k=length))
# Check uniqueness
if not cls.query.filter_by(short_id=candidate).first(): if not cls.query.filter_by(short_id=candidate).first():
return candidate return candidate

View File

@ -1,6 +1,31 @@
{ {
"name": "plant", "name": "Plant",
"version": "1.0.0", "version": "0.1.0",
"description": "Plant profile management plugin", "author": "Bryson Shepard <bryson@natureinpots.com>",
"entry_point": null "description": "Core plant catalog and management.",
"module": "plugins.plant",
"routes": {
"module": "plugins.plant.routes",
"blueprint": "bp",
"url_prefix": "/plant"
},
"models": [
"plugins.plant.models"
],
"subplugins": [
{
"name": "GrowLog",
"module": "plugins.plant.growlog",
"routes": {
"module": "plugins.plant.growlog.routes",
"blueprint": "bp",
"url_prefix": "/plant/growlog"
},
"models": [
"plugins.plant.growlog.models"
]
}
],
"license": "Proprietary",
"repository": "https://github.com/your-org/your-app"
} }

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %} {% extends 'core/base.html' %}
{% block content %} {% block content %}
<div class="card mb-4"> <div class="card mb-4">

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %} {% extends 'core/base.html' %}
{% block title %}Add New Plant Nature In Pots{% endblock %} {% block title %}Add New Plant Nature In Pots{% endblock %}
{% block content %} {% block content %}

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %} {% extends 'core/base.html' %}
{% block title %} {% block title %}
{{ plant.common_name.name if plant.common_name else "Unnamed Plant" }} Nature In Pots {{ plant.common_name.name if plant.common_name else "Unnamed Plant" }} Nature In Pots
{% endblock %} {% endblock %}

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %} {% extends 'core/base.html' %}
{% block title %}Edit Plant Nature In Pots{% endblock %} {% block title %}Edit Plant Nature In Pots{% endblock %}
{% block content %} {% block content %}

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %} {% extends 'core/base.html' %}
{% block title %}View Entries Nature In Pots{% endblock %} {% block title %}View Entries Nature In Pots{% endblock %}
{% block content %} {% block content %}

View File

@ -1,15 +0,0 @@
from flask_wtf import FlaskForm
from wtforms import StringField, SelectMultipleField, SubmitField
from wtforms.validators import Optional, Length, Regexp
class SearchForm(FlaskForm):
query = StringField(
'Search',
validators=[
Optional(),
Length(min=2, max=100, message="Search term must be between 2 and 100 characters."),
Regexp(r'^[\w\s\-]+$', message="Search can only include letters, numbers, spaces, and dashes.")
]
)
tags = SelectMultipleField('Tags', coerce=int)
submit = SubmitField('Search')

View File

@ -1,17 +0,0 @@
from app import db
#from plugins.plant.models import Plant
# plant_tags = db.Table(
# 'plant_tags',
# db.metadata,
# db.Column('plant_id', db.Integer, db.ForeignKey('plant.id'), primary_key=True),
# db.Column('tag_id', db.Integer, db.ForeignKey('tags.id'), primary_key=True),
# extend_existing=True
# )
# class Tag(db.Model):
# __tablename__ = 'tags'
# __table_args__ = {'extend_existing': True}
# id = db.Column(db.Integer, primary_key=True)
# name = db.Column(db.String(100), unique=True, nullable=False)

View File

@ -1,6 +0,0 @@
{
"name": "search",
"version": "1.1",
"description": "Updated search plugin with live Plant model integration",
"entry_point": null
}

View File

@ -1,37 +0,0 @@
from flask import Blueprint, render_template, request, jsonify
from flask_login import login_required, current_user
from app import db
from .forms import SearchForm
from plugins.plant.models import Plant, Tag
bp = Blueprint('search', __name__, template_folder='templates')
@bp.route('/search', methods=['GET', 'POST'])
@login_required
def search():
form = SearchForm()
form.tags.choices = [(tag.id, tag.name) for tag in Tag.query.order_by(Tag.name).all()]
results = []
if form.validate_on_submit():
query = db.session.query(Plant).join(PlantScientific).join(PlantCommon)
if form.query.data:
q = f"%{form.query.data}%"
query = query.filter(
db.or_(
PlantScientific.name.ilike(q),
PlantCommon.name.ilike(q),
Plant.current_status.ilike(q)
)
)
if form.tags.data:
query = query.filter(Plant.tags.any(Tag.id.in_(form.tags.data)))
query = query.filter(Plant.owner_id == current_user.id)
results = query.all()
return render_template('search/search.html', form=form, results=results)
@bp.route('/search/tags')
@login_required
def search_tags():
term = request.args.get('term', '')
tags = Tag.query.filter(Tag.name.ilike(f"%{term}%")).limit(10).all()
return jsonify([tag.name for tag in tags])

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