more, view and edit broken

This commit is contained in:
2025-06-11 05:23:52 -05:00
parent 38cf4d962d
commit e7a0f5b1be
38 changed files with 1008 additions and 141 deletions

BIN
main-app.zip Normal file

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,7 +31,7 @@
<ul class="navbar-nav me-auto"> <ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" href="{{ url_for('core_ui.home') }}">Home</a></li> <li class="nav-item"><a class="nav-link" href="{{ url_for('core_ui.home') }}">Home</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('plant.index') }}">Plants</a></li> <li class="nav-item"><a class="nav-link" href="{{ url_for('plant.index') }}">Plants</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('importer.upload') }}">Import</a></li> <li class="nav-item"><a class="nav-link" href="{{ url_for('utility.upload') }}">Import</a></li>
{% if current_user.is_authenticated and current_user.role == 'admin' %} {% if current_user.is_authenticated and current_user.role == 'admin' %}
<li class="nav-item"><a class="nav-link text-danger" href="{{ url_for('admin.admin_dashboard') }}">Admin Dashboard</a></li> <li class="nav-item"><a class="nav-link text-danger" href="{{ url_for('admin.admin_dashboard') }}">Admin Dashboard</a></li>
{% endif %} {% endif %}

View File

@ -1,6 +0,0 @@
{
"name": "importer",
"version": "1.0.0",
"description": "Plant import from stand alone application",
"entry_point": null
}

View File

@ -8,6 +8,7 @@ from flask import (
url_for, url_for,
request, request,
flash, flash,
current_app,
) )
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db from app import db
@ -37,19 +38,44 @@ def inject_image_helper():
@bp.route('/', methods=['GET']) @bp.route('/', methods=['GET'])
@login_required @login_required
def index(): def index():
# 1. Compute per-type stats
total = Plant.query.filter_by(owner_id=current_user.id).count()
raw_types = (
db.session
.query(Plant.plant_type)
.filter_by(owner_id=current_user.id)
.distinct()
.all()
)
plant_types = [pt[0] for pt in raw_types]
stats = {'All': total}
for pt in plant_types:
stats[pt] = Plant.query.filter_by(
owner_id=current_user.id,
plant_type=pt
).count()
# 2. Load ALL this users plants, ordered by common name
plants = ( plants = (
Plant.query Plant.query
.filter_by(owner_id=current_user.id) .filter_by(owner_id=current_user.id)
.order_by(Plant.created_at.desc()) .join(PlantCommonName, Plant.common_id == PlantCommonName.id)
.order_by(PlantCommonName.name)
.options(
db.joinedload(Plant.media),
db.joinedload(Plant.common_name)
)
.all() .all()
) )
stats = {
'user_plants': Plant.query.filter_by(owner_id=current_user.id).count(), # 3. Render the template (JS will handle filtering & pagination)
'user_images': Media.query.filter_by(uploader_id=current_user.id).count(), return render_template(
'total_plants': Plant.query.count(), 'plant/index.html',
'total_images': Media.query.count(), plants=plants,
} stats=stats,
return render_template('plant/index.html', plants=plants, stats=stats) plant_types=plant_types
)
# ─── CREATE ─────────────────────────────────────────────────────────────────── # ─── CREATE ───────────────────────────────────────────────────────────────────
@bp.route('/create', methods=['GET', 'POST']) @bp.route('/create', methods=['GET', 'POST'])

View File

