lots of things

This commit is contained in:
2025-06-06 22:02:44 -05:00
parent 9daee50a3a
commit 96c634897b
30 changed files with 1120 additions and 182 deletions

View File

@ -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

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 %}
<ul class="media-thumbnails">
<div class="row">
{% for media in media_list %}
<li>
<img src="{{ url_for('media.media_file', filename=media.file_url) }}" width="{{ thumb_width }}">
<div class="col-md-3 mb-4" data-media-id="{{ media.id }}">
<div class="card shadow-sm">
<img src="{{ url_for('media.media_file', filename=media.file_url.split('/')[-1]) }}"
class="card-img-top" style="width:100%; height:auto;">
{% if media.caption %}
<p>{{ media.caption }}</p>
<div class="card-body p-2">
<p class="card-text text-center">{{ media.caption }}</p>
</div>
{% endif %}
</li>
<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 }}">
❤️ <span class="heart-count">{{ media.imageheart_set|length }}</span>
</button>
{% 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) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if media.featuredimage %}
<button class="btn btn-sm btn-outline-secondary" type="submit">★ Featured</button>
{% else %}
<button class="btn btn-sm btn-outline-primary" type="submit">☆ Set Featured</button>
{% endif %}
</form>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</ul>
</div>
{% endif %}
{%- endmacro %}

View File

@ -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)

View File

@ -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')

View File

@ -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")

View File

