a ton of fun happened, refactored alot

This commit is contained in:
2025-07-03 04:29:43 -05:00
parent 72e060d783
commit 1bbe6e2743
121 changed files with 2315 additions and 900 deletions

View File

@ -1,6 +1,17 @@
{
"name": "admin",
"version": "0.1.1",
"description": "Admin panel plugin for Nature In Pots",
"entry_point": "register_cli"
"name": "Admin",
"version": "0.1.0",
"author": "Bryson Shepard <bryson@natureinpots.com>",
"description": "Provides the administrative UI and analytics hooks.",
"module": "plugins.admin",
"routes": {
"module": "plugins.admin.routes",
"blueprint": "bp",
"url_prefix": "/admin"
},
"models": [
"plugins.admin.models"
],
"license": "Proprietary",
"repository": "https://github.com/your-org/your-app"
}

View File

@ -11,7 +11,7 @@ from datetime import datetime, timedelta
from app import db
from plugins.auth.models import User
from plugins.growlog.models import GrowLog
from plugins.plant.growlog.models import GrowLog
from plugins.plant.models import Plant
from plugins.admin.models import AnalyticsEvent
from .forms import UserForm

View File

@ -1,5 +1,5 @@
{# plugins/admin/templates/admin/dashboard.html #}
{% extends 'core_ui/base.html' %}
{% extends 'core/base.html' %}
{% block title %}Admin Dashboard Nature In Pots{% endblock %}
{% block styles %}

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %}
{% extends 'core/base.html' %}
{% block title %}{{ action }} User Admin Nature In Pots{% endblock %}
{% block content %}

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %}
{% extends 'core/base.html' %}
{% block title %}Users Admin Nature In Pots{% endblock %}
{% block content %}
<h1>Users</h1>

15
plugins/auth/forms.py Normal file
View File

@ -0,0 +1,15 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, Length, EqualTo
class RegistrationForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=25)])
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired(), Length(min=6)])
confirm = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Register')
class LoginForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired()])
submit = SubmitField('Login')

View File

@ -1,7 +1,9 @@
# File: plugins/auth/models.py
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin
from datetime import datetime
from app import db
from app import db, login_manager
class User(db.Model, UserMixin):
__tablename__ = 'users'
@ -15,14 +17,10 @@ class User(db.Model, UserMixin):
excluded_from_analytics = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Soft-delete flag
is_deleted = db.Column(db.Boolean, nullable=False, default=False)
# Permanent ban flag
is_banned = db.Column(db.Boolean, nullable=False, default=False)
# Temporary suspension until this UTC datetime
suspended_until = db.Column(db.DateTime, nullable=True)
# Use back_populates, not backref
submitted_submissions = db.relationship(
"Submission",
foreign_keys="Submission.user_id",
@ -42,3 +40,20 @@ class User(db.Model, UserMixin):
def check_password(self, password):
return check_password_hash(self.password_hash, password)
# ─── Flask-Login integration ─────────────────────────────────────────────────
def _load_user(user_id):
"""Return a User by ID, or None."""
if not str(user_id).isdigit():
return None
return User.query.get(int(user_id))
def register_user_loader(app):
"""
Hook into Flask-Login to register the user_loader.
Called by our JSON-driven loader if declared in plugin.json.
"""
login_manager.user_loader(_load_user)

View File

@ -1,6 +1,27 @@
{
"name": "auth",
"version": "1.0.0",
"description": "User authentication and authorization plugin",
"entry_point": null
"name": "Auth",
"version": "0.1.0",
"author": "Bryson Shepard <bryson@natureinpots.com>",
"description": "Handles user registration, login, logout, and invitation flows.",
"module": "plugins.auth",
"routes": {
"module": "plugins.auth.routes",
"blueprint": "bp",
"url_prefix": "/auth"
},
"models": [
"plugins.auth.models"
],
"template_globals": [
{
"name": "current_user",
"callable": "flask_login.current_user"
}
],
"user_loader": {
"module": "plugins.auth.models",
"callable": "register_user_loader"
},
"license": "Proprietary",
"repository": "https://github.com/your-org/your-app"
}

View File

@ -1,51 +1,48 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
from flask_login import login_user, logout_user, login_required
from werkzeug.security import check_password_hash
from app import db
from .models import User
# File: plugins/auth/routes.py
from flask import Blueprint, render_template, redirect, flash, url_for, request
from flask_login import login_user, logout_user, login_required
from .models import User
from .forms import LoginForm, RegistrationForm
from app import db
bp = Blueprint(
'auth',
__name__,
template_folder='templates/auth', # ← now points at plugins/auth/templates/auth/
url_prefix='/auth'
)
bp = Blueprint('auth', __name__, template_folder='templates')
@bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
email = request.form['email']
password = request.form['password']
user = User.query.filter_by(email=email).first()
if user and check_password_hash(user.password_hash, password):
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user and user.check_password(form.password.data):
login_user(user)
flash('Logged in successfully.', 'success')
return redirect(url_for('core_ui.home'))
else:
flash('Invalid credentials.', 'danger')
return render_template('auth/login.html')
return redirect(url_for('home'))
flash('Invalid email or password.', 'danger')
return render_template('login.html', form=form) # resolves to templates/auth/login.html
@bp.route('/logout')
@login_required
def logout():
logout_user()
flash('Logged out.', 'info')
return redirect(url_for('core_ui.home'))
return redirect(url_for('home'))
@bp.route('/register', methods=['GET', 'POST'])
def register():
if not current_app.config.get('ALLOW_REGISTRATION', True):
flash('Registration is currently closed.', 'warning')
form = RegistrationForm()
if form.validate_on_submit():
user = User(email=form.email.data)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('Account created! Please log in.', 'success')
return redirect(url_for('auth.login'))
if request.method == 'POST':
email = request.form['email']
password = request.form['password']
existing_user = User.query.filter_by(email=email).first()
if existing_user:
flash('Email already registered.', 'warning')
else:
user = User(email=email)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash('Account created. You can now log in.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/register.html')
return render_template('register.html', form=form) # resolves to templates/auth/register.html

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %}
{% extends 'core/base.html' %}
{% block content %}
<h2>Login</h2>
<form method="POST" action="{{ url_for('auth.login') }}">

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %}
{% extends 'core/base.html' %}
{% block title %}Register{% endblock %}
{% block content %}
<h2>Register</h2>

View File

@ -1,6 +1,12 @@
{
"name": "cli",
"version": "1.0.0",
"description": "Command-line interface plugin",
"entry_point": null
}
"name": "CLI",
"version": "0.1.0",
"author": "Bryson Shepard <bryson@natureinpots.com>",
"description": "Adds custom Flask CLI commands for seeding and maintenance.",
"module": "plugins.cli",
"cli": {
"module": "plugins.cli.seed",
"callable": "preload_data_cli"
},
"license": "Proprietary"
}

View File

@ -1,6 +0,0 @@
{
"name": "core_ui",
"version": "1.1.0",
"description": "Media rendering macros and styling helpers",
"entry_point": null
}

View File

@ -1,12 +0,0 @@
from flask import Blueprint, render_template
from flask_login import login_required, current_user
bp = Blueprint('core_ui', __name__, template_folder='templates')
@bp.route('/')
def home():
return render_template('core_ui/home.html')
@bp.route('/health')
def health():
return 'OK', 200

View File

