diff --git a/docker-compose.yml b/docker-compose.yml index b971469..79081c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,7 @@ services: - "5000:5000" volumes: - .:/app + - ./static/uploads:/app/static/uploads environment: - FLASK_APP=app - FLASK_ENV=development @@ -24,7 +25,6 @@ services: retries: 3 start_period: 30s - db: image: mysql:8 restart: unless-stopped @@ -43,7 +43,6 @@ services: timeout: 5s retries: 5 - adminer: image: adminer restart: always diff --git a/migrations/versions/39f714eda2bf_auto.py b/migrations/versions/39f714eda2bf_auto.py new file mode 100644 index 0000000..50a28b1 --- /dev/null +++ b/migrations/versions/39f714eda2bf_auto.py @@ -0,0 +1,28 @@ +"""auto + +Revision ID: 39f714eda2bf +Revises: 6420e024f896 +Create Date: 2025-06-06 09:50:29.954004 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '39f714eda2bf' +down_revision = '6420e024f896' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/migrations/versions/408c432b5835_auto.py b/migrations/versions/408c432b5835_auto.py new file mode 100644 index 0000000..03d9250 --- /dev/null +++ b/migrations/versions/408c432b5835_auto.py @@ -0,0 +1,28 @@ +"""auto + +Revision ID: 408c432b5835 +Revises: 77087ff2442e +Create Date: 2025-06-06 08:34:26.804782 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '408c432b5835' +down_revision = '77087ff2442e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/migrations/versions/447ff559592b_auto.py b/migrations/versions/447ff559592b_auto.py new file mode 100644 index 0000000..f8416e2 --- /dev/null +++ b/migrations/versions/447ff559592b_auto.py @@ -0,0 +1,36 @@ +"""auto + +Revision ID: 447ff559592b +Revises: 408c432b5835 +Create Date: 2025-06-06 08:47:25.908940 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '447ff559592b' +down_revision = '408c432b5835' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('submissions', sa.Column('vendor_name', sa.String(length=255), nullable=True)) + op.add_column('submissions', sa.Column('rating', sa.Integer(), nullable=True)) + op.add_column('submissions', sa.Column('old_vendor', sa.String(length=255), nullable=True)) + op.add_column('submissions', sa.Column('new_vendor', sa.String(length=255), nullable=True)) + op.add_column('submissions', sa.Column('alias_reason', sa.Text(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('submissions', 'alias_reason') + op.drop_column('submissions', 'new_vendor') + op.drop_column('submissions', 'old_vendor') + op.drop_column('submissions', 'rating') + op.drop_column('submissions', 'vendor_name') + # ### end Alembic commands ### diff --git a/migrations/versions/6420e024f896_auto.py b/migrations/versions/6420e024f896_auto.py new file mode 100644 index 0000000..707bf9d --- /dev/null +++ b/migrations/versions/6420e024f896_auto.py @@ -0,0 +1,28 @@ +"""auto + +Revision ID: 6420e024f896 +Revises: 7d232205181b +Create Date: 2025-06-06 09:40:37.498453 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6420e024f896' +down_revision = '7d232205181b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/migrations/versions/64c1927562cc_auto.py b/migrations/versions/64c1927562cc_auto.py new file mode 100644 index 0000000..398f009 --- /dev/null +++ b/migrations/versions/64c1927562cc_auto.py @@ -0,0 +1,44 @@ +"""auto + +Revision ID: 64c1927562cc +Revises: fb0243eaa7c3 +Create Date: 2025-06-06 07:54:00.147383 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '64c1927562cc' +down_revision = 'fb0243eaa7c3' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('media', sa.Column('plant_id', sa.Integer(), nullable=True)) + op.add_column('media', sa.Column('update_id', sa.Integer(), nullable=True)) + op.drop_constraint(op.f('media_ibfk_2'), 'media', type_='foreignkey') + op.drop_constraint(op.f('media_ibfk_3'), 'media', type_='foreignkey') + op.create_foreign_key(None, 'media', 'plant', ['plant_id'], ['id']) + op.create_foreign_key(None, 'media', 'plant_updates', ['update_id'], ['id']) + op.drop_column('media', 'uploader_id') + op.drop_column('media', 'submission_id') + op.drop_column('media', 'caption') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('media', sa.Column('caption', mysql.VARCHAR(length=255), nullable=True)) + op.add_column('media', sa.Column('submission_id', mysql.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('media', sa.Column('uploader_id', mysql.INTEGER(), autoincrement=False, nullable=False)) + op.drop_constraint(None, 'media', type_='foreignkey') + op.drop_constraint(None, 'media', type_='foreignkey') + op.create_foreign_key(op.f('media_ibfk_3'), 'media', 'submissions', ['submission_id'], ['id']) + op.create_foreign_key(op.f('media_ibfk_2'), 'media', 'users', ['uploader_id'], ['id']) + op.drop_column('media', 'update_id') + op.drop_column('media', 'plant_id') + # ### end Alembic commands ### diff --git a/migrations/versions/6539ef5f5419_auto.py b/migrations/versions/6539ef5f5419_auto.py new file mode 100644 index 0000000..c57539f --- /dev/null +++ b/migrations/versions/6539ef5f5419_auto.py @@ -0,0 +1,28 @@ +"""auto + +Revision ID: 6539ef5f5419 +Revises: 39f714eda2bf +Create Date: 2025-06-06 10:03:52.256341 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6539ef5f5419' +down_revision = '39f714eda2bf' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/migrations/versions/6cb1c3054071_auto.py b/migrations/versions/6cb1c3054071_auto.py new file mode 100644 index 0000000..b3fc892 --- /dev/null +++ b/migrations/versions/6cb1c3054071_auto.py @@ -0,0 +1,28 @@ +"""auto + +Revision ID: 6cb1c3054071 +Revises: f4987441cc85 +Create Date: 2025-06-06 09:09:55.403015 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6cb1c3054071' +down_revision = 'f4987441cc85' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/migrations/versions/77087ff2442e_auto.py b/migrations/versions/77087ff2442e_auto.py new file mode 100644 index 0000000..15b5028 --- /dev/null +++ b/migrations/versions/77087ff2442e_auto.py @@ -0,0 +1,38 @@ +"""auto + +Revision ID: 77087ff2442e +Revises: 64c1927562cc +Create Date: 2025-06-06 08:10:58.028201 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '77087ff2442e' +down_revision = '64c1927562cc' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('media', sa.Column('uploader_id', sa.Integer(), nullable=False)) + op.add_column('media', sa.Column('caption', sa.String(length=255), nullable=True)) + op.create_foreign_key(None, 'media', 'users', ['uploader_id'], ['id']) + op.add_column('submissions', sa.Column('submission_type', sa.String(length=50), nullable=False)) + op.add_column('submissions', sa.Column('price', sa.Float(), nullable=True)) + op.add_column('submissions', sa.Column('source', sa.String(length=255), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('submissions', 'source') + op.drop_column('submissions', 'price') + op.drop_column('submissions', 'submission_type') + op.drop_constraint(None, 'media', type_='foreignkey') + op.drop_column('media', 'caption') + op.drop_column('media', 'uploader_id') + # ### end Alembic commands ### diff --git a/migrations/versions/7d232205181b_auto.py b/migrations/versions/7d232205181b_auto.py new file mode 100644 index 0000000..28a3a0a --- /dev/null +++ b/migrations/versions/7d232205181b_auto.py @@ -0,0 +1,28 @@ +"""auto + +Revision ID: 7d232205181b +Revises: fad6fe2b5e43 +Create Date: 2025-06-06 09:38:39.786953 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7d232205181b' +down_revision = 'fad6fe2b5e43' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/migrations/versions/e34cff15a95e_auto.py b/migrations/versions/e34cff15a95e_auto.py new file mode 100644 index 0000000..b329fdf --- /dev/null +++ b/migrations/versions/e34cff15a95e_auto.py @@ -0,0 +1,70 @@ +"""auto + +Revision ID: e34cff15a95e +Revises: 5c85ebc9451b +Create Date: 2025-06-06 07:34:33.699976 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'e34cff15a95e' +down_revision = '5c85ebc9451b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('featured_images', sa.Column('media_id', sa.Integer(), nullable=False)) + op.drop_constraint(op.f('featured_images_ibfk_1'), 'featured_images', type_='foreignkey') + op.create_foreign_key(None, 'featured_images', 'media', ['media_id'], ['id']) + op.drop_column('featured_images', 'submission_image_id') + op.add_column('image_hearts', sa.Column('media_id', sa.Integer(), nullable=False)) + op.drop_constraint(op.f('image_hearts_ibfk_1'), 'image_hearts', type_='foreignkey') + op.create_foreign_key(None, 'image_hearts', 'media', ['media_id'], ['id']) + op.drop_column('image_hearts', 'submission_image_id') + op.add_column('media', sa.Column('uploader_id', sa.Integer(), nullable=False)) + op.add_column('media', sa.Column('submission_id', sa.Integer(), nullable=True)) + op.drop_constraint(op.f('media_ibfk_2'), 'media', type_='foreignkey') + op.drop_constraint(op.f('media_ibfk_3'), 'media', type_='foreignkey') + op.create_foreign_key(None, 'media', 'users', ['uploader_id'], ['id']) + op.create_foreign_key(None, 'media', 'submissions', ['submission_id'], ['id']) + op.drop_column('media', 'update_id') + op.drop_column('media', 'plant_id') + op.add_column('submissions', sa.Column('submission_type', sa.String(length=50), nullable=False)) + op.add_column('submissions', sa.Column('price', sa.Float(), nullable=True)) + op.add_column('submissions', sa.Column('source', sa.String(length=255), nullable=True)) + op.alter_column('submissions', 'plant_name', + existing_type=mysql.VARCHAR(length=100), + nullable=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('submissions', 'plant_name', + existing_type=mysql.VARCHAR(length=100), + nullable=False) + op.drop_column('submissions', 'source') + op.drop_column('submissions', 'price') + op.drop_column('submissions', 'submission_type') + op.add_column('media', sa.Column('plant_id', mysql.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('media', sa.Column('update_id', mysql.INTEGER(), autoincrement=False, nullable=True)) + op.drop_constraint(None, 'media', type_='foreignkey') + op.drop_constraint(None, 'media', type_='foreignkey') + op.create_foreign_key(op.f('media_ibfk_3'), 'media', 'plant_updates', ['update_id'], ['id']) + op.create_foreign_key(op.f('media_ibfk_2'), 'media', 'plant', ['plant_id'], ['id']) + op.drop_column('media', 'submission_id') + op.drop_column('media', 'uploader_id') + op.add_column('image_hearts', sa.Column('submission_image_id', mysql.INTEGER(), autoincrement=False, nullable=False)) + op.drop_constraint(None, 'image_hearts', type_='foreignkey') + op.create_foreign_key(op.f('image_hearts_ibfk_1'), 'image_hearts', 'submission_images', ['submission_image_id'], ['id']) + op.drop_column('image_hearts', 'media_id') + op.add_column('featured_images', sa.Column('submission_image_id', mysql.INTEGER(), autoincrement=False, nullable=False)) + op.drop_constraint(None, 'featured_images', type_='foreignkey') + op.create_foreign_key(op.f('featured_images_ibfk_1'), 'featured_images', 'submission_images', ['submission_image_id'], ['id']) + op.drop_column('featured_images', 'media_id') + # ### end Alembic commands ### diff --git a/migrations/versions/f4987441cc85_auto.py b/migrations/versions/f4987441cc85_auto.py new file mode 100644 index 0000000..b82159c --- /dev/null +++ b/migrations/versions/f4987441cc85_auto.py @@ -0,0 +1,28 @@ +"""auto + +Revision ID: f4987441cc85 +Revises: 447ff559592b +Create Date: 2025-06-06 08:59:55.024371 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f4987441cc85' +down_revision = '447ff559592b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/migrations/versions/fad6fe2b5e43_auto.py b/migrations/versions/fad6fe2b5e43_auto.py new file mode 100644 index 0000000..01bac4d --- /dev/null +++ b/migrations/versions/fad6fe2b5e43_auto.py @@ -0,0 +1,32 @@ +"""auto + +Revision ID: fad6fe2b5e43 +Revises: 6cb1c3054071 +Create Date: 2025-06-06 09:24:38.663461 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'fad6fe2b5e43' +down_revision = '6cb1c3054071' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('submissions', 'plant_name', + existing_type=mysql.VARCHAR(length=100), + nullable=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('submissions', 'plant_name', + existing_type=mysql.VARCHAR(length=100), + nullable=False) + # ### end Alembic commands ### diff --git a/migrations/versions/fb0243eaa7c3_auto.py b/migrations/versions/fb0243eaa7c3_auto.py new file mode 100644 index 0000000..5afee5a --- /dev/null +++ b/migrations/versions/fb0243eaa7c3_auto.py @@ -0,0 +1,38 @@ +"""auto + +Revision ID: fb0243eaa7c3 +Revises: e34cff15a95e +Create Date: 2025-06-06 07:43:42.387700 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'fb0243eaa7c3' +down_revision = 'e34cff15a95e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('submissions', 'plant_name', + existing_type=mysql.VARCHAR(length=100), + nullable=False) + op.drop_column('submissions', 'price') + op.drop_column('submissions', 'source') + op.drop_column('submissions', 'submission_type') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('submissions', sa.Column('submission_type', mysql.VARCHAR(length=50), nullable=False)) + op.add_column('submissions', sa.Column('source', mysql.VARCHAR(length=255), nullable=True)) + op.add_column('submissions', sa.Column('price', mysql.FLOAT(), nullable=True)) + op.alter_column('submissions', 'plant_name', + existing_type=mysql.VARCHAR(length=100), + nullable=True) + # ### end Alembic commands ### diff --git a/plugins/core_ui/templates/core_ui/_media_macros.html b/plugins/core_ui/templates/core_ui/_media_macros.html index 774d214..051e6eb 100644 --- a/plugins/core_ui/templates/core_ui/_media_macros.html +++ b/plugins/core_ui/templates/core_ui/_media_macros.html @@ -1,14 +1,34 @@ -{% macro render_media_list(media_list, thumb_width=150) -%} +{% macro render_media_list(media_list, thumb_width=150, current_user=None) -%} {% if media_list %} - + {% endif %} -{%- endmacro %} \ No newline at end of file +{%- endmacro %} diff --git a/plugins/growlog/models.py b/plugins/growlog/models.py index d5ee187..6faebd0 100644 --- a/plugins/growlog/models.py +++ b/plugins/growlog/models.py @@ -25,5 +25,6 @@ class PlantUpdate(db.Model): update_type = db.Column(db.String(50), nullable=False) description = db.Column(db.Text, nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow) - media_items = db.relationship('Media', back_populates='update', lazy=True) + # Link to Media.update_id (must match the FK defined there) + media_items = db.relationship('Media', back_populates='update', lazy=True) diff --git a/plugins/media/forms.py b/plugins/media/forms.py deleted file mode 100644 index 08c5d5f..0000000 --- a/plugins/media/forms.py +++ /dev/null @@ -1,14 +0,0 @@ -from flask_wtf import FlaskForm -from wtforms import StringField, SelectField, SubmitField, IntegerField -from flask_wtf.file import FileField, FileAllowed, FileRequired -from wtforms.validators import DataRequired - -class MediaUploadForm(FlaskForm): - image = FileField('Image', validators=[ - FileRequired(), - FileAllowed(['jpg', 'jpeg', 'png', 'gif'], 'Images only!') - ]) - caption = StringField('Caption') - plant_id = IntegerField('Plant ID') - growlog_id = IntegerField('GrowLog ID') - submit = SubmitField('Upload') \ No newline at end of file diff --git a/plugins/media/models.py b/plugins/media/models.py index 8662ba4..bbe5b93 100644 --- a/plugins/media/models.py +++ b/plugins/media/models.py @@ -1,40 +1,42 @@ +# plugins/media/models.py + from app import db from datetime import datetime -from plugins.plant.models import Plant class Media(db.Model): - __tablename__ = 'media' - __table_args__ = {'extend_existing': True} + __tablename__ = "media" + __table_args__ = {"extend_existing": True} id = db.Column(db.Integer, primary_key=True) file_url = db.Column(db.String(256), nullable=False) uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) - - 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) - update_id = db.Column(db.Integer, db.ForeignKey('plant_updates.id'), nullable=True) - + uploader_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) caption = db.Column(db.String(255), nullable=True) - # Relationship to PlantUpdate - update = db.relationship('PlantUpdate', back_populates='media_items', lazy=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) + update_id = db.Column(db.Integer, db.ForeignKey("plant_updates.id"), nullable=True) + update = db.relationship("PlantUpdate", back_populates="media_items") class ImageHeart(db.Model): - __tablename__ = 'image_hearts' - __table_args__ = {'extend_existing': True} + __tablename__ = "image_hearts" + __table_args__ = {"extend_existing": True} id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - submission_image_id = db.Column(db.Integer, db.ForeignKey('submission_images.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) created_at = db.Column(db.DateTime, default=datetime.utcnow) + media = db.relationship("Media", backref="hearts") class FeaturedImage(db.Model): - __tablename__ = 'featured_images' - __table_args__ = {'extend_existing': True} + __tablename__ = "featured_images" + __table_args__ = {"extend_existing": True} id = db.Column(db.Integer, primary_key=True) - submission_image_id = db.Column(db.Integer, db.ForeignKey('submission_images.id'), nullable=False) + media_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=False) override_text = db.Column(db.String(255), nullable=True) is_featured = db.Column(db.Boolean, default=True) + + media = db.relationship("Media", backref="featured_entries") diff --git a/plugins/media/routes.py b/plugins/media/routes.py index 6f9a40b..02e95ff 100644 --- a/plugins/media/routes.py +++ b/plugins/media/routes.py @@ -1,77 +1,84 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_from_directory, current_app +# plugins/media/routes.py + +from flask import ( + Blueprint, + redirect, + url_for, + request, + flash, + send_from_directory, + current_app, + jsonify +) from flask_login import current_user, login_required -from werkzeug.utils import secure_filename import os from app import db from .models import Media, ImageHeart, FeaturedImage -from plugins.plant.models import Plant bp = Blueprint("media", __name__, template_folder="templates") -UPLOAD_FOLDER = "static/uploads" +# We store only "YYYY/MM/DD/.ext" in Media.file_url. +# All files live under "/app/static/uploads/YYYY/MM/DD/.ext" in the container. +BASE_UPLOAD_FOLDER = "static/uploads" ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif"} def allowed_file(filename): - return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS + return ( + "." in filename + and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS + ) -@bp.route("/media/upload", methods=["GET", "POST"]) -@login_required -def upload_media(): - if request.method == "POST": - file = request.files.get("image") - caption = request.form.get("caption") - plant_id = request.form.get("plant_id") +@bp.route("/media/", methods=["GET"]) +def media_index(): + """ + /media/ is not used stand‐alone—redirect back to homepage. + """ + return redirect(url_for("core_ui.home")) - if file and allowed_file(file.filename): - filename = secure_filename(file.filename) - save_path = os.path.join(current_app.root_path, UPLOAD_FOLDER) - os.makedirs(save_path, exist_ok=True) - file.save(os.path.join(save_path, filename)) - - media = Media(file_url=f"{UPLOAD_FOLDER}/{filename}", caption=caption, plant_id=plant_id) - db.session.add(media) - db.session.commit() - - flash("Image uploaded successfully.", "success") - return redirect(url_for("media.upload_media")) - else: - flash("Invalid file or no file uploaded.", "danger") - - return render_template("media/upload.html") - -@bp.route("/media/files/") +@bp.route("/media/files/", methods=["GET"]) def media_file(filename): - return send_from_directory(os.path.join(current_app.root_path, "static/uploads"), filename) + """ + Serve files from "/app/static/uploads/". + Example: GET /media/files/2025/06/07/abcdef1234abcd.jpg + """ + # Use os.getcwd() to guarantee "/app/static/uploads" (not "/app/app/static/uploads") + full_dir = os.path.join(os.getcwd(), BASE_UPLOAD_FOLDER) + return send_from_directory(full_dir, filename) -@bp.route("/media/heart/", methods=["POST"]) +@bp.route("/media/heart/", methods=["POST"]) @login_required -def toggle_heart(image_id): - existing = ImageHeart.query.filter_by(user_id=current_user.id, submission_image_id=image_id).first() +def toggle_heart(media_id): + """ + Add/remove a "heart" from an image. + """ + existing = ImageHeart.query.filter_by( + user_id=current_user.id, media_id=media_id + ).first() + if existing: db.session.delete(existing) db.session.commit() return jsonify({"status": "unhearted"}) else: - heart = ImageHeart(user_id=current_user.id, submission_image_id=image_id) + heart = ImageHeart(user_id=current_user.id, media_id=media_id) db.session.add(heart) db.session.commit() return jsonify({"status": "hearted"}) -@bp.route("/media/feature/", methods=["POST"]) +@bp.route("/media/feature/", methods=["POST"]) @login_required -def set_featured_image(image_id): - image = Media.query.get_or_404(image_id) - plant = image.plant - if not plant: - flash("This image is not linked to a plant.", "danger") - return redirect(request.referrer or url_for("core_ui.home")) - - if current_user.id != plant.owner_id and current_user.role != "admin": +def set_featured_image(media_id): + """ + Toggle featured status on a media item. Only the uploader or an admin may do so. + """ + 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(submission_image_id=image_id).delete() - featured = FeaturedImage(submission_image_id=image_id, is_featured=True) + # Remove any existing featured entries for this media + FeaturedImage.query.filter_by(media_id=media_id).delete() + featured = FeaturedImage(media_id=media_id, is_featured=True) db.session.add(featured) db.session.commit() flash("Image set as featured.", "success") diff --git a/plugins/media/templates/media/upload.html b/plugins/media/templates/media/upload.html deleted file mode 100644 index 39e56a7..0000000 --- a/plugins/media/templates/media/upload.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends 'core_ui/base.html' %} -{% block content %} -

Upload Media

-
- {{ form.hidden_tag() }} -

{{ form.image.label }}
{{ form.image() }}

-

{{ form.caption.label }}
{{ form.caption(size=40) }}

-

{{ form.plant_id.label }}
{{ form.plant_id() }}

-

{{ form.growlog_id.label }}
{{ form.growlog_id() }}

-

{{ form.submit() }}

-
-{% endblock %} \ No newline at end of file diff --git a/plugins/media/utils.py b/plugins/media/utils.py new file mode 100644 index 0000000..4611e34 --- /dev/null +++ b/plugins/media/utils.py @@ -0,0 +1,25 @@ +# plugins/media/utils.py + +import os +import uuid +from PIL import Image + +def generate_random_filename(original_filename): + """ + Returns a random filename preserving the original extension. + e.g. “abcd1234efgh.jpg” for “myphoto.jpg”. + """ + ext = os.path.splitext(original_filename)[1].lower() # includes dot, e.g. ".jpg" + random_name = uuid.uuid4().hex # 32‐char hex string + return f"{random_name}{ext}" + +def strip_metadata_and_save(source_file, destination_path): + """ + Opens an image with Pillow, strips EXIF (metadata), and saves it cleanly. + 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) diff --git a/plugins/submission/forms.py b/plugins/submission/forms.py new file mode 100644 index 0000000..42bbf6d --- /dev/null +++ b/plugins/submission/forms.py @@ -0,0 +1,50 @@ +# plugins/submission/forms.py + +from flask_wtf import FlaskForm +from wtforms import ( + StringField, + FloatField, + SelectField, + IntegerField, + TextAreaField, + MultipleFileField, + SubmitField +) +from wtforms.validators import Optional, NumberRange, Length, DataRequired + +class SubmissionForm(FlaskForm): + submission_type = SelectField( + "Submission Type", + choices=[ + ("market_price", "Market Price"), + ("name_correction", "Name Correction"), + ("new_plant", "New Plant Suggestion"), + ("mutation", "Mutation Discovery"), + ("vendor_rating", "Vendor Rating/Review"), + ("vendor_alias", "Vendor Alias Submission"), + ], + validators=[DataRequired()] + ) + + # Make plant_name Optional; route logic enforces when necessary + plant_name = StringField("Common Name", validators=[Optional(), Length(max=100)]) + scientific_name = StringField("Scientific Name", validators=[Optional(), Length(max=120)]) + notes = TextAreaField("Notes", validators=[Optional()]) + + # Market Price fields + price = FloatField("Price", validators=[Optional()]) + source = StringField("Source/Vendor (e.g. Etsy, eBay)", validators=[Optional(), Length(max=255)]) + + # Vendor Rating / Review fields + vendor_name = StringField("Vendor Name", validators=[Optional(), Length(max=255)]) + rating = IntegerField("Rating (1–5)", validators=[Optional(), NumberRange(min=1, max=5)]) + + # Vendor Alias Submission fields + old_vendor = StringField("Existing Vendor Name", validators=[Optional(), Length(max=255)]) + new_vendor = StringField("New Vendor Name (Alias)", validators=[Optional(), Length(max=255)]) + alias_reason = TextAreaField("Reason for Alias", validators=[Optional()]) + + # Images (max 10) + images = MultipleFileField("Upload Images (max 10)", validators=[Optional()]) + + submit = SubmitField("Submit") diff --git a/plugins/submission/models.py b/plugins/submission/models.py index 4f54289..0beea2a 100644 --- a/plugins/submission/models.py +++ b/plugins/submission/models.py @@ -1,7 +1,7 @@ +# plugins/submission/models.py + from datetime import datetime from app import db -from plugins.plant.models import Plant -from plugins.auth.models import User class Submission(db.Model): __tablename__ = "submissions" @@ -10,21 +10,48 @@ class Submission(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) submitted_at = db.Column(db.DateTime, default=datetime.utcnow) - plant_name = db.Column(db.String(100), nullable=False) + + # Core fields + plant_name = db.Column(db.String(100), nullable=True) # now nullable scientific_name = db.Column(db.String(120), nullable=True) notes = db.Column(db.Text, nullable=True) + + submission_type = db.Column(db.String(50), nullable=False) + + # Market price fields + price = db.Column(db.Float, nullable=True) + source = db.Column(db.String(255), nullable=True) + + # Vendor fields + vendor_name = db.Column(db.String(255), nullable=True) + rating = db.Column(db.Integer, nullable=True) + + # Alias fields + old_vendor = db.Column(db.String(255), nullable=True) + new_vendor = db.Column(db.String(255), nullable=True) + alias_reason = db.Column(db.Text, nullable=True) + approved = db.Column(db.Boolean, default=None) approved_at = db.Column(db.DateTime, nullable=True) reviewed_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) - # Explicit bidirectional relationships - submitter = db.relationship("User", foreign_keys=[user_id], back_populates="submitted_submissions") - reviewer = db.relationship("User", foreign_keys=[reviewed_by], back_populates="reviewed_submissions") + # Relationships + submitter = db.relationship( + "User", + foreign_keys=[user_id], + back_populates="submitted_submissions", + lazy=True + ) + reviewer = db.relationship( + "User", + foreign_keys=[reviewed_by], + back_populates="reviewed_submissions", + lazy=True + ) images = db.relationship("SubmissionImage", backref="submission", lazy=True) - class SubmissionImage(db.Model): __tablename__ = "submission_images" __table_args__ = {"extend_existing": True} diff --git a/plugins/submission/routes.py b/plugins/submission/routes.py index 566a629..3b1dca8 100644 --- a/plugins/submission/routes.py +++ b/plugins/submission/routes.py @@ -1,78 +1,144 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_from_directory, current_app -from flask_login import current_user, login_required -from werkzeug.utils import secure_filename -import os +# plugins/submission/routes.py + +from flask import ( + Blueprint, + render_template, + request, + redirect, + url_for, + flash, + jsonify +) +from flask_login import login_required, current_user from app import db -from .models import SubmissionImage, ImageHeart, FeaturedImage -from plugins.plant.models import Plant +from .models import Submission, SubmissionImage +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") +bp = Blueprint("submission", __name__, template_folder="templates", url_prefix="/submission") -UPLOAD_FOLDER = "static/uploads" +# We store only "YYYY/MM/DD/.ext" in SubmissionImage.file_url. +# All files live under "/app/static/uploads/YYYY/MM/DD/.ext" in the container. +BASE_UPLOAD_FOLDER = "static/uploads" ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif"} def allowed_file(filename): - return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS + return ( + "." in filename + and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS + ) -@bp.route("/submissions/upload", methods=["GET", "POST"]) +@bp.route("/", methods=["GET"]) @login_required -def upload_submissions(): - if request.method == "POST": - file = request.files.get("image") - caption = request.form.get("caption") - plant_id = request.form.get("plant_id") +def submission_index(): + return redirect(url_for("submission.new_submission")) - if file and allowed_file(file.filename): - filename = secure_filename(file.filename) - save_path = os.path.join(current_app.root_path, UPLOAD_FOLDER) - os.makedirs(save_path, exist_ok=True) - file.save(os.path.join(save_path, filename)) - - submissions = SubmissionImage(file_url=f"{UPLOAD_FOLDER}/{filename}", caption=caption, plant_id=plant_id) - db.session.add(submissions) - db.session.commit() - - flash("Image uploaded successfully.", "success") - return redirect(url_for("submissions.upload_submissions")) - else: - flash("Invalid file or no file uploaded.", "danger") - - return render_template("submissions/upload.html") - -@bp.route("/submissions/files/") -def submissions_file(filename): - return send_from_directory(os.path.join(current_app.root_path, "static/uploads"), filename) - -@bp.route("/submissions/heart/", methods=["POST"]) +@bp.route("/new", methods=["GET", "POST"]) +@bp.route("/new/", methods=["GET", "POST"]) @login_required -def toggle_heart(image_id): - existing = ImageHeart.query.filter_by(user_id=current_user.id, submission_image_id=image_id).first() - if existing: - db.session.delete(existing) +def new_submission(): + form = SubmissionForm() + if form.validate_on_submit(): + plant_types = {"market_price", "name_correction", "new_plant", "mutation"} + t = form.submission_type.data + + # Only require plant_name if the type is plant‐related + if t in plant_types and not form.plant_name.data.strip(): + flash("Common Name is required for this submission type.", "danger") + return render_template("submission/new.html", form=form) + + submission = Submission( + user_id=current_user.id, + submitted_at=datetime.utcnow(), + submission_type=t, + plant_name=form.plant_name.data, + scientific_name=form.scientific_name.data, + notes=form.notes.data, + price=form.price.data if form.price.data else None, + source=form.source.data, + vendor_name=form.vendor_name.data, + rating=form.rating.data, + old_vendor=form.old_vendor.data, + new_vendor=form.new_vendor.data, + alias_reason=form.alias_reason.data, + approved=None + ) + db.session.add(submission) + db.session.flush() + + # date subfolder: "YYYY/MM/DD" + 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") + for f in files: + if f and allowed_file(f.filename): + orig_name = secure_filename(f.filename) + rand_name = generate_random_filename(orig_name) + + # Temporarily save under "/app/temp_.ext" + temp_path = os.path.join(os.getcwd(), "temp_" + rand_name) + 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/.ext" + rel_url = f"{today}/{rand_name}" + + img = SubmissionImage( + submission_id=submission.id, + file_url=rel_url, + uploaded_at=datetime.utcnow() + ) + db.session.add(img) + db.session.commit() - return jsonify({"status": "unhearted"}) - else: - heart = ImageHeart(user_id=current_user.id, submission_image_id=image_id) - db.session.add(heart) - db.session.commit() - return jsonify({"status": "hearted"}) + flash("Submission received. Thank you!", "success") + return redirect(url_for("submission.new_submission")) -@bp.route("/submissions/feature/", methods=["POST"]) + return render_template("submission/new.html", form=form) + +@bp.route("/list", methods=["GET"]) +@bp.route("/list/", methods=["GET"]) @login_required -def set_featured_image(image_id): - image = SubmissionImage.query.get_or_404(image_id) - plant = image.plant - if not plant: - flash("This image is not linked to a plant.", "danger") - return redirect(request.referrer or url_for("core_ui.home")) +def list_submissions(): + selected_type = request.args.get("type", None) + query = Submission.query.filter_by(user_id=current_user.id) + if selected_type: + query = query.filter_by(submission_type=selected_type) + subs = query.order_by(Submission.submitted_at.desc()).all() - if current_user.id != plant.owner_id and current_user.role != "admin": - flash("Not authorized to set featured image.", "danger") - return redirect(request.referrer or url_for("core_ui.home")) + all_types = [ + ("", "All"), + ("market_price", "Market Price"), + ("name_correction", "Name Correction"), + ("new_plant", "New Plant Suggestion"), + ("mutation", "Mutation Discovery"), + ("vendor_rating", "Vendor Rating/Review"), + ("vendor_alias", "Vendor Alias Submission"), + ] + return render_template( + "submission/list.html", + submissions=subs, + selected_type=selected_type, + all_types=all_types + ) - FeaturedImage.query.filter_by(submission_image_id=image_id).delete() - featured = FeaturedImage(submission_image_id=image_id, is_featured=True) - db.session.add(featured) - db.session.commit() - flash("Image set as featured.", "success") - return redirect(request.referrer or url_for("core_ui.home")) \ No newline at end of file +@bp.route("/view/", methods=["GET"]) +@bp.route("/view//", methods=["GET"]) +@login_required +def view_submission(submission_id): + sub = Submission.query.get_or_404(submission_id) + if sub.user_id != current_user.id and current_user.role != "admin": + flash("Not authorized to view this submission.", "danger") + return redirect(url_for("submission.list_submissions")) + images = SubmissionImage.query.filter_by(submission_id=sub.id).all() + return render_template("submission/view.html", submission=sub, images=images) diff --git a/plugins/submission/templates/submission/index.html b/plugins/submission/templates/submission/index.html deleted file mode 100644 index 15c2563..0000000 --- a/plugins/submission/templates/submission/index.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "core_ui/base.html" %} -{% block content %} -

Submissions

-
    - {% for s in submissions %} -
  • {{ s.common_name }} ({{ s.scientific_name or 'N/A' }}) - ${{ s.price }}
  • - {% endfor %} -
-{% endblock %} diff --git a/plugins/submission/templates/submission/list.html b/plugins/submission/templates/submission/list.html new file mode 100644 index 0000000..41d83e1 --- /dev/null +++ b/plugins/submission/templates/submission/list.html @@ -0,0 +1,55 @@ +{% extends 'core_ui/base.html' %} +{% block content %} +
+

Your Submissions

+ + +
+
+ +
+
+ +
+
+ +
+
+ + {% if submissions %} +
    + {% for sub in submissions %} +
  • +
    + {{ sub.submission_type.replace('_', ' ').title() }} + — {{ sub.plant_name or '--' }} + {% if sub.submission_type == 'market_price' and sub.price is not none %} + · ${{ '%.2f' % sub.price }} + {% elif sub.submission_type == 'vendor_rating' and sub.vendor_name %} + · Rated “{{ sub.vendor_name }}” ({{ sub.rating }}/5) + {% elif sub.submission_type == 'vendor_alias' and sub.old_vendor %} + · Alias: “{{ sub.old_vendor }}” → “{{ sub.new_vendor }}” + {% endif %} +
    + Submitted {{ sub.submitted_at.strftime('%Y-%m-%d %H:%M') }} +
    + View +
  • + {% endfor %} +
+ {% else %} +

You have no submissions that match this filter.

+ {% endif %} + + +
+{% endblock %} diff --git a/plugins/submission/templates/submission/new.html b/plugins/submission/templates/submission/new.html new file mode 100644 index 0000000..9c2cbb8 --- /dev/null +++ b/plugins/submission/templates/submission/new.html @@ -0,0 +1,166 @@ +{% extends 'core_ui/base.html' %} +{% block content %} +
+

New Submission

+
+ {{ form.hidden_tag() }} + + +
+ {{ form.submission_type.label(class="form-label") }} + {{ form.submission_type(class="form-select", id="submission_type") }} +
+ + +
+ {{ form.plant_name.label(class="form-label") }} + {{ form.plant_name(class="form-control") }} +
Common name of the plant (required for plant‐related submissions).
+
+ +
+ {{ form.scientific_name.label(class="form-label") }} + {{ form.scientific_name(class="form-control") }} +
Scientific name (optional for plant‐related submissions).
+
+ + +
+ {{ form.price.label(class="form-label") }} + {{ form.price(class="form-control") }} +
Only for “Market Price” submissions.
+
+
+ {{ form.source.label(class="form-label") }} + {{ form.source(class="form-control") }} +
Source/vendor (for market price).
+
+ + + + +
+

+ For new plant suggestions, simply fill in common/scientific name and optional notes/images. +

+
+ + +
+

+ For mutation discovery, fill in plant details and upload mutation images. +

+
+ + +
+ {{ form.vendor_name.label(class="form-label") }} + {{ form.vendor_name(class="form-control") }} +
Vendor name to rate/review (e.g., “Etsy Seller X”).
+
+
+ {{ form.rating.label(class="form-label") }} + {{ form.rating(class="form-select") }} +
1 = Poor, 5 = Excellent.
+
+ + +
+ {{ form.old_vendor.label(class="form-label") }} + {{ form.old_vendor(class="form-control") }} +
Existing vendor name (to alias).
+
+
+ {{ form.new_vendor.label(class="form-label") }} + {{ form.new_vendor(class="form-control") }} +
New alias name for vendor.
+
+
+ {{ form.alias_reason.label(class="form-label") }} + {{ form.alias_reason(class="form-control", rows=3) }} +
Why this alias is needed (e.g., vendor changed name).
+
+ + +
+ {{ form.notes.label(class="form-label") }} + {{ form.notes(class="form-control", rows=3) }} +
Additional notes or context.
+
+
+ {{ form.images.label(class="form-label") }} + {{ form.images(class="form-control", multiple=True) }} +
Upload up to 10 images relevant to your submission.
+
+ + +
+
+ + +{% endblock %} diff --git a/plugins/submission/templates/submission/view.html b/plugins/submission/templates/submission/view.html new file mode 100644 index 0000000..395dec3 --- /dev/null +++ b/plugins/submission/templates/submission/view.html @@ -0,0 +1,101 @@ +{% extends 'core_ui/base.html' %} +{% block content %} +
+

Submission Details

+
+
Type:
+
+ {{ submission.submission_type.replace('_', ' ').title() }} +
+ +
Common Name:
+
+ {% if submission.plant_name %} + {{ submission.plant_name }} + {% else %} + — + {% endif %} +
+ +
Scientific Name:
+
+ {% if submission.scientific_name %} + {{ submission.scientific_name }} + {% else %} + — + {% endif %} +
+ + {% if submission.submission_type == 'market_price' %} +
Price:
+
${{ '%.2f' % submission.price }}
+
Source:
+
{{ submission.source or '—' }}
+ {% endif %} + + {% if submission.submission_type == 'vendor_rating' %} +
Vendor Name:
+
{{ submission.vendor_name }}
+
Rating:
+
{{ submission.rating }} / 5
+ {% endif %} + + {% if submission.submission_type == 'vendor_alias' %} +
Old Vendor:
+
{{ submission.old_vendor }}
+
New Vendor Alias:
+
{{ submission.new_vendor }}
+
Alias Reason:
+
{{ submission.alias_reason or '—' }}
+ {% endif %} + +
Notes:
+
{{ submission.notes or '—' }}
+ +
Submitted At:
+
+ {{ submission.submitted_at.strftime('%Y-%m-%d %H:%M') }} +
+ +
Status:
+
+ {% if submission.approved is none %} + Pending + {% elif submission.approved %} + Approved + {% else %} + Rejected + {% endif %} +
+
+ +

Images

+
+ {% if images %} + {% for img in images %} + {# img.file_url == "YYYY/MM/DD/.ext" #} +
+
+ + Submission Image + +
+

+ {{ img.uploaded_at.strftime('%Y-%m-%d') }} +

+
+
+
+ {% endfor %} + {% else %} +

No images uploaded.

+ {% endif %} +
+ + +
+{% endblock %} diff --git a/requirements.txt b/requirements.txt index 8cf5ae6..9ac49e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,5 +8,5 @@ Werkzeug>=2.3.0 pymysql python-dotenv cryptography - +Pillow neo4j>=5.18.0 diff --git a/static/uploads/2025/06/06/5c86afeae316454b8f8ca73f8f3b9825.jpeg b/static/uploads/2025/06/06/5c86afeae316454b8f8ca73f8f3b9825.jpeg new file mode 100644 index 0000000..e2b703b Binary files /dev/null and b/static/uploads/2025/06/06/5c86afeae316454b8f8ca73f8f3b9825.jpeg differ