@ -1,129 +1,176 @@
{# plugins/plant/templates/plant/index.html #}
{% extends 'core_ui/base.html' %} {% extends 'core_ui/base.html' %}
{% block title %}Plant List Nature In Pots{% endblock %}
{% block content %} {% block content %}
<div class="container my-4"> <div class="container mt-4">
<!-- Stats Section (responsive 2-col ↔ 1-col) -->
<div class="mb-4 p-3 bg-light border rounded"> <!-- Header + Add button -->
<h5>Statistics</h5> <div class="d-flex justify-content-between align-items-center mb-3">
<div class="row row-cols-1 row-cols-md-2 g-3 mt-3 text-center"> <h1>My Plants</h1>
<div class="col"> <a href="{{ url_for('plant.create') }}" class="btn btn-primary">
<div class="d-flex align-items-center justify-content-center"> + Add New Plant
<i class="bi bi-seedling fs-3 text-success me-3"></i> </a>
<div> </div>
<div class="small text-muted">Your plants</div>
<div class="fw-bold">{{ stats.user_plants }}</div> <!-- Stats Panel -->
</div> <div class="card mb-4">
</div> <div class="card-header">
</div> <a data-bs-toggle="collapse" href="#statsBox" aria-expanded="false">
<div class="col"> Stats &nbsp;<i class="bi bi-chevron-down"></i>
<div class="d-flex align-items-center justify-content-center"> </a>
<i class="bi bi-image fs-3 text-primary me-3"></i> </div>
<div> <div class="collapse" id="statsBox">
<div class="small text-muted">Your images</div> <ul class="list-group list-group-flush">
<div class="fw-bold">{{ stats.user_images }}</div> {% for label, count in stats.items() %}
</div> <li class="list-group-item d-flex justify-content-between">
</div> <span>{{ label.replace('_',' ').title() }}</span>
</div> <span>{{ count }}</span>
<div class="col"> </li>
<div class="d-flex align-items-center justify-content-center"> {% endfor %}
<i class="bi bi-tree fs-3 text-success me-3"></i> </ul>
<div>
<div class="small text-muted">Total plants</div>
<div class="fw-bold">{{ stats.total_plants }}</div>
</div>
</div>
</div>
<div class="col">
<div class="d-flex align-items-center justify-content-center">
<i class="bi bi-images fs-3 text-primary me-3"></i>
<div>
<div class="small text-muted">Total images</div>
<div class="fw-bold">{{ stats.total_images }}</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<h1 class="mb-4">Plant List</h1> <!-- Import/Export & Filter bar -->
<div class="mb-3 d-flex flex-wrap justify-content-between align-items-center">
<!-- Search & Add --> <div class="mb-2 d-flex align-items-center">
<div class="mb-3 d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center gap-2"> <a href="{{ url_for('utility.upload') }}" class="btn btn-primary me-2">
<div> Import CSV
<a href="{{ url_for('plant.create') }}" class="btn btn-success">Add New Plant</a> </a>
<a href="{{ url_for('utility.export_data') }}" class="btn btn-secondary me-2">
Export My Data
</a>
</div> </div>
<div class="input-group" style="max-width:300px; width:100%;"> <div class="d-flex flex-wrap flex-md-nowrap align-items-center">
<span class="input-group-text">Search</span> <div class="input-group me-2 mb-2 mb-md-0" style="min-width:200px;">
<input id="searchInput" type="text" class="form-control" placeholder="by name…"> <span class="input-group-text">Search</span>
<input id="searchInput" type="text" class="form-control" placeholder="by name…">
</div>
<select id="typeFilter" class="form-select me-2 mb-2 mb-md-0" style="min-width:140px;">
<option value="">All Types</option>
{% for t in plant_types %}
<option value="{{ t|lower }}">{{ t.replace('_',' ').title() }}</option>
{% endfor %}
</select>
<select id="pageSizeSelect" class="form-select mb-2 mb-md-0" style="min-width:140px;">
{% for size in [6,12,18,24] %}
<option value="{{ size }}" {% if size == 12 %}selected{% endif %}>
{{ size }} per page
</option>
{% endfor %}
</select>
</div> </div>
</div> </div>
{% if plants %} <!-- Plant Cards Container -->
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 g-4" id="plantContainer"> <div class="row row-cols-1 row-cols-md-3 g-4" id="plantContainer">
{% for plant in plants %} {% for plant in plants %}
<div class="col plant-card" data-name="{{ plant.common_name.name|lower if plant.common_name else '' }}"> <div class="col plant-card"
<div class="card h-100"> data-name="{{ plant.common_name.name|lower }}"
{# pick the featured media entry, or fall back to first item #} data-type="{{ plant.plant_type|lower }}">
{% set featured = plant.media <div class="card h-100">
|selectattr('id','equalto', plant.featured_media_id)
|first %}
{% if not featured and plant.media %}
{% set featured = plant.media|first %}
{% endif %}
{% if featured %} {% if plant.media %}
<a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}"> {% set featured = plant.media|selectattr('featured')|first or plant.media[0] %}
<img <a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}">
src="{{ generate_image_url(featured.file_url) }}"
class="card-img-top"
style="height:200px;object-fit:cover;"
alt="Image for {{ plant.common_name.name if plant.common_name else 'Plant' }}">
</a>
{% else %}
<img <img
src="https://placehold.co/300x200" src="{{ url_for('media.media_file', filename=featured.file_url) }}"
class="card-img-top" class="card-img-top"
style="height:200px;object-fit:cover;" style="height:200px; object-fit:cover;"
alt="No image available"> alt="{{ plant.common_name.name }}"
{% endif %} >
</a>
{% else %}
<!-- Placeholder when no media -->
<a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}">
<img
src="{{ generate_image_url(None) }}"
class="card-img-top"
style="height:200px; object-fit:cover;"
alt="Placeholder for {{ plant.common_name.name }}"
>
</a>
{% endif %}
<div class="card-body d-flex flex-column"> <div class="card-body d-flex flex-column">
<h5 class="card-title mb-1"> <h5 class="card-title">{{ plant.common_name.name }}</h5>
<a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}"> <p class="card-text">{{ plant.plant_type.replace('_',' ').title() }}</p>
{{ plant.common_name.name if plant.common_name else 'Untitled' }} <div class="mt-auto">
</a> <a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}"
</h5> class="btn btn-sm btn-outline-primary">View</a>
<h6 class="text-muted small">{{ plant.uuid }}</h6> <a href="{{ url_for('plant.edit', uuid_val=plant.uuid) }}"
<p class="mb-1"><strong>Type:</strong> {{ plant.plant_type }}</p> class="btn btn-sm btn-outline-secondary">Edit</a>
<p class="mb-2"><strong>Scientific:</strong>
{{ plant.scientific_name.name if plant.scientific_name else '' }}
</p>
<div class="mt-auto d-flex flex-wrap gap-1">
<a href="{{ url_for('plant.detail', uuid_val=plant.uuid) }}"
class="btn btn-sm btn-primary">View</a>
<a href="{{ url_for('plant.edit', uuid_val=plant.uuid) }}"
class="btn btn-sm btn-secondary">Edit</a>
</div>
</div> </div>
</div> </div>
</div> </div>
{% endfor %} </div>
</div> {% endfor %}
{% else %} </div>
<p>No plants found yet. <a href="{{ url_for('plant.create') }}">Add one now.</a></p>
{% endif %} <!-- Pagination Controls -->
<nav aria-label="Page navigation" class="mt-4">
<ul class="pagination justify-content-center" id="pagination"></ul>
</nav>
</div> </div>
<!-- Client-side filtering & pagination script -->
<script> <script>
// client-side filtering (function() {
document.getElementById('searchInput').addEventListener('input', function() { const searchInput = document.getElementById('searchInput');
const q = this.value.trim().toLowerCase(); const typeFilter = document.getElementById('typeFilter');
document.querySelectorAll('#plantContainer .plant-card').forEach(card => { const pageSizeSelect = document.getElementById('pageSizeSelect');
card.style.display = card.dataset.name.includes(q) ? '' : 'none'; const container = document.getElementById('plantContainer');
const pagination = document.getElementById('pagination');
const cards = Array.from(container.querySelectorAll('.plant-card'));
let currentPage = 1;
let pageSize = parseInt(pageSizeSelect.value, 10);
function filterAndPaginate() {
const q = searchInput.value.trim().toLowerCase();
const type = typeFilter.value;
const filtered = cards.filter(c => {
return c.dataset.name.includes(q) &&
(!type || c.dataset.type === type);
}); });
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
if (currentPage > totalPages) currentPage = totalPages;
cards.forEach(c => c.style.display = 'none');
const start = (currentPage - 1) * pageSize;
filtered.slice(start, start + pageSize).forEach(c => c.style.display = '');
pagination.innerHTML = '';
const makeLi = (label, page, disabled) => {
const li = document.createElement('li');
li.className = 'page-item' + (disabled ? ' disabled' : '') + (page === currentPage ? ' active' : '');
li.innerHTML = `<a class="page-link" href="#">${label}</a>`;
if (!disabled) {
li.onclick = e => { e.preventDefault(); currentPage = page; filterAndPaginate(); };
}
return li;
};
pagination.appendChild(makeLi('Prev', Math.max(1, currentPage-1), currentPage===1));
for (let i = 1; i <= Math.min(5, totalPages); i++) {
pagination.appendChild(makeLi(i, i, false));
}
if (totalPages > 5) {
const ell = document.createElement('li');
ell.className = 'page-item disabled';
ell.innerHTML = `<span class="page-link">…</span>`;
pagination.appendChild(ell);
pagination.appendChild(makeLi(totalPages, totalPages, false));
}
pagination.appendChild(makeLi('Next', Math.min(totalPages, currentPage+1), currentPage===totalPages));
}
[searchInput, typeFilter].forEach(el => el.addEventListener('input', () => { currentPage = 1; filterAndPaginate(); }));
pageSizeSelect.addEventListener('change', () => {
pageSize = parseInt(pageSizeSelect.value, 10);
currentPage = 1;
filterAndPaginate();
}); });
filterAndPaginate();
})();
</script> </script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,6 @@
{
"name": "utility",
"version": "1.0.0",
"description": "General utilities and such",
"entry_point": null
}

