changes
This commit is contained in:
127
plugins/auth/models.py
Normal file
127
plugins/auth/models.py
Normal file
@ -0,0 +1,127 @@
|
||||
from flask_login import UserMixin
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
|
||||
class User(db.Model, UserMixin):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(128), 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)
|
||||
|
||||
# Optional: relationship to submissions
|
||||
submissions = db.relationship('Submission', backref='user', lazy=True)
|
||||
|
||||
|
||||
class Submission(db.Model):
|
||||
__tablename__ = 'submission'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', name='fk_submission_user_id'),
|
||||
nullable=False
|
||||
)
|
||||
common_name = db.Column(db.String(120), nullable=False)
|
||||
scientific_name = db.Column(db.String(120))
|
||||
price = db.Column(db.Float, nullable=False)
|
||||
source = db.Column(db.String(120))
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
height = db.Column(db.Float)
|
||||
width = db.Column(db.Float)
|
||||
leaf_count = db.Column(db.Integer)
|
||||
potting_mix = db.Column(db.String(255))
|
||||
container_size = db.Column(db.String(120))
|
||||
health_status = db.Column(db.String(50))
|
||||
notes = db.Column(db.Text)
|
||||
plant_id = db.Column(db.Integer)
|
||||
images = db.relationship('SubmissionImage', backref='submission', lazy=True)
|
||||
|
||||
|
||||
class SubmissionImage(db.Model):
|
||||
__tablename__ = 'submission_images'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
submission_id = db.Column(db.Integer, db.ForeignKey('submission.id'), nullable=False)
|
||||
file_path = db.Column(db.String(255), nullable=False)
|
||||
|
||||
|
||||
class PlantCommonName(db.Model):
|
||||
__tablename__ = 'plants_common'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(120), unique=True, nullable=False)
|
||||
|
||||
|
||||
class PlantScientificName(db.Model):
|
||||
__tablename__ = 'plants_scientific'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(255), unique=True, nullable=False)
|
||||
|
||||
|
||||
class Plant(db.Model):
|
||||
__tablename__ = 'plants'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
common_name_id = db.Column(db.Integer, db.ForeignKey('plants_common.id'))
|
||||
scientific_name_id = db.Column(db.Integer, db.ForeignKey('plants_scientific.id'))
|
||||
parent_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=True)
|
||||
is_dead = db.Column(db.Boolean, default=False)
|
||||
date_added = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_by_user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||
|
||||
# Relationships
|
||||
updates = db.relationship('PlantUpdate', backref='plant', lazy=True)
|
||||
lineage = db.relationship('PlantLineage', backref='child', lazy=True,
|
||||
foreign_keys='PlantLineage.child_plant_id')
|
||||
|
||||
|
||||
class PlantLineage(db.Model):
|
||||
__tablename__ = 'plant_lineage'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
parent_plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False)
|
||||
child_plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False)
|
||||
|
||||
|
||||
class PlantOwnershipLog(db.Model):
|
||||
__tablename__ = 'plant_ownership_log'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
date_acquired = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
date_relinquished = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
|
||||
class PlantUpdate(db.Model):
|
||||
__tablename__ = 'plant_updates'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False)
|
||||
update_type = db.Column(db.String(100))
|
||||
description = db.Column(db.Text)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
images = db.relationship('UpdateImage', backref='update', lazy=True)
|
||||
|
||||
|
||||
class UpdateImage(db.Model):
|
||||
__tablename__ = 'update_images'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
update_id = db.Column(db.Integer, db.ForeignKey('plant_updates.id'), nullable=False)
|
||||
file_path = db.Column(db.String(255), nullable=False)
|
||||
|
||||
|
||||
class ImageHeart(db.Model):
|
||||
__tablename__ = 'image_hearts'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
submission_image_id = db.Column(db.Integer, db.ForeignKey('submission_images.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class FeaturedImage(db.Model):
|
||||
__tablename__ = 'featured_images'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
submission_image_id = db.Column(db.Integer, db.ForeignKey('submission_images.id'), nullable=False)
|
||||
override_text = db.Column(db.String(255), nullable=True)
|
||||
is_featured = db.Column(db.Boolean, default=True)
|
28
plugins/auth/routes.py
Normal file
28
plugins/auth/routes.py
Normal file
@ -0,0 +1,28 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
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
|
||||
|
||||
auth = Blueprint('auth', __name__)
|
||||
|
||||
@auth.route('/auth/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):
|
||||
login_user(user)
|
||||
flash('Logged in successfully.', 'success')
|
||||
return redirect(url_for('core.user_dashboard'))
|
||||
else:
|
||||
flash('Invalid credentials.', 'danger')
|
||||
return render_template('login.html')
|
||||
|
||||
@auth.route('/auth/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
flash('Logged out.', 'info')
|
||||
return redirect(url_for('core.index'))
|
15
plugins/auth/templates/auth/login.html
Normal file
15
plugins/auth/templates/auth/login.html
Normal file
@ -0,0 +1,15 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>Login</h2>
|
||||
<form method="POST" action="/login">
|
||||
<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>
|
||||
{% endblock %}
|
15
plugins/auth/templates/auth/register.html
Normal file
15
plugins/auth/templates/auth/register.html
Normal file
@ -0,0 +1,15 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>Register</h2>
|
||||
<form method="POST" action="/register">
|
||||
<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-success">Register</button>
|
||||
</form>
|
||||
{% endblock %}
|
2
plugins/cli/__init__.py
Normal file
2
plugins/cli/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .seed import seed_admin
|
||||
from .preload import preload_data
|
79
plugins/cli/preload.py
Normal file
79
plugins/cli/preload.py
Normal file
@ -0,0 +1,79 @@
|
||||
import click
|
||||
from flask.cli import with_appcontext
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
from app.models.user import (
|
||||
User, Plant, PlantCommonName, PlantScientificName, Submission, SubmissionImage
|
||||
)
|
||||
|
||||
@click.command("preload-data")
|
||||
@with_appcontext
|
||||
def preload_data():
|
||||
click.echo("[📦] Preloading demo data...")
|
||||
|
||||
if not User.query.filter_by(email="demo@example.com").first():
|
||||
demo_user = User(
|
||||
email="demo@example.com",
|
||||
password_hash="fakehash123", # you can hash a real one if needed
|
||||
role="user",
|
||||
is_verified=True
|
||||
)
|
||||
db.session.add(demo_user)
|
||||
db.session.commit()
|
||||
|
||||
common_entries = {
|
||||
"Monstera Albo": "Monstera deliciosa 'Albo-Variegata'",
|
||||
"Philodendron Pink Princess": "Philodendron erubescens",
|
||||
"Cebu Blue Pothos": "Epipremnum pinnatum"
|
||||
}
|
||||
|
||||
for common_name, sci_name in common_entries.items():
|
||||
common = PlantCommonName.query.filter_by(name=common_name).first()
|
||||
if not common:
|
||||
common = PlantCommonName(name=common_name)
|
||||
db.session.add(common)
|
||||
|
||||
scientific = PlantScientificName.query.filter_by(name=sci_name).first()
|
||||
if not scientific:
|
||||
scientific = PlantScientificName(name=sci_name)
|
||||
db.session.add(scientific)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
common_map = {c.name: c.id for c in PlantCommonName.query.all()}
|
||||
scientific_map = {s.name: s.id for s in PlantScientificName.query.all()}
|
||||
demo_user = User.query.filter_by(email="demo@example.com").first()
|
||||
|
||||
plants = []
|
||||
for i in range(1, 4):
|
||||
plant = Plant(
|
||||
common_name_id=common_map["Monstera Albo"],
|
||||
scientific_name_id=scientific_map["Monstera deliciosa 'Albo-Variegata'"],
|
||||
created_by_user_id=demo_user.id
|
||||
)
|
||||
db.session.add(plant)
|
||||
plants.append(plant)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
for plant in plants:
|
||||
for i in range(2):
|
||||
submission = Submission(
|
||||
user_id=demo_user.id,
|
||||
plant_id=plant.id,
|
||||
common_name="Monstera Albo",
|
||||
scientific_name="Monstera deliciosa 'Albo-Variegata'",
|
||||
price=85.0 + i * 15,
|
||||
source="Etsy",
|
||||
height=12 + i * 2,
|
||||
width=10 + i,
|
||||
potting_mix="Pumice:Bark:Coco 2:1:1",
|
||||
container_size="4 inch",
|
||||
health_status="Healthy",
|
||||
notes="Good variegation",
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
db.session.add(submission)
|
||||
|
||||
db.session.commit()
|
||||
click.echo("[✅] Demo data loaded.")
|
26
plugins/cli/seed.py
Normal file
26
plugins/cli/seed.py
Normal file
@ -0,0 +1,26 @@
|
||||
import click
|
||||
from flask.cli import with_appcontext
|
||||
from werkzeug.security import generate_password_hash
|
||||
from ..core.models import User
|
||||
from .. import db
|
||||
|
||||
@click.command("seed-admin")
|
||||
@with_appcontext
|
||||
def seed_admin():
|
||||
"""Seed a default admin user if none exists."""
|
||||
admin_email = "admin@example.com"
|
||||
admin_password = "admin123"
|
||||
|
||||
if User.query.filter_by(email=admin_email).first():
|
||||
click.echo("[ℹ] Admin user already exists.")
|
||||
return
|
||||
|
||||
user = User(
|
||||
email=admin_email,
|
||||
password_hash=generate_password_hash(admin_password),
|
||||
role="admin",
|
||||
is_verified=True
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
click.echo(f"[✔] Created default admin: {admin_email}")
|
1
plugins/core_ui/__init__.py
Normal file
1
plugins/core_ui/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# core_ui media patch
|
1
plugins/core_ui/plugin.json
Normal file
1
plugins/core_ui/plugin.json
Normal file
@ -0,0 +1 @@
|
||||
{ "name": "core_ui", "version": "1.1", "description": "Media rendering macros and styling helpers" }
|
14
plugins/core_ui/templates/core_ui/_media_macros.html
Normal file
14
plugins/core_ui/templates/core_ui/_media_macros.html
Normal file
@ -0,0 +1,14 @@
|
||||
{% macro render_media_list(media_list, thumb_width=150) -%}
|
||||
{% if media_list %}
|
||||
<ul class="media-thumbnails">
|
||||
{% for media in media_list %}
|
||||
<li>
|
||||
<img src="{{ url_for('media.media_file', filename=media.file_url) }}" width="{{ thumb_width }}">
|
||||
{% if media.caption %}
|
||||
<p>{{ media.caption }}</p>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
18
plugins/core_ui/templates/core_ui/media_styles.html
Normal file
18
plugins/core_ui/templates/core_ui/media_styles.html
Normal file
@ -0,0 +1,18 @@
|
||||
<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
plugins/growlog/__init__.py
Normal file
1
plugins/growlog/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# growlog plugin init
|
15
plugins/growlog/forms.py
Normal file
15
plugins/growlog/forms.py
Normal file
@ -0,0 +1,15 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import TextAreaField, SelectField, SubmitField
|
||||
from wtforms.validators import DataRequired, Length
|
||||
|
||||
class GrowLogForm(FlaskForm):
|
||||
event_type = SelectField('Event Type', choices=[
|
||||
('water', 'Watered'),
|
||||
('fertilizer', 'Fertilized'),
|
||||
('repot', 'Repotted'),
|
||||
('note', 'Note'),
|
||||
('pest', 'Pest Observed')
|
||||
], validators=[DataRequired()])
|
||||
|
||||
note = TextAreaField('Notes', validators=[Length(max=1000)])
|
||||
submit = SubmitField('Add Log')
|
15
plugins/growlog/models.py
Normal file
15
plugins/growlog/models.py
Normal file
@ -0,0 +1,15 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class GrowLog(db.Model):
|
||||
__tablename__ = 'grow_logs'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=False)
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
event_type = db.Column(db.String(64), nullable=False)
|
||||
note = db.Column(db.Text)
|
||||
|
||||
media = db.relationship("Media", backref="growlog", lazy=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<GrowLog {self.event_type} @ {self.timestamp}>"
|
1
plugins/growlog/plugin.json
Normal file
1
plugins/growlog/plugin.json
Normal file
@ -0,0 +1 @@
|
||||
{ "name": "growlog", "version": "1.0", "description": "Tracks time-based plant care logs" }
|
31
plugins/growlog/routes.py
Normal file
31
plugins/growlog/routes.py
Normal file
@ -0,0 +1,31 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, request
|
||||
from flask_login import login_required
|
||||
from app import db
|
||||
from .models import GrowLog
|
||||
from .forms import GrowLogForm
|
||||
from plugins.plant.models import Plant
|
||||
|
||||
bp = Blueprint('growlog', __name__, template_folder='templates')
|
||||
|
||||
@bp.route('/plants/<int:plant_id>/logs')
|
||||
@login_required
|
||||
def view_logs(plant_id):
|
||||
plant = Plant.query.get_or_404(plant_id)
|
||||
logs = GrowLog.query.filter_by(plant_id=plant.id).order_by(GrowLog.timestamp.desc()).all()
|
||||
return render_template('growlog/log_list.html', plant=plant, logs=logs)
|
||||
|
||||
@bp.route('/plants/<int:plant_id>/logs/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_log(plant_id):
|
||||
plant = Plant.query.get_or_404(plant_id)
|
||||
form = GrowLogForm()
|
||||
if form.validate_on_submit():
|
||||
log = GrowLog(
|
||||
plant_id=plant.id,
|
||||
event_type=form.event_type.data,
|
||||
note=form.note.data
|
||||
)
|
||||
db.session.add(log)
|
||||
db.session.commit()
|
||||
return redirect(url_for('growlog.view_logs', plant_id=plant.id))
|
||||
return render_template('growlog/log_form.html', form=form, plant=plant)
|
10
plugins/growlog/templates/growlog/log_form.html
Normal file
10
plugins/growlog/templates/growlog/log_form.html
Normal file
@ -0,0 +1,10 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>Add Log for Plant #{{ plant.id }}</h2>
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
<p>{{ form.event_type.label }}<br>{{ form.event_type() }}</p>
|
||||
<p>{{ form.note.label }}<br>{{ form.note(rows=4) }}</p>
|
||||
<p>{{ form.submit() }}</p>
|
||||
</form>
|
||||
{% endblock %}
|
25
plugins/growlog/templates/growlog/log_list.html
Normal file
25
plugins/growlog/templates/growlog/log_list.html
Normal file
@ -0,0 +1,25 @@
|
||||
{% import 'core_ui/_media_macros.html' as media %}
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>Logs for Plant #{{ plant.id }}</h2>
|
||||
<a href="{{ url_for('growlog.add_log', plant_id=plant.id) }}">Add New Log</a>
|
||||
<ul>
|
||||
{% for log in logs %}
|
||||
<li>
|
||||
<strong>{{ log.timestamp.strftime('%Y-%m-%d') }}:</strong> {{ log.event_type }} - {{ log.note }}
|
||||
{% if log.media %}
|
||||
<br><em>Images:</em>
|
||||
<ul>
|
||||
{% for image in log.media %}
|
||||
<li>
|
||||
<img src="{{ url_for('media.media_file', filename=image.file_url) }}" width="150"><br>
|
||||
{{ image.caption or "No caption" }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{{ media.render_media_list(log.media) }}
|
||||
{% endblock %}
|
1
plugins/media/__init__.py
Normal file
1
plugins/media/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# media plugin init
|
14
plugins/media/forms.py
Normal file
14
plugins/media/forms.py
Normal file
@ -0,0 +1,14 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SelectField, SubmitField, IntegerField
|
||||
from flask_wtf.file import FileField, FileAllowed, FileRequired
|
||||
from wtforms.validators import DataRequired
|
||||
|
||||
class MediaUploadForm(FlaskForm):
|
||||
image = FileField('Image', validators=[
|
||||
FileRequired(),
|
||||
FileAllowed(['jpg', 'jpeg', 'png', 'gif'], 'Images only!')
|
||||
])
|
||||
caption = StringField('Caption')
|
||||
plant_id = IntegerField('Plant ID')
|
||||
growlog_id = IntegerField('GrowLog ID')
|
||||
submit = SubmitField('Upload')
|
12
plugins/media/models.py
Normal file
12
plugins/media/models.py
Normal file
@ -0,0 +1,12 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Media(db.Model):
|
||||
__tablename__ = 'media'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
file_url = db.Column(db.String(256), nullable=False)
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
plant_id = db.Column(db.Integer, db.ForeignKey('plants.id'), nullable=True)
|
||||
growlog_id = db.Column(db.Integer, db.ForeignKey('grow_logs.id'), nullable=True)
|
||||
caption = db.Column(db.String(255), nullable=True)
|
1
plugins/media/plugin.json
Normal file
1
plugins/media/plugin.json
Normal file
@ -0,0 +1 @@
|
||||
{ "name": "media", "version": "1.0", "description": "Upload and attach media to plants and grow logs" }
|
42
plugins/media/routes.py
Normal file
42
plugins/media/routes.py
Normal file
@ -0,0 +1,42 @@
|
||||
import os
|
||||
import uuid
|
||||
from flask import Blueprint, render_template, redirect, url_for, request, current_app, flash
|
||||
from flask_login import login_required
|
||||
from werkzeug.utils import secure_filename
|
||||
from app import db
|
||||
from .models import Media
|
||||
from .forms import MediaUploadForm
|
||||
|
||||
bp = Blueprint('media', __name__, template_folder='templates')
|
||||
|
||||
@bp.route('/media/upload', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def upload_media():
|
||||
form = MediaUploadForm()
|
||||
if form.validate_on_submit():
|
||||
file = form.image.data
|
||||
filename = f"{uuid.uuid4().hex}_{secure_filename(file.filename)}"
|
||||
upload_path = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
file.save(upload_path)
|
||||
media = Media(
|
||||
file_url=filename,
|
||||
caption=form.caption.data,
|
||||
plant_id=form.plant_id.data or None,
|
||||
growlog_id=form.growlog_id.data or None
|
||||
)
|
||||
db.session.add(media)
|
||||
db.session.commit()
|
||||
flash("Image uploaded successfully.", "success")
|
||||
return redirect(url_for('media.upload_media'))
|
||||
return render_template('media/upload.html', form=form)
|
||||
|
||||
@bp.route('/media')
|
||||
@login_required
|
||||
def list_media():
|
||||
images = Media.query.order_by(Media.uploaded_at.desc()).all()
|
||||
return render_template('media/list.html', images=images)
|
||||
|
||||
@bp.route('/media/files/<filename>')
|
||||
def media_file(filename):
|
||||
from flask import send_from_directory
|
||||
return send_from_directory(current_app.config['UPLOAD_FOLDER'], filename)
|
14
plugins/media/templates/media/list.html
Normal file
14
plugins/media/templates/media/list.html
Normal file
@ -0,0 +1,14 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>All Uploaded Media</h2>
|
||||
<ul>
|
||||
{% for image in images %}
|
||||
<li>
|
||||
<img src="{{ url_for('media.media_file', filename=image.file_url) }}" alt="{{ image.caption }}" width="200"><br>
|
||||
{{ image.caption or "No caption" }}
|
||||
{% if image.plant_id %}<br>Plant ID: {{ image.plant_id }}{% endif %}
|
||||
{% if image.growlog_id %}<br>GrowLog ID: {{ image.growlog_id }}{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
12
plugins/media/templates/media/upload.html
Normal file
12
plugins/media/templates/media/upload.html
Normal file
@ -0,0 +1,12 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>Upload Media</h2>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
{{ form.hidden_tag() }}
|
||||
<p>{{ form.image.label }}<br>{{ form.image() }}</p>
|
||||
<p>{{ form.caption.label }}<br>{{ form.caption(size=40) }}</p>
|
||||
<p>{{ form.plant_id.label }}<br>{{ form.plant_id() }}</p>
|
||||
<p>{{ form.growlog_id.label }}<br>{{ form.growlog_id() }}</p>
|
||||
<p>{{ form.submit() }}</p>
|
||||
</form>
|
||||
{% endblock %}
|
1
plugins/plant/__init__.py
Normal file
1
plugins/plant/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# plant plugin init
|
10
plugins/plant/forms.py
Normal file
10
plugins/plant/forms.py
Normal file
@ -0,0 +1,10 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, TextAreaField, BooleanField, SubmitField
|
||||
from wtforms.validators import DataRequired
|
||||
|
||||
class PlantForm(FlaskForm):
|
||||
name = StringField('Name', validators=[DataRequired()])
|
||||
type = StringField('Type')
|
||||
notes = TextAreaField('Notes')
|
||||
is_active = BooleanField('Active', default=True)
|
||||
submit = SubmitField('Save')
|
11
plugins/plant/models.py
Normal file
11
plugins/plant/models.py
Normal file
@ -0,0 +1,11 @@
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
|
||||
class Plant(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(128), nullable=False)
|
||||
type = db.Column(db.String(64))
|
||||
notes = db.Column(db.Text)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
43
plugins/plant/routes.py
Normal file
43
plugins/plant/routes.py
Normal file
@ -0,0 +1,43 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, request, flash
|
||||
from app import db
|
||||
from .models import Plant
|
||||
from .forms import PlantForm
|
||||
|
||||
bp = Blueprint('plant', __name__, template_folder='templates')
|
||||
|
||||
@bp.route('/plants')
|
||||
def index():
|
||||
plants = Plant.query.order_by(Plant.created_at.desc()).all()
|
||||
return render_template('plant/index.html', plants=plants)
|
||||
|
||||
@bp.route('/plants/<int:plant_id>')
|
||||
def detail(plant_id):
|
||||
plant = Plant.query.get_or_404(plant_id)
|
||||
return render_template('plant/detail.html', plant=plant)
|
||||
|
||||
@bp.route('/plants/new', methods=['GET', 'POST'])
|
||||
def create():
|
||||
form = PlantForm()
|
||||
if form.validate_on_submit():
|
||||
plant = Plant(
|
||||
name=form.name.data,
|
||||
type=form.type.data,
|
||||
notes=form.notes.data,
|
||||
is_active=form.is_active.data
|
||||
)
|
||||
db.session.add(plant)
|
||||
db.session.commit()
|
||||
flash('Plant created successfully.', 'success')
|
||||
return redirect(url_for('plant.index'))
|
||||
return render_template('plant/form.html', form=form)
|
||||
|
||||
@bp.route('/plants/<int:plant_id>/edit', methods=['GET', 'POST'])
|
||||
def edit(plant_id):
|
||||
plant = Plant.query.get_or_404(plant_id)
|
||||
form = PlantForm(obj=plant)
|
||||
if form.validate_on_submit():
|
||||
form.populate_obj(plant)
|
||||
db.session.commit()
|
||||
flash('Plant updated successfully.', 'success')
|
||||
return redirect(url_for('plant.detail', plant_id=plant.id))
|
||||
return render_template('plant/form.html', form=form, plant=plant)
|
9
plugins/plant/templates/plant/detail.html
Normal file
9
plugins/plant/templates/plant/detail.html
Normal file
@ -0,0 +1,9 @@
|
||||
{% extends 'core_ui/base.html' %}
|
||||
{% block content %}
|
||||
<h1>{{ plant.name }}</h1>
|
||||
<p>Type: {{ plant.type }}</p>
|
||||
<p>{{ plant.notes }}</p>
|
||||
<p>Status: {% if plant.is_active %}Active{% else %}Inactive{% endif %}</p>
|
||||
<a href="{{ url_for('plant.edit', plant_id=plant.id) }}">Edit</a>
|
||||
<a href="{{ url_for('plant.index') }}">Back to list</a>
|
||||
{% endblock %}
|
12
plugins/plant/templates/plant/form.html
Normal file
12
plugins/plant/templates/plant/form.html
Normal file
@ -0,0 +1,12 @@
|
||||
{% extends 'core_ui/base.html' %}
|
||||
{% block content %}
|
||||
<h1>{% if plant %}Edit{% else %}New{% endif %} Plant</h1>
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
<p>{{ form.name.label }}<br>{{ form.name(size=40) }}</p>
|
||||
<p>{{ form.type.label }}<br>{{ form.type(size=40) }}</p>
|
||||
<p>{{ form.notes.label }}<br>{{ form.notes(rows=5, cols=40) }}</p>
|
||||
<p>{{ form.is_active() }} {{ form.is_active.label }}</p>
|
||||
<p>{{ form.submit() }}</p>
|
||||
</form>
|
||||
{% endblock %}
|
10
plugins/plant/templates/plant/index.html
Normal file
10
plugins/plant/templates/plant/index.html
Normal file
@ -0,0 +1,10 @@
|
||||
{% extends 'core_ui/base.html' %}
|
||||
{% block content %}
|
||||
<h1>Plant List</h1>
|
||||
<ul>
|
||||
{% for plant in plants %}
|
||||
<li><a href="{{ url_for('plant.detail', plant_id=plant.id) }}">{{ plant.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<a href="{{ url_for('plant.create') }}">Add New Plant</a>
|
||||
{% endblock %}
|
1
plugins/search/__init__.py
Normal file
1
plugins/search/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# search plugin initialization
|
15
plugins/search/forms.py
Normal file
15
plugins/search/forms.py
Normal file
@ -0,0 +1,15 @@
|
||||
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')
|
8
plugins/search/models.py
Normal file
8
plugins/search/models.py
Normal file
@ -0,0 +1,8 @@
|
||||
from app import db
|
||||
|
||||
class Tag(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(64), unique=True, nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Tag {self.name}>"
|
1
plugins/search/plugin.json
Normal file
1
plugins/search/plugin.json
Normal file
@ -0,0 +1 @@
|
||||
{ "name": "search", "version": "1.1", "description": "Updated search plugin with live Plant model integration" }
|
38
plugins/search/routes.py
Normal file
38
plugins/search/routes.py
Normal file
@ -0,0 +1,38 @@
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from .models import Tag
|
||||
from .forms import SearchForm
|
||||
from plugins.plant.models import Plant
|
||||
|
||||
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])
|
26
plugins/search/templates/search/search.html
Normal file
26
plugins/search/templates/search/search.html
Normal file
@ -0,0 +1,26 @@
|
||||
{% extends '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 %}
|
13
plugins/search/templates/search/search_results.html
Normal file
13
plugins/search/templates/search/search_results.html
Normal file
@ -0,0 +1,13 @@
|
||||
{% extends '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 %}
|
4
plugins/search/templates/search/search_tags.html
Normal file
4
plugins/search/templates/search/search_tags.html
Normal file
@ -0,0 +1,4 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<p>This page was replaced by AJAX functionality.</p>
|
||||
{% endblock %}
|
Reference in New Issue
Block a user