@ -1,35 +0,0 @@
{# plugins/core_ui/templates/core_ui/_media_macros.html #}
{% macro render_media_list(media_list, thumb_width=150, current_user=None) -%}
{% if media_list %}
<div class="row">
{% for media in media_list %}
<div class="col-md-3 mb-4" data-media-id="{{ media.id }}">
<div class="card shadow-sm">
<img src="{{ url_for('media.serve', plugin=media.plugin, filename=media.filename) }}"
class="card-img-top" style="width:100%; height:auto;">
{% if media.caption %}
<div class="card-body p-2">
<p class="card-text text-center">{{ media.caption }}</p>
</div>
{% endif %}
<div class="card-footer d-flex justify-content-between align-items-center p-2">
<button class="btn btn-sm btn-outline-danger heart-btn" data-id="{{ media.id }}">
❤️ <span class="heart-count">{{ media.hearts|length }}</span>
</button>
{% if current_user and (current_user.id == media.uploader_id or current_user.role == 'admin') %}
<form method="POST" action="{{ url_for('media.set_featured_image', media_id=media.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if media.featured_entries|length %}
<button class="btn btn-sm btn-outline-secondary" type="submit">★ Featured</button>
{% else %}
<button class="btn btn-sm btn-outline-primary" type="submit">☆ Set Featured</button>
{% endif %}
</form>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{%- endmacro %}

View File

@ -1,220 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Nature In Pots Community{% endblock %}</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet"
>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
rel="stylesheet"
>
<style>
body { display: flex; flex-direction: column; min-height: 100vh; }
main { flex: 1; }
footer { background: #f8f9fa; padding: 1rem 0; text-align: center; }
</style>
{% block styles %}{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-white shadow-sm mb-4">
<div class="container">
<a class="navbar-brand fw-bold" href="{{ url_for('core_ui.home') }}">
Nature In Pots
</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#mainNav"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<!-- Left links -->
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item me-2">
<a class="nav-link" href="{{ url_for('core_ui.home') }}">Home</a>
</li>
{% if current_user.is_authenticated %}
<li class="nav-item me-2">
<a class="nav-link" href="{{ url_for('plant.index') }}">Plants</a>
</li>
<li class="nav-item me-2">
<a class="nav-link" href="{{ url_for('growlog.list_logs') }}">Grow Logs</a>
</li>
<li class="nav-item me-2">
<a class="nav-link" href="{{ url_for('submission.list_submissions') }}">
Submissions
</a>
</li>
{% endif %}
</ul>
<!-- New-item + Admin + Plugins -->
{% if current_user.is_authenticated %}
<div class="d-flex align-items-center">
<!-- + New dropdown -->
<div class="btn-group me-3">
<button
type="button"
class="btn btn-success"
onclick="location.href='{{ url_for('plant.create') }}'"
>
<i class="bi bi-plus-lg"></i>
</button>
<button
type="button"
class="btn btn-success dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle menu</span>
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for('plant.create') }}">
New Plant
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('growlog.add_log') }}">
New Grow Log
</a>
</li>
<li>
<a
class="dropdown-item"
href="{{ url_for('submission.new_submission') }}"
>
New Submission
</a>
</li>
</ul>
</div>
<!-- Admin link -->
{% if current_user.role == 'admin' %}
<a
class="btn btn-outline-danger me-3"
href="{{ url_for('admin.dashboard') }}"
>
Admin Dashboard
</a>
{% endif %}
<!-- Plugins dropdown -->
<div class="dropdown me-3">
<a
class="btn btn-outline-secondary dropdown-toggle"
href="#"
id="pluginsDropdown"
role="button"
data-bs-toggle="dropdown"
>
Plugins
</a>
<ul
class="dropdown-menu dropdown-menu-end"
aria-labelledby="pluginsDropdown"
>
<li><a class="dropdown-item" href="#">Materials</a></li>
<li><a class="dropdown-item" href="#">Ledger</a></li>
<li><a class="dropdown-item" href="#">Inventory</a></li>
<li><a class="dropdown-item" href="#">Collectives</a></li>
</ul>
</div>
<!-- Profile dropdown -->
<div class="dropdown">
<a
class="d-flex align-items-center text-decoration-none dropdown-toggle"
href="#"
id="profileDropdown"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<i class="bi bi-person-circle fs-4 me-1"></i>
<span>{{ current_user.username }}</span>
</a>
<ul
class="dropdown-menu dropdown-menu-end text-small"
aria-labelledby="profileDropdown"
>
<li><a class="dropdown-item" href="#">My Profile</a></li>
<li><a class="dropdown-item" href="#">Settings</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{{ url_for('utility.upload') }}">
Import
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('utility.export_data') }}">
Export
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a
class="dropdown-item text-danger"
href="{{ url_for('auth.logout') }}"
>
<i class="bi bi-box-arrow-right me-1"></i> Logout
</a>
</li>
</ul>
</div>
</div>
{% else %}
<!-- Login / Register buttons -->
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li class="nav-item me-2">
<a class="btn btn-outline-primary" href="{{ url_for('auth.login') }}">
Login
</a>
</li>
<li class="nav-item">
<a class="btn btn-primary" href="{{ url_for('auth.register') }}">
Register
</a>
</li>
</ul>
{% endif %}
</div>
</div>
</nav>
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mt-3">
{% for category, message in messages %}
<div
class="alert alert-{{ category }} alert-dismissible fade show"
role="alert"
>
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer>
&copy; {{ current_year | default(2025) }} Nature In Pots Community. All rights reserved.
</footer>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -1,55 +0,0 @@
{% extends 'core_ui/base.html' %}
{% block title %}Home | Nature In Pots{% endblock %}
{% block content %}
<!-- Hero Section -->
<div class="py-5 text-center bg-light rounded-3">
<h1 class="display-5 fw-bold">Welcome to Nature In Pots</h1>
<p class="fs-5 text-muted mb-4">
Your internal platform for comprehensive plant tracking, propagation history, and collaborative logging.
</p>
<p class="mb-0">
<strong class="text-success">Free to use for the time being</strong><br>
(A future subscription or licensing model may be introduced as needed.)
</p>
</div>
<!-- Features Overview -->
<div class="row mt-5 gy-4">
<div class="col-md-4">
<h3>Plant Profiles</h3>
<p>Quickly create and manage detailed records—type, names, lineage, notes, and custom slugs for easy sharing.</p>
</div>
<div class="col-md-4">
<h3>Grow Logs</h3>
<p>Maintain a timeline of growth metrics, health events, substrate mixes, and propagation notes.</p>
</div>
<div class="col-md-4">
<h3>Image Gallery</h3>
<p>Upload, rotate, and feature photos for each plant. Community voting and one-click “featured” selection.</p>
</div>
<div class="col-md-4">
<h3>Lineage Tracking</h3>
<p>Visualize parentchild relationships with a graph powered by Neo4j—track every cutting, seed, and division.</p>
</div>
<div class="col-md-4">
<h3>Pricing & Transfers</h3>
<p>Securely log acquisition costs, resale prices, and ownership changes—with history retained and protected.</p>
</div>
<div class="col-md-4">
<h3>Import & Export</h3>
<p>Bulk-import plants and media via CSV/ZIP. Export your entire dataset and images for backups or reporting.</p>
</div>
</div>
<!-- Call to Action -->
<div class="text-center mt-5">
{% if current_user.is_authenticated %}
<a href="{{ url_for('plant.index') }}" class="btn btn-primary btn-lg me-2">View My Plants</a>
<a href="{{ url_for('utility.upload') }}" class="btn btn-outline-secondary btn-lg">Import Data</a>
{% else %}
<a href="{{ url_for('auth.register') }}" class="btn btn-success btn-lg me-2">Register Now</a>
<a href="{{ url_for('auth.login') }}" class="btn btn-outline-primary btn-lg">Log In</a>
{% endif %}
</div>
{% endblock %}

View File

@ -1,18 +0,0 @@
<style>
.media-thumbnails {
list-style-type: none;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.media-thumbnails li {
display: inline-block;
text-align: center;
}
.media-thumbnails img {
border: 1px solid #ccc;
border-radius: 4px;
padding: 2px;
}
</style>

View File

@ -1,25 +0,0 @@
# plugins/growlog/forms.py
from flask_wtf import FlaskForm
from wtforms import SelectField, StringField, TextAreaField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length
class GrowLogForm(FlaskForm):
plant_uuid = SelectField(
'Plant',
choices=[], # injected in view
validators=[DataRequired()]
)
event_type = SelectField('Event Type', choices=[
('water', 'Watered'),
('fertilizer', 'Fertilized'),
('repot', 'Repotted'),
('note', 'Note'),
('pest', 'Pest Observed')
], validators=[DataRequired()])
title = StringField('Title', validators=[Length(max=255)])
notes = TextAreaField('Notes', validators=[Length(max=1000)])
is_public = BooleanField('Public?')
submit = SubmitField('Add Log')

View File

@ -1,91 +0,0 @@
from datetime import datetime
from app import db
class GrowLog(db.Model):
__tablename__ = "grow_logs"
__table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True)
plant_id = db.Column(db.Integer, db.ForeignKey("plant.id"), nullable=False)
event_type = db.Column(db.String(50), nullable=False)
title = db.Column(db.String(255), nullable=True)
notes = db.Column(db.Text, nullable=True)
is_public = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(
db.DateTime,
default=datetime.utcnow,
onupdate=datetime.utcnow,
nullable=False
)
# ↔ images uploaded directly to this GrowLog
media_items = db.relationship(
"plugins.media.models.Media",
back_populates="growlog",
foreign_keys="plugins.media.models.Media.growlog_id",
lazy="dynamic",
cascade="all, delete-orphan",
)
# ↔ child updates
updates = db.relationship(
"plugins.growlog.models.PlantUpdate",
back_populates="growlog",
foreign_keys="plugins.growlog.models.PlantUpdate.growlog_id",
lazy="dynamic",
cascade="all, delete-orphan",
)
class PlantUpdate(db.Model):
__tablename__ = "plant_updates"
__table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True)
growlog_id = db.Column(db.Integer, db.ForeignKey("grow_logs.id"), nullable=False)
description = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# ↔ parent GrowLog.updates
growlog = db.relationship(
"plugins.growlog.models.GrowLog",
back_populates="updates",
foreign_keys=[growlog_id],
lazy="joined",
)
# ↔ images attached via UpdateImage join table
media_items = db.relationship(
"plugins.growlog.models.UpdateImage",
back_populates="update",
foreign_keys="plugins.growlog.models.UpdateImage.update_id",
lazy="dynamic",
cascade="all, delete-orphan",
)
class UpdateImage(db.Model):
__tablename__ = "update_images"
__table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True)
update_id = db.Column(db.Integer, db.ForeignKey("plant_updates.id"), nullable=False)
media_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# ↔ PlantUpdate.media_items
update = db.relationship(
"plugins.growlog.models.PlantUpdate",
back_populates="media_items",
foreign_keys=[update_id],
lazy="joined",
)
# ↔ the actual Media record
media = db.relationship(
"plugins.media.models.Media",
backref=db.backref("update_images", lazy="dynamic"),
foreign_keys=[media_id],
lazy="joined",
)

View File

@ -1,6 +0,0 @@
{
"name": "growlog",
"version": "1.0.0",
"description": "Tracks time-based plant care logs",
"entry_point": null
}

View File

@ -1,4 +1,5 @@
# plugins/media/models.py
from datetime import datetime
from flask import url_for
from app import db
@ -17,45 +18,40 @@ class Media(db.Model):
plant_id = db.Column(db.Integer, db.ForeignKey("plant.id"), nullable=True)
growlog_id = db.Column(db.Integer, db.ForeignKey("grow_logs.id"), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# You already have a file_url column in your DB
file_url = db.Column(db.String(512), nullable=False)
hearts = db.relationship(
"plugins.media.models.ImageHeart",
"ImageHeart",
backref="media",
lazy="dynamic",
cascade="all, delete-orphan",
)
featured_entries = db.relationship(
"plugins.media.models.FeaturedImage",
"FeaturedImage",
backref="media",
lazy="dynamic",
cascade="all, delete-orphan",
)
# ↔ Media items attached to a Plant
plant = db.relationship(
"plugins.plant.models.Plant",
"Plant",
back_populates="media_items",
foreign_keys=[plant_id],
lazy="joined",
)
# ↔ Media items attached to a GrowLog
growlog = db.relationship(
"plugins.growlog.models.GrowLog",
"GrowLog",
back_populates="media_items",
foreign_keys=[growlog_id],
lazy="joined",
)
def __init__(self, *args, **kwargs):
"""
Infer plugin & related_id from whichever FK is set,
and build the file_url path immediately so that INSERT
never tries to write plugin=None or related_id=None.
"""
super().__init__(*args, **kwargs)
# If they passed plant_id or growlog_id in kwargs, pick one:
if self.plant_id:
self.plugin = "plant"
self.related_id = self.plant_id
@ -63,7 +59,6 @@ class Media(db.Model):
self.plugin = "growlog"
self.related_id = self.growlog_id
else:
# fallback (you might choose to raise instead)
self.plugin = kwargs.get("plugin", "")
self.related_id = kwargs.get("related_id", 0)
@ -81,6 +76,16 @@ class Media(db.Model):
)
class ZipJob(db.Model):
__tablename__ = 'zip_jobs'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, nullable=False)
filename = db.Column(db.String(255), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
status = db.Column(db.String(20), default='queued') # queued|processing|done|failed
error = db.Column(db.Text, nullable=True)
class ImageHeart(db.Model):
__tablename__ = "image_hearts"
__table_args__ = {"extend_existing": True}

View File

@ -1,6 +1,23 @@
{
"name": "media",
"version": "1.0.0",
"description": "Upload and attach media to plants and grow logs",
"entry_point": null
{
"name": "Media",
"version": "0.1.0",
"author": "Bryson Shepard <bryson@natureinpots.com>",
"description": "Manages image uploads, storage, and URL generation.",
"module": "plugins.media",
"routes": {
"module": "plugins.media.routes",
"blueprint": "bp",
"url_prefix": "/media"
},
"models": [
"plugins.media.models"
],
"template_globals": [
{
"name": "generate_image_url",
"callable": "plugins.media.routes.generate_image_url"
}
],
"license": "Proprietary",
"repository": "https://github.com/your-org/your-app"
}

View File

@ -1,9 +1,6 @@
import os
import zipfile
import uuid
import io
import traceback
import tempfile
import logging
from datetime import datetime
from werkzeug.utils import secure_filename
from werkzeug.datastructures import FileStorage
@ -13,10 +10,11 @@ from flask import (
jsonify, abort
)
from flask_login import login_required, current_user
from PIL import Image, ExifTags
from PIL import Image, UnidentifiedImageError
from app import db
from .models import Media, ImageHeart, FeaturedImage
from .models import Media, ZipJob, ImageHeart, FeaturedImage
from .tasks import process_zip
bp = Blueprint(
"media",
@ -25,43 +23,91 @@ bp = Blueprint(
template_folder="templates"
)
# ─── Constants ──────────────────────────────────────────────────────────────
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
DOC_EXTS = {".pdf", ".txt", ".csv"}
ZIP_EXT = ".zip"
MAX_ZIP_FILES = 1000
MAX_IMAGE_PIXELS = 8000 * 8000 # ~64M pixels
# ─── Context Processor ──────────────────────────────────────────────────────
# ─── Context Processor ─────────────────────────────────────────────────────────
@bp.app_context_processor
def utility_processor():
def inject_helpers():
"""Expose generate_image_url in all media templates."""
return dict(generate_image_url=generate_image_url)
# ─── Helper Functions ───────────────────────────────────────────────────────
# ─── Helpers & Config ─────────────────────────────────────────────────────────
def allowed_file(filename):
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
return ext in current_app.config.get(
"ALLOWED_EXTENSIONS",
{"png", "jpg", "jpeg", "gif", "webp"}
)
def get_upload_path(plugin: str, related_id: int):
def allowed_file(filename: str) -> bool:
"""
Return (absolute_dir, subdir) where uploads are stored:
<UPLOAD_FOLDER>/<plugin>/<related_id>/
Return True if the file extension is allowed.
"""
ext = os.path.splitext(filename)[1].lower()
allowed = current_app.config.get(
"ALLOWED_EXTENSIONS",
IMAGE_EXTS | DOC_EXTS | {ZIP_EXT}
)
return ext in allowed
def get_upload_path(plugin: str, related_id: int) -> (str, str):
"""
Build and return (absolute_dir, relative_subdir) under UPLOAD_FOLDER.
"""
base = current_app.config["UPLOAD_FOLDER"]
subdir = os.path.join(plugin, str(related_id))
abs_dir = os.path.join(base, subdir)
abs_dir = os.path.abspath(os.path.join(base, subdir))
if not abs_dir.startswith(os.path.abspath(base) + os.sep):
raise RuntimeError("Upload path escapes base directory")
os.makedirs(abs_dir, exist_ok=True)
return abs_dir, subdir
def validate_image(path: str) -> bool:
"""
Verify image integrity and enforce pixel-size limit.
"""
try:
with Image.open(path) as img:
img.verify()
w, h = Image.open(path).size
return w * h <= MAX_IMAGE_PIXELS
except (UnidentifiedImageError, IOError):
return False
def _strip_exif(image: Image.Image) -> Image.Image:
def validate_pdf(path: str) -> bool:
"""
Quick header check for PDF files.
"""
try:
with open(path, "rb") as f:
return f.read(5) == b"%PDF-"
except IOError:
return False
def validate_text(path: str) -> bool:
"""
Ensure the file is valid UTF-8 text/CSV.
"""
try:
with open(path, "rb") as f:
f.read(1024).decode("utf-8")
return True
except Exception:
return False
def strip_exif(image: Image.Image) -> Image.Image:
"""
Rotate per EXIF orientation and strip metadata.
"""
try:
exif = image._getexif()
orient_key = next(
(k for k, v in ExifTags.TAGS.items() if v == "Orientation"),
None
)
if exif and orient_key in exif:
o = exif[orient_key]
if exif:
orientation_key = next(
(k for k, v in Image.ExifTags.TAGS.items()
if v == "Orientation"), None
)
o = exif.get(orientation_key)
if o == 3:
image = image.rotate(180, expand=True)
elif o == 6:
@ -70,8 +116,259 @@ def _strip_exif(image: Image.Image) -> Image.Image:
image = image.rotate(90, expand=True)
except Exception:
pass
return image
data = list(image.getdata())
clean = Image.new(image.mode, image.size)
clean.putdata(data)
return clean
def generate_image_url(media: Media):
"""
Given a Media instance, return its public URL or a placeholder.
"""
if media and media.file_url:
return url_for(
"media.serve_context_media",
context=media.plugin,
context_id=media.related_id,
filename=media.filename
)
# fallback placeholder
w, h = current_app.config.get("STANDARD_IMG_SIZE", (300, 200))
return f"https://placehold.co/{w}x{h}"
# ─── Core Media Routes ──────────────────────────────────────────────────────
@bp.route("/upload", methods=["POST"])
@login_required
def upload_media():
"""
Accept images, PDFs, text/CSV inline; enqueue ZIPs for async processing.
"""
uploaded: FileStorage = request.files.get("media")
if not uploaded or uploaded.filename == "":
flash("No file selected.", "warning")
return redirect(request.referrer or url_for("home"))
filename = secure_filename(uploaded.filename)
ext = os.path.splitext(filename)[1].lower()
if not allowed_file(filename):
flash("Unsupported file type.", "danger")
return redirect(request.referrer)
# Determine plugin & ID
plugin = request.form.get("plugin", "user")
related_id = int(request.form.get("related_id", current_user.id))
# Save location
abs_dir, subdir = get_upload_path(plugin, related_id)
save_path = os.path.join(abs_dir, filename)
uploaded.save(save_path)
# Validate & post-process
if ext in IMAGE_EXTS:
if not validate_image(save_path):
os.remove(save_path)
flash("Invalid or oversized image.", "danger")
return redirect(request.referrer)
with Image.open(save_path) as img:
clean = strip_exif(img)
clean.save(save_path)
elif ext == ".pdf":
if not validate_pdf(save_path):
os.remove(save_path)
flash("Invalid PDF.", "danger")
return redirect(request.referrer)
elif ext in {".txt", ".csv"}:
if not validate_text(save_path):
os.remove(save_path)
flash("Invalid text/CSV.", "danger")
return redirect(request.referrer)
elif ext == ZIP_EXT:
# Create and enqueue a ZipJob
job = ZipJob(user_id=current_user.id, filename=filename)
db.session.add(job)
db.session.commit()
process_zip.delay(job.id, save_path)
flash("ZIP received; processing in background.", "info")
return redirect(url_for("media.upload_status", job_id=job.id))
# Record small-file upload in DB
media = Media(
plugin = plugin,
related_id = related_id,
filename = filename,
file_url = f"{subdir}/{filename}",
uploader_id = current_user.id,
uploaded_at = datetime.utcnow()
)
db.session.add(media)
db.session.commit()
flash("File uploaded successfully.", "success")
return redirect(request.referrer or url_for("home"))
@bp.route("/upload/<int:job_id>/status", methods=["GET"])
@login_required
def upload_status(job_id: int):
"""
Return JSON status for a background ZIP processing job.
"""
job = ZipJob.query.get_or_404(job_id)
if job.user_id != current_user.id:
abort(403)
return jsonify({
"job_id": job.id,
"status": job.status,
"error": job.error
})
@bp.route("/<context>/<int:context_id>/<filename>")
def serve_context_media(context: str, context_id: int, filename: str):
"""
Serve a file from UPLOAD_FOLDER/<plugin>/<id>/<filename>,
with pathtraversal guard and DB check.
"""
# Normalize plugin name
valid = {"user", "plant", "growlog", "vendor"}
if context in valid:
plugin_name = context
elif context.endswith("s") and context[:-1] in valid:
plugin_name = context[:-1]
else:
abort(404)
# Sanitize filename
safe_filename = secure_filename(filename)
if safe_filename != filename:
abort(404)
# Build and verify path
base_dir = current_app.config["UPLOAD_FOLDER"]
dir_path = os.path.join(base_dir, plugin_name, str(context_id))
full_path = os.path.abspath(os.path.join(dir_path, safe_filename))
if not full_path.startswith(os.path.abspath(base_dir) + os.sep):
abort(404)
# Confirm DB row
Media.query.filter_by(
plugin = plugin_name,
related_id = context_id,
filename = filename
).first_or_404()
return send_from_directory(dir_path, filename)
# ─── Utility Routes ─────────────────────────────────────────────────────────
@bp.route("/heart/<int:media_id>", methods=["POST"])
@login_required
def toggle_heart(media_id: int):
"""
Toggle a “heart” (like) on an image for the current user.
"""
existing = ImageHeart.query.filter_by(
user_id = current_user.id,
media_id = media_id
).first()
if existing:
db.session.delete(existing)
db.session.commit()
return jsonify(status="unhearted")
heart = ImageHeart(user_id=current_user.id, media_id=media_id)
db.session.add(heart)
db.session.commit()
return jsonify(status="hearted")
@bp.route("/featured/<context>/<int:context_id>/<int:media_id>", methods=["POST"])
@login_required
def set_featured_image(context: str, context_id: int, media_id: int):
"""
Mark a single image as featured for a given context.
"""
valid = {"plant", "growlog", "user", "vendor"}
if context in valid:
plugin_name = context
elif context.endswith("s") and context[:-1] in valid:
plugin_name = context[:-1]
else:
abort(404)
media = Media.query.filter_by(
plugin = plugin_name,
related_id = context_id,
id = media_id
).first_or_404()
if media.uploader_id != current_user.id and current_user.role != "admin":
abort(403)
FeaturedImage.query.filter_by(
context = plugin_name,
context_id = context_id
).delete()
fi = FeaturedImage(
media_id = media.id,
context = plugin_name,
context_id = context_id,
is_featured = True
)
db.session.add(fi)
db.session.commit()
flash("Featured image updated.", "success")
return redirect(request.referrer or url_for("home"))
@bp.route("/delete/<int:media_id>", methods=["POST"])
@login_required
def delete_media(media_id: int):
"""
Delete a media file and its DB record (softdelete by permission).
"""
media = Media.query.get_or_404(media_id)
if media.uploader_id != current_user.id and current_user.role != "admin":
flash("Not authorized to delete this media.", "danger")
return redirect(request.referrer or url_for("home"))
# Remove file on disk
base = current_app.config["UPLOAD_FOLDER"]
full = os.path.abspath(os.path.join(base, media.file_url))
try:
os.remove(full)
except OSError:
current_app.logger.error(f"Failed to delete file {full}")
# Remove DB record
db.session.delete(media)
db.session.commit()
flash("Media deleted.", "success")
return redirect(request.referrer or url_for("home"))
@bp.route("/rotate/<int:media_id>", methods=["POST"])
@login_required
def rotate_media(media_id: int):
"""
Rotate an image 90° and strip its EXIF metadata.
"""
media = Media.query.get_or_404(media_id)
if media.uploader_id != current_user.id and current_user.role != "admin":
abort(403)
base = current_app.config["UPLOAD_FOLDER"]
full = os.path.abspath(os.path.join(base, media.file_url))
try:
with Image.open(full) as img:
rotated = img.rotate(-90, expand=True)
clean = strip_exif(rotated)
clean.save(full)
flash("Image rotated successfully.", "success")
except Exception as e:
current_app.logger.error(f"Rotation failed for {full}: {e}")
flash("Failed to rotate image.", "danger")
return redirect(request.referrer or url_for("home"))
# ─── Legacy Helpers for Other Plugins ───────────────────────────────────────
def _process_upload_file(
file: FileStorage,
@ -105,26 +402,29 @@ def _process_upload_file(
# 5) Build the Media row
now = datetime.utcnow()
media = Media(
plugin=plugin,
related_id=related_id,
filename=filename,
uploaded_at=now,
uploader_id=uploader_id,
caption=caption,
plant_id=plant_id,
growlog_id=growlog_id,
created_at=now,
file_url=file_url
plugin = plugin,
related_id = related_id,
filename = filename,
uploaded_at = now,
uploader_id = uploader_id,
caption = caption,
plant_id = plant_id,
growlog_id = growlog_id,
created_at = now,
file_url = file_url
)
return media
# ─── Exposed Utilities ─────────────────────────────────────────────────────────
def save_media_file(file, user_id, **ctx):
def save_media_file(file: FileStorage, user_id: int, **ctx) -> Media:
"""
Simple wrapper for other plugins to save an upload via the same logic.
"""
return _process_upload_file(file, user_id, **ctx)
def delete_media_file(media: Media):
"""
Remove a Media record and its file from disk, commit immediately.
"""
base = current_app.config["UPLOAD_FOLDER"]
full = os.path.normpath(os.path.join(base, media.file_url))
if os.path.exists(full):
@ -132,256 +432,12 @@ def delete_media_file(media: Media):
db.session.delete(media)
db.session.commit()
def rotate_media_file(media: Media):
"""
Rotate a Media file 90° in place and commit metadata-only change.
"""
base = current_app.config["UPLOAD_FOLDER"]
full = os.path.normpath(os.path.join(base, media.file_url))
with Image.open(full) as img:
img.rotate(-90, expand=True).save(full)
db.session.commit()
def generate_image_url(media: Media):
"""
Given a Media instance (or None), return its public URL
under our new schema, or a placeholder if no media.
"""
if media and media.file_url:
# use singular context
return url_for(
"media.serve_context_media",
context=media.plugin,
context_id=media.related_id,
filename=media.filename
)
# fallback
w, h = current_app.config.get("STANDARD_IMG_SIZE", (300, 200))
return f"https://placehold.co/{w}x{h}"
@bp.route("/<context>/<int:context_id>/<filename>")
def serve_context_media(context, context_id, filename):
"""
Serve files saved under:
<UPLOAD_FOLDER>/<plugin>/<context_id>/<filename>
Accepts both singular and trailing-'s' contexts:
/media/plant/1/foo.jpg OR /media/plants/1/foo.jpg
"""
# — determine plugin name (always singular) —
valid = {"user", "plant", "growlog", "vendor"}
if context in valid:
plugin = context
elif context.endswith("s") and context[:-1] in valid:
plugin = context[:-1]
else:
logging.debug(f"Invalid context '{context}' in URL")
abort(404)
# — build filesystem path —
base = current_app.config["UPLOAD_FOLDER"]
directory = os.path.join(base, plugin, str(context_id))
full_path = os.path.join(directory, filename)
# — Debug log what were about to do —
logging.debug(f"[serve_context_media] plugin={plugin!r}, "
f"context_id={context_id!r}, filename={filename!r}")
logging.debug(f"[serve_context_media] checking DB for media row…")
logging.debug(f"[serve_context_media] filesystem path = {full_path!r}, exists? {os.path.exists(full_path)}")
# — Check the DB row (but dont abort if missing) —
media = Media.query.filter_by(
plugin=plugin,
related_id=context_id,
filename=filename
).first()
if not media:
logging.warning(f"[serve_context_media] no Media DB row for "
f"{plugin}/{context_id}/{filename!r}, "
"will try serving from disk anyway")
# — If the file exists on disk, serve it — otherwise 404 —
if os.path.exists(full_path):
return send_from_directory(directory, filename)
logging.error(f"[serve_context_media] file not found on disk: {full_path!r}")
abort(404)
# ─── Legacy / Other Routes (you can leave these for backward compatibility) ────
@bp.route("/", methods=["GET"])
def media_index():
return redirect(url_for("core_ui.home"))
@bp.route("/<plugin>/<filename>")
def serve(plugin, filename):
# optional legacy support
m = Media.query.filter_by(file_url=f"{plugin}s/%/{filename}").first_or_404()
date_path = m.uploaded_at.strftime("%Y/%m/%d")
disk_dir = os.path.join(
current_app.config["UPLOAD_FOLDER"],
f"{plugin}s",
str(m.plant_id or m.growlog_id),
date_path
)
return send_from_directory(disk_dir, filename)
@bp.route("/<filename>")
def media_public(filename):
base = current_app.config["UPLOAD_FOLDER"]
m = Media.query.filter(Media.file_url.endswith(filename)).first_or_404()
full = os.path.normpath(os.path.join(base, m.file_url))
if not full.startswith(os.path.abspath(base)):
abort(404)
return send_from_directory(base, m.file_url)
@bp.route("/heart/<int:media_id>", methods=["POST"])
@login_required
def toggle_heart(media_id):
existing = ImageHeart.query.filter_by(
user_id=current_user.id, media_id=media_id
).first()
if existing:
db.session.delete(existing)
db.session.commit()
return jsonify({"status": "unhearted"})
heart = ImageHeart(user_id=current_user.id, media_id=media_id)
db.session.add(heart)
db.session.commit()
return jsonify({"status": "hearted"})
@bp.route("/add/<string:plant_uuid>", methods=["POST"])
@login_required
def add_media(plant_uuid):
plant = Plant.query.filter_by(uuid=plant_uuid).first_or_404()
file = request.files.get("file")
if not file or not allowed_file(file.filename):
flash("Invalid or missing file.", "danger")
return redirect(request.referrer or url_for("plant.edit", uuid_val=plant_uuid))
_process_upload_file(
file=file,
uploader_id=current_user.id,
plugin="plant",
related_id=plant.id
)
flash("Media uploaded successfully.", "success")
return redirect(request.referrer or url_for("plant.edit", uuid_val=plant_uuid))
@bp.route("/<context>/<int:context_id>/<filename>")
def media_file(context, context_id, filename):
# your existing serve_context_media logic here
# (unchanged)
from flask import current_app, send_from_directory
import os
valid = {"user", "plant", "growlog", "vendor"}
if context in valid:
plugin = context
elif context.endswith("s") and context[:-1] in valid:
plugin = context[:-1]
else:
abort(404)
media = Media.query.filter_by(
plugin=plugin,
related_id=context_id,
filename=filename
).first_or_404()
base = current_app.config["UPLOAD_FOLDER"]
directory = os.path.join(base, plugin, str(context_id))
return send_from_directory(directory, filename)
@bp.route('/featured/<context>/<int:context_id>/<int:media_id>', methods=['POST'])
def set_featured_image(context, context_id, media_id):
"""
Singleselect “featured” toggle for any plugin (plants, grow_logs, etc).
"""
# normalize to singular plugin name (matches Media.plugin & FeaturedImage.context)
valid = {'plant', 'growlog', 'user', 'vendor'}
if context in valid:
plugin_name = context
elif context.endswith('s') and context[:-1] in valid:
plugin_name = context[:-1]
else:
abort(404)
# must own that media row
media = Media.query.filter_by(
plugin=plugin_name,
related_id=context_id,
id=media_id
).first_or_404()
# clear out any existing featured rows
FeaturedImage.query.filter_by(
context=plugin_name,
context_id=context_id
).delete()
# insert new featured row
fi = FeaturedImage(
media_id=media.id,
context=plugin_name,
context_id=context_id,
is_featured=True
)
db.session.add(fi)
db.session.commit()
# Redirect back with a flash instead of JSON
flash("Featured image updated.", "success")
return redirect(request.referrer or url_for("core_ui.home"))
@bp.route("/delete/<int:media_id>", methods=["POST"])
@login_required
def delete_media(media_id):
media = Media.query.get_or_404(media_id)
if media.uploader_id != current_user.id and current_user.role != "admin":
flash("Not authorized to delete this media.", "danger")
return redirect(request.referrer or url_for("core_ui.home"))
delete_media_file(media)
flash("Media deleted.", "success")
return redirect(request.referrer or url_for("plant.edit", uuid_val=media.plant.uuid))
@bp.route("/bulk_delete/<string:plant_uuid>", methods=["POST"])
@login_required
def bulk_delete_media(plant_uuid):
plant = Plant.query.filter_by(uuid=plant_uuid).first_or_404()
media_ids = request.form.getlist("delete_ids")
deleted = 0
for mid in media_ids:
m = Media.query.filter_by(id=mid, plant_id=plant.id).first()
if m and (m.uploader_id == current_user.id or current_user.role == "admin"):
delete_media_file(m)
deleted += 1
flash(f"{deleted} image(s) deleted.", "success")
return redirect(request.referrer or url_for("plant.edit", uuid_val=plant_uuid))
@bp.route("/rotate/<int:media_id>", methods=["POST"])
@login_required
def rotate_media(media_id):
media = Media.query.get_or_404(media_id)
if media.uploader_id != current_user.id and current_user.role != "admin":
flash("Not authorized to rotate this media.", "danger")
return redirect(request.referrer or url_for("core_ui.home"))
try:
rotate_media_file(media)
flash("Image rotated successfully.", "success")
except Exception as e:
flash(f"Failed to rotate image: {e}", "danger")
return redirect(request.referrer or url_for("plant.edit", uuid_val=media.plant.uuid))

69
plugins/media/tasks.py Normal file
View File

@ -0,0 +1,69 @@
import os
import zipfile
from werkzeug.utils import secure_filename
from PIL import Image, UnidentifiedImageError
from app import db
from plugins.media.models import ZipJob
# Reimport your create_app and utility plugin to get Celery
from plugins.utility.celery import celery_app
# Constants
IMAGE_EXTS = {'.jpg','.jpeg','.png','.gif'}
DOC_EXTS = {'.pdf','.txt','.csv'}
MAX_ZIP_FILES = 1000
MAX_PIXELS = 8000 * 8000
def validate_image(path):
try:
with Image.open(path) as img:
img.verify()
w, h = Image.open(path).size
return (w*h) <= MAX_PIXELS
except (UnidentifiedImageError, IOError):
return False
@celery_app.task(bind=True)
def process_zip(self, job_id, zip_path):
job = ZipJob.query.get(job_id)
job.status = 'processing'
db.session.commit()
extract_dir = zip_path + '_contents'
try:
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
if len(names) > MAX_ZIP_FILES:
raise ValueError('ZIP contains too many files.')
os.makedirs(extract_dir, exist_ok=True)
for member in names:
safe = secure_filename(member)
if safe != member:
raise ValueError(f'Illegal filename {member}')
_, ext = os.path.splitext(safe.lower())
if ext not in IMAGE_EXTS | DOC_EXTS:
raise ValueError(f'Unsupported type {ext}')
target = os.path.join(extract_dir, safe)
with zf.open(member) as src, open(target, 'wb') as dst:
dst.write(src.read())
if ext in IMAGE_EXTS and not validate_image(target):
raise ValueError(f'Bad image: {member}')
elif ext == '.pdf':
if open(target,'rb').read(5)!=b'%PDF-':
raise ValueError(f'Bad PDF: {member}')
else:
# txt/csv → simple UTF-8 check
open(target,'rb').read(1024).decode('utf-8')
job.status = 'done'
except Exception as e:
job.status = 'failed'
job.error = str(e)
finally:
db.session.commit()

View File

@ -1,5 +1,5 @@
{# plugins/media/templates/media/list.html #}
{% extends 'core_ui/base.html' %}
{% extends 'core/base.html' %}
{% block content %}
<h2>All Uploaded Media</h2>

View File

@ -0,0 +1,14 @@
{
"name": "Ownership",
"version": "0.1.0",
"author": "Bryson Shepard <bryson@natureinpots.com>",
"description": "Tracks plant ownership transfers and history.",
"module": "plugins.ownership",
"routes": {
"module": "plugins.ownership.routes",
"blueprint": "bp",
"url_prefix": "/ownership"
},
"license": "Proprietary",
"repository": "https://github.com/your-org/your-app"
}

View File

@ -0,0 +1,29 @@
# plugins/plant/growlog/forms.py
from flask_wtf import FlaskForm
from wtforms import SelectField, StringField, TextAreaField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length
class GrowLogForm(FlaskForm):
plant_uuid = SelectField(
'Plant',
choices=[], # injected in view
validators=[DataRequired()]
)
event_type = SelectField(
'Event Type',
choices=[
('water', 'Watered'),
('fertilizer', 'Fertilized'),
('repot', 'Repotted'),
('note', 'Note'),
('pest', 'Pest Observed'),
],
validators=[DataRequired()]
)
title = StringField('Title', validators=[Length(max=255)])
notes = TextAreaField('Notes', validators=[Length(max=1000)])
is_public = BooleanField('Public?')
submit = SubmitField('Save Log')

View File

@ -0,0 +1,40 @@
# plugins/plant/growlog/models.py
from datetime import datetime
from app import db
class GrowLog(db.Model):
__tablename__ = "grow_logs"
__table_args__ = {"extend_existing": True}
id = db.Column(db.Integer, primary_key=True)
plant_id = db.Column(db.Integer, db.ForeignKey("plant.id"), nullable=False)
event_type = db.Column(db.String(50), nullable=False)
title = db.Column(db.String(255), nullable=True)
notes = db.Column(db.Text, nullable=True)
is_public = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(
db.DateTime,
default=datetime.utcnow,
onupdate=datetime.utcnow,
nullable=False
)
# ─── Single “primary” media for this log ───────────────────────────────────
media_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=True)
media = db.relationship(
"plugins.media.models.Media",
backref=db.backref("update_images", lazy="dynamic"),
foreign_keys=[media_id],
lazy="joined",
)
# ─── All Media items whose growlog_id points here ─────────────────────────
media_items = db.relationship(
"plugins.media.models.Media",
back_populates="growlog",
foreign_keys="plugins.media.models.Media.growlog_id",
lazy="dynamic",
cascade="all, delete-orphan"
)

View File

@ -1,7 +1,9 @@
# plugins/plant/growlog/routes.py
from uuid import UUID as _UUID
from werkzeug.exceptions import NotFound
from flask import (
Blueprint, render_template, abort, redirect, url_for, request, flash
Blueprint, render_template, abort, redirect,
url_for, request, flash
)
from flask_login import login_required, current_user
from app import db
@ -9,7 +11,6 @@ from .models import GrowLog
from .forms import GrowLogForm
from plugins.plant.models import Plant, PlantCommonName
bp = Blueprint(
'growlog',
__name__,
@ -17,33 +18,33 @@ bp = Blueprint(
template_folder='templates',
)
def _get_plant_by_uuid(uuid_val):
"""
uuid_val may already be a uuid.UUID (from a <uuid:> route converter)
or a string (from form POST). Normalize & validate it, then lookup.
Normalize & validate a UUID (may be a uuid.UUID or a string),
then return the Plant owned by current_user or 404.
"""
# 1) If Flask route gave us a UUID instance, just stringify it
# 1) If Flask gave us a real UUID, stringify it
if isinstance(uuid_val, _UUID):
val = str(uuid_val)
else:
# 2) Otherwise try to parse it as a hex string
# 2) Otherwise try to parse it
try:
val = str(_UUID(uuid_val))
except (ValueError, TypeError):
# invalid format → 404
abort(404)
# 3) Only return plants owned by current_user
# 3) Only return plants owned by this user
return (
Plant.query
.filter_by(uuid=val, owner_id=current_user.id)
.first_or_404()
)
def _user_plant_choices():
# join to the commonname table and sort by its name
"""
Return [(uuid, "Common Name uuid"), ...] for all plants
owned by current_user, sorted by common name.
"""
plants = (
Plant.query
.filter_by(owner_id=current_user.id)
@ -62,20 +63,19 @@ def _user_plant_choices():
@login_required
def add_log(plant_uuid=None):
form = GrowLogForm()
# 1) always populate the dropdown behind the scenes
# always populate the select behind the scenes
form.plant_uuid.choices = _user_plant_choices()
plant = None
hide_select = False
# 2) if URL had a plant_uuid, load & pre-select it, hide dropdown
# if URL gave us a plant_uuid, lock to that one
if plant_uuid:
plant = _get_plant_by_uuid(plant_uuid)
form.plant_uuid.data = str(plant_uuid)
hide_select = True
if form.validate_on_submit():
# 3) on POST, resolve via form.plant_uuid
plant = _get_plant_by_uuid(form.plant_uuid.data)
log = GrowLog(
plant_id = plant.id,
@ -93,9 +93,9 @@ def add_log(plant_uuid=None):
return render_template(
'growlog/log_form.html',
form = form,
plant = plant,
hide_plant_select = hide_select
form = form,
plant = plant,
hide_plant_select = hide_select,
)
@ -103,15 +103,17 @@ def add_log(plant_uuid=None):
@bp.route('/<uuid:plant_uuid>')
@login_required
def list_logs(plant_uuid):
# how many to show?
from plugins.utility.celery import celery_app
celery_app.send_task('plugins.utility.tasks.ping')
limit = request.args.get('limit', default=10, type=int)
if plant_uuid:
# logs for a single plant
# logs for one plant
plant = _get_plant_by_uuid(plant_uuid)
query = GrowLog.query.filter_by(plant_id=plant.id)
else:
# logs for all your plants
# logs across all of this users plants
plant = None
query = (
GrowLog.query
@ -128,20 +130,20 @@ def list_logs(plant_uuid):
return render_template(
'growlog/log_list.html',
plant=plant,
logs=logs,
limit=limit
plant = plant,
logs = logs,
limit = limit,
)
@bp.route('/<uuid:plant_uuid>/edit/<int:log_id>', methods=['GET', 'POST'])
@bp.route('/<uuid:plant_uuid>/edit/<int:log_id>', methods=['GET','POST'])
@login_required
def edit_log(plant_uuid, log_id):
plant = _get_plant_by_uuid(plant_uuid)
log = GrowLog.query.filter_by(id=log_id, plant_id=plant.id).first_or_404()
form = GrowLogForm(obj=log)
# Lock the dropdown to this one plant
# lock the dropdown to this plant
form.plant_uuid.choices = [(plant.uuid, plant.common_name.name)]
form.plant_uuid.data = plant.uuid
@ -151,16 +153,15 @@ def edit_log(plant_uuid, log_id):
log.notes = form.notes.data
log.is_public = form.is_public.data
db.session.commit()
flash('Grow log updated.', 'success')
return redirect(url_for('growlog.list_logs', plant_uuid=plant_uuid))
return render_template(
'growlog/log_form.html',
form=form,
plant_uuid=plant_uuid,
plant=plant,
log=log
form = form,
plant_uuid = plant_uuid,
plant = plant,
log = log,
)
@ -171,6 +172,5 @@ def delete_log(plant_uuid, log_id):
log = GrowLog.query.filter_by(id=log_id, plant_id=plant.id).first_or_404()
db.session.delete(log)
db.session.commit()
flash('Grow log deleted.', 'warning')
return redirect(url_for('growlog.list_logs', plant_uuid=plant_uuid))

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %}
{% extends 'core/base.html' %}
{% block title %}Add Grow Log{% endblock %}
{% block content %}

View File

@ -1,5 +1,5 @@
{# plugins/growlog/templates/growlog/log_list.html #}
{% extends 'core_ui/base.html' %}
{# plugins/plant/growlog/templates/growlog/log_list.html #}
{% extends 'core/base.html' %}
{% block title %}
{% if plant %}
@ -18,9 +18,12 @@
Recent Grow Logs
{% endif %}
</h2>
{# “Add” button: carry plant_uuid when in single-plant view #}
<a
href="{% if plant %}{{ url_for('growlog.add_log', plant_uuid=plant.uuid) }}{% else %}{{ url_for('growlog.add_log') }}{% endif %}"
href="{% if plant %}
{{ url_for('growlog.add_log', plant_uuid=plant.uuid) }}
{% else %}
{{ url_for('growlog.add_log') }}
{% endif %}"
class="btn btn-success">
<i class="bi bi-plus-lg"></i> Add Log
</a>
@ -38,7 +41,6 @@
</small>
</div>
{% if not plant %}
{# Show which plant this log belongs to when listing across all plants #}
<div class="ms-auto text-end">
<small class="text-secondary">Plant:</small><br>
<a href="{{ url_for('growlog.list_logs', plant_uuid=log.plant.uuid) }}">
@ -60,7 +62,7 @@
<img
src="{{ generate_image_url(media) }}"
class="img-thumbnail"
style="max-width:100px;"
style="max-width: 100px;"
alt="{{ media.caption or '' }}"
>
{% endfor %}
@ -70,20 +72,20 @@
<div class="mt-3">
<a
href="{{ url_for(
'growlog.edit_log',
plant_uuid=plant.uuid if plant else log.plant.uuid,
log_id=log.id
) }}"
'growlog.edit_log',
plant_uuid=plant.uuid if plant else log.plant.uuid,
log_id=log.id
) }}"
class="btn btn-sm btn-outline-primary me-2">
Edit
</a>
<form
method="POST"
action="{{ url_for(
'growlog.delete_log',
plant_uuid=plant.uuid if plant else log.plant.uuid,
log_id=log.id
) }}"
'growlog.delete_log',
plant_uuid=plant.uuid if plant else log.plant.uuid,
log_id=log.id
) }}"
class="d-inline"
onsubmit="return confirm('Delete this log?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
@ -96,7 +98,11 @@
{% else %}
<p class="text-muted">
No grow logs found{% if plant %} for {{ plant.common_name.name }}{% endif %}.
<a href="{% if plant %}{{ url_for('growlog.add_log', plant_uuid=plant.uuid) }}{% else %}{{ url_for('growlog.add_log') }}{% endif %}">
<a href="{% if plant %}
{{ url_for('growlog.add_log', plant_uuid=plant.uuid) }}
{% else %}
{{ url_for('growlog.add_log') }}
{% endif %}">
Add one now
</a>.
</p>

