broke
This commit is contained in:
@ -13,6 +13,7 @@ from app import db
|
||||
from plugins.auth.models import User
|
||||
from plugins.plant.growlog.models import GrowLog
|
||||
from plugins.plant.models import Plant
|
||||
from plugins.media.models import Media
|
||||
from plugins.admin.models import AnalyticsEvent
|
||||
from .forms import UserForm
|
||||
|
||||
@ -332,3 +333,12 @@ def undelete_user(user_id):
|
||||
page=request.args.get('page'),
|
||||
show_deleted=request.args.get('show_deleted'),
|
||||
q=request.args.get('q')))
|
||||
|
||||
|
||||
@bp.route('/orphaned-media')
|
||||
@login_required
|
||||
def orphaned_media_list():
|
||||
if not current_user.role == 'admin':
|
||||
abort(403)
|
||||
items = Media.query.filter_by(status='orphaned').order_by(Media.orphaned_at.desc()).all()
|
||||
return render_template('admin/orphaned_media_list.html', items=items)
|
33
plugins/admin/templates/admin/orphaned_media_list.html
Normal file
33
plugins/admin/templates/admin/orphaned_media_list.html
Normal file
@ -0,0 +1,33 @@
|
||||
<!-- plugins/admin/templates/admin/orphaned_media_list.html -->
|
||||
|
||||
{% extends 'core/base.html' %}
|
||||
|
||||
{% block title %}Orphaned Media – Admin – Nature In Pots{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Orphaned Media</h1>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Original URL</th>
|
||||
<th>New URL</th>
|
||||
<th>Orphaned At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in items %}
|
||||
<tr>
|
||||
<td>{{ m.id }}</td>
|
||||
<td><a href="{{ m.original_file_url }}" target="_blank">{{ m.original_file_url }}</a></td>
|
||||
<td><a href="{{ m.file_url }}" target="_blank">{{ m.file_url }}</a></td>
|
||||
<td>{{ m.orphaned_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center">No orphaned media found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
@ -1,15 +1,62 @@
|
||||
# plugins/auth/forms.py
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, PasswordField, SubmitField
|
||||
from wtforms.validators import DataRequired, Email, Length, EqualTo
|
||||
from wtforms import StringField, PasswordField, BooleanField, SubmitField, IntegerField
|
||||
from wtforms.validators import (
|
||||
DataRequired, Email, Length, EqualTo, Regexp, NumberRange
|
||||
)
|
||||
|
||||
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')
|
||||
username = StringField(
|
||||
'Username',
|
||||
validators=[DataRequired(), Length(min=3, max=25)]
|
||||
)
|
||||
email = StringField(
|
||||
'Email',
|
||||
validators=[DataRequired(), Email(), Length(max=120)]
|
||||
)
|
||||
invitation_code = StringField(
|
||||
'Invitation Code',
|
||||
validators=[DataRequired(),
|
||||
Length(min=36, max=36,
|
||||
message="Invitation code must be 36 characters.")]
|
||||
)
|
||||
password = PasswordField(
|
||||
'Password',
|
||||
validators=[DataRequired(), Length(min=6)]
|
||||
)
|
||||
confirm = PasswordField(
|
||||
'Confirm Password',
|
||||
validators=[DataRequired(),
|
||||
EqualTo('password', message='Passwords must match.')]
|
||||
)
|
||||
submit = SubmitField('Register')
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
email = StringField('Email', validators=[DataRequired(), Email()])
|
||||
password = PasswordField('Password', validators=[DataRequired()])
|
||||
submit = SubmitField('Login')
|
||||
email = StringField(
|
||||
'Email',
|
||||
validators=[DataRequired(), Email(), Length(max=120)]
|
||||
)
|
||||
password = PasswordField(
|
||||
'Password',
|
||||
validators=[DataRequired()]
|
||||
)
|
||||
remember_me = BooleanField('Remember me')
|
||||
submit = SubmitField('Log In')
|
||||
|
||||
|
||||
class InviteForm(FlaskForm):
|
||||
email = StringField(
|
||||
'Recipient Email',
|
||||
validators=[DataRequired(), Email(), Length(max=120)]
|
||||
)
|
||||
submit = SubmitField('Send Invite')
|
||||
|
||||
|
||||
class AdjustInvitesForm(FlaskForm):
|
||||
delta = IntegerField(
|
||||
'Adjustment',
|
||||
validators=[DataRequired(), NumberRange()]
|
||||
)
|
||||
submit = SubmitField('Adjust Invites')
|
||||
|
@ -1,33 +1,42 @@
|
||||
# File: plugins/auth/models.py
|
||||
|
||||
from uuid import uuid4
|
||||
from datetime import datetime
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask_login import UserMixin
|
||||
from datetime import datetime
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.orm import object_session
|
||||
from app import db, login_manager
|
||||
from app.config import Config
|
||||
|
||||
|
||||
class User(db.Model, UserMixin):
|
||||
__tablename__ = 'users'
|
||||
__table_args__ = {'extend_existing': True}
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.Text, nullable=False)
|
||||
role = db.Column(db.String(50), default='user')
|
||||
is_verified = db.Column(db.Boolean, default=False)
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.Text, nullable=False)
|
||||
role = db.Column(db.String(50), default='user')
|
||||
is_verified = db.Column(db.Boolean, default=False)
|
||||
excluded_from_analytics = db.Column(db.Boolean, default=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
is_deleted = db.Column(db.Boolean, nullable=False, default=False)
|
||||
is_banned = db.Column(db.Boolean, nullable=False, default=False)
|
||||
suspended_until = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
is_deleted = db.Column(db.Boolean, nullable=False, default=False)
|
||||
is_banned = db.Column(db.Boolean, nullable=False, default=False)
|
||||
suspended_until = db.Column(db.DateTime, nullable=True)
|
||||
# ← New: how many invites the user may still send
|
||||
invites_remaining = db.Column(
|
||||
db.Integer,
|
||||
nullable=False,
|
||||
default=Config.INVITES_PER_USER
|
||||
)
|
||||
|
||||
# relationships (existing)
|
||||
submitted_submissions = db.relationship(
|
||||
"Submission",
|
||||
foreign_keys="Submission.user_id",
|
||||
back_populates="submitter",
|
||||
lazy=True
|
||||
)
|
||||
|
||||
reviewed_submissions = db.relationship(
|
||||
"Submission",
|
||||
foreign_keys="Submission.reviewed_by",
|
||||
@ -42,18 +51,77 @@ class User(db.Model, UserMixin):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
|
||||
# ─── Flask-Login integration ─────────────────────────────────────────────────
|
||||
class Invitation(db.Model):
|
||||
__tablename__ = 'invitation'
|
||||
|
||||
def _load_user(user_id):
|
||||
"""Return a User by ID, or None."""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
code = db.Column(db.String(36), unique=True, nullable=False, default=lambda: str(uuid4()))
|
||||
recipient_email = db.Column(db.String(120), nullable=False)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
sender_ip = db.Column(db.String(45), nullable=False)
|
||||
receiver_ip = db.Column(db.String(45), nullable=True)
|
||||
is_used = db.Column(db.Boolean, default=False, nullable=False)
|
||||
used_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
used_at = db.Column(db.DateTime, nullable=True)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
|
||||
creator = db.relationship(
|
||||
'User',
|
||||
foreign_keys=[created_by],
|
||||
backref='invitations_sent'
|
||||
)
|
||||
user = db.relationship(
|
||||
'User',
|
||||
foreign_keys=[used_by],
|
||||
backref='invitation_received'
|
||||
)
|
||||
|
||||
def mark_used(self, user, ip_addr):
|
||||
self.is_used = True
|
||||
self.user = user
|
||||
self.used_at = datetime.utcnow()
|
||||
self.receiver_ip = ip_addr
|
||||
|
||||
|
||||
# ─── Auto‐revoke invites when a user is banned/suspended/deleted ────────────────
|
||||
|
||||
@event.listens_for(User, 'after_update')
|
||||
def revoke_user_invites(mapper, connection, target):
|
||||
sess = object_session(target)
|
||||
if not sess:
|
||||
return
|
||||
|
||||
state = db.inspect(target)
|
||||
banned_changed = state.attrs.is_banned.history.has_changes()
|
||||
deleted_changed = state.attrs.is_deleted.history.has_changes()
|
||||
suspended_changed = state.attrs.suspended_until.history.has_changes()
|
||||
|
||||
if (banned_changed and target.is_banned) \
|
||||
or (deleted_changed and target.is_deleted) \
|
||||
or (suspended_changed and target.suspended_until and target.suspended_until > datetime.utcnow()):
|
||||
invs = sess.query(Invitation) \
|
||||
.filter_by(created_by=target.id, is_active=True, is_used=False) \
|
||||
.all()
|
||||
refund = len(invs)
|
||||
for inv in invs:
|
||||
inv.is_active = False
|
||||
target.invites_remaining = target.invites_remaining + refund
|
||||
sess.add(target)
|
||||
sess.flush()
|
||||
|
||||
|
||||
# ─── Flask-Login user loader ────────────────────────────────────────────────────
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
if not str(user_id).isdigit():
|
||||
return None
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
|
||||
def register_user_loader(app):
|
||||
def register_user_loader(lm):
|
||||
"""
|
||||
Hook into Flask-Login to register the user_loader.
|
||||
Called by our JSON-driven loader if declared in plugin.json.
|
||||
Called by the JSON‐driven plugin loader to wire Flask‐Login.
|
||||
"""
|
||||
login_manager.user_loader(_load_user)
|
||||
lm.user_loader(load_user)
|
||||
|
@ -1,30 +1,40 @@
|
||||
# File: plugins/auth/routes.py
|
||||
# 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 datetime import datetime
|
||||
from flask import (
|
||||
Blueprint, render_template, redirect, flash, url_for, request
|
||||
)
|
||||
from flask_login import (
|
||||
login_user, logout_user, login_required, current_user
|
||||
)
|
||||
from app import db
|
||||
from .models import User, Invitation
|
||||
from .forms import (
|
||||
LoginForm, RegistrationForm, InviteForm, AdjustInvitesForm
|
||||
)
|
||||
|
||||
bp = Blueprint(
|
||||
'auth',
|
||||
__name__,
|
||||
template_folder='templates/auth', # ← now points at plugins/auth/templates/auth/
|
||||
template_folder='templates',
|
||||
url_prefix='/auth'
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('home'))
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(email=form.email.data).first()
|
||||
user = User.query.filter_by(email=form.email.data.lower()).first()
|
||||
if user and user.check_password(form.password.data):
|
||||
login_user(user)
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
flash('Logged in successfully.', 'success')
|
||||
return redirect(url_for('home'))
|
||||
next_page = request.args.get('next') or url_for('home')
|
||||
return redirect(next_page)
|
||||
flash('Invalid email or password.', 'danger')
|
||||
return render_template('login.html', form=form) # resolves to templates/auth/login.html
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
|
||||
@bp.route('/logout')
|
||||
@ -37,12 +47,92 @@ def logout():
|
||||
|
||||
@bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
form = RegistrationForm()
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('home'))
|
||||
|
||||
invite_code = request.args.get('invite', '').strip()
|
||||
form = RegistrationForm(invitation_code=invite_code)
|
||||
|
||||
if form.validate_on_submit():
|
||||
user = User(email=form.email.data)
|
||||
# Validate invitation
|
||||
invitation = Invitation.query.filter_by(
|
||||
code=form.invitation_code.data,
|
||||
is_active=True,
|
||||
is_used=False
|
||||
).first()
|
||||
if not invitation:
|
||||
flash('Registration is by invitation only. Provide a valid code.', 'warning')
|
||||
return render_template('auth/register.html', form=form)
|
||||
|
||||
if invitation.recipient_email and \
|
||||
invitation.recipient_email.lower() != form.email.data.lower():
|
||||
flash('This invitation is not valid for that email address.', 'warning')
|
||||
return render_template('auth/register.html', form=form)
|
||||
|
||||
# Create the user
|
||||
user = User(email=form.email.data.lower())
|
||||
user.set_password(form.password.data)
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
|
||||
# Mark invitation used
|
||||
invitation.mark_used(user, request.remote_addr)
|
||||
db.session.commit()
|
||||
|
||||
flash('Account created! Please log in.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
return render_template('register.html', form=form) # resolves to templates/auth/register.html
|
||||
|
||||
return render_template('auth/register.html', form=form)
|
||||
|
||||
|
||||
@bp.route('/invite', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def send_invite():
|
||||
form = InviteForm()
|
||||
|
||||
# Block banned/suspended users from sending
|
||||
if current_user.is_banned or current_user.is_deleted or (
|
||||
current_user.suspended_until and current_user.suspended_until > datetime.utcnow()
|
||||
):
|
||||
flash('You are not permitted to send invitations.', 'warning')
|
||||
return redirect(url_for('home'))
|
||||
|
||||
if form.validate_on_submit():
|
||||
if current_user.invites_remaining < 1:
|
||||
flash('No invites remaining. Ask an admin to increase your quota.', 'danger')
|
||||
else:
|
||||
recipient = form.email.data.strip().lower()
|
||||
inv = Invitation(
|
||||
recipient_email=recipient,
|
||||
created_by=current_user.id,
|
||||
sender_ip=request.remote_addr
|
||||
)
|
||||
db.session.add(inv)
|
||||
current_user.invites_remaining -= 1
|
||||
db.session.commit()
|
||||
flash(f'Invitation sent to {recipient}.', 'success')
|
||||
return redirect(url_for('auth.send_invite'))
|
||||
|
||||
return render_template(
|
||||
'auth/invite.html',
|
||||
form=form,
|
||||
invites=current_user.invites_remaining
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/admin/adjust_invites/<int:user_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def adjust_invites(user_id):
|
||||
# assume current_user has admin rights
|
||||
user = User.query.get_or_404(user_id)
|
||||
form = AdjustInvitesForm()
|
||||
if form.validate_on_submit():
|
||||
user.invites_remaining = max(0, user.invites_remaining + form.delta.data)
|
||||
db.session.commit()
|
||||
flash(f"{user.email}'s invite quota adjusted by {form.delta.data}.", 'info')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
return render_template(
|
||||
'auth/adjust_invites.html',
|
||||
form=form,
|
||||
user=user
|
||||
)
|
||||
|
19
plugins/auth/templates/auth/invite.html
Normal file
19
plugins/auth/templates/auth/invite.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% block title %}Send Invitation{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container mt-4" style="max-width: 500px;">
|
||||
<h2>Send Invitation</h2>
|
||||
<p>You have <strong>{{ invites }}</strong> invites remaining.</p>
|
||||
<form method="POST" action="{{ url_for('auth.send_invite') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="mb-3">
|
||||
{{ form.email.label(class="form-label") }}
|
||||
{{ form.email(class="form-control", placeholder="recipient@example.com") }}
|
||||
{% for error in form.email.errors %}
|
||||
<div class="text-danger small">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">{{ form.submit.label.text }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,16 +1,34 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% extends "core/base.html" %}
|
||||
{% block title %}Log In{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Login</h2>
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</form>
|
||||
<div class="container mt-4" style="max-width: 400px;">
|
||||
<h2 class="mb-3">Log In</h2>
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.email.label(class="form-label") }}
|
||||
{{ form.email(class="form-control", placeholder="you@example.com") }}
|
||||
{% for e in form.email.errors %}
|
||||
<div class="text-danger small">{{ e }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control", placeholder="Password") }}
|
||||
{% for e in form.password.errors %}
|
||||
<div class="text-danger small">{{ e }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
{{ form.remember_me(class="form-check-input") }}
|
||||
{{ form.remember_me.label(class="form-check-label") }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">{{ form.submit.label.text }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -1,17 +1,53 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% extends "core/base.html" %}
|
||||
{% block title %}Register{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Register</h2>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="container mt-4" style="max-width: 400px;">
|
||||
<h2 class="mb-3">Register</h2>
|
||||
<form method="POST" action="{{ url_for('auth.register') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label>Email</label>
|
||||
<input name="email" class="form-control" type="email" required>
|
||||
{{ form.username.label(class="form-label") }}
|
||||
{{ form.username(class="form-control", placeholder="Username") }}
|
||||
{% for e in form.username.errors %}
|
||||
<div class="text-danger small">{{ e }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label>Password</label>
|
||||
<input name="password" class="form-control" type="password" required>
|
||||
{{ form.email.label(class="form-label") }}
|
||||
{{ form.email(class="form-control", placeholder="you@example.com") }}
|
||||
{% for e in form.email.errors %}
|
||||
<div class="text-danger small">{{ e }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Register</button>
|
||||
</form>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.invitation_code.label(class="form-label") }}
|
||||
{{ form.invitation_code(class="form-control", placeholder="Invitation Code") }}
|
||||
{% for e in form.invitation_code.errors %}
|
||||
<div class="text-danger small">{{ e }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control", placeholder="Password") }}
|
||||
{% for e in form.password.errors %}
|
||||
<div class="text-danger small">{{ e }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.confirm.label(class="form-label") }}
|
||||
{{ form.confirm(class="form-control", placeholder="Confirm Password") }}
|
||||
{% for e in form.confirm.errors %}
|
||||
<div class="text-danger small">{{ e }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">{{ form.submit.label.text }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -1,4 +1,4 @@
|
||||
# plugins/media/models.py
|
||||
# File: plugins/media/models.py
|
||||
|
||||
from datetime import datetime
|
||||
from flask import url_for
|
||||
@ -8,17 +8,22 @@ class Media(db.Model):
|
||||
__tablename__ = "media"
|
||||
__table_args__ = {"extend_existing": True}
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
plugin = db.Column(db.String(50), nullable=False)
|
||||
related_id = db.Column(db.Integer, nullable=False)
|
||||
filename = db.Column(db.String(256), nullable=False)
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
uploader_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||||
caption = db.Column(db.String(255), nullable=True)
|
||||
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)
|
||||
file_url = db.Column(db.String(512), nullable=False)
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
plugin = db.Column(db.String(50), nullable=False)
|
||||
related_id = db.Column(db.Integer, nullable=False)
|
||||
filename = db.Column(db.String(256), nullable=False)
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
uploader_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||||
caption = db.Column(db.String(255), nullable=True)
|
||||
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)
|
||||
file_url = db.Column(db.String(512), nullable=False)
|
||||
original_file_url = db.Column(db.Text, nullable=True)
|
||||
|
||||
# ←─ NEW ── track orphaned state
|
||||
status = db.Column(db.String(20), nullable=False, default='active')
|
||||
orphaned_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
hearts = db.relationship(
|
||||
"ImageHeart",
|
||||
@ -33,7 +38,7 @@ class Media(db.Model):
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# ↔ Media items attached to a Plant
|
||||
# ↔ attached Plant
|
||||
plant = db.relationship(
|
||||
"Plant",
|
||||
back_populates="media_items",
|
||||
@ -41,7 +46,7 @@ class Media(db.Model):
|
||||
lazy="joined",
|
||||
)
|
||||
|
||||
# ↔ Media items attached to a GrowLog
|
||||
# ↔ attached GrowLog
|
||||
growlog = db.relationship(
|
||||
"GrowLog",
|
||||
back_populates="media_items",
|
||||
@ -75,6 +80,15 @@ class Media(db.Model):
|
||||
for fe in self.featured_entries
|
||||
)
|
||||
|
||||
def mark_orphaned(self, new_url):
|
||||
"""
|
||||
Move to orphaned state, recording original URL and timestamp.
|
||||
"""
|
||||
self.original_file_url = self.file_url
|
||||
self.file_url = new_url
|
||||
self.status = 'orphaned'
|
||||
self.orphaned_at = datetime.utcnow()
|
||||
|
||||
|
||||
class ZipJob(db.Model):
|
||||
__tablename__ = 'zip_jobs'
|
||||
@ -93,7 +107,7 @@ class ImageHeart(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.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)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
|
||||
class FeaturedImage(db.Model):
|
||||
@ -106,4 +120,4 @@ class FeaturedImage(db.Model):
|
||||
context_id = db.Column(db.Integer, nullable=False)
|
||||
override_text = db.Column(db.String(255), nullable=True)
|
||||
is_featured = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
@ -2,7 +2,7 @@
|
||||
"name": "Media",
|
||||
"version": "0.1.0",
|
||||
"author": "Bryson Shepard <bryson@natureinpots.com>",
|
||||
"description": "Manages image uploads, storage, and URL generation.",
|
||||
"description": "Upload, serve, and process images & other media.",
|
||||
"module": "plugins.media",
|
||||
"routes": {
|
||||
"module": "plugins.media.routes",
|
||||
@ -18,6 +18,15 @@
|
||||
"callable": "plugins.media.routes.generate_image_url"
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
"plugins.media.tasks"
|
||||
],
|
||||
"tasks_init": [
|
||||
{
|
||||
"module": "plugins.media.tasks",
|
||||
"callable": "init_media_tasks"
|
||||
}
|
||||
],
|
||||
"license": "Proprietary",
|
||||
"repository": "https://github.com/your-org/your-app"
|
||||
}
|
||||
}
|
||||
|
@ -1,35 +1,47 @@
|
||||
# File: plugins/media/tasks.py
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from werkzeug.utils import secure_filename
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from celery.schedules import crontab
|
||||
from flask import current_app
|
||||
from app import db
|
||||
from plugins.media.models import ZipJob
|
||||
from app.celery_app import celery
|
||||
from plugins.media.models import Media, 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'}
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
|
||||
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
|
||||
return (w * h) <= MAX_PIXELS
|
||||
except (UnidentifiedImageError, IOError):
|
||||
return False
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
|
||||
@celery.task(
|
||||
bind=True,
|
||||
name='plugins.media.tasks.process_zip',
|
||||
queue='media'
|
||||
)
|
||||
def process_zip(self, job_id, zip_path):
|
||||
"""
|
||||
Unpack and validate a user‐uploaded ZIP batch.
|
||||
"""
|
||||
job = ZipJob.query.get(job_id)
|
||||
job.status = 'processing'
|
||||
db.session.commit()
|
||||
|
||||
extract_dir = zip_path + '_contents'
|
||||
extract_dir = f"{zip_path}_contents"
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = zf.namelist()
|
||||
@ -50,20 +62,84 @@ def process_zip(self, job_id, zip_path):
|
||||
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}')
|
||||
if ext in IMAGE_EXTS:
|
||||
if not validate_image(target):
|
||||
raise ValueError(f'Bad image: {member}')
|
||||
elif ext == '.pdf':
|
||||
if open(target,'rb').read(5)!=b'%PDF-':
|
||||
with open(target, 'rb') as f:
|
||||
header = f.read(5)
|
||||
if header != b'%PDF-':
|
||||
raise ValueError(f'Bad PDF: {member}')
|
||||
else:
|
||||
# txt/csv → simple UTF-8 check
|
||||
open(target,'rb').read(1024).decode('utf-8')
|
||||
with open(target, 'rb') as f:
|
||||
f.read(1024).decode('utf-8')
|
||||
|
||||
job.status = 'done'
|
||||
|
||||
except Exception as e:
|
||||
job.status = 'failed'
|
||||
job.error = str(e)
|
||||
|
||||
job.error = str(e)
|
||||
finally:
|
||||
db.session.commit()
|
||||
if os.path.isdir(extract_dir):
|
||||
shutil.rmtree(extract_dir)
|
||||
|
||||
|
||||
@celery.on_after_configure.connect
|
||||
def setup_periodic_tasks(sender, **kwargs):
|
||||
"""
|
||||
Schedule periodic media prune job every day at 2am.
|
||||
"""
|
||||
sender.add_periodic_task(
|
||||
crontab(hour=2, minute=0),
|
||||
prune_orphans.s(),
|
||||
name='media_prune',
|
||||
queue='media'
|
||||
)
|
||||
|
||||
|
||||
@celery.task(
|
||||
name='plugins.media.tasks.prune_orphans',
|
||||
queue='media'
|
||||
)
|
||||
def prune_orphans():
|
||||
"""
|
||||
Mark orphaned Media records, move their files to /static/orphaned/,
|
||||
and log the change in the DB.
|
||||
"""
|
||||
orphan_dir = os.path.join(current_app.root_path, 'static', 'orphaned')
|
||||
os.makedirs(orphan_dir, exist_ok=True)
|
||||
|
||||
candidates = Media.query.filter(
|
||||
Media.status == 'active',
|
||||
Media.plant_id.is_(None),
|
||||
Media.growlog_id.is_(None),
|
||||
Media.related_id.is_(None)
|
||||
).all()
|
||||
|
||||
for m in candidates:
|
||||
src_rel = m.file_url.lstrip('/')
|
||||
src_abs = os.path.join(current_app.root_path, src_rel)
|
||||
if not os.path.isfile(src_abs):
|
||||
current_app.logger.warning(f"Orphan prune: file not found {src_abs}")
|
||||
continue
|
||||
|
||||
filename = os.path.basename(src_abs)
|
||||
dest_abs = os.path.join(orphan_dir, filename)
|
||||
shutil.move(src_abs, dest_abs)
|
||||
|
||||
new_url = f"/static/orphaned/{filename}"
|
||||
m.mark_orphaned(new_url)
|
||||
|
||||
current_app.logger.info(
|
||||
f"Orphaned media #{m.id}: moved {src_rel} → {new_url}"
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def init_media_tasks(celery_app):
|
||||
"""
|
||||
Called by the JSON‐driven loader so tasks_init no longer errors.
|
||||
Celery scheduling is handled via on_after_configure.
|
||||
"""
|
||||
celery_app.logger.info("[Media] init_media_tasks called (no‐op)")
|
||||
|
@ -1,4 +1,4 @@
|
||||
# plugins/plant/growlog/routes.py
|
||||
# File: plugins/plant/growlog/routes.py
|
||||
|
||||
from uuid import UUID as _UUID
|
||||
from flask import (
|
||||
@ -7,6 +7,7 @@ from flask import (
|
||||
)
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.celery_app import celery
|
||||
from .models import GrowLog
|
||||
from .forms import GrowLogForm
|
||||
from plugins.plant.models import Plant, PlantCommonName
|
||||
@ -18,6 +19,7 @@ bp = Blueprint(
|
||||
template_folder='templates',
|
||||
)
|
||||
|
||||
|
||||
def _get_plant_by_uuid(uuid_val):
|
||||
"""
|
||||
Normalize & validate a UUID (may be a uuid.UUID or a string),
|
||||
@ -40,6 +42,7 @@ def _get_plant_by_uuid(uuid_val):
|
||||
.first_or_404()
|
||||
)
|
||||
|
||||
|
||||
def _user_plant_choices():
|
||||
"""
|
||||
Return [(uuid, "Common Name – uuid"), ...] for all plants
|
||||
@ -103,9 +106,6 @@ def add_log(plant_uuid=None):
|
||||
@bp.route('/<uuid:plant_uuid>')
|
||||
@login_required
|
||||
def list_logs(plant_uuid):
|
||||
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:
|
||||
|
@ -90,6 +90,7 @@ class Plant(db.Model):
|
||||
price = db.Column(db.Numeric(10, 2), nullable=True)
|
||||
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
is_public = db.Column(db.Boolean, nullable=False, default=False)
|
||||
featured_media_id = db.Column(db.Integer, db.ForeignKey('media.id'), nullable=True)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
@ -1,13 +1,3 @@
|
||||
# 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
|
||||
|
@ -1,46 +0,0 @@
|
||||
# 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,15 +1,41 @@
|
||||
# File: plugins/utility/models.py
|
||||
|
||||
from datetime import datetime
|
||||
from app import db # ← changed from plugins.plant.models
|
||||
from app import db
|
||||
|
||||
class ImportBatch(db.Model):
|
||||
__tablename__ = 'import_batches'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
export_id = db.Column(db.String(64), nullable=False)
|
||||
user_id = db.Column(db.Integer, nullable=False, index=True)
|
||||
imported_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
export_id = db.Column(db.String(64), nullable=False)
|
||||
user_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
imported_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
# New columns to track background processing status
|
||||
status = db.Column(
|
||||
db.String(20),
|
||||
nullable=False,
|
||||
default='pending',
|
||||
doc="One of: pending, processing, done, failed"
|
||||
)
|
||||
error = db.Column(
|
||||
db.Text,
|
||||
nullable=True,
|
||||
doc="If status=='failed', the exception text"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
# ensure a given user can’t import the same export twice
|
||||
db.UniqueConstraint('export_id', 'user_id', name='uix_export_user'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<ImportBatch id={self.id!r} export_id={self.export_id!r} "
|
||||
f"user_id={self.user_id!r} status={self.status!r}>"
|
||||
)
|
||||
|
@ -2,7 +2,7 @@
|
||||
"name": "Utility",
|
||||
"version": "0.1.0",
|
||||
"author": "Bryson Shepard <bryson@natureinpots.com>",
|
||||
"description": "Miscellaneous utilities (import/export, tasks).",
|
||||
"description": "Import/export workflows for plant data and media orchestration.",
|
||||
"module": "plugins.utility",
|
||||
"routes": {
|
||||
"module": "plugins.utility.routes",
|
||||
@ -12,16 +12,15 @@
|
||||
"models": [
|
||||
"plugins.utility.models"
|
||||
],
|
||||
"tasks": {
|
||||
"module": "plugins.utility.tasks",
|
||||
"callable": "init_celery"
|
||||
},
|
||||
"tasks": [
|
||||
"plugins.utility.tasks"
|
||||
],
|
||||
"subplugins": [
|
||||
{
|
||||
"name": "Utility Search",
|
||||
"module": "plugins.utility.search",
|
||||
"routes": {
|
||||
"module": "plugins.utility.search.search",
|
||||
"module": "plugins.utility.search",
|
||||
"blueprint": "bp",
|
||||
"url_prefix": "/utility/search"
|
||||
},
|
||||
|
@ -1,4 +1,4 @@
|
||||
# plugins/utility/routes.py
|
||||
# File: plugins/utility/routes.py
|
||||
|
||||
# Standard library
|
||||
import csv
|
||||
@ -34,9 +34,8 @@ from plugins.plant.models import (
|
||||
PlantOwnershipLog,
|
||||
)
|
||||
from plugins.media.models import Media
|
||||
from plugins.media.routes import _process_upload_file
|
||||
from plugins.utility.models import ImportBatch
|
||||
|
||||
from plugins.utility.tasks import import_text_data
|
||||
|
||||
bp = Blueprint(
|
||||
'utility',
|
||||
@ -52,18 +51,28 @@ def index():
|
||||
return redirect(url_for("utility.upload"))
|
||||
|
||||
|
||||
@bp.route("/imports", methods=["GET"])
|
||||
@login_required
|
||||
def imports():
|
||||
batches = (
|
||||
ImportBatch.query
|
||||
.filter_by(user_id=current_user.id)
|
||||
.order_by(ImportBatch.imported_at.desc())
|
||||
.limit(20)
|
||||
.all()
|
||||
)
|
||||
return render_template("utility/imports.html", batches=batches)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────────
|
||||
# Required headers for your sub-app export ZIP
|
||||
PLANT_HEADERS = [
|
||||
PLANT_HEADERS = [
|
||||
"UUID","Type","Name","Scientific Name",
|
||||
"Vendor Name","Price","Mother UUID","Notes",
|
||||
"Short ID"
|
||||
]
|
||||
MEDIA_HEADERS = [
|
||||
MEDIA_HEADERS = [
|
||||
"Plant UUID","Image Path","Uploaded At","Source Type"
|
||||
]
|
||||
|
||||
# Headers for standalone CSV review flow
|
||||
REQUIRED_HEADERS = {"uuid", "plant_type", "name", "scientific_name", "mother_uuid"}
|
||||
|
||||
|
||||
@ -84,6 +93,7 @@ def upload():
|
||||
file.save(tmp_zip.name)
|
||||
tmp_zip.close()
|
||||
|
||||
# validate ZIP
|
||||
try:
|
||||
z = zipfile.ZipFile(tmp_zip.name)
|
||||
except zipfile.BadZipFile:
|
||||
@ -97,6 +107,7 @@ def upload():
|
||||
flash("ZIP must contain both plants.csv and media.csv", "danger")
|
||||
return redirect(request.url)
|
||||
|
||||
# extract export_id from metadata
|
||||
export_id = None
|
||||
if "metadata.txt" in names:
|
||||
meta = z.read("metadata.txt").decode("utf-8", "ignore")
|
||||
@ -109,23 +120,37 @@ def upload():
|
||||
flash("metadata.txt missing or missing export_id", "danger")
|
||||
return redirect(request.url)
|
||||
|
||||
# prevent duplicates
|
||||
if ImportBatch.query.filter_by(export_id=export_id, user_id=current_user.id).first():
|
||||
os.remove(tmp_zip.name)
|
||||
flash("This export has already been imported.", "info")
|
||||
return redirect(request.url)
|
||||
|
||||
# record batch
|
||||
batch = ImportBatch(
|
||||
export_id=export_id,
|
||||
user_id=current_user.id,
|
||||
imported_at=datetime.utcnow()
|
||||
export_id = export_id,
|
||||
user_id = current_user.id,
|
||||
imported_at = datetime.utcnow(),
|
||||
status = 'pending'
|
||||
)
|
||||
db.session.add(batch)
|
||||
db.session.commit()
|
||||
|
||||
# hand off to Celery
|
||||
try:
|
||||
import_text_data.delay(tmp_zip.name, "zip", batch.id)
|
||||
flash("ZIP received; import queued in background.", "success")
|
||||
return redirect(request.url)
|
||||
except Exception:
|
||||
current_app.logger.exception("Failed to enqueue import_text_data")
|
||||
flash("Failed to queue import job; falling back to inline import", "warning")
|
||||
|
||||
# ── Fallback: inline import ─────────────────────────────────────────
|
||||
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
z.extractall(tmpdir)
|
||||
|
||||
# --- load and validate plants.csv ---
|
||||
# load plants.csv
|
||||
plant_path = os.path.join(tmpdir, "plants.csv")
|
||||
with open(plant_path, newline="", encoding="utf-8-sig") as pf:
|
||||
reader = csv.DictReader(pf)
|
||||
@ -137,7 +162,7 @@ def upload():
|
||||
return redirect(request.url)
|
||||
plant_rows = list(reader)
|
||||
|
||||
# --- load and validate media.csv ---
|
||||
# load media.csv
|
||||
media_path = os.path.join(tmpdir, "media.csv")
|
||||
with open(media_path, newline="", encoding="utf-8-sig") as mf:
|
||||
mreader = csv.DictReader(mf)
|
||||
@ -149,113 +174,129 @@ def upload():
|
||||
return redirect(request.url)
|
||||
media_rows = list(mreader)
|
||||
|
||||
# --- import plants (first pass, only set mother_uuid if parent exists) ---
|
||||
neo = get_neo4j_handler()
|
||||
# import plants
|
||||
neo = get_neo4j_handler()
|
||||
plant_map = {}
|
||||
added_plants = 0
|
||||
plant_map = {}
|
||||
|
||||
for row in plant_rows:
|
||||
# common name
|
||||
common = PlantCommonName.query.filter_by(name=row["Name"]).first()
|
||||
if not common:
|
||||
common = PlantCommonName(name=row["Name"])
|
||||
db.session.add(common)
|
||||
db.session.flush()
|
||||
|
||||
scientific = PlantScientificName.query.filter_by(
|
||||
name=row["Scientific Name"]
|
||||
).first()
|
||||
# scientific name
|
||||
scientific = PlantScientificName.query.filter_by(name=row["Scientific Name"]).first()
|
||||
if not scientific:
|
||||
scientific = PlantScientificName(
|
||||
name=row["Scientific Name"],
|
||||
common_id=common.id
|
||||
name = row["Scientific Name"],
|
||||
common_id = common.id
|
||||
)
|
||||
db.session.add(scientific)
|
||||
db.session.flush()
|
||||
|
||||
raw_mu = row.get("Mother UUID") or None
|
||||
mu_for_insert = raw_mu if raw_mu in plant_map else None
|
||||
raw_mu = row.get("Mother UUID") or None
|
||||
mu_for_insert= raw_mu if raw_mu in plant_map else None
|
||||
|
||||
p = Plant(
|
||||
uuid=row["UUID"],
|
||||
common_id=common.id,
|
||||
scientific_id=scientific.id,
|
||||
plant_type=row["Type"],
|
||||
owner_id=current_user.id,
|
||||
vendor_name=row["Vendor Name"] or None,
|
||||
price=float(row["Price"]) if row["Price"] else None,
|
||||
mother_uuid=mu_for_insert,
|
||||
notes=row["Notes"] or None,
|
||||
short_id=(row.get("Short ID") or None),
|
||||
data_verified=True
|
||||
uuid = row["UUID"],
|
||||
common_id = common.id,
|
||||
scientific_id = scientific.id,
|
||||
plant_type = row["Type"],
|
||||
owner_id = current_user.id,
|
||||
vendor_name = row["Vendor Name"] or None,
|
||||
price = float(row["Price"]) if row["Price"] else None,
|
||||
mother_uuid = mu_for_insert,
|
||||
notes = row["Notes"] or None,
|
||||
short_id = row.get("Short ID") or None,
|
||||
data_verified = True
|
||||
)
|
||||
db.session.add(p)
|
||||
db.session.flush()
|
||||
|
||||
plant_map[p.uuid] = p.id
|
||||
|
||||
log = PlantOwnershipLog(
|
||||
plant_id=p.id,
|
||||
user_id=current_user.id,
|
||||
date_acquired=datetime.utcnow(),
|
||||
transferred=False,
|
||||
is_verified=True
|
||||
plant_id = p.id,
|
||||
user_id = current_user.id,
|
||||
date_acquired = datetime.utcnow(),
|
||||
transferred = False,
|
||||
is_verified = True
|
||||
)
|
||||
db.session.add(log)
|
||||
|
||||
neo.create_plant_node(p.uuid, row["Name"])
|
||||
if raw_mu:
|
||||
neo.create_lineage(
|
||||
child_uuid=p.uuid,
|
||||
parent_uuid=raw_mu
|
||||
)
|
||||
neo.create_lineage(child_uuid=p.uuid, parent_uuid=raw_mu)
|
||||
|
||||
added_plants += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# --- second pass: backfill mother_uuid for all rows ---
|
||||
# backfill mothers
|
||||
for row in plant_rows:
|
||||
raw_mu = row.get("Mother UUID") or None
|
||||
if raw_mu:
|
||||
if row.get("Mother UUID"):
|
||||
Plant.query.filter_by(uuid=row["UUID"]).update({
|
||||
'mother_uuid': raw_mu
|
||||
'mother_uuid': row["Mother UUID"]
|
||||
})
|
||||
db.session.commit()
|
||||
|
||||
# --- import media (unchanged) ---
|
||||
# import media images
|
||||
added_media = 0
|
||||
for mrow in media_rows:
|
||||
plant_uuid = mrow["Plant UUID"]
|
||||
plant_id = plant_map.get(plant_uuid)
|
||||
if not plant_id:
|
||||
puuid = mrow["Plant UUID"]
|
||||
pid = plant_map.get(puuid)
|
||||
if not pid:
|
||||
continue
|
||||
|
||||
subpath = mrow["Image Path"].split('uploads/', 1)[-1]
|
||||
src = os.path.join(tmpdir, "images", subpath)
|
||||
src = os.path.join(tmpdir, "images", subpath)
|
||||
if not os.path.isfile(src):
|
||||
continue
|
||||
|
||||
try:
|
||||
# build FileStorage for convenience
|
||||
with open(src, "rb") as f:
|
||||
file_storage = FileStorage(
|
||||
stream=io.BytesIO(f.read()),
|
||||
filename=os.path.basename(subpath),
|
||||
fs = FileStorage(
|
||||
stream = io.BytesIO(f.read()),
|
||||
filename = os.path.basename(subpath),
|
||||
content_type='image/jpeg'
|
||||
)
|
||||
media = _process_upload_file(
|
||||
file=file_storage,
|
||||
uploader_id=current_user.id,
|
||||
plugin="plant",
|
||||
related_id=plant_id,
|
||||
plant_id=plant_id
|
||||
)
|
||||
media.uploaded_at = datetime.fromisoformat(mrow["Uploaded At"])
|
||||
media.caption = mrow["Source Type"]
|
||||
db.session.add(media)
|
||||
added_media += 1
|
||||
except Exception as e:
|
||||
current_app.logger.warning(
|
||||
f"Failed to import media file: {subpath} → {e}"
|
||||
|
||||
# now save to our UPLOAD_FOLDER
|
||||
now = datetime.utcnow()
|
||||
secure_name = secure_filename(fs.filename)
|
||||
storage_dir = os.path.join(
|
||||
current_app.config["UPLOAD_FOLDER"],
|
||||
str(current_user.id),
|
||||
now.strftime("%Y/%m/%d")
|
||||
)
|
||||
os.makedirs(storage_dir, exist_ok=True)
|
||||
|
||||
unique_name = f"{uuid.uuid4().hex}_{secure_name}"
|
||||
full_path = os.path.join(storage_dir, unique_name)
|
||||
fs.save(full_path)
|
||||
|
||||
file_url = f"/{current_user.id}/{now.strftime('%Y/%m/%d')}/{unique_name}"
|
||||
|
||||
media = Media(
|
||||
plugin = "plant",
|
||||
related_id = pid,
|
||||
filename = unique_name,
|
||||
uploaded_at = datetime.fromisoformat(mrow["Uploaded At"]),
|
||||
uploader_id = current_user.id,
|
||||
caption = mrow["Source Type"],
|
||||
plant_id = pid,
|
||||
created_at = datetime.fromisoformat(mrow["Uploaded At"]),
|
||||
file_url = file_url
|
||||
)
|
||||
db.session.add(media)
|
||||
added_media += 1
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"Failed to import media file: {subpath} → {e}")
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
|
||||
db.session.commit()
|
||||
@ -282,9 +323,8 @@ def upload():
|
||||
|
||||
session["pending_rows"] = []
|
||||
review_list = []
|
||||
|
||||
all_common = {c.name.lower(): c for c in PlantCommonName.query.all()}
|
||||
all_sci = {s.name.lower(): s for s in PlantScientificName.query.all()}
|
||||
all_common = {c.name.lower(): c for c in PlantCommonName.query.all()}
|
||||
all_sci = {s.name.lower(): s for s in PlantScientificName.query.all()}
|
||||
|
||||
for row in reader:
|
||||
uuid_val = row.get("uuid", "").strip().strip('"')
|
||||
@ -297,23 +337,19 @@ def upload():
|
||||
continue
|
||||
|
||||
suggestions = difflib.get_close_matches(
|
||||
sci_name.lower(),
|
||||
list(all_sci.keys()),
|
||||
n=1,
|
||||
cutoff=0.8
|
||||
)
|
||||
suggested = (
|
||||
all_sci[suggestions[0]].name
|
||||
if suggestions and suggestions[0] != sci_name.lower()
|
||||
else None
|
||||
sci_name.lower(), list(all_sci.keys()),
|
||||
n=1, cutoff=0.8
|
||||
)
|
||||
suggested = None
|
||||
if suggestions and suggestions[0] != sci_name.lower():
|
||||
suggested = all_sci[suggestions[0]].name
|
||||
|
||||
item = {
|
||||
"uuid": uuid_val,
|
||||
"name": name,
|
||||
"sci_name": sci_name,
|
||||
"suggested": suggested,
|
||||
"plant_type": plant_type,
|
||||
"uuid": uuid_val,
|
||||
"name": name,
|
||||
"sci_name": sci_name,
|
||||
"suggested": suggested,
|
||||
"plant_type": plant_type,
|
||||
"mother_uuid": mother_uuid
|
||||
}
|
||||
review_list.append(item)
|
||||
@ -321,40 +357,41 @@ def upload():
|
||||
|
||||
session["review_list"] = review_list
|
||||
return redirect(url_for("utility.review"))
|
||||
|
||||
|
||||
# ── Direct Media Upload Flow ───────────────────────────────────────
|
||||
plugin = request.form.get("plugin", "")
|
||||
related_id = request.form.get("related_id", 0)
|
||||
plant_id = request.form.get("plant_id", None)
|
||||
growlog_id = request.form.get("growlog_id", None)
|
||||
caption = request.form.get("caption", None)
|
||||
|
||||
now = datetime.utcnow()
|
||||
unique_id = str(uuid.uuid4()).replace("-", "")
|
||||
secure_name= secure_filename(file.filename)
|
||||
storage_path = os.path.join(
|
||||
|
||||
now = datetime.utcnow()
|
||||
unique_id = uuid.uuid4().hex
|
||||
secure_name = secure_filename(file.filename)
|
||||
storage_path= os.path.join(
|
||||
current_app.config["UPLOAD_FOLDER"],
|
||||
str(current_user.id),
|
||||
now.strftime("%Y/%m/%d")
|
||||
)
|
||||
os.makedirs(storage_path, exist_ok=True)
|
||||
|
||||
full_file_path = os.path.join(storage_path, f"{unique_id}_{secure_name}")
|
||||
file.save(full_file_path)
|
||||
unique_name = f"{unique_id}_{secure_name}"
|
||||
full_path = os.path.join(storage_path, unique_name)
|
||||
file.save(full_path)
|
||||
|
||||
file_url = f"/{current_user.id}/{now.strftime('%Y/%m/%d')}/{unique_id}_{secure_name}"
|
||||
file_url = f"/{current_user.id}/{now.strftime('%Y/%m/%d')}/{unique_name}"
|
||||
|
||||
media = Media(
|
||||
plugin=plugin,
|
||||
related_id=related_id,
|
||||
filename=f"{unique_id}_{secure_name}",
|
||||
uploaded_at=now,
|
||||
uploader_id=current_user.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 = unique_name,
|
||||
uploaded_at = now,
|
||||
uploader_id = current_user.id,
|
||||
caption = caption,
|
||||
plant_id = plant_id,
|
||||
growlog_id = growlog_id,
|
||||
created_at = now,
|
||||
file_url = file_url
|
||||
)
|
||||
db.session.add(media)
|
||||
db.session.commit()
|
||||
@ -372,22 +409,21 @@ def review():
|
||||
review_list = session.get("review_list", [])
|
||||
|
||||
if request.method == "POST":
|
||||
neo = get_neo4j_handler()
|
||||
added = 0
|
||||
|
||||
neo = get_neo4j_handler()
|
||||
added = 0
|
||||
all_common = {c.name.lower(): c for c in PlantCommonName.query.all()}
|
||||
all_scientific = {s.name.lower(): s for s in PlantScientificName.query.all()}
|
||||
|
||||
for row in rows:
|
||||
uuid_val = row.get("uuid")
|
||||
name = row.get("name")
|
||||
sci_name = row.get("sci_name")
|
||||
suggested = row.get("suggested")
|
||||
plant_type = row.get("plant_type")
|
||||
mother_uuid = row.get("mother_uuid")
|
||||
|
||||
accepted = request.form.get(f"confirm_{uuid_val}")
|
||||
uuid_val = row["uuid"]
|
||||
name = row["name"]
|
||||
sci_name = row["sci_name"]
|
||||
suggested = row["suggested"]
|
||||
plant_type = row["plant_type"]
|
||||
mother_uuid = row["mother_uuid"]
|
||||
accepted = request.form.get(f"confirm_{uuid_val}") == "yes"
|
||||
|
||||
# handle names
|
||||
common = PlantCommonName.query.filter_by(name=name).first()
|
||||
if not common:
|
||||
common = PlantCommonName(name=name)
|
||||
@ -395,7 +431,7 @@ def review():
|
||||
db.session.flush()
|
||||
all_common[common.name.lower()] = common
|
||||
|
||||
use_name = suggested if (suggested and accepted) else sci_name
|
||||
use_name = suggested if (suggested and accepted) else sci_name
|
||||
scientific = PlantScientificName.query.filter_by(name=use_name).first()
|
||||
if not scientific:
|
||||
scientific = PlantScientificName(
|
||||
@ -407,7 +443,6 @@ def review():
|
||||
all_scientific[scientific.name.lower()] = scientific
|
||||
|
||||
verified = not suggested or (suggested and accepted)
|
||||
|
||||
plant = Plant.query.filter_by(uuid=uuid_val).first()
|
||||
if not plant:
|
||||
plant = Plant(
|
||||
@ -454,22 +489,18 @@ def review():
|
||||
@bp.route('/export_data', methods=['GET'])
|
||||
@login_required
|
||||
def export_data():
|
||||
# Unique export identifier
|
||||
export_id = f"{uuid.uuid4()}_{int(datetime.utcnow().timestamp())}"
|
||||
|
||||
# 1) Gather plants
|
||||
plants = (
|
||||
Plant.query
|
||||
.filter_by(owner_id=current_user.id)
|
||||
.order_by(Plant.id)
|
||||
.all()
|
||||
Plant.query.filter_by(owner_id=current_user.id)
|
||||
.order_by(Plant.id).all()
|
||||
)
|
||||
# Build plants.csv
|
||||
|
||||
# build plants.csv
|
||||
plant_io = io.StringIO()
|
||||
pw = csv.writer(plant_io)
|
||||
pw.writerow([
|
||||
'UUID', 'Type', 'Name', 'Scientific Name',
|
||||
'Vendor Name', 'Price', 'Mother UUID', 'Notes'
|
||||
'UUID','Type','Name','Scientific Name',
|
||||
'Vendor Name','Price','Mother UUID','Notes'
|
||||
])
|
||||
for p in plants:
|
||||
pw.writerow([
|
||||
@ -477,26 +508,23 @@ def export_data():
|
||||
p.plant_type,
|
||||
p.common_name.name if p.common_name else '',
|
||||
p.scientific_name.name if p.scientific_name else '',
|
||||
getattr(p, 'vendor_name', '') or '',
|
||||
getattr(p, 'price', '') or '',
|
||||
getattr(p, 'vendor_name','') or '',
|
||||
getattr(p, 'price','') or '',
|
||||
p.mother_uuid or '',
|
||||
p.notes or ''
|
||||
])
|
||||
plants_csv = plant_io.getvalue()
|
||||
|
||||
# 2) Gather media
|
||||
# build media.csv
|
||||
media_records = (
|
||||
Media.query
|
||||
.filter(Media.uploader_id == current_user.id, Media.plant_id.isnot(None))
|
||||
.order_by(Media.id)
|
||||
.all()
|
||||
)
|
||||
# Build media.csv
|
||||
Media.query.filter(
|
||||
Media.uploader_id==current_user.id,
|
||||
Media.plant_id.isnot(None)
|
||||
).order_by(Media.id).all()
|
||||
)
|
||||
media_io = io.StringIO()
|
||||
mw = csv.writer(media_io)
|
||||
mw.writerow([
|
||||
'Plant UUID', 'Image Path', 'Uploaded At', 'Source Type'
|
||||
])
|
||||
mw.writerow(['Plant UUID','Image Path','Uploaded At','Source Type'])
|
||||
for m in media_records:
|
||||
mw.writerow([
|
||||
m.plant.uuid,
|
||||
@ -506,9 +534,9 @@ def export_data():
|
||||
])
|
||||
media_csv = media_io.getvalue()
|
||||
|
||||
# 3) Assemble ZIP with images from UPLOAD_FOLDER
|
||||
# assemble ZIP
|
||||
zip_buf = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
with zipfile.ZipFile(zip_buf,'w',zipfile.ZIP_DEFLATED) as zf:
|
||||
meta = (
|
||||
f"export_id,{export_id}\n"
|
||||
f"user_id,{current_user.id}\n"
|
||||
@ -517,19 +545,17 @@ def export_data():
|
||||
zf.writestr('metadata.txt', meta)
|
||||
zf.writestr('plants.csv', plants_csv)
|
||||
zf.writestr('media.csv', media_csv)
|
||||
|
||||
media_root = current_app.config['UPLOAD_FOLDER']
|
||||
for m in media_records:
|
||||
rel = m.file_url.split('uploads/', 1)[-1]
|
||||
rel = m.file_url.split('uploads/',1)[-1]
|
||||
abs_path = os.path.join(media_root, rel)
|
||||
if os.path.isfile(abs_path):
|
||||
arcname = os.path.join('images', rel)
|
||||
zf.write(abs_path, arcname)
|
||||
|
||||
zip_buf.seek(0)
|
||||
safe_email = re.sub(r'\W+', '_', current_user.email)
|
||||
filename = f"{safe_email}_export_{export_id}.zip"
|
||||
|
||||
safe_email = re.sub(r'\W+','_',current_user.email)
|
||||
filename = f"{safe_email}_export_{export_id}.zip"
|
||||
return send_file(
|
||||
zip_buf,
|
||||
mimetype='application/zip',
|
||||
@ -538,39 +564,45 @@ def export_data():
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────────
|
||||
# QR-Code Generation Helpers & Routes
|
||||
# ────────────────────────────────────────────────────────────────────────────────
|
||||
def generate_label_with_name(qr_url, name, filename):
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import qrcode
|
||||
from qrcode.image.pil import PilImage
|
||||
from qrcode.constants import ERROR_CORRECT_H
|
||||
from flask import current_app, send_file
|
||||
from flask import send_file
|
||||
|
||||
# Generate QR code
|
||||
qr = qrcode.QRCode(version=2, error_correction=ERROR_CORRECT_H, box_size=10, border=1)
|
||||
qr = qrcode.QRCode(
|
||||
version=2,
|
||||
error_correction=ERROR_CORRECT_H,
|
||||
box_size=10,
|
||||
border=1
|
||||
)
|
||||
qr.add_data(qr_url)
|
||||
qr.make(fit=True)
|
||||
qr_img = qr.make_image(image_factory=PilImage, fill_color="black", back_color="white").convert("RGB")
|
||||
qr_img = qr.make_image(
|
||||
image_factory=PilImage,
|
||||
fill_color="black",
|
||||
back_color="white"
|
||||
).convert("RGB")
|
||||
|
||||
# Create 1.5"x1.5" canvas at 300 DPI
|
||||
dpi = 300
|
||||
dpi = 300
|
||||
label_px = int(1.5 * dpi)
|
||||
label_img = Image.new("RGB", (label_px, label_px), "white")
|
||||
label_img= Image.new("RGB", (label_px, label_px), "white")
|
||||
|
||||
# Resize QR code
|
||||
qr_size = 350
|
||||
qr_img = qr_img.resize((qr_size, qr_size), Image.LANCZOS)
|
||||
qr_x = (label_px - qr_size) // 2
|
||||
qr_size = 350
|
||||
qr_img = qr_img.resize((qr_size, qr_size), Image.LANCZOS)
|
||||
qr_x = (label_px - qr_size) // 2
|
||||
label_img.paste(qr_img, (qr_x, 10))
|
||||
|
||||
# Load font
|
||||
font_path = os.path.abspath(os.path.join(current_app.root_path, '..', 'font', 'ARIALLGT.TTF'))
|
||||
draw = ImageDraw.Draw(label_img)
|
||||
name = (name or '').strip()
|
||||
font_size = 28
|
||||
font_path= os.path.abspath(
|
||||
os.path.join(
|
||||
current_app.root_path, '..', 'font', 'ARIALLGT.TTF'
|
||||
)
|
||||
)
|
||||
draw = ImageDraw.Draw(label_img)
|
||||
name = (name or '').strip()
|
||||
|
||||
font_size = 28
|
||||
while font_size > 10:
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
@ -585,7 +617,6 @@ def generate_label_with_name(qr_url, name, filename):
|
||||
name = name[:-1]
|
||||
name += "…"
|
||||
|
||||
# Draw text centered
|
||||
text_x = (label_px - draw.textlength(name, font=font)) // 2
|
||||
text_y = 370
|
||||
draw.text((text_x, text_y), name, font=font, fill="black")
|
||||
@ -605,27 +636,23 @@ def generate_label_with_name(qr_url, name, filename):
|
||||
@bp.route('/download_qr/<string:uuid_val>', methods=['GET'])
|
||||
@login_required
|
||||
def download_qr(uuid_val):
|
||||
# Private “Direct QR” → f/<short_id> on plant.cards
|
||||
p = Plant.query.filter_by(uuid=uuid_val, owner_id=current_user.id).first_or_404()
|
||||
if not getattr(p, 'short_id', None):
|
||||
if not p.short_id:
|
||||
p.short_id = Plant.generate_short_id()
|
||||
db.session.commit()
|
||||
|
||||
base = current_app.config.get('PLANT_CARDS_BASE_URL', 'https://plant.cards')
|
||||
qr_url = f"{base}/f/{p.short_id}"
|
||||
base = current_app.config.get('PLANT_CARDS_BASE_URL', 'https://plant.cards')
|
||||
qr_url = f"{base}/f/{p.short_id}"
|
||||
filename = f"{p.short_id}.png"
|
||||
return generate_label_with_name(qr_url, p.common_name.name, filename)
|
||||
|
||||
|
||||
@bp.route('/download_qr_card/<string:uuid_val>', methods=['GET'])
|
||||
def download_qr_card(uuid_val):
|
||||
# Public “Card QR” → /<short_id> on plant.cards
|
||||
p = Plant.query.filter_by(uuid=uuid_val).first_or_404()
|
||||
if not getattr(p, 'short_id', None):
|
||||
if not p.short_id:
|
||||
p.short_id = Plant.generate_short_id()
|
||||
db.session.commit()
|
||||
|
||||
base = current_app.config.get('PLANT_CARDS_BASE_URL', 'https://plant.cards')
|
||||
qr_url = f"{base}/{p.short_id}"
|
||||
base = current_app.config.get('PLANT_CARDS_BASE_URL', 'https://plant.cards')
|
||||
qr_url = f"{base}/{p.short_id}"
|
||||
filename = f"{p.short_id}_card.png"
|
||||
return generate_label_with_name(qr_url, p.common_name.name, filename)
|
||||
|
94
plugins/utility/search.py
Normal file
94
plugins/utility/search.py
Normal file
@ -0,0 +1,94 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, 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'])
|
||||
@bp.route('/', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def search():
|
||||
form = SearchForm()
|
||||
form.tags.choices = [(t.id, t.name) for t in Tag.query.order_by(Tag.name).all()]
|
||||
if form.validate_on_submit():
|
||||
q = form.query.data or ''
|
||||
selected = form.tags.data or []
|
||||
tags_param = ','.join(map(str, selected)) if selected else ''
|
||||
return redirect(url_for('search.results', q=q, tags=tags_param))
|
||||
return render_template('search/search.html', form=form)
|
||||
|
||||
@bp.route('/results', methods=['GET'])
|
||||
@login_required
|
||||
def results():
|
||||
q = request.args.get('q', '').strip()
|
||||
tags_param = request.args.get('tags', '')
|
||||
tags = [int(t) for t in tags_param.split(',') if t] if tags_param else []
|
||||
like_term = f"%{q}%"
|
||||
|
||||
# Base query, joining name tables for text search
|
||||
query = (
|
||||
db.session.query(Plant)
|
||||
.join(Plant.common_name)
|
||||
.join(Plant.scientific_name)
|
||||
)
|
||||
|
||||
if q:
|
||||
query = query.filter(
|
||||
or_(
|
||||
PlantCommonName.name.ilike(like_term),
|
||||
PlantScientificName.name.ilike(like_term),
|
||||
Plant.plant_type.ilike(like_term),
|
||||
)
|
||||
)
|
||||
|
||||
if tags:
|
||||
query = query.filter(Plant.tags.any(Tag.id.in_(tags)))
|
||||
|
||||
# Only include active plants…
|
||||
query = query.filter(Plant.is_active.is_(True))
|
||||
|
||||
# …and either public plants or those owned by the current user
|
||||
query = query.filter(
|
||||
or_(
|
||||
Plant.is_public.is_(True),
|
||||
Plant.owner_id == current_user.id
|
||||
)
|
||||
)
|
||||
|
||||
results = query.all()
|
||||
return render_template(
|
||||
'search/results.html',
|
||||
results=results,
|
||||
query=q,
|
||||
tags=tags
|
||||
)
|
||||
|
||||
@bp.route('/tags')
|
||||
@login_required
|
||||
def search_tags():
|
||||
term = request.args.get('term', '')
|
||||
like_term = f"%{term}%"
|
||||
matches = Tag.query.filter(Tag.name.ilike(like_term)).limit(10).all()
|
||||
return jsonify([t.name for t in matches])
|
@ -1,63 +0,0 @@
|
||||
# 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])
|
@ -1,17 +1,218 @@
|
||||
from .celery import celery_app
|
||||
# File: plugins/utility/tasks.py
|
||||
|
||||
# Example placeholder task
|
||||
@celery_app.task
|
||||
def ping():
|
||||
return 'pong'
|
||||
# Standard library
|
||||
import csv
|
||||
import os
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
import io
|
||||
from datetime import datetime
|
||||
|
||||
# Third-party
|
||||
from celery.utils.log import get_task_logger
|
||||
from celery.exceptions import Retry
|
||||
from flask import current_app
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
# Application
|
||||
from app import db
|
||||
from app.neo4j_utils import get_neo4j_handler
|
||||
from app.celery_app import celery
|
||||
|
||||
# Plugins
|
||||
from plugins.plant.models import (
|
||||
Plant,
|
||||
PlantCommonName,
|
||||
PlantScientificName,
|
||||
PlantOwnershipLog,
|
||||
)
|
||||
from plugins.utility.models import ImportBatch
|
||||
from plugins.media.routes import _process_upload_file
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def init_celery(app):
|
||||
@celery.task(name="plugins.utility.tasks.import_text_data", bind=True)
|
||||
def import_text_data(self, filepath, import_type, batch_id):
|
||||
"""
|
||||
Initialize the Celery app with the Flask app's config and context.
|
||||
Called automatically by the JSON-driven loader.
|
||||
Celery task entrypoint for both ZIP and CSV imports.
|
||||
filepath: path to uploaded .zip or .csv
|
||||
import_type: "zip" or "csv"
|
||||
batch_id: ImportBatch.id to update status
|
||||
"""
|
||||
# Re-import the shared celery_app
|
||||
from .celery import celery_app
|
||||
celery_app.conf.update(app.config)
|
||||
return celery_app
|
||||
batch = ImportBatch.query.get(batch_id)
|
||||
try:
|
||||
# mark as started
|
||||
batch.status = 'started'
|
||||
db.session.commit()
|
||||
|
||||
# ZIP import
|
||||
if import_type == "zip":
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
with zipfile.ZipFile(filepath) as zf:
|
||||
zf.extractall(tmpdir)
|
||||
_do_import_zip(tmpdir, batch)
|
||||
finally:
|
||||
os.remove(filepath)
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
# CSV import (reviewed rows)
|
||||
elif import_type == "csv":
|
||||
_do_import_csv(filepath, batch)
|
||||
|
||||
# mark as complete
|
||||
batch.status = 'complete'
|
||||
db.session.commit()
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Import failed")
|
||||
batch.status = 'failed'
|
||||
batch.error = str(exc)
|
||||
db.session.commit()
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
|
||||
|
||||
def _do_import_zip(tmpdir, batch):
|
||||
"""
|
||||
Perform the plants.csv + media.csv import from tmpdir and log into Neo4j.
|
||||
"""
|
||||
# 1) read plants.csv
|
||||
plant_path = os.path.join(tmpdir, "plants.csv")
|
||||
with open(plant_path, newline="", encoding="utf-8-sig") as pf:
|
||||
reader = csv.DictReader(pf)
|
||||
plant_rows = list(reader)
|
||||
|
||||
# 2) insert plants
|
||||
neo = get_neo4j_handler()
|
||||
plant_map = {}
|
||||
for row in plant_rows:
|
||||
common = PlantCommonName.query.filter_by(name=row["Name"]).first()
|
||||
if not common:
|
||||
common = PlantCommonName(name=row["Name"])
|
||||
db.session.add(common)
|
||||
db.session.flush()
|
||||
scientific = PlantScientificName.query.filter_by(name=row["Scientific Name"]).first()
|
||||
if not scientific:
|
||||
scientific = PlantScientificName(
|
||||
name=row["Scientific Name"],
|
||||
common_id=common.id
|
||||
)
|
||||
db.session.add(scientific)
|
||||
db.session.flush()
|
||||
raw_mu = row.get("Mother UUID") or None
|
||||
mu_for_insert = raw_mu if raw_mu in plant_map else None
|
||||
p = Plant(
|
||||
uuid=row["UUID"],
|
||||
common_id=common.id,
|
||||
scientific_id=scientific.id,
|
||||
plant_type=row["Type"],
|
||||
owner_id=batch.user_id,
|
||||
vendor_name=row["Vendor Name"] or None,
|
||||
price=float(row["Price"]) if row["Price"] else None,
|
||||
mother_uuid=mu_for_insert,
|
||||
notes=row["Notes"] or None,
|
||||
short_id=row.get("Short ID") or None,
|
||||
data_verified=True
|
||||
)
|
||||
db.session.add(p)
|
||||
db.session.flush()
|
||||
plant_map[p.uuid] = p.id
|
||||
log = PlantOwnershipLog(
|
||||
plant_id=p.id,
|
||||
user_id=batch.user_id,
|
||||
date_acquired=datetime.utcnow(),
|
||||
transferred=False,
|
||||
is_verified=True
|
||||
)
|
||||
db.session.add(log)
|
||||
neo.create_plant_node(p.uuid, row["Name"])
|
||||
if raw_mu:
|
||||
neo.create_lineage(child_uuid=p.uuid, parent_uuid=raw_mu)
|
||||
db.session.commit()
|
||||
|
||||
# 3) import media.csv
|
||||
media_path = os.path.join(tmpdir, "media.csv")
|
||||
with open(media_path, newline="", encoding="utf-8-sig") as mf:
|
||||
mreader = csv.DictReader(mf)
|
||||
media_rows = list(mreader)
|
||||
|
||||
for mrow in media_rows:
|
||||
puuid = mrow["Plant UUID"]
|
||||
pid = plant_map.get(puuid)
|
||||
if not pid:
|
||||
continue
|
||||
subpath = mrow["Image Path"].split('uploads/', 1)[-1]
|
||||
src = os.path.join(tmpdir, "images", subpath)
|
||||
if not os.path.isfile(src):
|
||||
continue
|
||||
with open(src, "rb") as f:
|
||||
fs = io.BytesIO(f.read())
|
||||
file_storage = FileStorage(
|
||||
stream=fs,
|
||||
filename=os.path.basename(subpath),
|
||||
content_type='image/jpeg'
|
||||
)
|
||||
media = _process_upload_file(
|
||||
file=file_storage,
|
||||
uploader_id=batch.user_id,
|
||||
plugin="plant",
|
||||
related_id=pid,
|
||||
plant_id=pid
|
||||
)
|
||||
media.uploaded_at = datetime.fromisoformat(mrow["Uploaded At"])
|
||||
media.caption = mrow["Source Type"]
|
||||
db.session.add(media)
|
||||
db.session.commit()
|
||||
neo.close()
|
||||
|
||||
|
||||
def _do_import_csv(filepath, batch):
|
||||
"""
|
||||
Perform a reviewed‐CSV import (only plants, no media) from filepath.
|
||||
"""
|
||||
stream = io.StringIO(open(filepath, encoding='utf-8-sig').read())
|
||||
reader = csv.DictReader(stream)
|
||||
neo = get_neo4j_handler()
|
||||
for row in reader:
|
||||
uuid_val = row.get("uuid", "").strip()
|
||||
name = row.get("name", "").strip()
|
||||
sci_name = row.get("scientific_name", "").strip()
|
||||
plant_type = row.get("plant_type", "").strip() or "plant"
|
||||
mother_uuid = row.get("mother_uuid", "").strip() or None
|
||||
|
||||
common = PlantCommonName.query.filter_by(name=name).first()
|
||||
if not common:
|
||||
common = PlantCommonName(name=name)
|
||||
db.session.add(common)
|
||||
db.session.flush()
|
||||
scientific = PlantScientificName.query.filter_by(name=sci_name).first()
|
||||
if not scientific:
|
||||
scientific = PlantScientificName(
|
||||
name=sci_name,
|
||||
common_id=common.id
|
||||
)
|
||||
db.session.add(scientific)
|
||||
db.session.flush()
|
||||
|
||||
plant = Plant.query.filter_by(uuid=uuid_val).first()
|
||||
if not plant:
|
||||
plant = Plant(
|
||||
uuid=uuid_val,
|
||||
common_id=common.id,
|
||||
scientific_id=scientific.id,
|
||||
plant_type=plant_type,
|
||||
owner_id=batch.user_id,
|
||||
mother_uuid=mother_uuid,
|
||||
data_verified=True
|
||||
)
|
||||
db.session.add(plant)
|
||||
db.session.flush()
|
||||
|
||||
neo.create_plant_node(plant.uuid, common.name)
|
||||
if mother_uuid:
|
||||
neo.create_lineage(child_uuid=plant.uuid, parent_uuid=mother_uuid)
|
||||
|
||||
db.session.commit()
|
||||
neo.close()
|
||||
|
31
plugins/utility/templates/search/results.html
Normal file
31
plugins/utility/templates/search/results.html
Normal file
@ -0,0 +1,31 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% block title %}Search Results{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h1 class="mb-3">
|
||||
Search Results{% if query %} for "{{ query }}"{% endif %}
|
||||
</h1>
|
||||
|
||||
{% if results %}
|
||||
<ul class="list-group">
|
||||
{% for plant in results %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<a href="{{ url_for('plant.view', plant_uuid=plant.uuid) }}">
|
||||
{{ plant.common_name[0].name if plant.common_name else plant.scientific_name[0].name }}
|
||||
</a>
|
||||
{% if plant.owner_id == current_user.id and not plant.is_public %}
|
||||
<span class="badge bg-secondary">Private</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">
|
||||
No results found{% if query %} for "{{ query }}"{% endif %}.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('search.search') }}" class="btn btn-link mt-3">New Search</a>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
<!-- File: plugins/utility/templates/search/search.html -->
|
||||
{% extends "core/base.html" %}
|
||||
{% block title %}Search{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@ -33,7 +34,7 @@
|
||||
<ul class="list-group">
|
||||
{% for plant in results %}
|
||||
<li class="list-group-item">
|
||||
<a href="{{ url_for('plant.view', plant_id=plant.id) }}">
|
||||
<a href="{{ url_for('plant.view_card', plant_id=plant.id) }}">
|
||||
{{ plant.common_name or plant.scientific_name }}
|
||||
</a>
|
||||
</li>
|
||||
@ -43,3 +44,4 @@
|
||||
<p class="text-muted">No results found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
20
plugins/utility/templates/utility/imports.html
Normal file
20
plugins/utility/templates/utility/imports.html
Normal file
@ -0,0 +1,20 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% block content %}
|
||||
<h1>Your Recent Imports</h1>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>#</th><th>Export ID</th><th>Uploaded</th><th>Status</th><th>Error</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in batches %}
|
||||
<tr>
|
||||
<td>{{ b.id }}</td>
|
||||
<td>{{ b.export_id }}</td>
|
||||
<td>{{ b.imported_at }}</td>
|
||||
<td>{{ b.status }}</td>
|
||||
<td>{{ b.error or "" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
Reference in New Issue
Block a user