lots of changes
This commit is contained in:
@ -15,9 +15,21 @@ class User(db.Model, UserMixin):
|
||||
excluded_from_analytics = db.Column(db.Boolean, default=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Optional: relationship to submissions
|
||||
submissions = db.relationship('Submission', backref='user', lazy=True)
|
||||
|
||||
# Use back_populates, not backref
|
||||
submitted_submissions = db.relationship(
|
||||
"Submission",
|
||||
foreign_keys="Submission.user_id",
|
||||
back_populates="submitter",
|
||||
lazy=True
|
||||
)
|
||||
|
||||
reviewed_submissions = db.relationship(
|
||||
"Submission",
|
||||
foreign_keys="Submission.reviewed_by",
|
||||
back_populates="reviewer",
|
||||
lazy=True
|
||||
)
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
|
@ -4,6 +4,7 @@ import csv
|
||||
import io
|
||||
import difflib
|
||||
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, render_template, redirect, flash, session, url_for
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf.csrf import generate_csrf
|
||||
@ -11,109 +12,109 @@ from flask_wtf.csrf import generate_csrf
|
||||
from app.neo4j_utils import get_neo4j_handler
|
||||
from plugins.plant.models import (
|
||||
db,
|
||||
Plant, PlantCommonName, PlantScientificName, PlantOwnershipLog
|
||||
Plant,
|
||||
PlantCommonName,
|
||||
PlantScientificName,
|
||||
PlantOwnershipLog
|
||||
)
|
||||
|
||||
bp = Blueprint("importer", __name__, template_folder="templates", url_prefix="/import")
|
||||
bp = Blueprint('importer', __name__, template_folder='templates', url_prefix='/import')
|
||||
|
||||
REQUIRED_HEADERS = {"uuid", "plant_type", "name"}
|
||||
# ────────────────────────────────────────────────────────────────────────────────
|
||||
# Redirect “/import/” → “/import/upload”
|
||||
# ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route("/", methods=["GET"])
|
||||
@login_required
|
||||
def index():
|
||||
# When someone hits /import, send them to /import/upload
|
||||
return redirect(url_for("importer.upload"))
|
||||
|
||||
|
||||
@bp.route("/", methods=["GET", "POST"])
|
||||
# ────────────────────────────────────────────────────────────────────────────────
|
||||
# Required CSV headers for import
|
||||
# ────────────────────────────────────────────────────────────────────────────────
|
||||
REQUIRED_HEADERS = {"uuid", "plant_type", "name", "scientific_name", "mother_uuid"}
|
||||
|
||||
|
||||
@bp.route("/upload", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def upload():
|
||||
if request.method == "POST":
|
||||
file = request.files.get("file")
|
||||
if not file:
|
||||
flash("No file uploaded.", "error")
|
||||
flash("No file selected", "error")
|
||||
return redirect(request.url)
|
||||
|
||||
# Decode as UTF-8-SIG to strip any BOM, then parse with csv.DictReader
|
||||
try:
|
||||
decoded = file.read().decode("utf-8-sig")
|
||||
stream = io.StringIO(decoded)
|
||||
stream = io.StringIO(file.stream.read().decode("utf-8-sig"))
|
||||
reader = csv.DictReader(stream)
|
||||
|
||||
headers = set(reader.fieldnames or [])
|
||||
missing = REQUIRED_HEADERS - headers
|
||||
if missing:
|
||||
flash(f"Missing required CSV headers: {missing}", "error")
|
||||
return redirect(request.url)
|
||||
|
||||
session["pending_rows"] = []
|
||||
review_list = []
|
||||
|
||||
# Preload existing common/scientific names
|
||||
all_common = {c.name.lower(): c for c in PlantCommonName.query.all()}
|
||||
all_scientific = {s.name.lower(): s for s in PlantScientificName.query.all()}
|
||||
|
||||
for row in reader:
|
||||
uuid_raw = row.get("uuid", "")
|
||||
uuid = uuid_raw.strip().strip('"')
|
||||
|
||||
name_raw = row.get("name", "")
|
||||
name = name_raw.strip()
|
||||
|
||||
sci_raw = row.get("scientific_name", "")
|
||||
sci_name = sci_raw.strip()
|
||||
|
||||
plant_type = row.get("plant_type", "").strip() or "plant"
|
||||
|
||||
mother_raw = row.get("mother_uuid", "")
|
||||
mother_uuid = mother_raw.strip().strip('"')
|
||||
|
||||
# If any required field is missing, skip
|
||||
if not (uuid and name and plant_type):
|
||||
continue
|
||||
|
||||
# Try fuzzy‐matching scientific names if needed
|
||||
suggested_match = None
|
||||
original_sci = sci_name
|
||||
name_lc = name.lower()
|
||||
sci_lc = sci_name.lower()
|
||||
|
||||
if sci_lc and sci_lc not in all_scientific:
|
||||
close = difflib.get_close_matches(sci_lc, all_scientific.keys(), n=1, cutoff=0.85)
|
||||
if close:
|
||||
suggested_match = all_scientific[close[0]].name
|
||||
|
||||
if not sci_lc and name_lc in all_common:
|
||||
sci_obj = PlantScientificName.query.filter_by(common_id=all_common[name_lc].id).first()
|
||||
if sci_obj:
|
||||
sci_name = sci_obj.name
|
||||
elif not sci_lc:
|
||||
close_common = difflib.get_close_matches(name_lc, all_common.keys(), n=1, cutoff=0.85)
|
||||
if close_common:
|
||||
match_name = close_common[0]
|
||||
sci_obj = PlantScientificName.query.filter_by(common_id=all_common[match_name].id).first()
|
||||
if sci_obj:
|
||||
suggested_match = sci_obj.name
|
||||
sci_name = sci_obj.name
|
||||
|
||||
session["pending_rows"].append({
|
||||
"uuid": uuid,
|
||||
"name": name,
|
||||
"sci_name": sci_name,
|
||||
"original_sci_name": original_sci,
|
||||
"plant_type": plant_type,
|
||||
"mother_uuid": mother_uuid,
|
||||
"suggested_scientific_name": suggested_match,
|
||||
})
|
||||
|
||||
if suggested_match and suggested_match != original_sci:
|
||||
review_list.append({
|
||||
"uuid": uuid,
|
||||
"common_name": name,
|
||||
"user_input": original_sci or "(blank)",
|
||||
"suggested_name": suggested_match
|
||||
})
|
||||
|
||||
session["review_list"] = review_list
|
||||
return redirect(url_for("importer.review"))
|
||||
|
||||
except Exception as e:
|
||||
flash(f"Import failed: {e}", "error")
|
||||
except Exception:
|
||||
flash("Failed to read CSV file. Ensure it is valid UTF-8.", "error")
|
||||
return redirect(request.url)
|
||||
|
||||
headers = set(reader.fieldnames or [])
|
||||
missing = REQUIRED_HEADERS - headers
|
||||
if missing:
|
||||
flash(f"Missing required CSV headers: {missing}", "error")
|
||||
return redirect(request.url)
|
||||
|
||||
# Prepare session storage for the rows under review
|
||||
session["pending_rows"] = []
|
||||
review_list = []
|
||||
|
||||
# Preload existing common/scientific names (lowercased keys for fuzzy matching)
|
||||
all_common = {c.name.lower(): c for c in PlantCommonName.query.all()}
|
||||
all_scientific = {s.name.lower(): s for s in PlantScientificName.query.all()}
|
||||
|
||||
for row in reader:
|
||||
uuid_raw = row.get("uuid", "")
|
||||
uuid = uuid_raw.strip().strip('"')
|
||||
|
||||
name_raw = row.get("name", "")
|
||||
name = name_raw.strip()
|
||||
|
||||
sci_raw = row.get("scientific_name", "")
|
||||
sci_name = sci_raw.strip()
|
||||
|
||||
plant_type = row.get("plant_type", "").strip() or "plant"
|
||||
|
||||
mother_raw = row.get("mother_uuid", "")
|
||||
mother_uuid = mother_raw.strip().strip('"')
|
||||
|
||||
# Skip any row where required fields are missing
|
||||
if not (uuid and name and plant_type):
|
||||
continue
|
||||
|
||||
# ─── If the scientific name doesn’t match exactly, suggest a close match ─────
|
||||
# Only suggest if the “closest key” differs from the raw input:
|
||||
suggestions = difflib.get_close_matches(
|
||||
sci_name.lower(),
|
||||
list(all_scientific.keys()),
|
||||
n=1,
|
||||
cutoff=0.8
|
||||
)
|
||||
if suggestions and suggestions[0] != sci_name.lower():
|
||||
suggested = all_scientific[suggestions[0]].name
|
||||
else:
|
||||
suggested = None
|
||||
|
||||
review_item = {
|
||||
"uuid": uuid,
|
||||
"name": name,
|
||||
"sci_name": sci_name,
|
||||
"suggested": suggested,
|
||||
"plant_type": plant_type,
|
||||
"mother_uuid": mother_uuid
|
||||
}
|
||||
review_list.append(review_item)
|
||||
session["pending_rows"].append(review_item)
|
||||
|
||||
session["review_list"] = review_list
|
||||
return redirect(url_for("importer.review"))
|
||||
|
||||
# GET → show upload form
|
||||
return render_template("importer/upload.html", csrf_token=generate_csrf())
|
||||
|
||||
|
||||
@ -127,107 +128,97 @@ def review():
|
||||
neo = get_neo4j_handler()
|
||||
added = 0
|
||||
|
||||
# —————————————————————————————————————————————
|
||||
# (1) CREATE MySQL records & MERGE every Neo4j node
|
||||
# —————————————————————————————————————————————
|
||||
# Re-load preload maps to avoid NameError if used below
|
||||
all_common = {c.name.lower(): c for c in PlantCommonName.query.all()}
|
||||
all_scientific = {s.name.lower(): s for s in PlantScientificName.query.all()}
|
||||
|
||||
for row in rows:
|
||||
uuid_raw = row["uuid"]
|
||||
uuid = uuid_raw.strip().strip('"')
|
||||
uuid = row.get("uuid")
|
||||
name = row.get("name")
|
||||
sci_name = row.get("sci_name")
|
||||
suggested = row.get("suggested")
|
||||
plant_type = row.get("plant_type")
|
||||
mother_uuid = row.get("mother_uuid")
|
||||
|
||||
name_raw = row["name"]
|
||||
name = name_raw.strip()
|
||||
# Check if user clicked "confirm" for a suggested scientific name
|
||||
accepted_key = f"confirm_{uuid}"
|
||||
accepted = request.form.get(accepted_key)
|
||||
|
||||
sci_raw = row["sci_name"]
|
||||
sci_name = sci_raw.strip()
|
||||
|
||||
plant_type = row["plant_type"].strip()
|
||||
|
||||
mother_raw = row["mother_uuid"]
|
||||
mother_uuid = mother_raw.strip().strip('"')
|
||||
|
||||
suggested = row.get("suggested_scientific_name")
|
||||
|
||||
# ——— MySQL: PlantCommonName ———
|
||||
# ─── MySQL: PlantCommonName ────────────────────────────────────────────────
|
||||
common = PlantCommonName.query.filter_by(name=name).first()
|
||||
if not common:
|
||||
common = PlantCommonName(name=name)
|
||||
db.session.add(common)
|
||||
db.session.flush()
|
||||
all_common[common.name.lower()] = common
|
||||
else:
|
||||
all_common[common.name.lower()] = common
|
||||
|
||||
# ——— MySQL: PlantScientificName ———
|
||||
accepted = request.form.get(f"confirm_{uuid}")
|
||||
# ─── MySQL: PlantScientificName ───────────────────────────────────────────
|
||||
sci_to_use = suggested if (suggested and accepted) else sci_name
|
||||
|
||||
scientific = PlantScientificName.query.filter_by(name=sci_to_use).first()
|
||||
if not scientific:
|
||||
scientific = PlantScientificName(name=sci_to_use, common_id=common.id)
|
||||
scientific = PlantScientificName(
|
||||
name = sci_to_use,
|
||||
common_id = common.id
|
||||
)
|
||||
db.session.add(scientific)
|
||||
db.session.flush()
|
||||
all_scientific[scientific.name.lower()] = scientific
|
||||
else:
|
||||
all_scientific[scientific.name.lower()] = scientific
|
||||
|
||||
# ——— MySQL: Plant row ———
|
||||
# ─── Decide if this plant’s data is “verified” by the user ────────────────
|
||||
data_verified = False
|
||||
if (not suggested) or (suggested and accepted):
|
||||
data_verified = True
|
||||
|
||||
# ─── MySQL: Plant record ─────────────────────────────────────────────────
|
||||
plant = Plant.query.filter_by(uuid=uuid).first()
|
||||
if not plant:
|
||||
plant = Plant(
|
||||
uuid=uuid,
|
||||
common_id=common.id,
|
||||
scientific_id=scientific.id,
|
||||
plant_type=plant_type,
|
||||
owner_id=current_user.id,
|
||||
is_verified=bool(accepted)
|
||||
uuid = uuid,
|
||||
common_id = common.id,
|
||||
scientific_id = scientific.id,
|
||||
plant_type = plant_type,
|
||||
owner_id = current_user.id,
|
||||
data_verified = data_verified
|
||||
)
|
||||
db.session.add(plant)
|
||||
db.session.flush() # so plant.id is available immediately
|
||||
added += 1
|
||||
db.session.flush() # so plant.id is now available
|
||||
|
||||
# ——— MySQL: Create initial ownership log entry ———
|
||||
log = PlantOwnershipLog(
|
||||
plant_id = plant.id,
|
||||
user_id = current_user.id,
|
||||
date_acquired = datetime.utcnow(),
|
||||
transferred = False,
|
||||
is_verified = bool(accepted)
|
||||
is_verified = data_verified
|
||||
)
|
||||
db.session.add(log)
|
||||
added += 1
|
||||
else:
|
||||
# Skip duplicates if the same UUID already exists
|
||||
pass
|
||||
|
||||
# ——— Neo4j: ensure a node exists for this plant UUID ———
|
||||
# ─── Neo4j: ensure the Plant node exists ─────────────────────────────────
|
||||
neo.create_plant_node(uuid, name)
|
||||
|
||||
# Commit MySQL so that all Plant/OwnershipLog rows exist
|
||||
db.session.commit()
|
||||
|
||||
|
||||
# —————————————————————————————————————————————
|
||||
# (2) CREATE Neo4j LINEAGE relationships (child → parent). (Unchanged)
|
||||
# —————————————————————————————————————————————
|
||||
for row in rows:
|
||||
child_raw = row.get("uuid", "")
|
||||
child_uuid = child_raw.strip().strip('"')
|
||||
|
||||
mother_raw = row.get("mother_uuid", "")
|
||||
mother_uuid = mother_raw.strip().strip('"')
|
||||
|
||||
print(
|
||||
f"[DEBUG] row → child_raw={child_raw!r}, child_uuid={child_uuid!r}; "
|
||||
f"mother_raw={mother_raw!r}, mother_uuid={mother_uuid!r}"
|
||||
)
|
||||
|
||||
# ─── Neo4j: create a LINEAGE relationship if mother_uuid was provided ─────
|
||||
if mother_uuid:
|
||||
neo.create_plant_node(mother_uuid, name="Unknown")
|
||||
neo.create_lineage(child_uuid, mother_uuid)
|
||||
else:
|
||||
print(f"[DEBUG] Skipping LINEAGE creation for child {child_uuid!r} (no mother_uuid)")
|
||||
|
||||
# (Optional) Check two known UUIDs
|
||||
neo.debug_check_node("8b1059c8-8dd3-487a-af19-1eb548788e87")
|
||||
neo.debug_check_node("2ee2e0e7-69de-4d8f-abfe-4ed973c3d760")
|
||||
# Replace the old call with the correct method name:
|
||||
neo.create_lineage(child_uuid=uuid, parent_uuid=mother_uuid)
|
||||
|
||||
# Commit all MySQL changes at once
|
||||
db.session.commit()
|
||||
neo.close()
|
||||
flash(f"{added} plants added (MySQL) + Neo4j nodes/relations created.", "success")
|
||||
|
||||
flash(f"{added} plants added (MySQL) and Neo4j nodes/relationships created.", "success")
|
||||
|
||||
session.pop("pending_rows", None)
|
||||
session.pop("review_list", None)
|
||||
return redirect(url_for("importer.upload"))
|
||||
|
||||
# GET → re-render the review page with the same review_list
|
||||
return render_template(
|
||||
"importer/review.html",
|
||||
review_list=review_list,
|
||||
|
@ -1,17 +1,39 @@
|
||||
{% extends "core_ui/base.html" %}
|
||||
{% block title %}Review Matches{% endblock %}
|
||||
{% block title %}Review Suggested Matches{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<h2 class="mb-4">🔍 Review Suggested Matches</h2>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{% if review_list %}
|
||||
<p class="text-muted mb-3">Confirm the suggested scientific name replacements below. Only confirmed matches will override user input.</p>
|
||||
<table class="table table-bordered table-sm align-middle">
|
||||
<p>
|
||||
Confirm the suggested scientific‐name replacements below.
|
||||
Only checked boxes (“Confirm”) will override the raw user input.
|
||||
</p>
|
||||
|
||||
{# Display flash messages (error, success, etc.) #}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div
|
||||
class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show"
|
||||
role="alert"
|
||||
>
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if review_list and review_list|length > 0 %}
|
||||
<form method="POST">
|
||||
{# Hidden CSRF token #}
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Common Name</th>
|
||||
<th>User Input</th>
|
||||
<th>User Input (Scientific Name)</th>
|
||||
<th>Suggested Match</th>
|
||||
<th>Confirm</th>
|
||||
</tr>
|
||||
@ -19,20 +41,32 @@
|
||||
<tbody>
|
||||
{% for row in review_list %}
|
||||
<tr>
|
||||
<td>{{ row.common_name }}</td>
|
||||
<td><code>{{ row.user_input }}</code></td>
|
||||
<td><code>{{ row.suggested_name }}</code></td>
|
||||
<td>{{ row.name }}</td>
|
||||
<td>{{ row.sci_name }}</td>
|
||||
<td>{{ row.suggested or '-' }}</td>
|
||||
<td>
|
||||
<input type="checkbox" name="confirm_{{ row.uuid }}" value="1">
|
||||
{% if row.suggested %}
|
||||
<input
|
||||
type="checkbox"
|
||||
name="confirm_{{ row.uuid }}"
|
||||
aria-label="Confirm suggested match for {{ row.uuid }}"
|
||||
>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No matches found that need confirmation.</p>
|
||||
{% endif %}
|
||||
<button type="submit" class="btn btn-primary mt-3">Finalize Import</button>
|
||||
</form>
|
||||
|
||||
<button type="submit" class="btn btn-success">Confirm & Import</button>
|
||||
<a href="{{ url_for('importer.upload') }}" class="btn btn-secondary ms-2">Cancel</a>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
No rows to review. <a href="{{ url_for('importer.upload') }}">Upload another CSV?</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -1,28 +1,45 @@
|
||||
{% extends "core_ui/base.html" %}
|
||||
{% block title %}CSV Import{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<h2 class="mb-4">📤 Import Plant Data</h2>
|
||||
|
||||
{# Display flash messages (error, success, etc.) #}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||
<div
|
||||
class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show"
|
||||
role="alert"
|
||||
>
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
{# Hidden CSRF token #}
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="file" class="form-label">Choose CSV File</label>
|
||||
<input type="file" class="form-control" id="file" name="file" required>
|
||||
<input
|
||||
type="file"
|
||||
class="form-control"
|
||||
id="file"
|
||||
name="file"
|
||||
accept=".csv"
|
||||
required
|
||||
>
|
||||
<div class="form-text">
|
||||
Required: <code>uuid</code>, <code>plant_type</code>, <code>name</code><br>
|
||||
Optional: <code>scientific_name</code>, <code>mother_uuid</code>
|
||||
Required columns: <code>uuid</code>, <code>plant_type</code>, <code>name</code><br>
|
||||
Optional columns: <code>scientific_name</code>, <code>mother_uuid</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success">Upload</button>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -1,5 +1,78 @@
|
||||
from flask import Blueprint
|
||||
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
|
||||
from app import db
|
||||
from .models import Media, ImageHeart, FeaturedImage
|
||||
from plugins.plant.models import Plant
|
||||
|
||||
media_bp = Blueprint('media', __name__)
|
||||
bp = Blueprint("media", __name__, template_folder="templates")
|
||||
|
||||
# Add routes here as needed; do NOT define models here.
|
||||
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
|
||||
|
||||
@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")
|
||||
|
||||
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>")
|
||||
def media_file(filename):
|
||||
return send_from_directory(os.path.join(current_app.root_path, "static/uploads"), filename)
|
||||
|
||||
@bp.route("/media/heart/<int:image_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()
|
||||
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"})
|
||||
|
||||
@bp.route("/media/feature/<int:image_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":
|
||||
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)
|
||||
db.session.add(featured)
|
||||
db.session.commit()
|
||||
flash("Image set as featured.", "success")
|
||||
return redirect(request.referrer or url_for("core_ui.home"))
|
||||
|
@ -2,18 +2,11 @@
|
||||
|
||||
from datetime import datetime
|
||||
import uuid as uuid_lib
|
||||
|
||||
# Import the central SQLAlchemy instance, not a new one
|
||||
from app import db
|
||||
# from plugins.auth.models import User
|
||||
|
||||
# If your User model lives in plugins/auth/models.py, import it here:
|
||||
from plugins.auth.models import User
|
||||
|
||||
# -----------------------------
|
||||
# (We no longer need PlantLineage)
|
||||
# -----------------------------
|
||||
|
||||
# Association table for tags (unchanged)
|
||||
# Association table for Plant ↔ Tag (unchanged)
|
||||
plant_tags = db.Table(
|
||||
'plant_tags',
|
||||
db.metadata,
|
||||
@ -30,7 +23,6 @@ class Tag(db.Model):
|
||||
name = db.Column(db.String(128), unique=True, nullable=False)
|
||||
# … any other columns you had …
|
||||
|
||||
|
||||
class PlantCommonName(db.Model):
|
||||
__tablename__ = 'plant_common_name'
|
||||
__table_args__ = {'extend_existing': True}
|
||||
@ -46,7 +38,6 @@ class PlantCommonName(db.Model):
|
||||
cascade='all, delete-orphan'
|
||||
)
|
||||
|
||||
|
||||
class PlantScientificName(db.Model):
|
||||
__tablename__ = 'plant_scientific_name'
|
||||
__table_args__ = {'extend_existing': True}
|
||||
@ -56,12 +47,8 @@ class PlantScientificName(db.Model):
|
||||
common_id = db.Column(db.Integer, db.ForeignKey('plant_common_name.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
plants = db.relationship(
|
||||
'plugins.plant.models.Plant',
|
||||
backref='scientific',
|
||||
lazy='dynamic'
|
||||
)
|
||||
|
||||
# We removed the “plants” relationship from here to avoid backref conflicts.
|
||||
# If you need it, you can still do Plant.query.filter_by(scientific_id=<this id>).
|
||||
|
||||
class PlantOwnershipLog(db.Model):
|
||||
__tablename__ = 'plant_ownership_log'
|
||||
@ -72,11 +59,16 @@ class PlantOwnershipLog(db.Model):
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
date_acquired = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
transferred = db.Column(db.Boolean, default=False, nullable=False)
|
||||
graph_node_id = db.Column(db.String(255), nullable=True) # optional
|
||||
is_verified = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
user = db.relationship('plugins.auth.models.User', backref='ownership_logs', lazy=True)
|
||||
# Optional: if you ever want to store a pointer to the Neo4j node, you can re-add:
|
||||
# graph_node_id = db.Column(db.String(255), nullable=True)
|
||||
|
||||
user = db.relationship(
|
||||
'plugins.auth.models.User',
|
||||
backref='ownership_logs',
|
||||
lazy=True
|
||||
)
|
||||
|
||||
class Plant(db.Model):
|
||||
__tablename__ = 'plant'
|
||||
@ -94,6 +86,10 @@ class Plant(db.Model):
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow)
|
||||
|
||||
# ─── NEW: Flag that indicates whether the common/scientific name pair was human-verified ─────────────────
|
||||
data_verified = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
# Relationships
|
||||
updates = db.relationship(
|
||||
'plugins.growlog.models.PlantUpdate',
|
||||
backref='plant',
|
||||
|
@ -1,39 +1,35 @@
|
||||
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'
|
||||
__table_args__ = {'extend_existing': True}
|
||||
__tablename__ = "submissions"
|
||||
__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)
|
||||
plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=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)
|
||||
scientific_name = db.Column(db.String(120), nullable=True)
|
||||
notes = 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)
|
||||
|
||||
common_name = db.Column(db.String(120), nullable=False)
|
||||
scientific_name = db.Column(db.String(120))
|
||||
price = db.Column(db.Float, nullable=False)
|
||||
source = db.Column(db.String(120))
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
# 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")
|
||||
|
||||
height = db.Column(db.Float)
|
||||
width = db.Column(db.Float)
|
||||
leaf_count = db.Column(db.Integer)
|
||||
potting_mix = db.Column(db.String(255))
|
||||
container_size = db.Column(db.String(120))
|
||||
health_status = db.Column(db.String(50))
|
||||
notes = db.Column(db.Text)
|
||||
images = db.relationship("SubmissionImage", backref="submission", lazy=True)
|
||||
|
||||
# Image references via SubmissionImage table
|
||||
images = db.relationship('SubmissionImage', backref='submission', lazy=True)
|
||||
|
||||
|
||||
class SubmissionImage(db.Model):
|
||||
__tablename__ = 'submission_images'
|
||||
__table_args__ = {'extend_existing': True}
|
||||
__tablename__ = "submission_images"
|
||||
__table_args__ = {"extend_existing": True}
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
submission_id = db.Column(db.Integer, db.ForeignKey('submissions.id'), nullable=False)
|
||||
file_path = db.Column(db.String(255), nullable=False)
|
||||
is_visible = db.Column(db.Boolean, default=True)
|
||||
submission_id = db.Column(db.Integer, db.ForeignKey("submissions.id"), nullable=False)
|
||||
file_url = db.Column(db.String(256), nullable=False)
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
@ -1,10 +1,78 @@
|
||||
from flask import Blueprint, render_template
|
||||
from .models import Submission
|
||||
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
|
||||
from app import db
|
||||
from .models import SubmissionImage, ImageHeart, FeaturedImage
|
||||
from plugins.plant.models import Plant
|
||||
|
||||
bp = Blueprint('submission', __name__, url_prefix='/submission')
|
||||
bp = Blueprint("submission", __name__, template_folder="templates")
|
||||
|
||||
@bp.route('/')
|
||||
def index():
|
||||
submissions = Submission.query.order_by(Submission.timestamp.desc()).all()
|
||||
return render_template('submission/index.html', submissions=submissions)
|
||||
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
|
||||
|
||||
@bp.route("/submissions/upload", methods=["GET", "POST"])
|
||||
@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")
|
||||
|
||||
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"])
|
||||
@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"})
|
||||
|
||||
@bp.route("/submissions/feature/<int:image_id>", methods=["POST"])
|
||||
@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"))
|
||||
|
||||
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"))
|
||||
|
||||
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"))
|
0
plugins/transfer/__init__.py
Normal file
0
plugins/transfer/__init__.py
Normal file
41
plugins/transfer/models.py
Normal file
41
plugins/transfer/models.py
Normal file
@ -0,0 +1,41 @@
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
|
||||
class TransferRequest(db.Model):
|
||||
__tablename__ = 'transfer_request'
|
||||
__table_args__ = {'extend_existing': True}
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
plant_id = db.Column(db.Integer, db.ForeignKey('plant.id'), nullable=False)
|
||||
seller_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
buyer_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
status = db.Column(
|
||||
db.String(20),
|
||||
nullable=False,
|
||||
default='pending'
|
||||
)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow)
|
||||
seller_message = db.Column(db.String(512), nullable=True)
|
||||
buyer_message = db.Column(db.String(512), nullable=True)
|
||||
|
||||
plant = db.relationship(
|
||||
'plugins.plant.models.Plant',
|
||||
backref=db.backref('transfer_requests', lazy='dynamic'),
|
||||
lazy=True
|
||||
)
|
||||
seller = db.relationship(
|
||||
'plugins.auth.models.User',
|
||||
foreign_keys=[seller_id],
|
||||
backref='outgoing_transfers',
|
||||
lazy=True
|
||||
)
|
||||
buyer = db.relationship(
|
||||
'plugins.auth.models.User',
|
||||
foreign_keys=[buyer_id],
|
||||
backref='incoming_transfers',
|
||||
lazy=True
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TransferRequest id={self.id} plant={self.plant_id} seller={self.seller_id} buyer={self.buyer_id} status={self.status}>"
|
6
plugins/transfer/plugin.json
Normal file
6
plugins/transfer/plugin.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "transfer",
|
||||
"version": "1.0.0",
|
||||
"description": "Handles plant transfer requests between users",
|
||||
"entry_point": ""
|
||||
}
|
152
plugins/transfer/routes.py
Normal file
152
plugins/transfer/routes.py
Normal file
@ -0,0 +1,152 @@
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, redirect, url_for, flash, render_template
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from plugins.plant.models import db, Plant, PlantOwnershipLog
|
||||
from plugins.transfer.models import TransferRequest
|
||||
from plugins.auth.models import User
|
||||
|
||||
bp = Blueprint('transfer', __name__, template_folder='templates', url_prefix='/transfer')
|
||||
|
||||
@bp.route('/request/<int:plant_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def request_transfer(plant_id):
|
||||
plant = Plant.query.get_or_404(plant_id)
|
||||
|
||||
if plant.owner_id == current_user.id:
|
||||
seller = current_user
|
||||
if request.method == 'POST':
|
||||
buyer_id = request.form.get('buyer_id', type=int)
|
||||
buyer = User.query.get(buyer_id)
|
||||
if not buyer or buyer.id == seller.id:
|
||||
flash("Please select a valid buyer.", "error")
|
||||
return redirect(request.url)
|
||||
|
||||
tr = TransferRequest(
|
||||
plant_id=plant.id,
|
||||
seller_id=seller.id,
|
||||
buyer_id=buyer.id,
|
||||
status='pending',
|
||||
seller_message=request.form.get('seller_message', '').strip()
|
||||
)
|
||||
db.session.add(tr)
|
||||
db.session.commit()
|
||||
flash("Transfer request sent to buyer. Waiting for their approval.", "info")
|
||||
return redirect(url_for('plant.view', plant_id=plant.id))
|
||||
|
||||
all_users = User.query.filter(User.id != seller.id).all()
|
||||
return render_template(
|
||||
'transfer/request_transfer.html',
|
||||
plant=plant,
|
||||
all_users=all_users
|
||||
)
|
||||
|
||||
else:
|
||||
buyer = current_user
|
||||
if request.method == 'POST':
|
||||
seller_id = request.form.get('seller_id', type=int)
|
||||
seller = User.query.get(seller_id)
|
||||
if not seller or seller.id != plant.owner_id:
|
||||
flash("Please select the correct seller (current owner).", "error")
|
||||
return redirect(request.url)
|
||||
|
||||
tr = TransferRequest(
|
||||
plant_id=plant.id,
|
||||
seller_id=seller.id,
|
||||
buyer_id=buyer.id,
|
||||
status='pending',
|
||||
buyer_message=request.form.get('buyer_message', '').strip()
|
||||
)
|
||||
db.session.add(tr)
|
||||
db.session.commit()
|
||||
flash("Transfer request sent to seller. Waiting for their approval.", "info")
|
||||
return redirect(url_for('plant.view', plant_id=plant.id))
|
||||
|
||||
return render_template(
|
||||
'transfer/request_transfer.html',
|
||||
plant=plant,
|
||||
all_users=[User.query.get(plant.owner_id)]
|
||||
)
|
||||
|
||||
@bp.route('/incoming', methods=['GET'])
|
||||
@login_required
|
||||
def incoming_requests():
|
||||
pending = TransferRequest.query.filter_by(
|
||||
buyer_id=current_user.id,
|
||||
status='pending'
|
||||
).all()
|
||||
return render_template('transfer/incoming.html', pending=pending)
|
||||
|
||||
@bp.route('/approve/<int:request_id>', methods=['POST'])
|
||||
@login_required
|
||||
def approve_request(request_id):
|
||||
tr = TransferRequest.query.get_or_404(request_id)
|
||||
if current_user.id not in (tr.seller_id, tr.buyer_id):
|
||||
flash("You’re not authorized to approve this transfer.", "error")
|
||||
return redirect(url_for('transfer.incoming_requests'))
|
||||
|
||||
if current_user.id == tr.buyer_id:
|
||||
tr.status = 'buyer_approved'
|
||||
tr.buyer_message = request.form.get('message', tr.buyer_message)
|
||||
else:
|
||||
tr.status = 'seller_approved'
|
||||
tr.seller_message = request.form.get('message', tr.seller_message)
|
||||
|
||||
tr.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
flash("You have approved the transfer. Waiting on the other party.", "info")
|
||||
return redirect(url_for('transfer.incoming_requests'))
|
||||
|
||||
@bp.route('/finalize/<int:request_id>', methods=['POST'])
|
||||
@login_required
|
||||
def finalize_request(request_id):
|
||||
tr = TransferRequest.query.get_or_404(request_id)
|
||||
|
||||
buyer_approved = (
|
||||
TransferRequest.query
|
||||
.filter_by(id=tr.id, buyer_id=tr.buyer_id, status='buyer_approved')
|
||||
.first() is not None
|
||||
)
|
||||
seller_approved = (
|
||||
TransferRequest.query
|
||||
.filter_by(id=tr.id, seller_id=tr.seller_id, status='seller_approved')
|
||||
.first() is not None
|
||||
)
|
||||
|
||||
if not (buyer_approved and seller_approved):
|
||||
flash("Both parties must approve before finalizing.", "error")
|
||||
return redirect(url_for('transfer.incoming_requests'))
|
||||
|
||||
new_log = PlantOwnershipLog(
|
||||
plant_id=tr.plant_id,
|
||||
user_id=tr.buyer_id,
|
||||
date_acquired=datetime.utcnow(),
|
||||
transferred=True,
|
||||
is_verified=True
|
||||
)
|
||||
db.session.add(new_log)
|
||||
|
||||
plant = Plant.query.get(tr.plant_id)
|
||||
plant.owner_id = tr.buyer_id
|
||||
|
||||
tr.status = 'complete'
|
||||
tr.updated_at = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
flash("Transfer finalized—ownership updated.", "success")
|
||||
return redirect(url_for('plant.view', plant_id=tr.plant_id))
|
||||
|
||||
@bp.route('/reject/<int:request_id>', methods=['POST'])
|
||||
@login_required
|
||||
def reject_request(request_id):
|
||||
tr = TransferRequest.query.get_or_404(request_id)
|
||||
|
||||
if current_user.id not in (tr.seller_id, tr.buyer_id):
|
||||
flash("You’re not authorized to reject this transfer.", "error")
|
||||
return redirect(url_for('transfer.incoming_requests'))
|
||||
|
||||
tr.status = 'rejected'
|
||||
tr.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
flash("Transfer request has been rejected.", "warning")
|
||||
return redirect(url_for('transfer.incoming_requests'))
|
25
plugins/transfer/templates/transfer/incoming.html
Normal file
25
plugins/transfer/templates/transfer/incoming.html
Normal file
@ -0,0 +1,25 @@
|
||||
{% extends 'core_ui/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Incoming Transfer Requests</h2>
|
||||
{% if pending %}
|
||||
<ul>
|
||||
{% for tr in pending %}
|
||||
<li>
|
||||
Plant: {{ tr.plant.custom_slug or tr.plant.uuid }} |
|
||||
From: {{ tr.seller.username }} |
|
||||
<form action="{{ url_for('transfer.approve_request', request_id=tr.id) }}" method="post" style="display:inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit">Approve</button>
|
||||
</form>
|
||||
<form action="{{ url_for('transfer.reject_request', request_id=tr.id) }}" method="post" style="display:inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit">Reject</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No pending requests.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
28
plugins/transfer/templates/transfer/request_transfer.html
Normal file
28
plugins/transfer/templates/transfer/request_transfer.html
Normal file
@ -0,0 +1,28 @@
|
||||
{% extends 'core_ui/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Request Transfer: {{ plant.custom_slug or plant.uuid }}</h2>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% if plant.owner_id == current_user.id %}
|
||||
<label for="buyer_id">Select Buyer:</label>
|
||||
<select name="buyer_id" id="buyer_id">
|
||||
{% for user in all_users %}
|
||||
<option value="{{ user.id }}">{{ user.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<br>
|
||||
<label for="seller_message">Message (optional):</label><br>
|
||||
<textarea name="seller_message" id="seller_message"></textarea><br>
|
||||
{% else %}
|
||||
<label for="seller_id">Confirm Seller:</label>
|
||||
<select name="seller_id" id="seller_id">
|
||||
<option value="{{ all_users[0].id }}">{{ all_users[0].username }}</option>
|
||||
</select>
|
||||
<br>
|
||||
<label for="buyer_message">Message (optional):</label><br>
|
||||
<textarea name="buyer_message" id="buyer_message"></textarea><br>
|
||||
{% endif %}
|
||||
<button type="submit">Send Request</button>
|
||||
</form>
|
||||
{% endblock %}
|
Reference in New Issue
Block a user