broke currently

This commit is contained in:
2025-06-22 16:11:29 -05:00
parent e7a0f5b1be
commit 2bb7a29141
77 changed files with 1748 additions and 2298 deletions

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
__pycache__/
*.pyc
*.pyo
*.db
*.sqlite3
.env
.env.*
mysql_data/
migrations/versions/
uploads/
static/uploads/

View File

@ -41,6 +41,10 @@ down:
rebuild: rebuild:
$(DOCKER_COMPOSE) down -v --remove-orphans $(DOCKER_COMPOSE) down -v --remove-orphans
$(DOCKER_COMPOSE) build
$(DOCKER_COMPOSE) up -d db
sleep 5 # give MySQL a moment to initialize
$(DOCKER_COMPOSE) exec -T db mysql -uroot -p$$(grep MYSQL_ROOT_PASSWORD .env | cut -d '=' -f2) -e "DROP DATABASE IF EXISTS natureinpots; CREATE DATABASE natureinpots CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
$(DOCKER_COMPOSE) up --build -d $(DOCKER_COMPOSE) up --build -d
@$(MAKE) wait @$(MAKE) wait

View File

@ -1,40 +1,41 @@
import os import os
# Define basedir so its available inside the Config class # CONFIG_DIR is your app package; go up one to the project root
basedir = os.path.abspath(os.path.dirname(__file__)) CONFIG_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(CONFIG_DIR)
class Config: class Config:
SECRET_KEY = os.environ['SECRET_KEY'] SECRET_KEY = os.environ['SECRET_KEY']
UPLOAD_FOLDER = os.environ['UPLOAD_FOLDER'] MAX_CONTENT_LENGTH = int(
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH', 20 * 1024 * 1024 * 1024)) os.environ.get('MAX_CONTENT_LENGTH', 20 * 1024 * 1024 * 1024)
)
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
# MySQL connection parameters from .env UPLOAD_FOLDER = os.path.join(PROJECT_ROOT, "static", "uploads")
MYSQL_USER = os.environ['MYSQL_USER']
# MySQL connection parameters
MYSQL_USER = os.environ['MYSQL_USER']
MYSQL_PASSWORD = os.environ['MYSQL_PASSWORD'] MYSQL_PASSWORD = os.environ['MYSQL_PASSWORD']
MYSQL_HOST = os.environ['MYSQL_HOST'] MYSQL_HOST = os.environ['MYSQL_HOST']
MYSQL_PORT = os.environ.get('MYSQL_PORT', 3306) MYSQL_PORT = int(os.environ.get('MYSQL_PORT', 3306))
MYSQL_DB = os.environ['MYSQL_DATABASE'] MYSQL_DATABASE = os.environ['MYSQL_DATABASE']
# Build the SQLAlchemy database URI # 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_DB}" f"@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DATABASE}"
) )
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
# Optional toggles # Optional toggles
ENABLE_DB_SEEDING = os.environ.get('ENABLE_DB_SEEDING', '0') == '1' ENABLE_DB_SEEDING = os.environ.get('ENABLE_DB_SEEDING', '0') == '1'
DOCKER_ENV = os.environ.get('FLASK_ENV', 'production') DOCKER_ENV = os.environ.get('FLASK_ENV', 'production')
NEO4J_URI = os.getenv('NEO4J_URI', 'bolt://neo4j:7687') # Neo4j configuration
NEO4J_USER = os.getenv('NEO4J_USER', 'neo4j') NEO4J_URI = os.getenv('NEO4J_URI', 'bolt://neo4j:7687')
NEO4J_USER = os.getenv('NEO4J_USER', 'neo4j')
NEO4J_PASSWORD = os.getenv('NEO4J_PASSWORD', 'your_secure_password') NEO4J_PASSWORD = os.getenv('NEO4J_PASSWORD', 'your_secure_password')
# Override or default upload folder
UPLOAD_FOLDER = os.path.join(basedir, "static", "uploads")
# Standard image size (for placeholders, etc.) # Standard image size (for placeholders, etc.)
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'))

456
data/MR860.pdf Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,5 @@
version: '3.8'
services: services:
web: web:
build: . build: .
@ -29,14 +31,15 @@ services:
image: mysql:8 image: mysql:8
restart: unless-stopped restart: unless-stopped
environment: environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE} - MYSQL_DATABASE=${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER} - MYSQL_USER=${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD} - MYSQL_PASSWORD=${MYSQL_PASSWORD}
ports: ports:
- "42000:3306" - "42000:3306"
volumes: volumes:
- plant_price_tracker_mysql_data:/var/lib/mysql - ./mysql_data:/var/lib/mysql
entrypoint: ["sh", "-c", "mkdir -p /var/lib/mysql && chown -R 1000:998 /var/lib/mysql && chmod -R 770 /var/lib/mysql && exec docker-entrypoint.sh mysqld"]
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s interval: 10s
@ -47,9 +50,9 @@ services:
image: adminer image: adminer
restart: always restart: always
ports: ports:
- 8080:8080 - "8080:8080"
environment: environment:
ADMINER_DEFAULT_SERVER: db - ADMINER_DEFAULT_SERVER=db
depends_on: depends_on:
- db - db
@ -57,13 +60,12 @@ services:
image: neo4j:5.18 image: neo4j:5.18
container_name: nip_neo4j container_name: nip_neo4j
ports: ports:
- '7474:7474' - "7474:7474"
- '7687:7687' - "7687:7687"
environment: environment:
- NEO4J_AUTH=neo4j/your_secure_password - NEO4J_AUTH=neo4j/your_secure_password
volumes: volumes:
- neo4j_data:/data - neo4j_data:/data
volumes: volumes:
plant_price_tracker_mysql_data:
neo4j_data: neo4j_data:

View File

@ -1,39 +1,51 @@
#!/bin/bash #!/usr/bin/env bash
set -e set -e
# 1) Wait for the database service to be ready UPLOAD_DIR="/app/static/uploads"
mkdir -p "$UPLOAD_DIR"
chown -R 1000:998 "$UPLOAD_DIR"
chmod -R 775 "$UPLOAD_DIR"
DB_HOST=${DB_HOST:-db} DB_HOST=${DB_HOST:-db}
DB_PORT=${DB_PORT:-3306} 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 and reachable" echo "[✔] Database is up"
# If theres no migrations folder yet, initialize Alembic here: # Initialize Alembic if not present
if [ ! -d "./migrations" ]; then if [ ! -d "./migrations" ]; then
echo "[🆕] No migrations directory found; initializing Alembic" echo "[🆕] No migrations directory found; initializing Alembic"
flask db init flask db init
echo "[🆕] Generating initial migration"
flask db migrate -m "initial" || echo "[] Nothing to migrate"
fi fi
# 2) Always apply any already-created migration scripts first # Autogenerate new migration if needed
echo "[] Applying existing migrations (upgrade)" echo "[🛠] Checking for new schema changes"
if ! flask db migrate -m "auto-migrate" --compare-type --render-as-batch; then
echo "[] No schema changes detected"
fi
# Apply migrations
echo "[▶️] Applying database migrations"
flask db upgrade flask db upgrade
# 3) Now attempt to autogenerate a new migration if the models changed # Create any missing tables (edge case fallback)
echo "[] Autogenerating new migration (if needed)" echo "[🔧] Running db.create_all() to ensure full sync"
flask db migrate -m "auto" python <<EOF
from app import create_app, db
app = create_app()
with app.app_context():
db.create_all()
EOF
# 4) Apply that new migration (if one was generated) # Optional seeding
echo "[▶️] Applying any newly autogenerated migration"
flask db upgrade
# 5) Optionally seed data
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 fi
# 6) Finally, run the Flask application
echo "[🚀] Starting Flask" echo "[🚀] Starting Flask"
exec "$@" exec "$@"

BIN
font/ARIALLGT.TTF Normal file

Binary file not shown.

Binary file not shown.

View File