@ -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/<uuid>.ext" in Media.file_url.
# All files live under "/app/static/uploads/YYYY/MM/DD/<uuid>.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 standalone—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/<path:filename>")
@bp.route("/media/files/<path:filename>", 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/<filename>".
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/<int:image_id>", methods=["POST"])
@bp.route("/media/heart/<int:media_id>", 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/<int:image_id>", methods=["POST"])
@bp.route("/media/feature/<int:media_id>", 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")

View File

@ -1,12 +0,0 @@
{% extends 'core_ui/base.html' %}
{% block content %}
<h2>Upload Media</h2>
<form method="POST" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<p>{{ form.image.label }}<br>{{ form.image() }}</p>
<p>{{ form.caption.label }}<br>{{ form.caption(size=40) }}</p>
<p>{{ form.plant_id.label }}<br>{{ form.plant_id() }}</p>
<p>{{ form.growlog_id.label }}<br>{{ form.growlog_id() }}</p>
<p>{{ form.submit() }}</p>
</form>
{% endblock %}

25
plugins/media/utils.py Normal file
View File

@ -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 # 32char 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)

View File

@ -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 (15)", 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")

View File

@ -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}

View File

@ -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/<uuid>.ext" in SubmissionImage.file_url.
# All files live under "/app/static/uploads/YYYY/MM/DD/<uuid>.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/<path:filename>")
def submissions_file(filename):
return send_from_directory(os.path.join(current_app.root_path, "static/uploads"), filename)
@bp.route("/submissions/heart/<int:image_id>", 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)
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"})
def new_submission():
form = SubmissionForm()
if form.validate_on_submit():
plant_types = {"market_price", "name_correction", "new_plant", "mutation"}
t = form.submission_type.data
@bp.route("/submissions/feature/<int:image_id>", methods=["POST"])
# Only require plant_name if the type is plantrelated
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_<uuid>.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/<uuid>.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()
flash("Submission received. Thank you!", "success")
return redirect(url_for("submission.new_submission"))
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"))
@bp.route("/view/<int:submission_id>", methods=["GET"])
@bp.route("/view/<int:submission_id>/", 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)

View File

@ -1,9 +0,0 @@
{% extends "core_ui/base.html" %}
{% block content %}
<h2>Submissions</h2>
<ul>
{% for s in submissions %}
<li>{{ s.common_name }} ({{ s.scientific_name or 'N/A' }}) - ${{ s.price }}</li>
{% endfor %}
</ul>
{% endblock %}

View File

@ -0,0 +1,55 @@
{% extends 'core_ui/base.html' %}
{% block content %}
<div class="container mt-4">
<h2>Your Submissions</h2>
<!-- Filter dropdown -->
<form method="GET" class="row g-2 align-items-center mb-3">
<div class="col-auto">
<label for="filter_type" class="form-label">Filter by Type:</label>
</div>
<div class="col-auto">
<select id="filter_type" name="type" class="form-select" onchange="this.form.submit()">
{% for val, label in all_types %}
<option value="{{ val }}" {% if selected_type == val %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<noscript><button type="submit" class="btn btn-primary">Apply</button></noscript>
</div>
</form>
{% if submissions %}
<ul class="list-group">
{% for sub in submissions %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{{ sub.submission_type.replace('_', ' ').title() }}</strong>
&mdash; {{ sub.plant_name or '--' }}
{% if sub.submission_type == 'market_price' and sub.price is not none %}
&middot; ${{ '%.2f' % sub.price }}
{% elif sub.submission_type == 'vendor_rating' and sub.vendor_name %}
&middot; Rated “{{ sub.vendor_name }}” ({{ sub.rating }}/5)
{% elif sub.submission_type == 'vendor_alias' and sub.old_vendor %}
&middot; Alias: “{{ sub.old_vendor }}” → “{{ sub.new_vendor }}”
{% endif %}
<br>
<small class="text-muted">Submitted {{ sub.submitted_at.strftime('%Y-%m-%d %H:%M') }}</small>
</div>
<a href="{{ url_for('submission.view_submission', submission_id=sub.id) }}"
class="btn btn-outline-primary btn-sm">View</a>
</li>
{% endfor %}
</ul>
{% else %}
<p>You have no submissions that match this filter.</p>
{% endif %}
<div class="mt-3">
<a href="{{ url_for('submission.new_submission') }}" class="btn btn-primary">New Submission</a>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,166 @@
{% extends 'core_ui/base.html' %}
{% block content %}
<div class="container mt-4">
<h2>New Submission</h2>
<form method="POST" enctype="multipart/form-data" id="submission-form">
{{ form.hidden_tag() }}
<!-- Submission Type selector -->
<div class="mb-3">
{{ form.submission_type.label(class="form-label") }}
{{ form.submission_type(class="form-select", id="submission_type") }}
</div>
<!-- Plant fields: only for plantrelated types -->
<div class="mb-3" id="plant-name-group">
{{ form.plant_name.label(class="form-label") }}
{{ form.plant_name(class="form-control") }}
<div class="form-text">Common name of the plant (required for plantrelated submissions).</div>
</div>
<div class="mb-3" id="scientific-name-group">
{{ form.scientific_name.label(class="form-label") }}
{{ form.scientific_name(class="form-control") }}
<div class="form-text">Scientific name (optional for plantrelated submissions).</div>
</div>
<!-- Market Price fields -->
<div class="mb-3" id="price-group">
{{ form.price.label(class="form-label") }}
{{ form.price(class="form-control") }}
<div class="form-text">Only for “Market Price” submissions.</div>
</div>
<div class="mb-3" id="source-group">
{{ form.source.label(class="form-label") }}
{{ form.source(class="form-control") }}
<div class="form-text">Source/vendor (for market price).</div>
</div>
<!-- Name Correction: no extra fields beyond notes -->
<!-- New Plant Suggestion helper text -->
<div class="mb-3" id="new-plant-group">
<p class="text-muted">
For new plant suggestions, simply fill in common/scientific name and optional notes/images.
</p>
</div>
<!-- Mutation Discovery helper text -->
<div class="mb-3" id="mutation-group">
<p class="text-muted">
For mutation discovery, fill in plant details and upload mutation images.
</p>
</div>
<!-- Vendor Rating / Review fields -->
<div class="mb-3" id="vendor-name-group">
{{ form.vendor_name.label(class="form-label") }}
{{ form.vendor_name(class="form-control") }}
<div class="form-text">Vendor name to rate/review (e.g., “Etsy Seller X”).</div>
</div>
<div class="mb-3" id="rating-group">
{{ form.rating.label(class="form-label") }}
{{ form.rating(class="form-select") }}
<div class="form-text">1 = Poor, 5 = Excellent.</div>
</div>
<!-- Vendor Alias Submission fields -->
<div class="mb-3" id="old-vendor-group">
{{ form.old_vendor.label(class="form-label") }}
{{ form.old_vendor(class="form-control") }}
<div class="form-text">Existing vendor name (to alias).</div>
</div>
<div class="mb-3" id="new-vendor-group">
{{ form.new_vendor.label(class="form-label") }}
{{ form.new_vendor(class="form-control") }}
<div class="form-text">New alias name for vendor.</div>
</div>
<div class="mb-3" id="alias-reason-group">
{{ form.alias_reason.label(class="form-label") }}
{{ form.alias_reason(class="form-control", rows=3) }}
<div class="form-text">Why this alias is needed (e.g., vendor changed name).</div>
</div>
<!-- Notes & Images (always present) -->
<div class="mb-3">
{{ form.notes.label(class="form-label") }}
{{ form.notes(class="form-control", rows=3) }}
<div class="form-text">Additional notes or context.</div>
</div>
<div class="mb-3">
{{ form.images.label(class="form-label") }}
{{ form.images(class="form-control", multiple=True) }}
<div class="form-text">Upload up to 10 images relevant to your submission.</div>
</div>
<button class="btn btn-success">{{ form.submit.label.text }}</button>
</form>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const typeField = document.getElementById('submission_type');
// Groups to show/hide
const plantNameGroup = document.getElementById('plant-name-group');
const scientificNameGroup = document.getElementById('scientific-name-group');
const priceGroup = document.getElementById('price-group');
const sourceGroup = document.getElementById('source-group');
const newPlantGroup = document.getElementById('new-plant-group');
const mutationGroup = document.getElementById('mutation-group');
const vendorNameGroup = document.getElementById('vendor-name-group');
const ratingGroup = document.getElementById('rating-group');
const oldVendorGroup = document.getElementById('old-vendor-group');
const newVendorGroup = document.getElementById('new-vendor-group');
const aliasReasonGroup = document.getElementById('alias-reason-group');
function toggleFields() {
const t = typeField.value;
// First hide everything
[
plantNameGroup, scientificNameGroup,
priceGroup, sourceGroup,
newPlantGroup, mutationGroup,
vendorNameGroup, ratingGroup,
oldVendorGroup, newVendorGroup, aliasReasonGroup
].forEach(div => div.style.display = 'none');
// Show core plant fields for plantrelated types
if (['market_price', 'name_correction', 'new_plant', 'mutation'].includes(t)) {
plantNameGroup.style.display = 'block';
scientificNameGroup.style.display = 'block';
}
// Show Market Price fields
if (t === 'market_price') {
priceGroup.style.display = 'block';
sourceGroup.style.display = 'block';
}
// Show New Plant helper
else if (t === 'new_plant') {
newPlantGroup.style.display = 'block';
}
// Show Mutation helper
else if (t === 'mutation') {
mutationGroup.style.display = 'block';
}
// Show Vendor Rating / Review fields
else if (t === 'vendor_rating') {
vendorNameGroup.style.display = 'block';
ratingGroup.style.display = 'block';
}
// Show Vendor Alias fields
else if (t === 'vendor_alias') {
oldVendorGroup.style.display = 'block';
newVendorGroup.style.display = 'block';
aliasReasonGroup.style.display = 'block';
}
// For name_correction: only plant fields + notes/images
}
typeField.addEventListener('change', toggleFields);
toggleFields(); // initial call when page first loads
});
</script>
{% endblock %}

View File

@ -0,0 +1,101 @@
{% extends 'core_ui/base.html' %}
{% block content %}
<div class="container mt-4">
<h2>Submission Details</h2>
<dl class="row">
<dt class="col-sm-3">Type:</dt>
<dd class="col-sm-9">
{{ submission.submission_type.replace('_', ' ').title() }}
</dd>
<dt class="col-sm-3">Common Name:</dt>
<dd class="col-sm-9">
{% if submission.plant_name %}
{{ submission.plant_name }}
{% else %}
&mdash;
{% endif %}
</dd>
<dt class="col-sm-3">Scientific Name:</dt>
<dd class="col-sm-9">
{% if submission.scientific_name %}
{{ submission.scientific_name }}
{% else %}
&mdash;
{% endif %}
</dd>
{% if submission.submission_type == 'market_price' %}
<dt class="col-sm-3">Price:</dt>
<dd class="col-sm-9">${{ '%.2f' % submission.price }}</dd>
<dt class="col-sm-3">Source:</dt>
<dd class="col-sm-9">{{ submission.source or '&mdash;' }}</dd>
{% endif %}
{% if submission.submission_type == 'vendor_rating' %}
<dt class="col-sm-3">Vendor Name:</dt>
<dd class="col-sm-9">{{ submission.vendor_name }}</dd>
<dt class="col-sm-3">Rating:</dt>
<dd class="col-sm-9">{{ submission.rating }} / 5</dd>
{% endif %}
{% if submission.submission_type == 'vendor_alias' %}
<dt class="col-sm-3">Old Vendor:</dt>
<dd class="col-sm-9">{{ submission.old_vendor }}</dd>
<dt class="col-sm-3">New Vendor Alias:</dt>
<dd class="col-sm-9">{{ submission.new_vendor }}</dd>
<dt class="col-sm-3">Alias Reason:</dt>
<dd class="col-sm-9">{{ submission.alias_reason or '&mdash;' }}</dd>
{% endif %}
<dt class="col-sm-3">Notes:</dt>
<dd class="col-sm-9">{{ submission.notes or '&mdash;' }}</dd>
<dt class="col-sm-3">Submitted At:</dt>
<dd class="col-sm-9">
{{ submission.submitted_at.strftime('%Y-%m-%d %H:%M') }}
</dd>
<dt class="col-sm-3">Status:</dt>
<dd class="col-sm-9">
{% if submission.approved is none %}
<span class="badge bg-warning">Pending</span>
{% elif submission.approved %}
<span class="badge bg-success">Approved</span>
{% else %}
<span class="badge bg-danger">Rejected</span>
{% endif %}
</dd>
</dl>
<h4>Images</h4>
<div class="row">
{% if images %}
{% for img in images %}
{# img.file_url == "YYYY/MM/DD/<uuid>.ext" #}
<div class="col-md-3 mb-3">
<div class="card shadow-sm">
<a href="{{ url_for('media.media_file', filename=img.file_url) }}" target="_blank">
<img src="{{ url_for('media.media_file', filename=img.file_url) }}"
class="card-img-top"
alt="Submission Image">
</a>
<div class="card-body p-2">
<p class="card-text text-center">
<small class="text-muted">{{ img.uploaded_at.strftime('%Y-%m-%d') }}</small>
</p>
</div>
</div>
</div>
{% endfor %}
{% else %}
<p>No images uploaded.</p>
{% endif %}
</div>
<div class="mt-3">
<a href="{{ url_for('submission.list_submissions') }}" class="btn btn-secondary">Back to List</a>
</div>
</div>
{% endblock %}

View File

@ -8,5 +8,5 @@ Werkzeug>=2.3.0
pymysql
python-dotenv
cryptography
Pillow
neo4j>=5.18.0

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB