a ton of fun happened, refactored alot
This commit is contained in:
@ -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"
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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 %}
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% extends 'core_ui/base.html' %}
|
||||
{% extends 'core/base.html' %}
|
||||
{% block title %}{{ action }} User – Admin – Nature In Pots{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
@ -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
15
plugins/auth/forms.py
Normal 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')
|
@ -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)
|
||||
|
@ -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"
|
||||
}
|
@ -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
|
||||
|
@ -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') }}">
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% extends 'core_ui/base.html' %}
|
||||
{% extends 'core/base.html' %}
|
||||
{% block title %}Register{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Register</h2>
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "core_ui",
|
||||
"version": "1.1.0",
|
||||
"description": "Media rendering macros and styling helpers",
|
||||
"entry_point": null
|
||||
}
|
@ -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
|
@ -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 %}
|
@ -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>
|
||||
© {{ 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>
|
@ -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 parent–child 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 %}
|
@ -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>
|
@ -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')
|
@ -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",
|
||||
)
|
@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "growlog",
|
||||
"version": "1.0.0",
|
||||
"description": "Tracks time-based plant care logs",
|
||||
"entry_point": null
|
||||
}
|
@ -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}
|
||||
|
@ -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"
|
||||
}
|
@ -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 path‐traversal 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 (soft‐delete 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 we’re 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 don’t 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):
|
||||
"""
|
||||
Single‐select “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
69
plugins/media/tasks.py
Normal 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
|
||||
|
||||
# Re‐import 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()
|
@ -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>
|
||||
|
14
plugins/ownership/plugin.json
Normal file
14
plugins/ownership/plugin.json
Normal 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"
|
||||
}
|
29
plugins/plant/growlog/forms.py
Normal file
29
plugins/plant/growlog/forms.py
Normal 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')
|
40
plugins/plant/growlog/models.py
Normal file
40
plugins/plant/growlog/models.py
Normal 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"
|
||||
)
|
@ -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 common‐name 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 user’s 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))
|
@ -1,4 +1,4 @@
|
||||
{% extends 'core_ui/base.html' %}
|
||||
{% extends 'core/base.html' %}
|
||||
{% block title %}Add Grow Log{% endblock %}
|
||||
|
||||
{% block content %}
|
@ -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>
|
@ -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
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% extends 'core_ui/base.html' %}
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card mb-4">
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% extends 'core_ui/base.html' %}
|
||||
{% extends 'core/base.html' %}
|
||||
{% block title %}Add New Plant – Nature In Pots{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
@ -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 %}
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% extends 'core_ui/base.html' %}
|
||||
{% extends 'core/base.html' %}
|
||||
{% block title %}Edit Plant – Nature In Pots{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% extends 'core_ui/base.html' %}
|
||||
{% extends 'core/base.html' %}
|
||||
{% block title %}View Entries – Nature In Pots{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
@ -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')
|
@ -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)
|
@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "search",
|
||||
"version": "1.1",
|
||||
"description": "Updated search plugin with live Plant model integration",
|
||||
"entry_point": null
|
||||
}
|
@ -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])
|
@ -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 %}
|
@ -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 %}
|
@ -1,4 +0,0 @@
|
||||
{% extends 'core_ui/base.html' %}
|
||||
{% block content %}
|
||||
<p>This page was replaced by AJAX functionality.</p>
|
||||
{% endblock %}
|
@ -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"
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
{% extends 'core_ui/base.html' %}
|
||||
{% extends 'core/base.html' %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Your Submissions</h2>
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% extends 'core_ui/base.html' %}
|
||||
{% extends 'core/base.html' %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>New Submission</h2>
|
||||
|
@ -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">
|
||||
|
@ -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"
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
{% extends 'core_ui/base.html' %}
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Incoming Transfer Requests</h2>
|
||||
|
@ -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>
|
||||
|
@ -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
46
plugins/utility/celery.py
Normal 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
|
@ -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"
|
||||
}
|
||||
|
63
plugins/utility/search/search.py
Normal file
63
plugins/utility/search/search.py
Normal 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])
|
24
plugins/utility/search/templates/search/_form_scripts.html
Normal file
24
plugins/utility/search/templates/search/_form_scripts.html
Normal 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>
|
45
plugins/utility/search/templates/search/search.html
Normal file
45
plugins/utility/search/templates/search/search.html
Normal 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
17
plugins/utility/tasks.py
Normal 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
|
@ -1,4 +1,4 @@
|
||||
{% extends "core_ui/base.html" %}
|
||||
{% extends "core/base.html" %}
|
||||
{% block title %}Review Suggested Matches{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% extends "core_ui/base.html" %}
|
||||
{% extends "core/base.html" %}
|
||||
{% block title %}CSV Import{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
Reference in New Issue
Block a user