@ -17,10 +17,8 @@ from app import db
plugin_model_paths = glob.glob(os.path.join("plugins", "*", "models.py")) plugin_model_paths = glob.glob(os.path.join("plugins", "*", "models.py"))
for path in plugin_model_paths: for path in plugin_model_paths:
# e.g. path = "plugins/plant/models.py" rel = path[len("plugins/") : -len("/models.py")]
# We want to turn that into "plugins.plant.models" pkg = f"plugins.{rel}.models"
rel = path[len("plugins/") : -len("/models.py")] # e.g. "plant"
pkg = f"plugins.{rel}.models" # e.g. "plugins.plant.models"
try: try:
importlib.import_module(pkg) importlib.import_module(pkg)
print(f"✅ Loaded: {pkg}") print(f"✅ Loaded: {pkg}")
@ -31,8 +29,8 @@ for path in plugin_model_paths:
config = context.config config = context.config
fileConfig(config.config_file_name) fileConfig(config.config_file_name)
logger = logging.getLogger("alembic.env") logger = logging.getLogger("alembic.env")
logger.setLevel(logging.WARN) # optional: silence alembic spam
# SQLAlchemy will look at this `target_metadata` when autogenerating
target_metadata = db.metadata target_metadata = db.metadata
def run_migrations_offline(): def run_migrations_offline():
@ -41,13 +39,12 @@ def run_migrations_offline():
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()
print("🧠 Alembic sees these tables:")
print(sorted(db.metadata.tables.keys()))
def run_migrations_online(): def run_migrations_online():
connectable = db.engine connectable = db.engine
with connectable.connect() as connection: with connectable.connect() as connection:
@ -55,10 +52,15 @@ def run_migrations_online():
connection=connection, connection=connection,
target_metadata=target_metadata, target_metadata=target_metadata,
compare_type=True, compare_type=True,
sort_tables=True,
render_as_batch=True, # ✅ important!
) )
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,28 +0,0 @@
"""auto
Revision ID: e4ece621c461
Revises: a10cbbbeb3f6
Create Date: 2025-06-09 08:55:16.262879
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e4ece621c461'
down_revision = 'a10cbbbeb3f6'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('media', sa.Column('created_at', sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('media', 'created_at')
# ### end Alembic commands ###

View File

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

View File

@ -1,28 +0,0 @@
"""auto
Revision ID: f347a4fd8e4f
Revises: 082f6fe2681f
Create Date: 2025-06-11 09:33:30.217742
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f347a4fd8e4f'
down_revision = '082f6fe2681f'
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,217 +0,0 @@
"""auto
Revision ID: fa22b011d450
Revises:
Create Date: 2025-06-09 06:59:45.406606
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fa22b011d450'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('import_batches',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('export_id', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('imported_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('export_id', 'user_id', name='uix_export_user')
)
op.create_index(op.f('ix_import_batches_user_id'), 'import_batches', ['user_id'], unique=False)
op.create_table('plant_common_name',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('tag',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(length=120), nullable=False),
sa.Column('password_hash', sa.Text(), nullable=False),
sa.Column('role', sa.String(length=50), nullable=True),
sa.Column('is_verified', sa.Boolean(), nullable=True),
sa.Column('excluded_from_analytics', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
op.create_table('plant_scientific_name',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=256), nullable=False),
sa.Column('common_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['common_id'], ['plant_common_name.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('submissions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('submitted_at', sa.DateTime(), nullable=True),
sa.Column('plant_name', sa.String(length=100), nullable=True),
sa.Column('scientific_name', sa.String(length=120), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('submission_type', sa.String(length=50), nullable=False),
sa.Column('price', sa.Float(), nullable=True),
sa.Column('source', sa.String(length=255), nullable=True),
sa.Column('vendor_name', sa.String(length=255), nullable=True),
sa.Column('rating', sa.Integer(), nullable=True),
sa.Column('old_vendor', sa.String(length=255), nullable=True),
sa.Column('new_vendor', sa.String(length=255), nullable=True),
sa.Column('alias_reason', sa.Text(), nullable=True),
sa.Column('approved', sa.Boolean(), nullable=True),
sa.Column('approved_at', sa.DateTime(), nullable=True),
sa.Column('reviewed_by', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['reviewed_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('plant',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('uuid', sa.String(length=36), nullable=False),
sa.Column('custom_slug', sa.String(length=255), nullable=True),
sa.Column('owner_id', sa.Integer(), nullable=False),
sa.Column('common_id', sa.Integer(), nullable=False),
sa.Column('scientific_id', sa.Integer(), nullable=False),
sa.Column('plant_type', sa.String(length=50), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('data_verified', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['common_id'], ['plant_common_name.id'], ),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['scientific_id'], ['plant_scientific_name.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('custom_slug'),
sa.UniqueConstraint('uuid')
)
op.create_table('submission_images',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('submission_id', sa.Integer(), nullable=False),
sa.Column('file_url', sa.String(length=256), nullable=False),
sa.Column('uploaded_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['submission_id'], ['submissions.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('grow_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('plant_id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['plant_id'], ['plant.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('plant_ownership_log',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('plant_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('date_acquired', sa.DateTime(), nullable=True),
sa.Column('transferred', sa.Boolean(), nullable=False),
sa.Column('is_verified', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['plant_id'], ['plant.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('plant_tags',
sa.Column('plant_id', sa.Integer(), nullable=False),
sa.Column('tag_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['plant_id'], ['plant.id'], ),
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], ),
sa.PrimaryKeyConstraint('plant_id', 'tag_id')
)
op.create_table('transfer_request',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('plant_id', sa.Integer(), nullable=False),
sa.Column('seller_id', sa.Integer(), nullable=False),
sa.Column('buyer_id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('seller_message', sa.String(length=512), nullable=True),
sa.Column('buyer_message', sa.String(length=512), nullable=True),
sa.ForeignKeyConstraint(['buyer_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['plant_id'], ['plant.id'], ),
sa.ForeignKeyConstraint(['seller_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('plant_updates',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('plant_id', sa.Integer(), nullable=False),
sa.Column('growlog_id', sa.Integer(), nullable=True),
sa.Column('update_type', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['growlog_id'], ['grow_logs.id'], ),
sa.ForeignKeyConstraint(['plant_id'], ['plant.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('media',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('file_url', sa.String(length=256), nullable=False),
sa.Column('uploaded_at', sa.DateTime(), nullable=True),
sa.Column('uploader_id', sa.Integer(), nullable=False),
sa.Column('caption', sa.String(length=255), nullable=True),
sa.Column('plant_id', sa.Integer(), nullable=True),
sa.Column('growlog_id', sa.Integer(), nullable=True),
sa.Column('update_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['growlog_id'], ['grow_logs.id'], ),
sa.ForeignKeyConstraint(['plant_id'], ['plant.id'], ),
sa.ForeignKeyConstraint(['update_id'], ['plant_updates.id'], ),
sa.ForeignKeyConstraint(['uploader_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('featured_images',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('media_id', sa.Integer(), nullable=False),
sa.Column('override_text', sa.String(length=255), nullable=True),
sa.Column('is_featured', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['media_id'], ['media.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('image_hearts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('media_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['media_id'], ['media.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('image_hearts')
op.drop_table('featured_images')
op.drop_table('media')
op.drop_table('plant_updates')
op.drop_table('transfer_request')
op.drop_table('plant_tags')
op.drop_table('plant_ownership_log')
op.drop_table('grow_logs')
op.drop_table('submission_images')
op.drop_table('plant')
op.drop_table('submissions')
op.drop_table('plant_scientific_name')
op.drop_table('users')
op.drop_table('tag')
op.drop_table('plant_common_name')
op.drop_index(op.f('ix_import_batches_user_id'), table_name='import_batches')
op.drop_table('import_batches')
# ### end Alembic commands ###

View File

@ -1,38 +0,0 @@
"""auto
Revision ID: fa3de05c91fb
Revises: cb3ce762cabb
Create Date: 2025-06-09 10:06:16.352992
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fa3de05c91fb'
down_revision = 'cb3ce762cabb'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('plant', sa.Column('mother_uuid', sa.String(length=36), nullable=True))
op.add_column('plant', sa.Column('notes', sa.Text(), nullable=True))
op.add_column('plant', sa.Column('is_active', sa.Boolean(), nullable=False))
op.add_column('plant', sa.Column('featured_media_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'plant', 'media', ['featured_media_id'], ['id'])
op.create_foreign_key(None, 'plant', 'plant', ['mother_uuid'], ['uuid'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'plant', type_='foreignkey')
op.drop_constraint(None, 'plant', type_='foreignkey')
op.drop_column('plant', 'featured_media_id')
op.drop_column('plant', 'is_active')
op.drop_column('plant', 'notes')
op.drop_column('plant', 'mother_uuid')
# ### end Alembic commands ###

BIN
nip.zip Normal file

Binary file not shown.

View File

@ -1,10 +1,11 @@
{# 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">
{% for media in media_list %} {% for media in media_list %}
<div class="col-md-3 mb-4" data-media-id="{{ media.id }}"> <div class="col-md-3 mb-4" data-media-id="{{ media.id }}">
<div class="card shadow-sm"> <div class="card shadow-sm">
<img src="{{ url_for('media.media_file', filename=media.file_url.split('/')[-1]) }}" <img src="{{ url_for('media.serve', plugin=media.plugin, filename=media.filename) }}"
class="card-img-top" style="width:100%; height:auto;"> class="card-img-top" style="width:100%; height:auto;">
{% if media.caption %} {% if media.caption %}
<div class="card-body p-2"> <div class="card-body p-2">
@ -13,12 +14,12 @@
{% endif %} {% endif %}
<div class="card-footer d-flex justify-content-between align-items-center p-2"> <div class="card-footer d-flex justify-content-between align-items-center p-2">
<button class="btn btn-sm btn-outline-danger heart-btn" data-id="{{ media.id }}"> <button class="btn btn-sm btn-outline-danger heart-btn" data-id="{{ media.id }}">
❤️ <span class="heart-count">{{ media.imageheart_set|length }}</span> ❤️ <span class="heart-count">{{ media.hearts|length }}</span>
</button> </button>
{% if current_user and (current_user.id == media.uploader_id or current_user.role == 'admin') %} {% if current_user and (current_user.id == media.uploader_id or current_user.role == 'admin') %}
<form method="POST" action="{{ url_for('media.set_featured_image', media_id=media.id) }}"> <form method="POST" action="{{ url_for('media.set_featured_image', media_id=media.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if media.featuredimage %} {% if media.featured_entries|length %}
<button class="btn btn-sm btn-outline-secondary" type="submit">★ Featured</button> <button class="btn btn-sm btn-outline-secondary" type="submit">★ Featured</button>
{% else %} {% else %}
<button class="btn btn-sm btn-outline-primary" type="submit">☆ Set Featured</button> <button class="btn btn-sm btn-outline-primary" type="submit">☆ Set Featured</button>

View File

@ -29,11 +29,30 @@
</button> </button>
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto"> <ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" href="{{ url_for('core_ui.home') }}">Home</a></li> <li class="nav-item">
<li class="nav-item"><a class="nav-link" href="{{ url_for('plant.index') }}">Plants</a></li> <a class="nav-link" href="{{ url_for('core_ui.home') }}">Home</a>
<li class="nav-item"><a class="nav-link" href="{{ url_for('utility.upload') }}">Import</a></li> </li>
{% if current_user.is_authenticated and current_user.role == 'admin' %} <li class="nav-item">
<li class="nav-item"><a class="nav-link text-danger" href="{{ url_for('admin.admin_dashboard') }}">Admin Dashboard</a></li> <a class="nav-link" href="{{ url_for('plant.index') }}">Plants</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('plant.index') }}#plantContainer">Grow Logs</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('submission.list_submissions') }}">Submissions</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('utility.upload') }}">Import</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('utility.export_data') }}">Export</a>
</li>
{% if current_user.is_authenticated and current_user.role == 'admin' %}
<li class="nav-item">
<a class="nav-link text-danger" href="{{ url_for('admin.admin_dashboard') }}">
Admin Dashboard
</a>
</li>
{% endif %} {% endif %}
{% block plugin_links %}{% endblock %} {% block plugin_links %}{% endblock %}
</ul> </ul>
@ -43,8 +62,12 @@
<a class="nav-link" href="{{ url_for('auth.logout') }}">Logout</a> <a class="nav-link" href="{{ url_for('auth.logout') }}">Logout</a>
</li> </li>
{% else %} {% else %}
<li class="nav-item ms-3"><a class="nav-link" href="{{ url_for('auth.login') }}">Login</a></li> <li class="nav-item ms-3">
<li class="nav-item"><a class="nav-link" href="{{ url_for('auth.register') }}">Register</a></li> <a class="nav-link" href="{{ url_for('auth.login') }}">Login</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.register') }}">Register</a>
</li>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
@ -56,8 +79,8 @@
<div class="container mt-3"> <div class="container mt-3">
{% for category, message in messages %} {% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert"> <div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }} {{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View File

@ -1,30 +1,90 @@
from datetime import datetime from datetime import datetime
from app import db from app import db
from plugins.plant.models import Plant
class GrowLog(db.Model): class GrowLog(db.Model):
__tablename__ = 'grow_logs' __tablename__ = "grow_logs"
__table_args__ = {'extend_existing': True} __table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=False) plant_id = db.Column(db.Integer, db.ForeignKey("plant.id"), nullable=False)
title = db.Column(db.String(255), nullable=False) title = db.Column(db.String(255), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow) 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
)
updates = db.relationship('PlantUpdate', backref='grow_log', lazy=True) # ↔ 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): class PlantUpdate(db.Model):
__tablename__ = 'plant_updates' __tablename__ = "plant_updates"
__table_args__ = {'extend_existing': True} __table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=False) growlog_id = db.Column(db.Integer, db.ForeignKey("grow_logs.id"), nullable=False)
growlog_id = db.Column(db.Integer, db.ForeignKey('grow_logs.id'), nullable=True) description = db.Column(db.Text, nullable=True)
update_type = db.Column(db.String(50), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
description = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Link to Media.update_id (must match the FK defined there) # ↔ parent GrowLog.updates
media_items = db.relationship('Media', back_populates='update', lazy=True) 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,25 +1,36 @@
{# plugins/growlog/templates/growlog/log_list.html #}
{% import 'core_ui/_media_macros.html' as media %} {% import 'core_ui/_media_macros.html' as media %}
{% extends 'core_ui/base.html' %} {% extends 'core_ui/base.html' %}
{% block content %} {% block content %}
<h2>Logs for Plant #{{ plant.id }}</h2> <h2>Logs for Plant #{{ plant.id }}</h2>
<a href="{{ url_for('growlog.add_log', plant_id=plant.id) }}">Add New Log</a> <a href="{{ url_for('growlog.add_log', plant_id=plant.id) }}">Add New Log</a>
<ul> <ul>
{% for log in logs %} {% for log in logs %}
<li> <li class="mb-3">
<strong>{{ log.timestamp.strftime('%Y-%m-%d') }}:</strong> {{ log.event_type }} - {{ log.note }} <strong>{{ log.timestamp.strftime('%Y-%m-%d') }}:</strong>
{% if log.media %} {{ log.event_type }} {{ log.note }}
<br><em>Images:</em> {% if log.media_items %}
<ul> <br><em>Images:</em>
{% for image in log.media %} <ul class="list-unstyled ps-3">
<li> {% for image in log.media_items %}
<img src="{{ url_for('media.media_file', filename=image.file_url) }}" width="150"><br> <li class="mb-2">
{{ image.caption or "No caption" }} <img
</li> src="{{ generate_image_url(image) }}"
{% endfor %} width="150"
</ul> class="img-thumbnail"
{% endif %} alt="Log image"
</li> ><br>
{{ image.caption or "No caption" }}
</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %} {% endfor %}
</ul> </ul>
{{ media.render_media_list(log.media) }}
{# Use shared macro for any remaining media lists #}
{{ media.render_media_list(logs|map(attribute='media_items')|sum, thumb_width=150, current_user=current_user) }}
{% endblock %} {% endblock %}

View File

@ -1,43 +1,107 @@
# plugins/media/models.py # plugins/media/models.py
from app import db
from datetime import datetime from datetime import datetime
from flask import url_for
from app import db
class Media(db.Model): class Media(db.Model):
__tablename__ = "media" __tablename__ = "media"
__table_args__ = {"extend_existing": True} __table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
file_url = db.Column(db.String(256), nullable=False) plugin = db.Column(db.String(50), nullable=False)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) related_id = db.Column(db.Integer, nullable=False)
uploader_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) filename = db.Column(db.String(256), nullable=False)
caption = db.Column(db.String(255), nullable=True) uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
uploader_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
caption = db.Column(db.String(255), nullable=True)
plant_id = db.Column(db.Integer, db.ForeignKey("plant.id"), nullable=True)
growlog_id = db.Column(db.Integer, db.ForeignKey("grow_logs.id"), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
plant_id = db.Column(db.Integer, db.ForeignKey("plant.id"), nullable=True) # You already have a file_url column in your DB
growlog_id = db.Column(db.Integer, db.ForeignKey("grow_logs.id"), nullable=True) file_url = db.Column(db.String(512), nullable=False)
update_id = db.Column(db.Integer, db.ForeignKey("plant_updates.id"), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow) hearts = db.relationship(
"plugins.media.models.ImageHeart",
backref="media",
lazy="dynamic",
cascade="all, delete-orphan",
)
featured_entries = db.relationship(
"plugins.media.models.FeaturedImage",
backref="media",
lazy="dynamic",
cascade="all, delete-orphan",
)
plant = db.relationship(
"plugins.plant.models.Plant",
back_populates="media_items",
foreign_keys=[plant_id],
lazy="joined",
)
growlog = db.relationship(
"plugins.growlog.models.GrowLog",
back_populates="media_items",
foreign_keys=[growlog_id],
lazy="joined",
)
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)
# If they passed plant_id or growlog_id in kwargs, pick one:
if self.plant_id:
self.plugin = "plants"
self.related_id = self.plant_id
elif self.growlog_id:
self.plugin = "grow_logs"
self.related_id = self.growlog_id
else:
# fallback (you might choose to raise instead)
self.plugin = kwargs.get("plugin", "")
self.related_id = kwargs.get("related_id", 0)
# They must also supply `filename` before commit.
# Build `file_url` in the same format your property used to:
date_path = self.uploaded_at.strftime("%Y/%m/%d")
self.file_url = f"{self.plugin}/{self.related_id}/{date_path}/{self.filename}"
@property
def url(self):
return url_for("media.media_file", filename=self.file_url)
@property
def featured(self):
return any(
fe.context == "plant" and fe.is_featured
for fe in self.featured_entries
)
update = db.relationship("PlantUpdate", back_populates="media_items")
class ImageHeart(db.Model): class ImageHeart(db.Model):
__tablename__ = "image_hearts" __tablename__ = "image_hearts"
__table_args__ = {"extend_existing": True} __table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
media_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=False) media_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
media = db.relationship("Media", backref="hearts")
class FeaturedImage(db.Model): class FeaturedImage(db.Model):
__tablename__ = "featured_images" __tablename__ = "featured_images"
__table_args__ = {"extend_existing": True} __table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
media_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=False) media_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=False)
context = db.Column(db.String(50), nullable=False)
context_id = db.Column(db.Integer, nullable=False)
override_text = db.Column(db.String(255), nullable=True) override_text = db.Column(db.String(255), nullable=True)
is_featured = db.Column(db.Boolean, default=True) is_featured = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
media = db.relationship("Media", backref="featured_entries")

View File

