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

@ -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)
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 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()
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/<int:image_id>", 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"))
@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 %}