View File

@ -1,8 +1,10 @@
# plugins/plant/models.py
from datetime import datetime
import uuid as uuid_lib
import string, random # for generate_short_id
from app import db
from plugins.plant.growlog.models import GrowLog
# Association table for Plant ↔ Tag
plant_tags = db.Table(
@ -82,7 +84,7 @@ class Plant(db.Model):
plant_type = db.Column(db.String(50), nullable=False)
notes = db.Column(db.Text, nullable=True)
short_id = db.Column(db.String(8), unique=True, nullable=True, index=True)
short_id = db.Column(db.String(8), unique=True, nullable=True, index=True)
vendor_name = db.Column(db.String(255), nullable=True)
price = db.Column(db.Numeric(10, 2), nullable=True)
@ -102,14 +104,14 @@ class Plant(db.Model):
media_items = db.relationship(
'plugins.media.models.Media',
back_populates='plant',
lazy='select', # ← this is the fix
lazy='select',
cascade='all, delete-orphan',
foreign_keys='plugins.media.models.Media.plant_id'
)
@property
def media(self):
return self.media_items # already a list when lazy='select'
return self.media_items
# the one you see on the detail page
featured_media = db.relationship(
@ -120,7 +122,7 @@ class Plant(db.Model):
# ↔ GrowLog instances for this plant
updates = db.relationship(
'plugins.growlog.models.GrowLog',
GrowLog,
backref='plant',
lazy=True,
cascade='all, delete-orphan'
@ -152,7 +154,7 @@ class Plant(db.Model):
def __repr__(self):
return f"<Plant {self.uuid} ({self.plant_type})>"
@classmethod
def generate_short_id(cls, length: int = 6) -> str:
"""
@ -162,6 +164,5 @@ class Plant(db.Model):
alphabet = string.ascii_lowercase + string.digits
while True:
candidate = ''.join(random.choices(alphabet, k=length))
# Check uniqueness
if not cls.query.filter_by(short_id=candidate).first():
return candidate

View File

@ -1,6 +1,31 @@
{
"name": "plant",
"version": "1.0.0",
"description": "Plant profile management plugin",
"entry_point": null
}
"name": "Plant",
"version": "0.1.0",
"author": "Bryson Shepard <bryson@natureinpots.com>",
"description": "Core plant catalog and management.",
"module": "plugins.plant",
"routes": {
"module": "plugins.plant.routes",
"blueprint": "bp",
"url_prefix": "/plant"
},
"models": [
"plugins.plant.models"
],
"subplugins": [
{
"name": "GrowLog",
"module": "plugins.plant.growlog",
"routes": {
"module": "plugins.plant.growlog.routes",
"blueprint": "bp",
"url_prefix": "/plant/growlog"
},
"models": [
"plugins.plant.growlog.models"
]
}
],
"license": "Proprietary",
"repository": "https://github.com/your-org/your-app"
}

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %}
{% extends 'core/base.html' %}
{% block content %}
<div class="card mb-4">

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %}
{% extends 'core/base.html' %}
{% block title %}Add New Plant Nature In Pots{% endblock %}
{% block content %}

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %}
{% extends 'core/base.html' %}
{% block title %}
{{ plant.common_name.name if plant.common_name else "Unnamed Plant" }} Nature In Pots
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %}
{% extends 'core/base.html' %}
{% block title %}Edit Plant Nature In Pots{% endblock %}
{% block content %}

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %}
{% extends 'core/base.html' %}
{% block title %}View Entries Nature In Pots{% endblock %}
{% block content %}

View File

@ -1,15 +0,0 @@
from flask_wtf import FlaskForm
from wtforms import StringField, SelectMultipleField, SubmitField
from wtforms.validators import Optional, Length, Regexp
class SearchForm(FlaskForm):
query = StringField(
'Search',
validators=[
Optional(),
Length(min=2, max=100, message="Search term must be between 2 and 100 characters."),
Regexp(r'^[\w\s\-]+$', message="Search can only include letters, numbers, spaces, and dashes.")
]
)
tags = SelectMultipleField('Tags', coerce=int)
submit = SubmitField('Search')

View File

@ -1,17 +0,0 @@
from app import db
#from plugins.plant.models import Plant
# plant_tags = db.Table(
# 'plant_tags',
# db.metadata,
# db.Column('plant_id', db.Integer, db.ForeignKey('plant.id'), primary_key=True),
# db.Column('tag_id', db.Integer, db.ForeignKey('tags.id'), primary_key=True),
# extend_existing=True
# )
# class Tag(db.Model):
# __tablename__ = 'tags'
# __table_args__ = {'extend_existing': True}
# id = db.Column(db.Integer, primary_key=True)
# name = db.Column(db.String(100), unique=True, nullable=False)

View File

@ -1,6 +0,0 @@
{
"name": "search",
"version": "1.1",
"description": "Updated search plugin with live Plant model integration",
"entry_point": null
}

View File

@ -1,37 +0,0 @@
from flask import Blueprint, render_template, request, jsonify
from flask_login import login_required, current_user
from app import db
from .forms import SearchForm
from plugins.plant.models import Plant, Tag
bp = Blueprint('search', __name__, template_folder='templates')
@bp.route('/search', methods=['GET', 'POST'])
@login_required
def search():
form = SearchForm()
form.tags.choices = [(tag.id, tag.name) for tag in Tag.query.order_by(Tag.name).all()]
results = []
if form.validate_on_submit():
query = db.session.query(Plant).join(PlantScientific).join(PlantCommon)
if form.query.data:
q = f"%{form.query.data}%"
query = query.filter(
db.or_(
PlantScientific.name.ilike(q),
PlantCommon.name.ilike(q),
Plant.current_status.ilike(q)
)
)
if form.tags.data:
query = query.filter(Plant.tags.any(Tag.id.in_(form.tags.data)))
query = query.filter(Plant.owner_id == current_user.id)
results = query.all()
return render_template('search/search.html', form=form, results=results)
@bp.route('/search/tags')
@login_required
def search_tags():
term = request.args.get('term', '')
tags = Tag.query.filter(Tag.name.ilike(f"%{term}%")).limit(10).all()
return jsonify([tag.name for tag in tags])

View File

@ -1,26 +0,0 @@
{% extends 'core_ui/base.html' %}
{% block content %}
<h2>Search Plants</h2>
<form method="POST">
{{ form.hidden_tag() }}
<p>
{{ form.query.label }}<br>
{{ form.query(size=32) }}
</p>
<p>
{{ form.tags.label }}<br>
{{ form.tags(multiple=True) }}
</p>
<p>{{ form.submit() }}</p>
</form>
{% if results %}
<h3>Search Results</h3>
<ul>
{% for result in results %}
<li>{{ result.name }}</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View File

@ -1,13 +0,0 @@
{% extends 'core_ui/base.html' %}
{% block content %}
<h2>Search Results</h2>
{% if results %}
<ul>
{% for result in results %}
<li>{{ result.name }}</li>
{% endfor %}
</ul>
{% else %}
<p>No results found.</p>
{% endif %}
{% endblock %}

View File

@ -1,4 +0,0 @@
{% extends 'core_ui/base.html' %}
{% block content %}
<p>This page was replaced by AJAX functionality.</p>
{% endblock %}

View File

@ -1,6 +1,17 @@
{
"name": "submission",
"version": "1.0.0",
"description": "Plugin to handle user-submitted plant data and images.",
"entry_point": null
}
"name": "Submission",
"version": "0.1.0",
"author": "Bryson Shepard <bryson@natureinpots.com>",
"description": "Handles vendor price submissions and listings.",
"module": "plugins.submission",
"routes": {
"module": "plugins.submission.routes",
"blueprint": "bp",
"url_prefix": "/submission"
},
"models": [
"plugins.submission.models"
],
"license": "Proprietary",
"repository": "https://github.com/your-org/your-app"
}

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %}
{% extends 'core/base.html' %}
{% block content %}
<div class="container mt-4">
<h2>Your Submissions</h2>

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %}
{% extends 'core/base.html' %}
{% block content %}
<div class="container mt-4">
<h2>New Submission</h2>

View File

@ -1,5 +1,5 @@
{# plugins/submission/templates/submission/view.html #}
{% extends 'core_ui/base.html' %}
{% extends 'core/base.html' %}
{% block content %}
<div class="container mt-4">

View File

@ -1,6 +1,17 @@
{
"name": "transfer",
"version": "1.0.0",
"description": "Handles plant transfer requests between users",
"entry_point": ""
}
"name": "Transfer",
"version": "0.1.0",
"author": "Bryson Shepard <bryson@natureinpots.com>",
"description": "Manages plant transfers between users.",
"module": "plugins.transfer",
"routes": {
"module": "plugins.transfer.routes",
"blueprint": "bp",
"url_prefix": "/transfer"
},
"models": [
"plugins.transfer.models"
],
"license": "Proprietary",
"repository": "https://github.com/your-org/your-app"
}

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %}
{% extends 'core/base.html' %}
{% block content %}
<h2>Incoming Transfer Requests</h2>

View File

@ -1,4 +1,4 @@
{% extends 'core_ui/base.html' %}
{% extends 'core/base.html' %}
{% block content %}
<h2>Request Transfer: {{ plant.custom_slug or plant.uuid }}</h2>

View File

@ -0,0 +1,13 @@
# plugins/utility/__init__.py
def register_cli(app):
# no CLI commands for now
pass
def init_celery(app):
# Called via plugin.json entry_point
from .celery import init_celery as _init, celery_app
_init(app)
# Attach it if you like: app.celery = celery_app
app.celery = celery_app
return celery_app

46
plugins/utility/celery.py Normal file
View File

@ -0,0 +1,46 @@
# plugins/utility/celery.py
from celery import Celery
# 1) Create Celery instance at import time so tasks can import celery_app
# Include your plugin's tasks module and leave room to add more as you go.
celery_app = Celery(
__name__,
include=[
'plugins.utility.tasks',
# 'plugins.backup.tasks',
# 'plugins.cleanup.tasks',
# …add other plugin.task modules here
]
)
def init_celery(app):
"""
Configure the global celery_app with Flask settings and
ensure tasks run inside the Flask application context.
"""
# Pull broker/backend from Flask config
celery_app.conf.broker_url = app.config['CELERY_BROKER_URL']
celery_app.conf.result_backend = app.config.get(
'CELERY_RESULT_BACKEND',
app.config['CELERY_BROKER_URL']
)
celery_app.conf.update(app.config)
# Wrap all tasks in Flask app context
TaskBase = celery_app.Task
class ContextTask(TaskBase):
def __call__(self, *args, **kwargs):
with app.app_context():
return super().__call__(*args, **kwargs)
celery_app.Task = ContextTask
# And auto-discover any other tasks modules you add under the plugins/ namespace
celery_app.autodiscover_tasks([
'plugins.utility',
# 'plugins.backup',
# 'plugins.cleanup',
# …your other plugins here
], force=True)
return celery_app

View File

@ -1,6 +1,33 @@
{
"name": "utility",
"version": "1.0.0",
"description": "General utilities and such",
"entry_point": null
}
"name": "Utility",
"version": "0.1.0",
"author": "Bryson Shepard <bryson@natureinpots.com>",
"description": "Miscellaneous utilities (import/export, tasks).",
"module": "plugins.utility",
"routes": {
"module": "plugins.utility.routes",
"blueprint": "bp",
"url_prefix": "/utility"
},
"models": [
"plugins.utility.models"
],
"tasks": {
"module": "plugins.utility.tasks",
"callable": "init_celery"
},
"subplugins": [
{
"name": "Utility Search",
"module": "plugins.utility.search",
"routes": {
"module": "plugins.utility.search.search",
"blueprint": "bp",
"url_prefix": "/utility/search"
},
"models": []
}
],
"license": "Proprietary",
"repository": "https://github.com/your-org/your-app"
}

View File

@ -0,0 +1,63 @@
# plugins/utility/search/routes.py
from flask import Blueprint, render_template, request, jsonify
from flask_login import login_required, current_user
from app import db
from sqlalchemy import or_
from plugins.plant.models import Plant, PlantCommonName, PlantScientificName, Tag
from flask_wtf import FlaskForm
from wtforms import StringField, SelectMultipleField, SubmitField
from wtforms.validators import Optional, Length, Regexp
bp = Blueprint(
'search',
__name__,
url_prefix='/search',
template_folder='templates/search'
)
class SearchForm(FlaskForm):
query = StringField(
'Search',
validators=[
Optional(),
Length(min=2, max=100, message="Search term must be between 2 and 100 characters."),
Regexp(r'^[\w\s\-]+$', message="Search can only include letters, numbers, spaces, and dashes.")
]
)
tags = SelectMultipleField('Tags', coerce=int)
submit = SubmitField('Search')
@bp.route('', methods=['GET', 'POST'])
@login_required
def search():
form = SearchForm()
# populate tag choices
form.tags.choices = [(t.id, t.name) for t in Tag.query.order_by(Tag.name).all()]
results = []
if form.validate_on_submit():
q = form.query.data or ''
db_query = db.session.query(Plant).join(PlantScientific).join(PlantCommon)
if q:
like_term = f"%{q}%"
db_query = db_query.filter(
or_(
Plant.common_name.ilike(like_term),
Plant.scientific_name.ilike(like_term),
Plant.current_status.ilike(like_term)
)
)
if form.tags.data:
db_query = db_query.filter(Plant.tags.any(Tag.id.in_(form.tags.data)))
db_query = db_query.filter(Plant.owner_id == current_user.id)
results = db_query.all()
return render_template('search/search.html', form=form, results=results)
@bp.route('/tags')
@login_required
def search_tags():
term = request.args.get('term', '')
matches = Tag.query.filter(Tag.name.ilike(f"%{term}%")).limit(10).all()
return jsonify([t.name for t in matches])

View File

@ -0,0 +1,24 @@
<!-- File: plugins/utility/templates/search/_form_scripts.html -->
{# Optional: include this partial if you use Select2 for tags autocomplete #}
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0/dist/css/select2.min.css" rel="stylesheet"/>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0/dist/js/select2.min.js"></script>
<script>
$(document).ready(function() {
$('select[name="tags"]').select2({
placeholder: 'Select tags',
ajax: {
url: '{{ url_for("search.search_tags") }}',
dataType: 'json',
delay: 250,
data: params => ({ term: params.term }),
processResults: data => ({
results: data.map(name => ({ id: name, text: name }))
}),
cache: true
},
minimumInputLength: 1,
allowClear: true
});
});
</script>

View File

@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Search{% endblock %}
{% block content %}
<div class="container mt-4">
<h1 class="mb-3">Search Plants</h1>
<form method="post" novalidate>
{{ form.hidden_tag() }}
<div class="form-group mb-3">
{{ form.query.label(class="form-label") }}
{{ form.query(class="form-control", placeholder="Enter search term…") }}
{% if form.query.errors %}
<div class="text-danger small">
{{ form.query.errors[0] }}
</div>
{% endif %}
</div>
<div class="form-group mb-3">
{{ form.tags.label(class="form-label") }}
{{ form.tags(class="form-select", multiple=true) }}
{% if form.tags.errors %}
<div class="text-danger small">
{{ form.tags.errors[0] }}
</div>
{% endif %}
</div>
<button type="submit" class="btn btn-primary">{{ form.submit.label.text }}</button>
</form>
<hr class="my-4">
{% if results %}
<ul class="list-group">
{% for plant in results %}
<li class="list-group-item">
<a href="{{ url_for('plant.view', plant_id=plant.id) }}">
{{ plant.common_name or plant.scientific_name }}
</a>
</li>
{% endfor %}
</ul>
{% elif request.method == 'POST' %}
<p class="text-muted">No results found.</p>
{% endif %}
</div>

17
plugins/utility/tasks.py Normal file
View File

@ -0,0 +1,17 @@
from .celery import celery_app
# Example placeholder task
@celery_app.task
def ping():
return 'pong'
def init_celery(app):
"""
Initialize the Celery app with the Flask app's config and context.
Called automatically by the JSON-driven loader.
"""
# Re-import the shared celery_app
from .celery import celery_app
celery_app.conf.update(app.config)
return celery_app

View File

@ -1,4 +1,4 @@
{% extends "core_ui/base.html" %}
{% extends "core/base.html" %}
{% block title %}Review Suggested Matches{% endblock %}
{% block content %}

View File

@ -1,4 +1,4 @@
{% extends "core_ui/base.html" %}
{% extends "core/base.html" %}
{% block title %}CSV Import{% endblock %}
{% block content %}