@ -3,17 +3,12 @@
import os import os
from uuid import uuid4 from uuid import uuid4
from datetime import datetime from datetime import datetime
from PIL import Image from PIL import Image, ExifTags
from flask import ( from flask import (
Blueprint, Blueprint, request, redirect, url_for,
redirect, flash, send_from_directory, current_app,
url_for, jsonify, abort
request,
flash,
send_from_directory,
current_app,
jsonify
) )
from flask_login import login_required, current_user from flask_login import login_required, current_user
@ -21,54 +16,195 @@ from app import db
from .models import Media, ImageHeart, FeaturedImage from .models import Media, ImageHeart, FeaturedImage
from plugins.plant.models import Plant from plugins.plant.models import Plant
bp = Blueprint("media", __name__, url_prefix="/media", template_folder="templates") bp = Blueprint(
"media",
__name__,
url_prefix="/media",
template_folder="templates"
)
# -----------------------------------------------------------------------------
# Make generate_image_url available in all templates # ─── Context Processor ─────────────────────────────────────────────────────────
# -----------------------------------------------------------------------------
@bp.app_context_processor @bp.app_context_processor
def utility_processor(): def utility_processor():
def generate_image_url(path):
if path:
return url_for("media.media_file", filename=path)
w, h = current_app.config.get("STANDARD_IMG_SIZE", (300, 200))
return f"https://placehold.co/{w}x{h}"
return dict(generate_image_url=generate_image_url) return dict(generate_image_url=generate_image_url)
# -----------------------------------------------------------------------------
# Helpers & config # ─── Helpers & Config ─────────────────────────────────────────────────────────
# -----------------------------------------------------------------------------
def allowed_file(filename): def allowed_file(filename):
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
return ext in current_app.config.get("ALLOWED_EXTENSIONS", {"png","jpg","jpeg","gif"}) return ext in current_app.config.get(
"ALLOWED_EXTENSIONS",
{"png", "jpg", "jpeg", "gif", "webp"}
)
def get_upload_path():
base = current_app.config.get("UPLOAD_FOLDER", "static/uploads") def get_upload_path(plugin: str, related_id: int):
"""
Build and return (absolute_dir, subdir) where uploads are stored:
{UPLOAD_FOLDER}/{plugin}s/{related_id}/YYYY/MM/DD/
"""
now = datetime.utcnow() now = datetime.utcnow()
subdir = os.path.join(str(now.year), f"{now.month:02}", f"{now.day:02}") date_path = f"{now.year}/{now.month:02}/{now.day:02}"
full = os.path.join(base, subdir) subdir = f"{plugin}s/{related_id}/{date_path}"
os.makedirs(full, exist_ok=True) base = current_app.config["UPLOAD_FOLDER"]
return full, subdir abs_dir = os.path.join(base, subdir)
os.makedirs(abs_dir, exist_ok=True)
return abs_dir, subdir
# -----------------------------------------------------------------------------
# Routes def _strip_exif(image: Image.Image) -> Image.Image:
# ----------------------------------------------------------------------------- try:
exif = image._getexif()
orient_key = next(
(k for k, v in ExifTags.TAGS.items() if v == "Orientation"),
None
)
if exif and orient_key in exif:
o = exif[orient_key]
if o == 3:
image = image.rotate(180, expand=True)
elif o == 6:
image = image.rotate(270, expand=True)
elif o == 8:
image = image.rotate(90, expand=True)
except Exception:
pass
return image
def _process_upload_file(
file,
uploader_id: int,
plugin: str,
related_id: int
):
"""
Save the uploaded image (strip EXIF), write Media row with
file_url, and return the Media instance.
"""
ext = os.path.splitext(file.filename)[1].lower()
if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
raise ValueError("Unsupported file type.")
# generate a stable filename
filename = f"{uuid4().hex}{ext}"
# determine disk path
abs_dir, subdir = get_upload_path(plugin, related_id)
full_path = os.path.join(abs_dir, filename)
# strip EXIF and save
img = Image.open(file)
img = _strip_exif(img)
img.save(full_path)
# create the DB record
now = datetime.utcnow()
media = Media(
uploader_id=uploader_id,
file_url=f"{subdir}/{filename}",
uploaded_at=now
)
# legacy relationships
if plugin == "plant":
media.plant_id = related_id
elif plugin == "growlog":
media.growlog_id = related_id
db.session.add(media)
db.session.commit()
return media
# ─── Exposed Utilities ─────────────────────────────────────────────────────────
def save_media_file(file, user_id, **ctx):
return _process_upload_file(file, user_id, **ctx)
def delete_media_file(media: Media):
"""
Remove file from disk and delete DB record.
"""
base = current_app.config["UPLOAD_FOLDER"]
path = os.path.join(base, media.file_url)
if os.path.exists(path):
os.remove(path)
db.session.delete(media)
db.session.commit()
def rotate_media_file(media: Media):
"""
Rotate the image on disk by -90° and save.
"""
base = current_app.config["UPLOAD_FOLDER"]
path = os.path.join(base, media.file_url)
with Image.open(path) as img:
img.rotate(-90, expand=True).save(path)
db.session.commit()
def generate_image_url(media: Media):
"""
Given a Media instance (or None), return its public URL
or a placeholder if no media.
"""
if media and media.file_url:
return url_for("media.media_file", filename=media.file_url)
w, h = current_app.config.get("STANDARD_IMG_SIZE", (300, 200))
return f"https://placehold.co/{w}x{h}"
# ─── Routes ────────────────────────────────────────────────────────────────────
@bp.route("/", methods=["GET"]) @bp.route("/", methods=["GET"])
def media_index(): def media_index():
return redirect(url_for("core_ui.home")) return redirect(url_for("core_ui.home"))
@bp.route("/files/<path:filename>", methods=["GET"])
@bp.route("/<plugin>/<filename>")
def serve(plugin, filename):
"""
Stream uploaded media by plugin & filename, enforcing Media lookup.
"""
m = Media.query.filter_by(file_url=f"{plugin}s/%/{filename}").first_or_404()
# reconstruct disk path
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("/files/<path:filename>")
def media_file(filename): def media_file(filename):
# Strip leading "uploads/" if present base = current_app.config["UPLOAD_FOLDER"]
if filename.startswith("uploads/"): full = os.path.normpath(os.path.join(base, filename))
filename = filename[len("uploads/"):] if not full.startswith(os.path.abspath(base)):
folder = current_app.config.get("UPLOAD_FOLDER", "static/uploads") abort(404)
return send_from_directory(folder, filename) return send_from_directory(base, 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"]) @bp.route("/heart/<int:media_id>", methods=["POST"])
@login_required @login_required
def toggle_heart(media_id): def toggle_heart(media_id):
existing = ImageHeart.query.filter_by(user_id=current_user.id, media_id=media_id).first() existing = ImageHeart.query.filter_by(
user_id=current_user.id, media_id=media_id
).first()
if existing: if existing:
db.session.delete(existing) db.session.delete(existing)
db.session.commit() db.session.commit()
@ -78,6 +214,7 @@ def toggle_heart(media_id):
db.session.commit() db.session.commit()
return jsonify({"status": "hearted"}) return jsonify({"status": "hearted"})
@bp.route("/add/<string:plant_uuid>", methods=["POST"]) @bp.route("/add/<string:plant_uuid>", methods=["POST"])
@login_required @login_required
def add_media(plant_uuid): def add_media(plant_uuid):
@ -87,65 +224,84 @@ def add_media(plant_uuid):
flash("Invalid or missing file.", "danger") flash("Invalid or missing file.", "danger")
return redirect(request.referrer or url_for("plant.edit", uuid_val=plant_uuid)) return redirect(request.referrer or url_for("plant.edit", uuid_val=plant_uuid))
ext = file.filename.rsplit(".", 1)[-1].lower() _process_upload_file(
filename = f"{uuid4()}.{ext}" file=file,
full_path, subdir = get_upload_path()
file.save(os.path.join(full_path, filename))
media = Media(
file_url=os.path.join(subdir, filename).replace("\\", "/"),
uploader_id=current_user.id, uploader_id=current_user.id,
plant_id=plant.id plugin="plant",
related_id=plant.id
) )
db.session.add(media)
db.session.commit()
flash("Media uploaded successfully.", "success") flash("Media uploaded successfully.", "success")
return redirect(request.referrer or url_for("plant.edit", uuid_val=plant_uuid)) return redirect(request.referrer or url_for("plant.edit", uuid_val=plant_uuid))
@bp.route("/feature/<int:media_id>", methods=["POST"])
@login_required
def set_featured_image(media_id):
media = Media.query.get_or_404(media_id)
if current_user.id != media.uploader_id and current_user.role != "admin":
flash("Not authorized to set featured image.", "danger")
return redirect(request.referrer or url_for("core_ui.home"))
FeaturedImage.query.filter_by(media_id=media_id).delete() @bp.route("/feature/<string:context>/<int:context_id>/<int:media_id>", methods=["POST"])
featured = FeaturedImage(media_id=media_id, is_featured=True) @login_required
db.session.add(featured) def set_featured_image(context, context_id, media_id):
media = Media.query.get_or_404(media_id)
if media.uploader_id != current_user.id and current_user.role != "admin":
return jsonify({"error": "Not authorized"}), 403
FeaturedImage.query.filter_by(
context=context,
context_id=context_id
).delete()
feat = FeaturedImage(
media_id=media.id,
context=context,
context_id=context_id,
is_featured=True
)
db.session.add(feat)
if context == "plant":
plant = Plant.query.get_or_404(context_id)
plant.featured_media_id = media.id
db.session.commit() db.session.commit()
flash("Image set as featured.", "success") return jsonify({"status": "success", "media_id": media.id})
return redirect(request.referrer or url_for("plant.edit", uuid_val=media.plant.uuid))
@bp.route("/delete/<int:media_id>", methods=["POST"]) @bp.route("/delete/<int:media_id>", methods=["POST"])
@login_required @login_required
def delete_media(media_id): def delete_media(media_id):
media = Media.query.get_or_404(media_id) media = Media.query.get_or_404(media_id)
if current_user.id != media.uploader_id and current_user.role != "admin": if media.uploader_id != current_user.id and current_user.role != "admin":
flash("Not authorized to delete this media.", "danger") flash("Not authorized to delete this media.", "danger")
return redirect(request.referrer or url_for("core_ui.home")) return redirect(request.referrer or url_for("core_ui.home"))
full_path = os.path.join(current_app.config.get("UPLOAD_FOLDER", "static/uploads"), media.file_url) delete_media_file(media)
if os.path.exists(full_path):
os.remove(full_path)
db.session.delete(media)
db.session.commit()
flash("Media deleted.", "success") flash("Media deleted.", "success")
return redirect(request.referrer or url_for("plant.edit", uuid_val=media.plant.uuid)) 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"]) @bp.route("/rotate/<int:media_id>", methods=["POST"])
@login_required @login_required
def rotate_media(media_id): def rotate_media(media_id):
media = Media.query.get_or_404(media_id) media = Media.query.get_or_404(media_id)
if current_user.id != media.uploader_id and current_user.role != "admin": if media.uploader_id != current_user.id and current_user.role != "admin":
flash("Not authorized to rotate this media.", "danger") flash("Not authorized to rotate this media.", "danger")
return redirect(request.referrer or url_for("core_ui.home")) return redirect(request.referrer or url_for("core_ui.home"))
full_path = os.path.join(current_app.config.get("UPLOAD_FOLDER", "static/uploads"), media.file_url)
try: try:
with Image.open(full_path) as img: rotate_media_file(media)
img.rotate(-90, expand=True).save(full_path)
flash("Image rotated successfully.", "success") flash("Image rotated successfully.", "success")
except Exception as e: except Exception as e:
flash(f"Failed to rotate image: {e}", "danger") flash(f"Failed to rotate image: {e}", "danger")

View File

@ -1,14 +1,26 @@
{# plugins/media/templates/media/list.html #}
{% extends 'core_ui/base.html' %} {% extends 'core_ui/base.html' %}
{% block content %} {% block content %}
<h2>All Uploaded Media</h2> <h2>All Uploaded Media</h2>
<ul> <ul>
{% for image in images %} {% for image in images %}
<li> <li class="mb-3">
<img src="{{ url_for('media.media_file', filename=image.file_url) }}" alt="{{ image.caption }}" width="200"><br> <img
{{ image.caption or "No caption" }} src="{{ generate_image_url(image) }}"
{% if image.plant_id %}<br>Plant ID: {{ image.plant_id }}{% endif %} alt="{{ image.caption or 'No caption' }}"
{% if image.growlog_id %}<br>GrowLog ID: {{ image.growlog_id }}{% endif %} width="200"
</li> class="img-thumbnail"
><br>
{{ image.caption or "No caption" }}
{% if image.plant_id %}
<br><small class="text-muted">Plant ID: {{ image.plant_id }}</small>
{% endif %}
{% if image.growlog_id %}
<br><small class="text-muted">GrowLog ID: {{ image.growlog_id }}</small>
{% endif %}
<br><small class="text-muted">Uploaded by user #{{ image.uploader_id }}</small>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endblock %} {% endblock %}

View File

@ -1,111 +0,0 @@
import os
import uuid
from datetime import datetime
from PIL import Image
from flask import current_app, url_for
from app import db
from .models import Media
from plugins.plant.models import Plant
def get_upload_path():
"""
Return (full_disk_path, subdir) based on UTC date,
creating directories if needed.
e.g. ('/app/static/uploads/2025/06/09', '2025/06/09')
"""
base = current_app.config.get("UPLOAD_FOLDER", "static/uploads")
now = datetime.utcnow()
subdir = os.path.join(str(now.year), f"{now.month:02}", f"{now.day:02}")
full = os.path.join(base, subdir)
os.makedirs(full, exist_ok=True)
return full, subdir
def generate_random_filename(original_filename):
"""
Preserve extension, randomize base name.
"""
ext = os.path.splitext(original_filename)[1].lower()
return f"{uuid.uuid4().hex}{ext}"
def strip_metadata_and_save(source_file, destination_path):
"""
Opens an image with Pillow, strips EXIF metadata, and saves it.
Supports common formats (JPEG, PNG).
"""
with Image.open(source_file) as img:
data = list(img.getdata())
clean_image = Image.new(img.mode, img.size)
clean_image.putdata(data)
clean_image.save(destination_path)
def generate_image_url(path):
"""
If path is set, route through /media/files/<path>; otherwise
return a placehold.co URL sized to STANDARD_IMG_SIZE.
"""
if path:
return url_for("media.media_file", filename=path)
w, h = current_app.config.get("STANDARD_IMG_SIZE", (300, 200))
return f"https://placehold.co/{w}x{h}"
def save_media_file(file_storage, uploader_id, related_model=None, related_uuid=None):
"""
- file_storage: Werkzeug FileStorage
- uploader_id: current_user.id
- related_model: e.g. 'plant'
- related_uuid: the Plant.uuid string
Returns the new Media instance.
"""
full_path, subdir = get_upload_path()
filename = generate_random_filename(file_storage.filename)
disk_path = os.path.join(full_path, filename)
file_storage.save(disk_path)
media = Media(
file_url=os.path.join(subdir, filename).replace("\\", "/"),
uploader_id=uploader_id
)
# Associate to plant if requested
if related_model == "plant" and related_uuid:
plant = Plant.query.filter_by(uuid=related_uuid).first()
if plant:
media.plant_id = plant.id
db.session.add(media)
db.session.commit()
return media
def delete_media_file(media):
"""
Remove file from disk and delete DB record.
"""
base = current_app.config.get("UPLOAD_FOLDER", "static/uploads")
path = os.path.join(base, media.file_url)
try:
os.remove(path)
except OSError:
pass
db.session.delete(media)
db.session.commit()
def rotate_media_file(media, angle=-90):
"""
Rotate the file on disk (in place) and leave DB record intact.
"""
base = current_app.config.get("UPLOAD_FOLDER", "static/uploads")
path = os.path.join(base, media.file_url)
try:
with Image.open(path) as img:
rotated = img.rotate(angle, expand=True)
rotated.save(path)
except Exception:
pass
# no DB changes needed

View File

@ -1,20 +1,25 @@
# plugins/plant/forms.py
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, BooleanField, SubmitField, SelectField from wtforms import (
from wtforms.validators import Optional, DataRequired SelectField,
StringField,
TextAreaField,
BooleanField,
DecimalField,
SubmitField
)
from wtforms.validators import DataRequired, Optional
class PlantForm(FlaskForm): class PlantForm(FlaskForm):
common_name = SelectField('Common Name', validators=[Optional()], coerce=int) plant_type = SelectField('Type', validators=[DataRequired()])
scientific_name = SelectField('Scientific Name', validators=[Optional()], coerce=int) common_name = SelectField('Common Name', coerce=int, validators=[DataRequired()])
mother_uuid = SelectField('Mother UUID', validators=[Optional()], coerce=str) scientific_name = SelectField('Scientific Name', coerce=int, validators=[DataRequired()])
plant_type = SelectField('Plant Type', validators=[DataRequired()], choices=[ mother_uuid = SelectField('Mother Plant', validators=[Optional()], coerce=str)
('cutting', 'Cutting'), custom_slug = StringField('Custom Slug', validators=[Optional()])
('tissue_culture', 'Tissue Culture'), vendor_name = StringField('Vendor Name', validators=[Optional()])
('plant', 'Plant'), price = DecimalField('Price', validators=[Optional()])
('seed', 'Seed'), notes = TextAreaField('Notes', validators=[Optional()])
('division', 'Division'), data_verified = BooleanField('Data Verified')
]) is_active = BooleanField('Active')
custom_slug = StringField('Custom Slug', validators=[Optional()]) submit = SubmitField('Save Changes')
notes = TextAreaField('Notes', validators=[Optional()])
data_verified = BooleanField('Data Verified', default=False)
is_active = BooleanField('Active', default=True)
submit = SubmitField('Save')

View File

@ -1,16 +1,15 @@
# 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
from app import db
from plugins.media.models import Media # import Media so we can refer to Media.plant_id
# Association table for Plant ↔ Tag (unchanged) from app import db
# Association table for Plant ↔ Tag
plant_tags = db.Table( plant_tags = db.Table(
'plant_tags', 'plant_tags',
db.metadata, db.metadata,
db.Column('plant_id', db.Integer, db.ForeignKey('plant.id'), primary_key=True), db.Column('plant_id', db.Integer, db.ForeignKey('plant.id'), primary_key=True),
db.Column('tag_id', db.Integer, db.ForeignKey('tag.id'), primary_key=True), db.Column('tag_id', db.Integer, db.ForeignKey('tag.id'), primary_key=True),
extend_existing=True extend_existing=True
) )
@ -21,14 +20,13 @@ class Tag(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128), unique=True, nullable=False) name = db.Column(db.String(128), unique=True, nullable=False)
class PlantCommonName(db.Model): class PlantCommonName(db.Model):
__tablename__ = 'plant_common_name' __tablename__ = 'plant_common_name'
__table_args__ = {'extend_existing': True} __table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128), unique=True, nullable=False) name = db.Column(db.String(128), unique=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
scientific_names = db.relationship( scientific_names = db.relationship(
'plugins.plant.models.PlantScientificName', 'plugins.plant.models.PlantScientificName',
@ -37,7 +35,6 @@ class PlantCommonName(db.Model):
cascade='all, delete-orphan' cascade='all, delete-orphan'
) )
class PlantScientificName(db.Model): class PlantScientificName(db.Model):
__tablename__ = 'plant_scientific_name' __tablename__ = 'plant_scientific_name'
__table_args__ = {'extend_existing': True} __table_args__ = {'extend_existing': True}
@ -47,17 +44,16 @@ class PlantScientificName(db.Model):
common_id = db.Column(db.Integer, db.ForeignKey('plant_common_name.id'), nullable=False) common_id = db.Column(db.Integer, db.ForeignKey('plant_common_name.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
class PlantOwnershipLog(db.Model): class PlantOwnershipLog(db.Model):
__tablename__ = 'plant_ownership_log' __tablename__ = 'plant_ownership_log'
__table_args__ = {'extend_existing': True} __table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=False) plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
date_acquired = db.Column(db.DateTime, default=datetime.utcnow) date_acquired = db.Column(db.DateTime, default=datetime.utcnow)
transferred = db.Column(db.Boolean, default=False, nullable=False) transferred = db.Column(db.Boolean, default=False, nullable=False)
is_verified = db.Column(db.Boolean, default=False, nullable=False) is_verified = db.Column(db.Boolean, default=False, nullable=False)
user = db.relationship( user = db.relationship(
'plugins.auth.models.User', 'plugins.auth.models.User',
@ -65,13 +61,17 @@ class PlantOwnershipLog(db.Model):
lazy=True lazy=True
) )
class Plant(db.Model): class Plant(db.Model):
__tablename__ = 'plant' __tablename__ = 'plant'
__table_args__ = {'extend_existing': True} __table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
uuid = db.Column(db.String(36), default=lambda: str(uuid_lib.uuid4()), unique=True, nullable=False) uuid = db.Column(
db.String(36),
default=lambda: str(uuid_lib.uuid4()),
unique=True,
nullable=False
)
custom_slug = db.Column(db.String(255), unique=True, nullable=True) custom_slug = db.Column(db.String(255), unique=True, nullable=True)
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
@ -82,56 +82,72 @@ class Plant(db.Model):
plant_type = db.Column(db.String(50), nullable=False) plant_type = db.Column(db.String(50), nullable=False)
notes = db.Column(db.Text, nullable=True) notes = db.Column(db.Text, nullable=True)
vendor_name = db.Column(db.String(255), nullable=True)
price = db.Column(db.Numeric(10, 2), nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False) is_active = db.Column(db.Boolean, default=True, nullable=False)
featured_media_id = db.Column(db.Integer, db.ForeignKey('media.id'), nullable=True) featured_media_id = db.Column(db.Integer, db.ForeignKey('media.id'), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow) updated_at = db.Column(
db.DateTime,
default=datetime.utcnow,
onupdate=datetime.utcnow
)
data_verified = db.Column(db.Boolean, default=False, nullable=False) data_verified = db.Column(db.Boolean, default=False, nullable=False)
# ─── FIXED: explicitly join on Media.plant_id ────────────────────────────── # ↔ Media upload gallery
media = db.relationship( media_items = db.relationship(
Media, 'plugins.media.models.Media',
backref='plant', back_populates='plant',
lazy=True, lazy='dynamic',
cascade='all, delete-orphan', cascade='all, delete-orphan',
foreign_keys=[Media.plant_id] foreign_keys='plugins.media.models.Media.plant_id'
) )
featured_media = db.relationship( @property
Media, def media(self):
foreign_keys=[featured_media_id], return self.media_items.all()
uselist=False
)
updates = db.relationship( # the one you see on the detail page
'plugins.growlog.models.PlantUpdate', featured_media = db.relationship(
backref='plant', 'plugins.media.models.Media',
lazy=True, foreign_keys=[featured_media_id],
cascade='all, delete-orphan' uselist=False
) )
tags = db.relationship(
Tag, # ↔ GrowLog instances for this plant
secondary=plant_tags, updates = db.relationship(
backref='plants', 'plugins.growlog.models.GrowLog',
lazy='dynamic' backref='plant',
) lazy=True,
common_name = db.relationship( cascade='all, delete-orphan'
PlantCommonName, )
backref=db.backref('plants', lazy='dynamic'),
lazy=True # tagging
) tags = db.relationship(
scientific_name = db.relationship( Tag,
PlantScientificName, secondary=plant_tags,
backref=db.backref('plants', lazy='dynamic'), backref='plants',
lazy=True lazy='dynamic'
) )
ownership_logs = db.relationship( common_name = db.relationship(
PlantOwnershipLog, PlantCommonName,
backref='plant', backref=db.backref('plants', lazy='dynamic'),
lazy=True, lazy=True
cascade='all, delete-orphan' )
) scientific_name = db.relationship(
PlantScientificName,
backref=db.backref('plants', lazy='dynamic'),
lazy=True
)
ownership_logs = db.relationship(
PlantOwnershipLog,
backref='plant',
lazy=True,
cascade='all, delete-orphan'
)
def __repr__(self): def __repr__(self):
return f"<Plant {self.uuid} ({self.plant_type})>" return f"<Plant {self.uuid} ({self.plant_type})>"

View File

@ -1,6 +1,5 @@
# plugins/plant/routes.py
from uuid import uuid4 from uuid import uuid4
import os
from flask import ( from flask import (
Blueprint, Blueprint,
render_template, render_template,
@ -15,75 +14,68 @@ from app import db
from .models import Plant, PlantCommonName, PlantScientificName from .models import Plant, PlantCommonName, PlantScientificName
from .forms import PlantForm from .forms import PlantForm
from plugins.media.models import Media from plugins.media.models import Media
from plugins.media.utils import ( from plugins.media.routes import (
save_media_file, save_media_file,
delete_media_file, delete_media_file,
rotate_media_file, rotate_media_file,
generate_image_url generate_image_url,
) )
bp = Blueprint( bp = Blueprint(
'plant', 'plant',
__name__, __name__,
url_prefix='/plants', url_prefix='/plants',
template_folder='templates' template_folder='templates',
) )
# Make generate_image_url available in all plant templates
@bp.app_context_processor @bp.app_context_processor
def inject_image_helper(): def inject_image_helper():
return dict(generate_image_url=generate_image_url) return dict(generate_image_url=generate_image_url)
# ─── LIST ─────────────────────────────────────────────────────────────────────
@bp.route('/', methods=['GET']) @bp.route('/', methods=['GET'])
@login_required @login_required
def index(): def index():
# 1. Compute per-type stats
total = Plant.query.filter_by(owner_id=current_user.id).count()
raw_types = (
db.session
.query(Plant.plant_type)
.filter_by(owner_id=current_user.id)
.distinct()
.all()
)
plant_types = [pt[0] for pt in raw_types]
stats = {'All': total}
for pt in plant_types:
stats[pt] = Plant.query.filter_by(
owner_id=current_user.id,
plant_type=pt
).count()
# 2. Load ALL this users plants, ordered by common name
plants = ( plants = (
Plant.query Plant.query.filter_by(owner_id=current_user.id)
.filter_by(owner_id=current_user.id) .order_by(Plant.id.desc())
.join(PlantCommonName, Plant.common_id == PlantCommonName.id) .all()
.order_by(PlantCommonName.name)
.options(
db.joinedload(Plant.media),
db.joinedload(Plant.common_name)
)
.all()
) )
# 3. Render the template (JS will handle filtering & pagination) user_plants_count = Plant.query.filter_by(owner_id=current_user.id).count()
user_images_count = Media.query.filter_by(uploader_id=current_user.id).count()
total_plants_count = Plant.query.count()
total_images_count = Media.query.count()
plant_types = [
pt[0]
for pt in (
db.session.query(Plant.plant_type)
.filter_by(owner_id=current_user.id)
.distinct()
.all()
)
]
stats = {
'user_plants': user_plants_count,
'user_images': user_images_count,
'total_plants': total_plants_count,
'total_images': total_images_count,
}
return render_template( return render_template(
'plant/index.html', 'plant/index.html',
plants=plants, plants=plants,
plant_types=plant_types,
stats=stats, stats=stats,
plant_types=plant_types
) )
# ─── CREATE ───────────────────────────────────────────────────────────────────
@bp.route('/create', methods=['GET', 'POST']) @bp.route('/create', methods=['GET', 'POST'])
@login_required @login_required
def create(): def create():
form = PlantForm() form = PlantForm()
# ─── dropdown choices ───────────────────────────────────────────────────────
form.plant_type.choices = [ form.plant_type.choices = [
('plant', 'Plant'), ('plant', 'Plant'),
('cutting', 'Cutting'), ('cutting', 'Cutting'),
@ -93,14 +85,17 @@ def create():
] ]
form.common_name.choices = [ form.common_name.choices = [
(c.id, c.name) (c.id, c.name)
for c in PlantCommonName.query.order_by(PlantCommonName.name) for c in PlantCommonName.query.order_by(PlantCommonName.name).all()
] ]
form.scientific_name.choices = [ form.scientific_name.choices = [
(s.id, s.name) (s.id, s.name)
for s in PlantScientificName.query.order_by(PlantScientificName.name) for s in PlantScientificName.query.order_by(PlantScientificName.name).all()
] ]
form.mother_uuid.choices = [('N/A', 'None')] + [ form.mother_uuid.choices = [('N/A', 'None')] + [
(p.uuid, f"{p.common_name.name if p.common_name else 'Unnamed'} {p.uuid}") (
p.uuid,
f"{p.common_name.name if p.common_name else 'Unnamed'} {p.uuid}"
)
for p in Plant.query.order_by(Plant.created_at.desc()).all() for p in Plant.query.order_by(Plant.created_at.desc()).all()
] ]
@ -116,8 +111,9 @@ def create():
if form.mother_uuid.data != 'N/A' if form.mother_uuid.data != 'N/A'
else None else None
), ),
# ← HERE: convert blank slug to NULL
custom_slug=(form.custom_slug.data.strip() or None), custom_slug=(form.custom_slug.data.strip() or None),
vendor_name=(form.vendor_name.data.strip() or None),
price=(form.price.data or None),
notes=form.notes.data, notes=form.notes.data,
data_verified=form.data_verified.data, data_verified=form.data_verified.data,
is_active=form.is_active.data, is_active=form.is_active.data,
@ -129,21 +125,25 @@ def create():
return render_template('plant/create.html', form=form) return render_template('plant/create.html', form=form)
# ─── DETAIL ───────────────────────────────────────────────────────────────────
@bp.route('/<uuid:uuid_val>', methods=['GET']) @bp.route('/<uuid:uuid_val>', methods=['GET'])
@login_required @login_required
def detail(uuid_val): def detail(uuid_val):
plant = Plant.query.filter_by(uuid=uuid_val).first_or_404() plant = Plant.query.filter_by(
uuid=str(uuid_val),
owner_id=current_user.id,
).first_or_404()
return render_template('plant/detail.html', plant=plant) return render_template('plant/detail.html', plant=plant)
# ─── EDIT ─────────────────────────────────────────────────────────────────────
@bp.route('/<uuid:uuid_val>/edit', methods=['GET', 'POST']) @bp.route('/<uuid:uuid_val>/edit', methods=['GET', 'POST'])
@login_required @login_required
def edit(uuid_val): def edit(uuid_val):
plant = Plant.query.filter_by(uuid=uuid_val).first_or_404() plant = Plant.query.filter_by(
uuid=str(uuid_val),
owner_id=current_user.id,
).first_or_404()
form = PlantForm() form = PlantForm()
# Populate dropdowns (same as in create)
form.plant_type.choices = [ form.plant_type.choices = [
('plant', 'Plant'), ('plant', 'Plant'),
('cutting', 'Cutting'), ('cutting', 'Cutting'),
@ -153,14 +153,17 @@ def edit(uuid_val):
] ]
form.common_name.choices = [ form.common_name.choices = [
(c.id, c.name) (c.id, c.name)
for c in PlantCommonName.query.order_by(PlantCommonName.name) for c in PlantCommonName.query.order_by(PlantCommonName.name).all()
] ]
form.scientific_name.choices = [ form.scientific_name.choices = [
(s.id, s.name) (s.id, s.name)
for s in PlantScientificName.query.order_by(PlantScientificName.name) for s in PlantScientificName.query.order_by(PlantScientificName.name).all()
] ]
form.mother_uuid.choices = [('N/A', 'None')] + [ form.mother_uuid.choices = [('N/A', 'None')] + [
(p.uuid, f"{p.common_name.name if p.common_name else 'Unnamed'} {p.uuid}") (
p.uuid,
f"{p.common_name.name if p.common_name else 'Unnamed'} {p.uuid}"
)
for p in Plant.query.filter(Plant.uuid != plant.uuid).all() for p in Plant.query.filter(Plant.uuid != plant.uuid).all()
] ]
@ -170,6 +173,8 @@ def edit(uuid_val):
form.scientific_name.data = plant.scientific_id form.scientific_name.data = plant.scientific_id
form.mother_uuid.data = plant.mother_uuid or 'N/A' form.mother_uuid.data = plant.mother_uuid or 'N/A'
form.custom_slug.data = plant.custom_slug or '' form.custom_slug.data = plant.custom_slug or ''
form.vendor_name.data = plant.vendor_name or ''
form.price.data = plant.price or None
form.notes.data = plant.notes form.notes.data = plant.notes
form.data_verified.data = plant.data_verified form.data_verified.data = plant.data_verified
form.is_active.data = getattr(plant, 'is_active', True) form.is_active.data = getattr(plant, 'is_active', True)
@ -183,47 +188,55 @@ def edit(uuid_val):
if form.mother_uuid.data != 'N/A' if form.mother_uuid.data != 'N/A'
else None else None
) )
# ← HERE as well
plant.custom_slug = (form.custom_slug.data.strip() or None) plant.custom_slug = (form.custom_slug.data.strip() or None)
plant.vendor_name = (form.vendor_name.data.strip() or None)
plant.price = (form.price.data or None)
plant.notes = form.notes.data plant.notes = form.notes.data
plant.data_verified = form.data_verified.data plant.data_verified = form.data_verified.data
plant.is_active = form.is_active.data plant.is_active = form.is_active.data
# — patch to save whichever radio was checked —
featured_id = request.form.get("featured_media_id")
if featured_id and featured_id.isdigit():
plant.featured_media_id = int(featured_id)
db.session.commit() db.session.commit()
flash('Plant updated successfully.', 'success') flash('Plant updated successfully.', 'success')
return redirect(url_for('plant.detail', uuid_val=plant.uuid)) return redirect(url_for('plant.detail', uuid_val=plant.uuid))
return render_template('plant/edit.html', form=form, plant=plant) return render_template('plant/edit.html', form=form, plant=plant)
# ─── IMAGE ROUTES ────────────────────────────────────────────────────────────
@bp.route('/<uuid:uuid_val>/upload', methods=['POST']) @bp.route('/<uuid:uuid_val>/upload', methods=['POST'])
@login_required @login_required
def upload_image(uuid_val): def upload_image(uuid_val):
plant = Plant.query.filter_by(uuid=uuid_val).first_or_404() plant = Plant.query.filter_by(uuid=str(uuid_val)).first_or_404()
file = request.files.get('file') file = request.files.get('file')
if file and file.filename: if file and file.filename:
save_media_file( save_media_file(
file, file,
current_user.id, current_user.id,
related_model='plant', related_model='plant',
related_uuid=str(plant.uuid) related_uuid=str(plant.uuid),
) )
flash('Image uploaded successfully.', 'success') flash('Image uploaded successfully.', 'success')
return redirect(url_for('plant.edit', uuid_val=plant.uuid)) return redirect(url_for('plant.edit', uuid_val=plant.uuid))
@bp.route('/<uuid:uuid_val>/feature/<int:media_id>', methods=['POST']) @bp.route("/feature/<int:media_id>", methods=["POST"])
@login_required @login_required
def set_featured_image(uuid_val, media_id): def set_featured_image(media_id):
plant = Plant.query.filter_by(uuid=uuid_val).first_or_404()
media = Media.query.get_or_404(media_id) media = Media.query.get_or_404(media_id)
if current_user.id != media.uploader_id and current_user.role != "admin":
return jsonify({"error": "Not authorized"}), 403
plant = media.plant
plant.featured_media_id = media.id plant.featured_media_id = media.id
db.session.commit() db.session.commit()
flash('Featured image set.', 'success') return jsonify({"status": "success", "media_id": media.id})
return redirect(url_for('plant.edit', uuid_val=plant.uuid))
@bp.route('/<uuid:uuid_val>/delete/<int:media_id>', methods=['POST']) @bp.route('/<uuid:uuid_val>/delete/<int:media_id>', methods=['POST'])
@login_required @login_required
def delete_image(uuid_val, media_id): def delete_image(uuid_val, media_id):
plant = Plant.query.filter_by(uuid=uuid_val).first_or_404() plant = Plant.query.filter_by(uuid=str(uuid_val)).first_or_404()
media = Media.query.get_or_404(media_id) media = Media.query.get_or_404(media_id)
delete_media_file(media) delete_media_file(media)
flash('Image deleted.', 'success') flash('Image deleted.', 'success')
@ -232,7 +245,7 @@ def delete_image(uuid_val, media_id):
@bp.route('/<uuid:uuid_val>/rotate/<int:media_id>', methods=['POST']) @bp.route('/<uuid:uuid_val>/rotate/<int:media_id>', methods=['POST'])
@login_required @login_required
def rotate_image(uuid_val, media_id): def rotate_image(uuid_val, media_id):
plant = Plant.query.filter_by(uuid=uuid_val).first_or_404() plant = Plant.query.filter_by(uuid=str(uuid_val)).first_or_404()
media = Media.query.get_or_404(media_id) media = Media.query.get_or_404(media_id)
rotate_media_file(media) rotate_media_file(media)
flash('Image rotated.', 'success') flash('Image rotated.', 'success')

View File

@ -1,4 +1,3 @@
{# plugins/plant/templates/plant/detail.html #}
{% extends 'core_ui/base.html' %} {% extends 'core_ui/base.html' %}
{% block title %} {% block title %}
@ -9,10 +8,9 @@
<div class="container my-4"> <div class="container my-4">
<div class="row gx-4"> <div class="row gx-4">
<div class="col-md-4"> <div class="col-md-4">
{# determine featured or fallback to first media item #}
{% set featured = plant.featured_media or plant.media|first %} {% set featured = plant.featured_media or plant.media|first %}
<img <img
src="{{ generate_image_url(featured.file_url if featured else None) }}" src="{{ generate_image_url(featured) }}"
alt="Image of {{ plant.common_name.name if plant.common_name else 'Plant' }}" alt="Image of {{ plant.common_name.name if plant.common_name else 'Plant' }}"
class="img-fluid rounded shadow-sm" class="img-fluid rounded shadow-sm"
style="object-fit: cover; width: 100%; height: auto;" style="object-fit: cover; width: 100%; height: auto;"
@ -61,7 +59,7 @@
<div class="d-flex flex-wrap gap-3"> <div class="d-flex flex-wrap gap-3">
{% for img in plant.media if img != featured %} {% for img in plant.media if img != featured %}
<img <img
src="{{ generate_image_url(img.file_url) }}" src="{{ generate_image_url(img) }}"
alt="Plant image" alt="Plant image"
class="img-thumbnail" class="img-thumbnail"
style="height: 160px; object-fit: cover;" style="height: 160px; object-fit: cover;"

View File

@ -5,7 +5,6 @@
<div class="container mt-4"> <div class="container mt-4">
<h2>Edit Plant</h2> <h2>Edit Plant</h2>
{# ─── Main plantdata form ──────────────────────────────────────────── #}
<form method="POST" enctype="multipart/form-data"> <form method="POST" enctype="multipart/form-data">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
@ -13,37 +12,38 @@
{{ form.plant_type.label(class="form-label") }} {{ form.plant_type.label(class="form-label") }}
{{ form.plant_type(class="form-select") }} {{ form.plant_type(class="form-select") }}
</div> </div>
<div class="mb-3"> <div class="mb-3">
{{ form.common_name.label(class="form-label") }} {{ form.common_name.label(class="form-label") }}
{{ form.common_name(class="form-select") }} {{ form.common_name(class="form-select") }}
</div> </div>
<div class="mb-3"> <div class="mb-3">
{{ form.scientific_name.label(class="form-label") }} {{ form.scientific_name.label(class="form-label") }}
{{ form.scientific_name(class="form-select") }} {{ form.scientific_name(class="form-select") }}
</div> </div>
<div class="mb-3"> <div class="mb-3">
{{ form.mother_uuid.label(class="form-label") }} {{ form.mother_uuid.label(class="form-label") }}
{{ form.mother_uuid(class="form-select") }} {{ form.mother_uuid(class="form-select") }}
</div> </div>
<div class="mb-3"> <div class="mb-3">
{{ form.custom_slug.label(class="form-label") }} {{ form.custom_slug.label(class="form-label") }}
{{ form.custom_slug(class="form-control") }} {{ form.custom_slug(class="form-control") }}
</div> </div>
<div class="mb-3">
{{ form.vendor_name.label(class="form-label") }}
{{ form.vendor_name(class="form-control") }}
</div>
<div class="mb-3">
{{ form.price.label(class="form-label") }}
{{ form.price(class="form-control") }}
</div>
<div class="mb-3"> <div class="mb-3">
{{ form.notes.label(class="form-label") }} {{ form.notes.label(class="form-label") }}
{{ form.notes(class="form-control", rows=4) }} {{ form.notes(class="form-control", rows=4) }}
</div> </div>
<div class="form-check mb-3"> <div class="form-check mb-3">
{{ form.data_verified(class="form-check-input") }} {{ form.data_verified(class="form-check-input") }}
{{ form.data_verified.label(class="form-check-label") }} {{ form.data_verified.label(class="form-check-label") }}
</div> </div>
<div class="form-check mb-3"> <div class="form-check mb-3">
{{ form.is_active(class="form-check-input") }} {{ form.is_active(class="form-check-input") }}
{{ form.is_active.label(class="form-check-label") }} {{ form.is_active.label(class="form-check-label") }}
@ -54,74 +54,121 @@
<hr> <hr>
{# ─── Upload new image ─────────────────────────────────────────────── #}
<h4>Upload Image</h4> <h4>Upload Image</h4>
<form <form
method="POST" method="POST"
action="{{ url_for('plant.upload_image', uuid_val=plant.uuid) }}" action="{{ url_for('media.add_media', plant_uuid=plant.uuid) }}"
enctype="multipart/form-data" enctype="multipart/form-data"
class="mb-4" class="mb-4"
> >
<input {{ form.csrf_token }}
type="hidden"
name="csrf_token"
value="{{ csrf_token() }}"
>
<div class="input-group"> <div class="input-group">
<input type="file" name="file" class="form-control" required> <input type="file" name="file" class="form-control" required>
<button class="btn btn-secondary" type="submit">Upload</button> <button class="btn btn-secondary" type="submit">Upload</button>
</div> </div>
</form> </form>
{# ─── Existing images ──────────────────────────────────────────────── #}
<h4>Existing Images</h4> <h4>Existing Images</h4>
<div class="row"> <form method="POST" id="delete-images-form"
{% for media in plant.media %} action="{{ url_for('media.bulk_delete_media', plant_uuid=plant.uuid) }}"
<div class="col-md-3 mb-4"> onsubmit="return confirm('Are you sure you want to delete selected images?');">
<div class="card h-100"> {{ form.csrf_token }}
<img <div class="row">
src="{{ generate_image_url(media.file_url) }}" {% for media in plant.media %}
class="card-img-top img-fluid" <div class="col-md-3 mb-4">
alt="Plant Image" <div class="card h-100">
style="object-fit:cover; height:150px;" <img
> id="image-{{ media.id }}"
<div class="card-body text-center"> src="{{ generate_image_url(media) }}"
{% if plant.featured_media_id == media.id %} class="card-img-top img-fluid"
<span class="badge bg-success mb-2">Featured</span> alt="Plant Image"
{% else %} style="object-fit:cover; height:150px;"
<form >
method="POST" <div class="card-body text-center">
action="{{ url_for('plant.set_featured_image', uuid_val=plant.uuid, media_id=media.id) }}"
class="mb-2" <div class="form-check mb-2">
> <input
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> class="form-check-input featured-radio"
<button class="btn btn-outline-primary btn-sm">Set Featured</button> type="radio"
</form> name="featured_media"
{% endif %} value="{{ media.id }}"
{% if plant.featured_media_id == media.id %} checked {% endif %}
data-url="{{ url_for('media.set_featured_image',
context='plant',
context_id=plant.id,
media_id=media.id) }}"
>
<label class="form-check-label">Featured</label>
</div>
<div class="d-grid gap-1">
<button
type="button"
class="btn btn-outline-secondary btn-sm rotate-btn"
data-url="{{ url_for('media.rotate_media', media_id=media.id) }}"
data-id="{{ media.id }}"
>Rotate</button>
<div class="form-check mt-2">
<input class="form-check-input delete-checkbox"
type="checkbox"
name="delete_ids"
value="{{ media.id }}">
<label class="form-check-label">Delete</label>
</div>
</div>
<div class="d-grid gap-1">
<form
method="POST"
action="{{ url_for('plant.rotate_image', uuid_val=plant.uuid, media_id=media.id) }}"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-secondary btn-sm">Rotate</button>
</form>
<form
method="POST"
action="{{ url_for('plant.delete_image', uuid_val=plant.uuid, media_id=media.id) }}"
onsubmit="return confirm('Delete this image?');"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-danger btn-sm">Delete</button>
</form>
</div> </div>
</div> </div>
</div> </div>
</div> {% else %}
{% else %} <p class="text-muted">No images uploaded yet.</p>
<p class="text-muted">No images uploaded yet.</p> {% endfor %}
{% endfor %} </div>
</div>
<button type="submit" class="btn btn-danger mt-2">Delete Selected Images</button>
</form>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
const csrfToken = "{{ form.csrf_token._value() }}";
// Rotate
document.querySelectorAll('.rotate-btn').forEach(btn => {
btn.addEventListener('click', () => {
const url = btn.dataset.url;
const id = btn.dataset.id;
fetch(url, {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken }
})
.then(r => {
if (!r.ok) throw Error();
const img = document.getElementById(`image-${id}`);
img.src = img.src.split('?')[0] + `?v=${Date.now()}`;
})
.catch(() => alert('Failed to rotate image.'));
});
});
// Feature
document.querySelectorAll('.featured-radio').forEach(radio => {
radio.addEventListener('change', () => {
const url = radio.dataset.url;
fetch(url, {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken }
})
.then(r => {
if (!r.ok) throw Error();
// uncheck all, then check this one
document.querySelectorAll('.featured-radio').forEach(r => r.checked = false);
radio.checked = true;
})
.catch(() => alert('Could not set featured image.'));
});
});
</script>
{% endblock %}