View File

@ -1,18 +1,20 @@
# plugins/importer/routes.py # plugins/utility/routes.py
import csv import csv
import io import io
import uuid import uuid
import difflib import difflib
import os import os
import re
import zipfile import zipfile
import tempfile import tempfile
from datetime import datetime from datetime import datetime
from flask import ( from flask import (
Blueprint, request, render_template, redirect, flash, Blueprint, request, render_template, redirect, flash,
session, url_for, current_app session, url_for, send_file, current_app
) )
from werkzeug.utils import secure_filename
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf.csrf import generate_csrf from flask_wtf.csrf import generate_csrf
@ -25,20 +27,20 @@ from plugins.plant.models import (
PlantOwnershipLog, PlantOwnershipLog,
) )
from plugins.media.models import Media from plugins.media.models import Media
from plugins.importer.models import ImportBatch # tracks which exports have been imported from plugins.utility.models import ImportBatch # tracks which exports have been imported
bp = Blueprint( bp = Blueprint(
'importer', 'utility',
__name__, __name__,
template_folder='templates', template_folder='templates',
url_prefix='/importer' url_prefix='/utility'
) )
@bp.route("/", methods=["GET"]) @bp.route("/", methods=["GET"])
@login_required @login_required
def index(): def index():
# When someone hits /importer/, redirect to /importer/upload # When someone hits /utility/, redirect to /utility/upload
return redirect(url_for("importer.upload")) return redirect(url_for("utility.upload"))
# ──────────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────────
# Required headers for your sub-app export ZIP # Required headers for your sub-app export ZIP
@ -195,7 +197,6 @@ def upload():
if not plant_obj: if not plant_obj:
continue continue
# derive subpath inside ZIP by stripping "uploads/"
subpath = mrow["Image Path"].split('uploads/', 1)[1] subpath = mrow["Image Path"].split('uploads/', 1)[1]
src = os.path.join(tmpdir, "images", subpath) src = os.path.join(tmpdir, "images", subpath)
if not os.path.isfile(src): if not os.path.isfile(src):
@ -213,7 +214,6 @@ def upload():
with open(src, "rb") as sf, open(dst, "wb") as df: with open(src, "rb") as sf, open(dst, "wb") as df:
df.write(sf.read()) df.write(sf.read())
# 🔧 FIXED: match your Media model exactly
media = Media( media = Media(
file_url=f"uploads/{current_user.id}/{plant_obj.id}/{fname}", file_url=f"uploads/{current_user.id}/{plant_obj.id}/{fname}",
uploaded_at=datetime.fromisoformat(mrow["Uploaded At"]), uploaded_at=datetime.fromisoformat(mrow["Uploaded At"]),
@ -288,13 +288,13 @@ def upload():
session["pending_rows"].append(item) session["pending_rows"].append(item)
session["review_list"] = review_list session["review_list"] = review_list
return redirect(url_for("importer.review")) return redirect(url_for("utility.review"))
flash("Unsupported file type. Please upload a ZIP or CSV.", "danger") flash("Unsupported file type. Please upload a ZIP or CSV.", "danger")
return redirect(request.url) return redirect(request.url)
# GET → render the upload form # GET → render the upload form
return render_template("importer/upload.html", csrf_token=generate_csrf()) return render_template("utility/upload.html", csrf_token=generate_csrf())
@bp.route("/review", methods=["GET", "POST"]) @bp.route("/review", methods=["GET", "POST"])
@ -373,10 +373,104 @@ def review():
flash(f"{added} plants added (MySQL) and Neo4j updated.", "success") flash(f"{added} plants added (MySQL) and Neo4j updated.", "success")
session.pop("pending_rows", None) session.pop("pending_rows", None)
session.pop("review_list", None) session.pop("review_list", None)
return redirect(url_for("importer.upload")) return redirect(url_for("utility.upload"))
return render_template( return render_template(
"importer/review.html", "utility/review.html",
review_list=review_list, review_list=review_list,
csrf_token=generate_csrf() csrf_token=generate_csrf()
) )
@bp.route('/export_data', methods=['GET'])
@login_required
def export_data():
# Unique export identifier
export_id = f"{uuid.uuid4()}_{int(datetime.utcnow().timestamp())}"
# 1) Gather plants
plants = (
Plant.query
.filter_by(owner_id=current_user.id)
.order_by(Plant.id)
.all()
)
# Build plants.csv
plant_io = io.StringIO()
pw = csv.writer(plant_io)
pw.writerow([
'UUID', 'Type', 'Name', 'Scientific Name',
'Vendor Name', 'Price', 'Mother UUID', 'Notes'
])
for p in plants:
pw.writerow([
p.uuid,
p.plant_type,
p.common_name.name if p.common_name else '',
p.scientific_name.name if p.scientific_name else '',
getattr(p, 'vendor_name', '') or '',
getattr(p, 'price', '') or '',
p.mother_uuid or '',
p.notes or ''
])
plants_csv = plant_io.getvalue()
# 2) Gather media
media_records = (
Media.query
.filter_by(uploader_id=current_user.id)
.order_by(Media.id)
.all()
)
# Build media.csv
media_io = io.StringIO()
mw = csv.writer(media_io)
mw.writerow([
'Plant UUID', 'Image Path', 'Uploaded At', 'Source Type'
])
for m in media_records:
mw.writerow([
m.plant.uuid,
m.file_url,
m.uploaded_at.isoformat() if m.uploaded_at else '',
m.caption or ''
])
media_csv = media_io.getvalue()
# 3) Assemble ZIP with images from UPLOAD_FOLDER
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, 'w', zipfile.ZIP_DEFLATED) as zf:
# metadata.txt
meta = (
f"export_id,{export_id}\n"
f"user_id,{current_user.id}\n"
f"exported_at,{datetime.utcnow().isoformat()}\n"
)
zf.writestr('metadata.txt', meta)
# CSV files
zf.writestr('plants.csv', plants_csv)
zf.writestr('media.csv', media_csv)
# real image files under images/
media_root = current_app.config['UPLOAD_FOLDER']
for m in media_records:
# file_url is “uploads/...”
rel = m.file_url.split('uploads/', 1)[-1]
abs_path = os.path.join(media_root, rel)
if os.path.isfile(abs_path):
arcname = os.path.join('images', rel)
zf.write(abs_path, arcname)
zip_buf.seek(0)
# Safe filename
safe_email = re.sub(r'\W+', '_', current_user.email)
filename = f"{safe_email}_export_{export_id}.zip"
return send_file(
zip_buf,
mimetype='application/zip',
as_attachment=True,
download_name=filename
)

View File

@ -61,11 +61,11 @@
</table> </table>
<button type="submit" class="btn btn-success">Confirm &amp; Import</button> <button type="submit" class="btn btn-success">Confirm &amp; Import</button>
<a href="{{ url_for('importer.upload') }}" class="btn btn-secondary ms-2">Cancel</a> <a href="{{ url_for('utility.upload') }}" class="btn btn-secondary ms-2">Cancel</a>
</form> </form>
{% else %} {% else %}
<div class="alert alert-info"> <div class="alert alert-info">
No rows to review. <a href="{{ url_for('importer.upload') }}">Upload another CSV?</a> No rows to review. <a href="{{ url_for('utility.upload') }}">Upload another CSV?</a>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@ -1,7 +1,7 @@
You are ChatGPT, an expert Flask developer. I will upload two ZIP files: You are ChatGPT, an expert Flask developer. I will upload two ZIP files:
* **plant-scan-main.zip** (the standalone sub-app) * **plant-scan-main.zip** (the standalone sub-app)
* **natureinpots\_main.zip** (the main app with its importer plugin) * **natureinpots\_main.zip** (the main app with its utility plugin)
When I upload them, **immediately reply**: When I upload them, **immediately reply**:
@ -62,11 +62,11 @@ Then, following the exact file structures in those ZIPs, **implement** all of th
--- ---
## 3. MAIN-APP IMPORTER REFACTOR (“Nature in Pots”) ## 3. MAIN-APP utility REFACTOR (“Nature in Pots”)
1. **Web-Based ZIP Upload** 1. **Web-Based ZIP Upload**
* Change the importer form to accept a `.zip` file. * Change the utility form to accept a `.zip` file.
* On upload, unzip into a temp directory expecting `plants.csv`, `media.csv`, and `images/`. * On upload, unzip into a temp directory expecting `plants.csv`, `media.csv`, and `images/`.
* If only a CSV is uploaded, process it but skip media. * If only a CSV is uploaded, process it but skip media.
@ -98,7 +98,7 @@ Then, following the exact file structures in those ZIPs, **implement** all of th
* Supply defaults for any non-nullable fields missing from `plants.csv`. * Supply defaults for any non-nullable fields missing from `plants.csv`.
* Pass all imported fields (e.g. `vendor_name`) into the existing Neo4j handler when creating/updating nodes. * Pass all imported fields (e.g. `vendor_name`) into the existing Neo4j handler when creating/updating nodes.
6. **Manual Testing (Importer)** 6. **Manual Testing (utility)**
* As a main-app user, upload the `<username>_export.zip`; confirm no duplicates on re-upload. * As a main-app user, upload the `<username>_export.zip`; confirm no duplicates on re-upload.
* Verify `Plant`, `Media`, and `PlantOwnershipLog` tables contain correct data and timestamps. * Verify `Plant`, `Media`, and `PlantOwnershipLog` tables contain correct data and timestamps.