View File

@ -1,43 +1,124 @@
{% extends 'core_ui/base.html' %} {% extends 'core_ui/base.html' %}
{% block title %}View Entries Nature In Pots{% endblock %}
{% block content %} {% block content %}
<div class="container mt-4"> <!-- BETA NOTICE -->
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<!-- Header + Add button --> <strong>Heads up!</strong> Were in alpha, do not share the main system for the time being as this is not on a reliable host.
<div class="d-flex justify-content-between align-items-center mb-3"> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<h1>My Plants</h1> </div>
<a href="{{ url_for('plant.create') }}" class="btn btn-primary"> <div class="alert alert-warning alert-dismissible fade show" role="alert">
+ Add New Plant <strong>Heads up!</strong> Were in alpha expect occasional hiccups, use the zip backup.
</a> <br /><strong>Also</strong>, QR codes work but generation will change more soon to be more user friendly.
<br /><strong>Update</strong>, QR Card is public, direct is private. Do not use this for sharing currently please.
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> </div>
<!-- Stats Panel --> <!-- Stats container (desktop only, collapsed by default) -->
<div class="card mb-4"> <div class="collapse mb-3" id="statsBox">
<div class="card-header"> <div class="d-none d-md-block p-3 bg-light border rounded">
<a data-bs-toggle="collapse" href="#statsBox" aria-expanded="false"> <h5 class="text-center">Statistics</h5>
Stats &nbsp;<i class="bi bi-chevron-down"></i> <div class="row row-cols-1 row-cols-md-2 g-3 mt-3">
</a> <div class="col">
</div> <div class="d-flex flex-column align-items-center">
<div class="collapse" id="statsBox"> <i class="bi bi-flower3 fs-2 text-success mb-2"></i>
<ul class="list-group list-group-flush"> <div class="small text-muted">Your plants</div>
{% for label, count in stats.items() %} <div class="fw-bold fs-4">{{ stats.user_plants }}</div>
<li class="list-group-item d-flex justify-content-between"> </div>
<span>{{ label.replace('_',' ').title() }}</span> </div>
<span>{{ count }}</span> <div class="col">
</li> <div class="d-flex flex-column align-items-center">
{% endfor %} <i class="bi bi-file-earmark-image fs-2 text-primary mb-2"></i>
</ul> <div class="small text-muted">Your images</div>
<div class="fw-bold fs-4">{{ stats.user_images }}</div>
</div>
</div>
<div class="col">
<div class="d-flex flex-column align-items-center">
<i class="bi bi-tree fs-2 text-success mb-2"></i>
<div class="small text-muted">Total plants</div>
<div class="fw-bold fs-4">{{ stats.total_plants }}</div>
</div>
</div>
<div class="col">
<div class="d-flex flex-column align-items-center">
<i class="bi bi-images fs-2 text-primary mb-2"></i>
<div class="small text-muted">Total images</div>
<div class="fw-bold fs-4">{{ stats.total_images }}</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<!-- Import/Export & Filter bar --> <!-- Stats Modal (for mobile) -->
<div class="modal fade" id="statsModal" tabindex="-1" aria-labelledby="statsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="statsModalLabel">Statistics</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row row-cols-1 row-cols-2 g-3">
<div class="col">
<div class="d-flex flex-column align-items-center">
<i class="bi bi-flower3 fs-2 text-success mb-2"></i>
<div class="small text-muted">Your plants</div>
<div class="fw-bold fs-4">{{ stats.user_plants }}</div>
</div>
</div>
<div class="col">
<div class="d-flex flex-column align-items-center">
<i class="bi bi-file-earmark-image fs-2 text-primary mb-2"></i>
<div class="small text-muted">Your images</div>
<div class="fw-bold fs-4">{{ stats.user_images }}</div>
</div>
</div>
<div class="col">
<div class="d-flex flex-column align-items-center">
<i class="bi bi-tree fs-2 text-success mb-2"></i>
<div class="small text-muted">Total plants</div>
<div class="fw-bold fs-4">{{ stats.total_plants }}</div>
</div>
</div>
<div class="col">
<div class="d-flex flex-column align-items-center">
<i class="bi bi-images fs-2 text-primary mb-2"></i>
<div class="small text-muted">Total images</div>
<div class="fw-bold fs-4">{{ stats.total_images }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<h2>View Entries</h2>
<!-- Import / Export & Filter bar -->
<div class="mb-3 d-flex flex-wrap justify-content-between align-items-center"> <div class="mb-3 d-flex flex-wrap justify-content-between align-items-center">
<div class="mb-2 d-flex align-items-center"> <div class="mb-2 d-flex align-items-center">
<a href="{{ url_for('utility.upload') }}" class="btn btn-primary me-2"> <button class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#importModal">
Import CSV Import CSV
</a> </button>
<a href="{{ url_for('utility.export_data') }}" class="btn btn-secondary me-2"> <a href="{{ url_for('utility.export_data') }}" class="btn btn-secondary me-2">Export My Data</a>
Export My Data <button
</a> class="btn btn-secondary me-2 d-inline-block d-md-none"
data-bs-toggle="modal"
data-bs-target="#statsModal">
Stats
</button>
<button
class="btn btn-secondary me-2 d-none d-md-inline-block"
data-bs-toggle="collapse"
data-bs-target="#statsBox"
aria-expanded="false"
aria-controls="statsBox"
id="statsToggle">
Stats <i class="bi bi-chevron-down"></i>
</button>
</div> </div>
<div class="d-flex flex-wrap flex-md-nowrap align-items-center"> <div class="d-flex flex-wrap flex-md-nowrap align-items-center">
<div class="input-group me-2 mb-2 mb-md-0" style="min-width:200px;"> <div class="input-group me-2 mb-2 mb-md-0" style="min-width:200px;">
@ -47,10 +128,10 @@
<select id="typeFilter" class="form-select me-2 mb-2 mb-md-0" style="min-width:140px;"> <select id="typeFilter" class="form-select me-2 mb-2 mb-md-0" style="min-width:140px;">
<option value="">All Types</option> <option value="">All Types</option>
{% for t in plant_types %} {% for t in plant_types %}
<option value="{{ t|lower }}">{{ t.replace('_',' ').title() }}</option> <option value="{{ t|lower }}">{{ t }}</option>
{% endfor %} {% endfor %}
</select> </select>
<select id="pageSizeSelect" class="form-select mb-2 mb-md-0" style="min-width:140px;"> <select id="pageSizeSelect" class="form-select me-2 mb-2 mb-md-0" style="min-width:140px;">
{% for size in [6,12,18,24] %} {% for size in [6,12,18,24] %}
<option value="{{ size }}" {% if size == 12 %}selected{% endif %}> <option value="{{ size }}" {% if size == 12 %}selected{% endif %}>
{{ size }} per page {{ size }} per page
@ -60,44 +141,52 @@
</div> </div>
</div> </div>
<!-- Plant Cards Container --> <!-- Plant cards -->
<div class="row row-cols-1 row-cols-md-3 g-4" id="plantContainer"> <div class="row row-cols-1 row-cols-lg-3 g-3" id="plantContainer">
{% for plant in plants %} {% for plant in plants %}
<div class="col plant-card" <div class="col plant-card"
data-name="{{ plant.common_name.name|lower }}" data-name="{{ plant.common_name.name|lower }}"
data-type="{{ plant.plant_type|lower }}"> data-type="{{ plant.plant_type|lower }}">
<div class="card h-100"> <div class="card h-100">
{% set featured = plant.featured_media %}
{% if plant.media %} {% if not featured and plant.media %}
{% set featured = plant.media|selectattr('featured')|first or plant.media[0] %} {% set featured = plant.media[0] %}
<a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}">
<img
src="{{ url_for('media.media_file', filename=featured.file_url) }}"
class="card-img-top"
style="height:200px; object-fit:cover;"
alt="{{ plant.common_name.name }}"
>
</a>
{% else %}
<!-- Placeholder when no media -->
<a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}">
<img
src="{{ generate_image_url(None) }}"
class="card-img-top"
style="height:200px; object-fit:cover;"
alt="Placeholder for {{ plant.common_name.name }}"
>
</a>
{% endif %} {% endif %}
<a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}">
<img src="{{ generate_image_url(featured) }}"
class="card-img-top"
style="height:200px;object-fit:cover;"
alt="Image for {{ plant.common_name.name }}">
</a>
<div class="card-body d-flex flex-column"> <div class="card-body d-flex flex-column">
<h5 class="card-title">{{ plant.common_name.name }}</h5> <h5 class="card-title">
<p class="card-text">{{ plant.plant_type.replace('_',' ').title() }}</p> <a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}">
{{ plant.common_name.name }}
</a>
</h5>
<h6 class="text-muted">{{ plant.uuid }}</h6>
<p class="mb-1"><strong>Type:</strong> {{ plant.plant_type }}</p>
<p class="mb-1"><strong>Scientific Name:</strong> {{ plant.scientific_name.name }}</p>
{% if plant.mother_uuid %}
<p class="mb-1">
<strong>Mother:</strong>
<a href="{{ url_for('plant.detail', uuid_val=plant.mother_uuid) }}">
{{ plant.mother_uuid }}
</a>
</p>
{% endif %}
<div class="mt-auto"> <div class="mt-auto">
<a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}" <a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}"
class="btn btn-sm btn-outline-primary">View</a> class="btn btn-sm btn-primary me-1">View</a>
<a href="{{ url_for('plant.edit', uuid_val=plant.uuid) }}" <a href="{{ url_for('plant.edit', uuid_val=plant.uuid) }}"
class="btn btn-sm btn-outline-secondary">Edit</a> class="btn btn-sm btn-secondary me-1">Edit</a>
<a href="{{ url_for('utility.download_qr', uuid_val=plant.uuid) }}"
class="btn btn-sm btn-outline-primary me-1">Direct QR</a>
<a href="{{ url_for('utility.download_qr_card', uuid_val=plant.uuid) }}"
class="btn btn-sm btn-outline-secondary">Card QR</a>
</div> </div>
</div> </div>
</div> </div>
@ -105,72 +194,123 @@
{% endfor %} {% endfor %}
</div> </div>
<!-- Pagination Controls --> <!-- pagination controls -->
<nav aria-label="Page navigation" class="mt-4"> <nav aria-label="Page navigation" class="mt-4 mb-5">
<ul class="pagination justify-content-center" id="pagination"></ul> <ul class="pagination justify-content-center" id="pagination"></ul>
</nav> </nav>
</div>
<!-- Client-side filtering & pagination script --> <!-- client-side filtering & pagination script -->
<script> <script>
(function() { (function() {
const searchInput = document.getElementById('searchInput'); const searchInput = document.getElementById('searchInput');
const typeFilter = document.getElementById('typeFilter'); const typeFilter = document.getElementById('typeFilter');
const pageSizeSelect = document.getElementById('pageSizeSelect'); const pageSizeSelect = document.getElementById('pageSizeSelect');
const container = document.getElementById('plantContainer'); const container = document.getElementById('plantContainer');
const pagination = document.getElementById('pagination'); const pagination = document.getElementById('pagination');
const cards = Array.from(container.querySelectorAll('.plant-card')); const cards = Array.from(container.querySelectorAll('.plant-card'));
let currentPage = 1; let currentPage = 1;
let pageSize = parseInt(pageSizeSelect.value, 10); let pageSize = parseInt(pageSizeSelect.value, 10);
function filterAndPaginate() { function filterAndPaginate() {
const q = searchInput.value.trim().toLowerCase(); const q = searchInput.value.trim().toLowerCase();
const type = typeFilter.value; const type = typeFilter.value;
const filtered = cards.filter(c => { const filtered = cards.filter(card => {
return c.dataset.name.includes(q) && return card.dataset.name.includes(q) &&
(!type || c.dataset.type === type); (!type || card.dataset.type === type);
}); });
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
if (currentPage > totalPages) currentPage = totalPages; if (currentPage > totalPages) currentPage = totalPages;
cards.forEach(c => c.style.display = 'none'); cards.forEach(c => c.style.display = 'none');
const start = (currentPage - 1) * pageSize; const start = (currentPage - 1) * pageSize;
filtered.slice(start, start + pageSize).forEach(c => c.style.display = ''); filtered.slice(start, start + pageSize)
.forEach(c => c.style.display = '');
pagination.innerHTML = ''; pagination.innerHTML = '';
const makeLi = (label, page, disabled) => {
const li = document.createElement('li'); // Prev button
li.className = 'page-item' + (disabled ? ' disabled' : '') + (page === currentPage ? ' active' : ''); const prevLi = document.createElement('li');
li.innerHTML = `<a class="page-link" href="#">${label}</a>`; prevLi.className = 'page-item' + (currentPage === 1 ? ' disabled' : '');
if (!disabled) { prevLi.innerHTML = `<a class="page-link" href="#">Prev</a>`;
li.onclick = e => { e.preventDefault(); currentPage = page; filterAndPaginate(); }; prevLi.onclick = e => {
e.preventDefault();
if (currentPage > 1) { currentPage--; filterAndPaginate(); }
};
pagination.appendChild(prevLi);
// Page numbers
if (totalPages <= 5) {
for (let i = 1; i <= totalPages; i++) {
const li = document.createElement('li');
li.className = 'page-item' + (i === currentPage ? ' active' : '');
li.innerHTML = `<a class="page-link" href="#">${i}</a>`;
li.onclick = e => {
e.preventDefault();
currentPage = i;
filterAndPaginate();
};
pagination.appendChild(li);
}
} else {
[1,2,3].forEach(n => {
const li = document.createElement('li');
li.className = 'page-item' + (n === currentPage ? ' active' : '');
li.innerHTML = `<a class="page-link" href="#">${n}</a>`;
li.onclick = e => {
e.preventDefault();
currentPage = n;
filterAndPaginate();
};
pagination.appendChild(li);
});
const ell = document.createElement('li');
ell.className = 'page-item disabled';
ell.innerHTML = `<span class="page-link">…</span>`;
pagination.appendChild(ell);
const lastLi = document.createElement('li');
lastLi.className = 'page-item' + (totalPages === currentPage ? ' active' : '');
lastLi.innerHTML = `<a class="page-link" href="#">${totalPages}</a>`;
lastLi.onclick = e => {
e.preventDefault();
currentPage = totalPages;
filterAndPaginate();
};
pagination.appendChild(lastLi);
}
// Next button
const nextLi = document.createElement('li');
nextLi.className = 'page-item' + (currentPage === totalPages ? ' disabled' : '');
nextLi.innerHTML = `<a class="page-link" href="#">Next</a>`;
nextLi.onclick = e => {
e.preventDefault();
if (currentPage < totalPages) { currentPage++; filterAndPaginate(); }
};
pagination.appendChild(nextLi);
} }
return li;
};
pagination.appendChild(makeLi('Prev', Math.max(1, currentPage-1), currentPage===1)); // Initialize and bind events
for (let i = 1; i <= Math.min(5, totalPages); i++) { filterAndPaginate();
pagination.appendChild(makeLi(i, i, false)); searchInput.addEventListener('input', () => { currentPage = 1; filterAndPaginate(); });
} typeFilter.addEventListener('change', () => { currentPage = 1; filterAndPaginate(); });
if (totalPages > 5) { pageSizeSelect.addEventListener('change',() => {
const ell = document.createElement('li'); pageSize = parseInt(pageSizeSelect.value, 10);
ell.className = 'page-item disabled'; currentPage = 1; filterAndPaginate();
ell.innerHTML = `<span class="page-link">…</span>`; });
pagination.appendChild(ell); })();
pagination.appendChild(makeLi(totalPages, totalPages, false)); </script>
}
pagination.appendChild(makeLi('Next', Math.min(totalPages, currentPage+1), currentPage===totalPages));
}
[searchInput, typeFilter].forEach(el => el.addEventListener('input', () => { currentPage = 1; filterAndPaginate(); })); <script>
pageSizeSelect.addEventListener('change', () => { // Toggle chevron icon on desktop collapse
pageSize = parseInt(pageSizeSelect.value, 10); const statsBox = document.getElementById('statsBox');
currentPage = 1; statsBox.addEventListener('shown.bs.collapse', () => {
filterAndPaginate(); document.querySelector('#statsToggle i')
}); .classList.replace('bi-chevron-down','bi-chevron-up');
});
filterAndPaginate(); statsBox.addEventListener('hidden.bs.collapse', () => {
})(); document.querySelector('#statsToggle i')
</script> .classList.replace('bi-chevron-up','bi-chevron-down');
});
</script>
{% endblock %} {% endblock %}

View File

@ -1,29 +1,35 @@
# plugins/submission/routes.py # plugins/submission/routes.py
# Standard library
from datetime import datetime
# Thirdparty
from flask import ( from flask import (
Blueprint, Blueprint, render_template, request,
render_template, redirect, url_for, flash, jsonify
request,
redirect,
url_for,
flash,
jsonify
) )
from flask_login import login_required, current_user from flask_login import login_required, current_user
# Application
from app import db from app import db
# Plugins
from plugins.media.routes import _process_upload_file
# Local
from .models import Submission, SubmissionImage from .models import Submission, SubmissionImage
from .forms import SubmissionForm from .forms import SubmissionForm
from datetime import datetime
import os
from werkzeug.utils import secure_filename
from plugins.media.utils import generate_random_filename, strip_metadata_and_save
bp = Blueprint("submission", __name__, template_folder="templates", url_prefix="/submission")
# We store only "YYYY/MM/DD/<uuid>.ext" in SubmissionImage.file_url. bp = Blueprint(
# All files live under "/app/static/uploads/YYYY/MM/DD/<uuid>.ext" in the container. "submission",
BASE_UPLOAD_FOLDER = "static/uploads" __name__,
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif"} template_folder="templates",
url_prefix="/submission"
)
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
def allowed_file(filename): def allowed_file(filename):
return ( return (
@ -31,11 +37,13 @@ def allowed_file(filename):
and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
) )
@bp.route("/", methods=["GET"]) @bp.route("/", methods=["GET"])
@login_required @login_required
def submission_index(): def submission_index():
return redirect(url_for("submission.new_submission")) return redirect(url_for("submission.new_submission"))
@bp.route("/new", methods=["GET", "POST"]) @bp.route("/new", methods=["GET", "POST"])
@bp.route("/new/", methods=["GET", "POST"]) @bp.route("/new/", methods=["GET", "POST"])
@login_required @login_required
@ -45,7 +53,6 @@ def new_submission():
plant_types = {"market_price", "name_correction", "new_plant", "mutation"} plant_types = {"market_price", "name_correction", "new_plant", "mutation"}
t = form.submission_type.data t = form.submission_type.data
# Only require plant_name if the type is plantrelated
if t in plant_types and not form.plant_name.data.strip(): if t in plant_types and not form.plant_name.data.strip():
flash("Common Name is required for this submission type.", "danger") flash("Common Name is required for this submission type.", "danger")
return render_template("submission/new.html", form=form) return render_template("submission/new.html", form=form)
@ -69,34 +76,20 @@ def new_submission():
db.session.add(submission) db.session.add(submission)
db.session.flush() db.session.flush()
# date subfolder: "YYYY/MM/DD" # Handle any uploaded images
today = datetime.utcnow().strftime("%Y/%m/%d")
# Write into "/app/static/uploads/YYYY/MM/DD", not "/app/app/static/uploads..."
save_dir = os.path.join(os.getcwd(), BASE_UPLOAD_FOLDER, today)
os.makedirs(save_dir, exist_ok=True)
files = request.files.getlist("images") files = request.files.getlist("images")
for f in files: for f in files:
if f and allowed_file(f.filename): if f and allowed_file(f.filename):
orig_name = secure_filename(f.filename) media = _process_upload_file(
rand_name = generate_random_filename(orig_name) file = f,
uploader_id = current_user.id,
# Temporarily save under "/app/temp_<uuid>.ext" plugin = "submission",
temp_path = os.path.join(os.getcwd(), "temp_" + rand_name) related_id = submission.id
f.save(temp_path) )
final_path = os.path.join(save_dir, rand_name)
strip_metadata_and_save(temp_path, final_path)
os.remove(temp_path)
# Store only "YYYY/MM/DD/<uuid>.ext"
rel_url = f"{today}/{rand_name}"
img = SubmissionImage( img = SubmissionImage(
submission_id=submission.id, submission_id = submission.id,
file_url=rel_url, file_url = media.filename,
uploaded_at=datetime.utcnow() uploaded_at = media.uploaded_at
) )
db.session.add(img) db.session.add(img)
@ -106,6 +99,7 @@ def new_submission():
return render_template("submission/new.html", form=form) return render_template("submission/new.html", form=form)
@bp.route("/list", methods=["GET"]) @bp.route("/list", methods=["GET"])
@bp.route("/list/", methods=["GET"]) @bp.route("/list/", methods=["GET"])
@login_required @login_required
@ -132,6 +126,7 @@ def list_submissions():
all_types=all_types all_types=all_types
) )
@bp.route("/view/<int:submission_id>", methods=["GET"]) @bp.route("/view/<int:submission_id>", methods=["GET"])
@bp.route("/view/<int:submission_id>/", methods=["GET"]) @bp.route("/view/<int:submission_id>/", methods=["GET"])
@login_required @login_required
@ -140,5 +135,6 @@ def view_submission(submission_id):
if sub.user_id != current_user.id and current_user.role != "admin": if sub.user_id != current_user.id and current_user.role != "admin":
flash("Not authorized to view this submission.", "danger") flash("Not authorized to view this submission.", "danger")
return redirect(url_for("submission.list_submissions")) return redirect(url_for("submission.list_submissions"))
images = SubmissionImage.query.filter_by(submission_id=sub.id).all() images = SubmissionImage.query.filter_by(submission_id=sub.id).all()
return render_template("submission/view.html", submission=sub, images=images) return render_template("submission/view.html", submission=sub, images=images)

View File

@ -1,4 +1,6 @@
{# plugins/submission/templates/submission/view.html #}
{% extends 'core_ui/base.html' %} {% extends 'core_ui/base.html' %}
{% block content %} {% block content %}
<div class="container mt-4"> <div class="container mt-4">
<h2>Submission Details</h2> <h2>Submission Details</h2>
@ -73,13 +75,14 @@
<div class="row"> <div class="row">
{% if images %} {% if images %}
{% for img in images %} {% for img in images %}
{# img.file_url == "YYYY/MM/DD/<uuid>.ext" #}
<div class="col-md-3 mb-3"> <div class="col-md-3 mb-3">
<div class="card shadow-sm"> <div class="card shadow-sm">
<a href="{{ url_for('media.media_file', filename=img.file_url) }}" target="_blank"> <a href="{{ generate_image_url(img) }}" target="_blank">
<img src="{{ url_for('media.media_file', filename=img.file_url) }}" <img
class="card-img-top" src="{{ generate_image_url(img) }}"
alt="Submission Image"> class="card-img-top"
alt="Submission Image"
>
</a> </a>
<div class="card-body p-2"> <div class="card-body p-2">
<p class="card-text text-center"> <p class="card-text text-center">

View File

@ -1,13 +1,13 @@
from datetime import datetime from datetime import datetime
from plugins.plant.models import db from app import db # ← changed from plugins.plant.models
class ImportBatch(db.Model): class ImportBatch(db.Model):
__tablename__ = 'import_batches' __tablename__ = 'import_batches'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
export_id = db.Column(db.String(64), nullable=False) export_id = db.Column(db.String(64), nullable=False)
user_id = db.Column(db.Integer, nullable=False, index=True) user_id = db.Column(db.Integer, nullable=False, index=True)
imported_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) imported_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = ( __table_args__ = (
# ensure a given user cant import the same export twice # ensure a given user cant import the same export twice

View File

@ -1,33 +1,45 @@
# plugins/utility/routes.py # plugins/utility/routes.py
# Standard library
import csv import csv
import io import io
import uuid
import difflib
import os import os
import re import re
import uuid
import zipfile import zipfile
import tempfile import tempfile
import difflib
from datetime import datetime from datetime import datetime
# Thirdparty
from flask import ( from flask import (
Blueprint, request, render_template, redirect, flash, Blueprint, request, render_template, redirect, flash,
session, url_for, send_file, current_app session, url_for, send_file, current_app
) )
from werkzeug.utils import secure_filename
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf.csrf import generate_csrf from flask_wtf.csrf import generate_csrf
from werkzeug.utils import secure_filename
from werkzeug.datastructures import FileStorage
import qrcode
from PIL import Image, ImageDraw, ImageFont
from qrcode.image.pil import PilImage
from qrcode.constants import ERROR_CORRECT_H
# Application
from app import db
from app.neo4j_utils import get_neo4j_handler from app.neo4j_utils import get_neo4j_handler
# Plugins
from plugins.plant.models import ( from plugins.plant.models import (
db,
Plant, Plant,
PlantCommonName, PlantCommonName,
PlantScientificName, PlantScientificName,
PlantOwnershipLog, PlantOwnershipLog,
) )
from plugins.media.models import Media from plugins.media.models import Media
from plugins.utility.models import ImportBatch # tracks which exports have been imported from plugins.media.routes import _process_upload_file
from plugins.utility.models import ImportBatch
bp = Blueprint( bp = Blueprint(
'utility', 'utility',
@ -36,12 +48,13 @@ bp = Blueprint(
url_prefix='/utility' url_prefix='/utility'
) )
@bp.route("/", methods=["GET"]) @bp.route("/", methods=["GET"])
@login_required @login_required
def index(): def index():
# When someone hits /utility/, redirect to /utility/upload
return redirect(url_for("utility.upload")) return redirect(url_for("utility.upload"))
# ──────────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────────
# Required headers for your sub-app export ZIP # Required headers for your sub-app export ZIP
PLANT_HEADERS = [ PLANT_HEADERS = [
@ -55,6 +68,7 @@ MEDIA_HEADERS = [
# Headers for standalone CSV review flow # Headers for standalone CSV review flow
REQUIRED_HEADERS = {"uuid", "plant_type", "name", "scientific_name", "mother_uuid"} REQUIRED_HEADERS = {"uuid", "plant_type", "name", "scientific_name", "mother_uuid"}
@bp.route("/upload", methods=["GET", "POST"]) @bp.route("/upload", methods=["GET", "POST"])
@login_required @login_required
def upload(): def upload():
@ -68,12 +82,10 @@ def upload():
# ── ZIP Import Flow ─────────────────────────────────────────────────── # ── ZIP Import Flow ───────────────────────────────────────────────────
if filename.endswith(".zip"): if filename.endswith(".zip"):
# 1) Save upload to disk
tmp_zip = tempfile.NamedTemporaryFile(delete=False, suffix=".zip") tmp_zip = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
file.save(tmp_zip.name) file.save(tmp_zip.name)
tmp_zip.close() tmp_zip.close()
# 2) Open as ZIP
try: try:
z = zipfile.ZipFile(tmp_zip.name) z = zipfile.ZipFile(tmp_zip.name)
except zipfile.BadZipFile: except zipfile.BadZipFile:
@ -81,14 +93,12 @@ def upload():
flash("Uploaded file is not a valid ZIP.", "danger") flash("Uploaded file is not a valid ZIP.", "danger")
return redirect(request.url) return redirect(request.url)
# 3) Ensure both CSVs
names = z.namelist() names = z.namelist()
if "plants.csv" not in names or "media.csv" not in names: if "plants.csv" not in names or "media.csv" not in names:
os.remove(tmp_zip.name) os.remove(tmp_zip.name)
flash("ZIP must contain both plants.csv and media.csv", "danger") flash("ZIP must contain both plants.csv and media.csv", "danger")
return redirect(request.url) return redirect(request.url)
# 4) Read export_id from metadata.txt
export_id = None export_id = None
if "metadata.txt" in names: if "metadata.txt" in names:
meta = z.read("metadata.txt").decode("utf-8", "ignore") meta = z.read("metadata.txt").decode("utf-8", "ignore")
@ -101,13 +111,11 @@ def upload():
flash("metadata.txt missing or missing export_id", "danger") flash("metadata.txt missing or missing export_id", "danger")
return redirect(request.url) return redirect(request.url)
# 5) Skip if already imported
if ImportBatch.query.filter_by(export_id=export_id, user_id=current_user.id).first(): if ImportBatch.query.filter_by(export_id=export_id, user_id=current_user.id).first():
os.remove(tmp_zip.name) os.remove(tmp_zip.name)
flash("This export has already been imported.", "info") flash("This export has already been imported.", "info")
return redirect(request.url) return redirect(request.url)
# 6) Record import batch
batch = ImportBatch( batch = ImportBatch(
export_id=export_id, export_id=export_id,
user_id=current_user.id, user_id=current_user.id,
@ -116,37 +124,35 @@ def upload():
db.session.add(batch) db.session.add(batch)
db.session.commit() db.session.commit()
# 7) Extract into temp dir
tmpdir = tempfile.mkdtemp() tmpdir = tempfile.mkdtemp()
z.extractall(tmpdir) z.extractall(tmpdir)
# 8) Validate plants.csv
plant_path = os.path.join(tmpdir, "plants.csv") plant_path = os.path.join(tmpdir, "plants.csv")
with open(plant_path, newline="", encoding="utf-8-sig") as pf: with open(plant_path, newline="", encoding="utf-8-sig") as pf:
reader = csv.DictReader(pf) reader = csv.DictReader(pf)
if reader.fieldnames != PLANT_HEADERS: if reader.fieldnames != PLANT_HEADERS:
missing = set(PLANT_HEADERS) - set(reader.fieldnames or []) missing = set(PLANT_HEADERS) - set(reader.fieldnames or [])
extra = set(reader.fieldnames or []) - set(PLANT_HEADERS) extra = set(reader.fieldnames or []) - set(PLANT_HEADERS)
os.remove(tmp_zip.name) os.remove(tmp_zip.name)
flash(f"plants.csv header mismatch. Missing: {missing}, Extra: {extra}", "danger") flash(f"plants.csv header mismatch. Missing: {missing}, Extra: {extra}", "danger")
return redirect(request.url) return redirect(request.url)
plant_rows = list(reader) plant_rows = list(reader)
# 9) Validate media.csv
media_path = os.path.join(tmpdir, "media.csv") media_path = os.path.join(tmpdir, "media.csv")
with open(media_path, newline="", encoding="utf-8-sig") as mf: with open(media_path, newline="", encoding="utf-8-sig") as mf:
mreader = csv.DictReader(mf) mreader = csv.DictReader(mf)
if mreader.fieldnames != MEDIA_HEADERS: if mreader.fieldnames != MEDIA_HEADERS:
missing = set(MEDIA_HEADERS) - set(mreader.fieldnames or []) missing = set(MEDIA_HEADERS) - set(mreader.fieldnames or [])
extra = set(mreader.fieldnames or []) - set(MEDIA_HEADERS) extra = set(mreader.fieldnames or []) - set(MEDIA_HEADERS)
os.remove(tmp_zip.name) os.remove(tmp_zip.name)
flash(f"media.csv header mismatch. Missing: {missing}, Extra: {extra}", "danger") flash(f"media.csv header mismatch. Missing: {missing}, Extra: {extra}", "danger")
return redirect(request.url) return redirect(request.url)
media_rows = list(mreader) media_rows = list(mreader)
# 10) Import plants + Neo4j
neo = get_neo4j_handler() neo = get_neo4j_handler()
added_plants = 0 added_plants = 0
plant_map = {}
for row in plant_rows: for row in plant_rows:
common = PlantCommonName.query.filter_by(name=row["Name"]).first() common = PlantCommonName.query.filter_by(name=row["Name"]).first()
if not common: if not common:
@ -173,6 +179,7 @@ def upload():
) )
db.session.add(p) db.session.add(p)
db.session.flush() db.session.flush()
plant_map[p.uuid] = p.id
log = PlantOwnershipLog( log = PlantOwnershipLog(
plant_id=p.id, plant_id=p.id,
@ -189,42 +196,39 @@ def upload():
added_plants += 1 added_plants += 1
# 11) Import media files (by Plant UUID) # Import media once for the full batch
added_media = 0 added_media = 0
for mrow in media_rows: for mrow in media_rows:
plant_uuid = mrow["Plant UUID"] plant_uuid = mrow["Plant UUID"]
plant_obj = Plant.query.filter_by(uuid=plant_uuid).first() plant_id = plant_map.get(plant_uuid)
if not plant_obj: if not plant_id:
continue continue
subpath = mrow["Image Path"].split('uploads/', 1)[1] subpath = mrow["Image Path"].split('uploads/', 1)[-1]
src = os.path.join(tmpdir, "images", subpath) src = os.path.join(tmpdir, "images", subpath)
if not os.path.isfile(src): if not os.path.isfile(src):
continue continue
dest_dir = os.path.join( try:
current_app.static_folder, "uploads", with open(src, "rb") as f:
str(current_user.id), str(plant_obj.id) file_storage = FileStorage(
) stream=io.BytesIO(f.read()),
os.makedirs(dest_dir, exist_ok=True) filename=os.path.basename(subpath),
content_type='image/jpeg'
)
media = _process_upload_file(
file=file_storage,
uploader_id=current_user.id,
plugin="plant",
related_id=plant_id
)
media.uploaded_at = datetime.fromisoformat(mrow["Uploaded At"])
media.caption = mrow["Source Type"]
db.session.add(media)
added_media += 1
except Exception as e:
current_app.logger.warning(f"Failed to import media file: {subpath}{e}")
ext = os.path.splitext(src)[1]
fname = f"{uuid.uuid4().hex}{ext}"
dst = os.path.join(dest_dir, fname)
with open(src, "rb") as sf, open(dst, "wb") as df:
df.write(sf.read())
media = Media(
file_url=f"uploads/{current_user.id}/{plant_obj.id}/{fname}",
uploaded_at=datetime.fromisoformat(mrow["Uploaded At"]),
uploader_id=current_user.id,
caption=mrow["Source Type"],
plant_id=plant_obj.id
)
db.session.add(media)
added_media += 1
# 12) Finalize & cleanup
db.session.commit() db.session.commit()
neo.close() neo.close()
os.remove(tmp_zip.name) os.remove(tmp_zip.name)
@ -251,17 +255,17 @@ def upload():
review_list = [] review_list = []
all_common = {c.name.lower(): c for c in PlantCommonName.query.all()} all_common = {c.name.lower(): c for c in PlantCommonName.query.all()}
all_sci = {s.name.lower(): s for s in PlantScientificName.query.all()} all_sci = {s.name.lower(): s for s in PlantScientificName.query.all()}
for row in reader: for row in reader:
uuid_raw = row.get("uuid", "") uuid_raw = row.get("uuid", "")
uuid_val = uuid_raw.strip().strip('"') uuid_val = uuid_raw.strip().strip('"')
name_raw = row.get("name", "") name_raw = row.get("name", "")
name = name_raw.strip() name = name_raw.strip()
sci_raw = row.get("scientific_name", "") sci_raw = row.get("scientific_name", "")
sci_name = sci_raw.strip() sci_name = sci_raw.strip()
plant_type = row.get("plant_type", "").strip() or "plant" plant_type = row.get("plant_type", "").strip() or "plant"
mother_raw = row.get("mother_uuid", "") mother_raw = row.get("mother_uuid", "")
mother_uuid = mother_raw.strip().strip('"') mother_uuid = mother_raw.strip().strip('"')
if not (uuid_val and name and plant_type): if not (uuid_val and name and plant_type):
@ -277,11 +281,11 @@ def upload():
else None) else None)
item = { item = {
"uuid": uuid_val, "uuid": uuid_val,
"name": name, "name": name,
"sci_name": sci_name, "sci_name": sci_name,
"suggested": suggested, "suggested": suggested,
"plant_type": plant_type, "plant_type": plant_type,
"mother_uuid": mother_uuid "mother_uuid": mother_uuid
} }
review_list.append(item) review_list.append(item)
@ -293,7 +297,6 @@ def upload():
flash("Unsupported file type. Please upload a ZIP or CSV.", "danger") flash("Unsupported file type. Please upload a ZIP or CSV.", "danger")
return redirect(request.url) return redirect(request.url)
# GET → render the upload form
return render_template("utility/upload.html", csrf_token=generate_csrf()) return render_template("utility/upload.html", csrf_token=generate_csrf())
@ -304,8 +307,8 @@ def review():
review_list = session.get("review_list", []) review_list = session.get("review_list", [])
if request.method == "POST": if request.method == "POST":
neo = get_neo4j_handler() neo = get_neo4j_handler()
added = 0 added = 0
all_common = {c.name.lower(): c for c in PlantCommonName.query.all()} all_common = {c.name.lower(): c for c in PlantCommonName.query.all()}
all_scientific = {s.name.lower(): s for s in PlantScientificName.query.all()} all_scientific = {s.name.lower(): s for s in PlantScientificName.query.all()}
@ -416,12 +419,10 @@ def export_data():
plants_csv = plant_io.getvalue() plants_csv = plant_io.getvalue()
# 2) Gather media # 2) Gather media
media_records = ( media_records = (Media.query
Media.query .filter_by(uploader_id=current_user.id)
.filter_by(uploader_id=current_user.id) .order_by(Media.id)
.order_by(Media.id) .all())
.all()
)
# Build media.csv # Build media.csv
media_io = io.StringIO() media_io = io.StringIO()
mw = csv.writer(media_io) mw = csv.writer(media_io)
@ -440,22 +441,17 @@ def export_data():
# 3) Assemble ZIP with images from UPLOAD_FOLDER # 3) Assemble ZIP with images from UPLOAD_FOLDER
zip_buf = io.BytesIO() zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, 'w', zipfile.ZIP_DEFLATED) as zf: with zipfile.ZipFile(zip_buf, 'w', zipfile.ZIP_DEFLATED) as zf:
# metadata.txt
meta = ( meta = (
f"export_id,{export_id}\n" f"export_id,{export_id}\n"
f"user_id,{current_user.id}\n" f"user_id,{current_user.id}\n"
f"exported_at,{datetime.utcnow().isoformat()}\n" f"exported_at,{datetime.utcnow().isoformat()}\n"
) )
zf.writestr('metadata.txt', meta) zf.writestr('metadata.txt', meta)
# CSV files
zf.writestr('plants.csv', plants_csv) zf.writestr('plants.csv', plants_csv)
zf.writestr('media.csv', media_csv) zf.writestr('media.csv', media_csv)
# real image files under images/
media_root = current_app.config['UPLOAD_FOLDER'] media_root = current_app.config['UPLOAD_FOLDER']
for m in media_records: for m in media_records:
# file_url is “uploads/...”
rel = m.file_url.split('uploads/', 1)[-1] rel = m.file_url.split('uploads/', 1)[-1]
abs_path = os.path.join(media_root, rel) abs_path = os.path.join(media_root, rel)
if os.path.isfile(abs_path): if os.path.isfile(abs_path):
@ -463,8 +459,6 @@ def export_data():
zf.write(abs_path, arcname) zf.write(abs_path, arcname)
zip_buf.seek(0) zip_buf.seek(0)
# Safe filename
safe_email = re.sub(r'\W+', '_', current_user.email) safe_email = re.sub(r'\W+', '_', current_user.email)
filename = f"{safe_email}_export_{export_id}.zip" filename = f"{safe_email}_export_{export_id}.zip"
@ -474,3 +468,79 @@ def export_data():
as_attachment=True, as_attachment=True,
download_name=filename download_name=filename
) )
# ────────────────────────────────────────────────────────────────────────────────
# QR-Code Generation Helpers & Routes
# ────────────────────────────────────────────────────────────────────────────────
def generate_label_with_name(qr_url, name, download_filename):
"""
Build a 1.5"x1.5" PNG (300dpi) with a QR code
and the plant name underneath.
"""
qr = qrcode.QRCode(version=2, error_correction=ERROR_CORRECT_H, box_size=10, border=1)
qr.add_data(qr_url)
qr.make(fit=True)
qr_img = qr.make_image(image_factory=PilImage, fill_color="black", back_color="white").convert("RGB")
dpi = 300
label_px = int(1.5 * dpi)
canvas_h = label_px + 400
label_img = Image.new("RGB", (label_px, canvas_h), "white")
label_img.paste(qr_img.resize((label_px, label_px)), (0, 0))
font_path = os.path.join(current_app.root_path, '..', 'font', 'ARIALLGT.TTF')
draw = ImageDraw.Draw(label_img)
text = (name or '').strip()
font_size = 28
while font_size > 10:
try:
font = ImageFont.truetype(font_path, font_size)
except OSError:
font = ImageFont.load_default()
if draw.textlength(text, font=font) <= label_px - 20:
break
font_size -= 1
while draw.textlength(text, font=font) > label_px - 20 and len(text) > 1:
text = text[:-1]
if len(text) < len((name or '').strip()):
text += ""
x = (label_px - draw.textlength(text, font=font)) // 2
y = label_px + 20
draw.text((x, y), text, font=font, fill="black")
buf = io.BytesIO()
label_img.save(buf, format='PNG', dpi=(dpi, dpi))
buf.seek(0)
return send_file(buf, mimetype='image/png', download_name=download_filename, as_attachment=True)
@bp.route('/<uuid:uuid_val>/download_qr', methods=['GET'])
def download_qr(uuid_val):
p = Plant.query.filter_by(uuid=uuid_val).first_or_404()
if not p.short_id:
p.short_id = Plant.generate_short_id()
db.session.commit()
qr_url = f'https://plant.cards/{p.short_id}'
filename = f"{p.short_id}.png"
return generate_label_with_name(
qr_url,
p.common_name.name or p.scientific_name,
filename
)
@bp.route('/<uuid:uuid_val>/download_qr_card', methods=['GET'])
def download_qr_card(uuid_val):
p = Plant.query.filter_by(uuid=uuid_val).first_or_404()
if not p.short_id:
p.short_id = Plant.generate_short_id()
db.session.commit()
qr_url = f'https://plant.cards/{p.short_id}'
filename = f"{p.short_id}_card.png"
return generate_label_with_name(
qr_url,
p.common_name.name or p.scientific_name,
filename
)

View File

@ -10,3 +10,4 @@ python-dotenv
cryptography cryptography
Pillow Pillow
neo4j>=5.18.0 neo4j>=5.18.0
qrcode