changes
This commit is contained in:
parent
132073ca19
commit
c19bedc54a
@ -1,7 +1,10 @@
|
|||||||
# Environment toggles
|
# Environment toggles
|
||||||
USE_REMOTE_MYSQL=0
|
USE_REMOTE_MYSQL=0
|
||||||
|
ENABLE_DB_PRELOAD=0
|
||||||
ENABLE_DB_SEEDING=1
|
ENABLE_DB_SEEDING=1
|
||||||
SEED_EXTRA_DATA=false
|
SEED_EXTRA_DATA=false
|
||||||
|
UPLOAD_FOLDER=app/static/uploads
|
||||||
|
SECRET_KEY=supersecretplantappkey
|
||||||
|
|
||||||
# MySQL configuration
|
# MySQL configuration
|
||||||
MYSQL_HOST=db
|
MYSQL_HOST=db
|
||||||
|
0
LICENSE.md
Normal file
0
LICENSE.md
Normal file
18
Makefile
18
Makefile
@ -44,6 +44,9 @@ rebuild:
|
|||||||
$(DOCKER_COMPOSE) up --build -d
|
$(DOCKER_COMPOSE) up --build -d
|
||||||
@$(MAKE) wait
|
@$(MAKE) wait
|
||||||
|
|
||||||
|
preload:
|
||||||
|
@docker exec -it $$(docker ps -qf "name=$(PROJECT_NAME)-web") flask preload-data
|
||||||
|
|
||||||
logs:
|
logs:
|
||||||
$(DOCKER_COMPOSE) logs -f
|
$(DOCKER_COMPOSE) logs -f
|
||||||
|
|
||||||
@ -77,7 +80,14 @@ test:
|
|||||||
|
|
||||||
wait:
|
wait:
|
||||||
@echo "[⏳] Waiting for web container to be healthy..."
|
@echo "[⏳] Waiting for web container to be healthy..."
|
||||||
@until [ "$$(docker inspect -f '{{.State.Health.Status}}' $(PROJECT_NAME)-web-1 2>/dev/null)" = "healthy" ]; do \
|
@timeout 90 bash -c '\
|
||||||
printf "."; sleep 2; \
|
WEB_CONTAINER=$$($(DOCKER_COMPOSE) ps -q web); \
|
||||||
done
|
if [ -z "$$WEB_CONTAINER" ]; then \
|
||||||
@echo "\n[✅] Web container is healthy!"
|
echo "[❌] Could not detect web container!"; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
echo "[ℹ] Detected container: $$WEB_CONTAINER"; \
|
||||||
|
while [ "$$(docker inspect -f "{{.State.Health.Status}}" $$WEB_CONTAINER 2>/dev/null)" != "healthy" ]; do \
|
||||||
|
sleep 2; echo -n "."; \
|
||||||
|
done; echo "\n[✅] $$WEB_CONTAINER is healthy!"'
|
||||||
|
|
||||||
|
48
README.md
48
README.md
@ -1,3 +1,47 @@
|
|||||||
# Plant Price Tracker
|
# Nature In Pots – Modular Flask Plant Management App
|
||||||
|
|
||||||
Initial setup for the community plant tracking app.
|
This project is a modular, plugin-driven Flask application for tracking plants, their health, lineage, pricing, media, and more.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Core Features
|
||||||
|
|
||||||
|
- 🔐 User Authentication (`auth`)
|
||||||
|
- 🌱 Plant Identity Profiles (`plant`)
|
||||||
|
- 📊 Grow Logs with Events (`growlog`)
|
||||||
|
- 🖼 Media Upload & Attachments (`media`)
|
||||||
|
- 🔍 Tag-based and name-based Search (`search`)
|
||||||
|
- 🛠 CLI Tools for Preloading and Seeding (`cli`)
|
||||||
|
- 🎨 UI Macros and Shared Layout (`core_ui`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧱 Plugin System
|
||||||
|
|
||||||
|
Each feature is implemented as a plugin under the `plugins/` directory. Each plugin is self-contained and includes:
|
||||||
|
|
||||||
|
- `models.py`
|
||||||
|
- `routes.py`
|
||||||
|
- `forms.py` *(if applicable)*
|
||||||
|
- `templates/`
|
||||||
|
- `plugin.json`
|
||||||
|
|
||||||
|
Plugins are auto-loaded via `app/__init__.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗 Installation
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
- MySQL server
|
||||||
|
- Virtualenv or Docker
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install # Install dependencies
|
||||||
|
make dev # Start Flask development server
|
||||||
|
make db # Initialize database
|
||||||
|
make seed # Seed database with test data
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import os
|
||||||
|
import importlib.util
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
@ -10,28 +12,33 @@ login_manager = LoginManager()
|
|||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_object('config.Config')
|
app.config.from_object('app.config.Config')
|
||||||
|
|
||||||
# Initialize core extensions
|
# Initialize core extensions
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
migrate.init_app(app, db)
|
migrate.init_app(app, db)
|
||||||
login_manager.init_app(app)
|
login_manager.init_app(app)
|
||||||
login_manager.login_view = 'auth.login' # Optional: redirect for @login_required
|
login_manager.login_view = 'auth.login' # Redirect for @login_required
|
||||||
|
|
||||||
# Register Blueprints
|
# Register error handlers
|
||||||
from .core.routes import core
|
from .errors import bp as errors_bp
|
||||||
from .core.auth import auth
|
app.register_blueprint(errors_bp)
|
||||||
app.register_blueprint(core)
|
|
||||||
app.register_blueprint(auth, url_prefix="/auth")
|
|
||||||
|
|
||||||
# Register CLI commands
|
# Plugin auto-loader
|
||||||
from .cli import seed_admin
|
plugin_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'plugins'))
|
||||||
app.cli.add_command(seed_admin)
|
for plugin in os.listdir(plugin_path):
|
||||||
|
full_path = os.path.join(plugin_path, plugin, 'routes.py')
|
||||||
|
if os.path.isfile(full_path):
|
||||||
|
spec = importlib.util.spec_from_file_location(f"plugins.{plugin}.routes", full_path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
if hasattr(mod, 'bp'):
|
||||||
|
app.register_blueprint(mod.bp)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def load_user(user_id):
|
def load_user(user_id):
|
||||||
from .core.models import User
|
from plugins.auth.models import User
|
||||||
return User.query.get(int(user_id))
|
return User.query.get(int(user_id))
|
||||||
|
35
app/cli.py
35
app/cli.py
@ -1,35 +0,0 @@
|
|||||||
import click
|
|
||||||
from flask.cli import with_appcontext
|
|
||||||
from . import db
|
|
||||||
from .core.models import User
|
|
||||||
import os
|
|
||||||
|
|
||||||
@click.command('seed-admin')
|
|
||||||
@with_appcontext
|
|
||||||
def seed_admin():
|
|
||||||
"""Seeds only the default admin user unless SEED_EXTRA_DATA=true"""
|
|
||||||
admin_email = os.getenv('ADMIN_EMAIL', 'admin@example.com')
|
|
||||||
admin_password = os.getenv('ADMIN_PASSWORD', 'admin123')
|
|
||||||
|
|
||||||
if not User.query.filter_by(email=admin_email).first():
|
|
||||||
click.echo(f"[✔] Creating default admin: {admin_email}")
|
|
||||||
user = User(
|
|
||||||
email=admin_email,
|
|
||||||
password_hash=admin_password, # In production, hash this
|
|
||||||
role='admin',
|
|
||||||
is_verified=True
|
|
||||||
)
|
|
||||||
db.session.add(user)
|
|
||||||
db.session.commit()
|
|
||||||
else:
|
|
||||||
click.echo("[ℹ] Admin user already exists.")
|
|
||||||
|
|
||||||
if os.getenv("SEED_EXTRA_DATA", "false").lower() == "true":
|
|
||||||
click.echo("[ℹ] SEED_EXTRA_DATA=true, seeding additional demo content...")
|
|
||||||
seed_extra_data()
|
|
||||||
else:
|
|
||||||
click.echo("[✔] Admin-only seed complete. Skipping extras.")
|
|
||||||
|
|
||||||
def seed_extra_data():
|
|
||||||
# Placeholder for future extended seed logic
|
|
||||||
pass
|
|
27
app/config.py
Normal file
27
app/config.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
SECRET_KEY = os.environ['SECRET_KEY']
|
||||||
|
UPLOAD_FOLDER = os.environ['UPLOAD_FOLDER']
|
||||||
|
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH', 16 * 1024 * 1024)) # default 16MB
|
||||||
|
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
|
||||||
|
|
||||||
|
# MySQL connection parameters from .env
|
||||||
|
MYSQL_USER = os.environ['MYSQL_USER']
|
||||||
|
MYSQL_PASSWORD = os.environ['MYSQL_PASSWORD']
|
||||||
|
MYSQL_HOST = os.environ['MYSQL_HOST']
|
||||||
|
MYSQL_PORT = os.environ.get('MYSQL_PORT', 3306)
|
||||||
|
MYSQL_DB = os.environ['MYSQL_DATABASE']
|
||||||
|
|
||||||
|
# Build the SQLAlchemy database URI
|
||||||
|
SQLALCHEMY_DATABASE_URI = (
|
||||||
|
f"mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}"
|
||||||
|
f"@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DB}"
|
||||||
|
)
|
||||||
|
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
|
||||||
|
# Optional toggles
|
||||||
|
ENABLE_DB_PRELOAD = os.environ.get('ENABLE_DB_PRELOAD', '0') == '1'
|
||||||
|
ENABLE_DB_SEEDING = os.environ.get('ENABLE_DB_SEEDING', '0') == '1'
|
||||||
|
SEED_EXTRA_DATA = os.environ.get('SEED_EXTRA_DATA', 'false').lower() == 'true'
|
@ -1,38 +0,0 @@
|
|||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
|
||||||
from flask_login import login_user, logout_user, login_required, current_user
|
|
||||||
from .. import db
|
|
||||||
from .models import User
|
|
||||||
|
|
||||||
auth = Blueprint('auth', __name__)
|
|
||||||
|
|
||||||
@auth.route('/login', methods=['GET', 'POST'])
|
|
||||||
def login():
|
|
||||||
if request.method == 'POST':
|
|
||||||
user = User.query.filter_by(email=request.form['email']).first()
|
|
||||||
if user and check_password_hash(user.password_hash, request.form['password']):
|
|
||||||
login_user(user)
|
|
||||||
return redirect(url_for('core.index'))
|
|
||||||
flash('Invalid email or password.')
|
|
||||||
return render_template('auth/login.html')
|
|
||||||
|
|
||||||
@auth.route('/register', methods=['GET', 'POST'])
|
|
||||||
def register():
|
|
||||||
if request.method == 'POST':
|
|
||||||
email = request.form['email']
|
|
||||||
password = request.form['password']
|
|
||||||
if User.query.filter_by(email=email).first():
|
|
||||||
flash('Email already registered.')
|
|
||||||
else:
|
|
||||||
user = User(email=email, password_hash=generate_password_hash(password))
|
|
||||||
db.session.add(user)
|
|
||||||
db.session.commit()
|
|
||||||
flash('Account created, please log in.')
|
|
||||||
return redirect(url_for('auth.login'))
|
|
||||||
return render_template('auth/register.html')
|
|
||||||
|
|
||||||
@auth.route('/logout')
|
|
||||||
@login_required
|
|
||||||
def logout():
|
|
||||||
logout_user()
|
|
||||||
return redirect(url_for('core.index'))
|
|
@ -1,145 +0,0 @@
|
|||||||
from flask import Blueprint, render_template, request, jsonify, redirect, url_for, flash
|
|
||||||
from datetime import datetime
|
|
||||||
from flask_login import login_required, current_user
|
|
||||||
from sqlalchemy import and_
|
|
||||||
from markupsafe import escape
|
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
import os
|
|
||||||
|
|
||||||
from .. import db
|
|
||||||
from .models import Submission, SubmissionImage
|
|
||||||
|
|
||||||
core = Blueprint('core', __name__)
|
|
||||||
|
|
||||||
UPLOAD_FOLDER = 'app/static/uploads'
|
|
||||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg'}
|
|
||||||
|
|
||||||
def allowed_file(filename):
|
|
||||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
|
||||||
|
|
||||||
@core.route("/health")
|
|
||||||
def health_check():
|
|
||||||
return "OK", 200
|
|
||||||
|
|
||||||
@core.route('/')
|
|
||||||
def index():
|
|
||||||
submissions = Submission.query.order_by(Submission.timestamp.desc()).limit(6).all()
|
|
||||||
use_placeholder = len(submissions) == 0
|
|
||||||
return render_template('index.html', submissions=submissions, use_placeholder=use_placeholder, current_year=datetime.now().year)
|
|
||||||
|
|
||||||
@core.route('/dashboard')
|
|
||||||
@login_required
|
|
||||||
def user_dashboard():
|
|
||||||
return render_template('dashboard.html', current_year=datetime.now().year)
|
|
||||||
|
|
||||||
@core.route('/admin')
|
|
||||||
@login_required
|
|
||||||
def admin_dashboard():
|
|
||||||
if current_user.role != 'admin':
|
|
||||||
return render_template('unauthorized.html'), 403
|
|
||||||
return render_template('admin_dashboard.html', current_year=datetime.now().year)
|
|
||||||
|
|
||||||
@core.route('/search', methods=['GET', 'POST'])
|
|
||||||
def search():
|
|
||||||
query = escape(request.values.get('q', '').strip())
|
|
||||||
source = escape(request.values.get('source', '').strip())
|
|
||||||
min_price = request.values.get('min_price', type=float)
|
|
||||||
max_price = request.values.get('max_price', type=float)
|
|
||||||
from_date = request.values.get('from_date')
|
|
||||||
to_date = request.values.get('to_date')
|
|
||||||
|
|
||||||
if request.method == 'POST' and request.is_json:
|
|
||||||
removed = request.json.get('remove')
|
|
||||||
if removed == "Name":
|
|
||||||
query = ""
|
|
||||||
elif removed == "Source":
|
|
||||||
source = ""
|
|
||||||
elif removed == "Min Price":
|
|
||||||
min_price = None
|
|
||||||
elif removed == "Max Price":
|
|
||||||
max_price = None
|
|
||||||
elif removed == "From":
|
|
||||||
from_date = None
|
|
||||||
elif removed == "To":
|
|
||||||
to_date = None
|
|
||||||
|
|
||||||
filters = []
|
|
||||||
filter_tags = {}
|
|
||||||
|
|
||||||
if query:
|
|
||||||
filters.append(Submission.common_name.ilike(f"%{query}%"))
|
|
||||||
filter_tags['Name'] = query
|
|
||||||
if source:
|
|
||||||
filters.append(Submission.source.ilike(f"%{source}%"))
|
|
||||||
filter_tags['Source'] = source
|
|
||||||
if min_price is not None:
|
|
||||||
filters.append(Submission.price >= min_price)
|
|
||||||
filter_tags['Min Price'] = f"${min_price:.2f}"
|
|
||||||
if max_price is not None:
|
|
||||||
filters.append(Submission.price <= max_price)
|
|
||||||
filter_tags['Max Price'] = f"${max_price:.2f}"
|
|
||||||
if from_date:
|
|
||||||
filters.append(Submission.timestamp >= from_date)
|
|
||||||
filter_tags['From'] = from_date
|
|
||||||
if to_date:
|
|
||||||
filters.append(Submission.timestamp <= to_date)
|
|
||||||
filter_tags['To'] = to_date
|
|
||||||
|
|
||||||
results = Submission.query.filter(and_(*filters)).order_by(Submission.timestamp.desc()).limit(50).all()
|
|
||||||
|
|
||||||
if request.is_json:
|
|
||||||
return jsonify({
|
|
||||||
"results_html": render_template("search_results.html", results=results),
|
|
||||||
"tags_html": render_template("search_tags.html", filter_tags=filter_tags)
|
|
||||||
})
|
|
||||||
|
|
||||||
return render_template('search.html', results=results, filter_tags=filter_tags)
|
|
||||||
|
|
||||||
@core.route('/submit', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def submit():
|
|
||||||
if request.method == 'POST':
|
|
||||||
common_name = escape(request.form.get('common_name', '').strip())
|
|
||||||
scientific_name = escape(request.form.get('scientific_name', '').strip())
|
|
||||||
price = request.form.get('price', type=float)
|
|
||||||
source = escape(request.form.get('source', '').strip())
|
|
||||||
timestamp = request.form.get('timestamp') or datetime.utcnow()
|
|
||||||
height = request.form.get('height', type=float)
|
|
||||||
width = request.form.get('width', type=float)
|
|
||||||
leaf_count = request.form.get('leaf_count', type=int)
|
|
||||||
potting_mix = escape(request.form.get('potting_mix', '').strip())
|
|
||||||
container_size = escape(request.form.get('container_size', '').strip())
|
|
||||||
health_status = escape(request.form.get('health_status', '').strip())
|
|
||||||
notes = escape(request.form.get('notes', '').strip())
|
|
||||||
|
|
||||||
new_submission = Submission(
|
|
||||||
user_id=current_user.id,
|
|
||||||
common_name=common_name,
|
|
||||||
scientific_name=scientific_name,
|
|
||||||
price=price,
|
|
||||||
source=source,
|
|
||||||
timestamp=timestamp,
|
|
||||||
height=height,
|
|
||||||
width=width,
|
|
||||||
leaf_count=leaf_count,
|
|
||||||
potting_mix=potting_mix,
|
|
||||||
container_size=container_size,
|
|
||||||
health_status=health_status,
|
|
||||||
notes=notes,
|
|
||||||
)
|
|
||||||
db.session.add(new_submission)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
files = request.files.getlist('images')
|
|
||||||
for f in files[:5]:
|
|
||||||
if f and allowed_file(f.filename):
|
|
||||||
filename = secure_filename(f.filename)
|
|
||||||
save_path = os.path.join(UPLOAD_FOLDER, filename)
|
|
||||||
f.save(save_path)
|
|
||||||
db.session.add(SubmissionImage(submission_id=new_submission.id, file_path=save_path))
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
flash("Submission received!", "success")
|
|
||||||
return redirect(url_for('core.index'))
|
|
||||||
|
|
||||||
return render_template('submit.html')
|
|
11
app/errors.py
Normal file
11
app/errors.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from flask import render_template, Blueprint
|
||||||
|
|
||||||
|
bp = Blueprint('errors', __name__)
|
||||||
|
|
||||||
|
@bp.app_errorhandler(400)
|
||||||
|
def bad_request(error):
|
||||||
|
return render_template('400.html'), 400
|
||||||
|
|
||||||
|
@bp.app_errorhandler(500)
|
||||||
|
def internal_error(error):
|
||||||
|
return render_template('500.html'), 500
|
1
app/templates/404.html
Normal file
1
app/templates/404.html
Normal file
@ -0,0 +1 @@
|
|||||||
|
<h1>404 ERROR</h1><p>Something went wrong.</p>
|
1
app/templates/500.html
Normal file
1
app/templates/500.html
Normal file
@ -0,0 +1 @@
|
|||||||
|
<h1>500 ERROR</h1><p>Something went wrong.</p>
|
@ -1,5 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block content %}
|
|
||||||
<h2>Admin Dashboard</h2>
|
|
||||||
<p>You are logged in as an administrator.</p>
|
|
||||||
{% endblock %}
|
|
@ -1,98 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Nature In Pots Community</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
main {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
footer {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
padding: 1rem 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-4">
|
|
||||||
<div class="container">
|
|
||||||
<a class="navbar-brand" href="{{ url_for('core.index') }}">Nature In Pots</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul class="navbar-nav me-auto">
|
|
||||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('core.index') }}">Home</a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('core.user_dashboard') }}">Dashboard</a></li>
|
|
||||||
{% if current_user.is_authenticated and current_user.role == 'admin' %}
|
|
||||||
<li class="nav-item"><a class="nav-link text-danger" href="{{ url_for('core.admin_dashboard') }}">Admin Dashboard</a></li>
|
|
||||||
{% endif %}
|
|
||||||
{% block plugin_links %}{% endblock %}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<ul class="navbar-nav align-items-center">
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" id="filterDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
Search
|
|
||||||
</a>
|
|
||||||
<div class="dropdown-menu dropdown-menu-end p-3 shadow" style="min-width: 300px;">
|
|
||||||
<form method="GET" action="{{ url_for('core.search') }}">
|
|
||||||
<div class="mb-2">
|
|
||||||
<input class="form-control" type="text" name="q" placeholder="Plant name">
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<input class="form-control" type="text" name="source" placeholder="Source">
|
|
||||||
</div>
|
|
||||||
<div class="mb-2 d-flex gap-2">
|
|
||||||
<input class="form-control" type="number" name="min_price" placeholder="Min $" step="0.01">
|
|
||||||
<input class="form-control" type="number" name="max_price" placeholder="Max $" step="0.01">
|
|
||||||
</div>
|
|
||||||
<div class="mb-2 d-flex gap-2">
|
|
||||||
<input class="form-control" type="date" name="from_date">
|
|
||||||
<input class="form-control" type="date" name="to_date">
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-sm btn-success w-100" type="submit">Apply Filters</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
{% if current_user.is_authenticated %}
|
|
||||||
<li class="nav-item ms-3">
|
|
||||||
<a class="nav-link" href="{{ url_for('auth.logout') }}">Logout</a>
|
|
||||||
</li>
|
|
||||||
{% else %}
|
|
||||||
<li class="nav-item ms-3"><a class="nav-link" href="{{ url_for('auth.login') }}">Login</a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('auth.register') }}">Register</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="container">
|
|
||||||
{% with messages = get_flashed_messages() %}
|
|
||||||
{% if messages %}
|
|
||||||
<div class="alert alert-warning">
|
|
||||||
{% for message in messages %}
|
|
||||||
<div>{{ message }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
© {{ current_year }} Nature In Pots Community. All rights reserved.
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,5 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block content %}
|
|
||||||
<h2>User Dashboard</h2>
|
|
||||||
<p>Welcome to your dashboard, {{ current_user.email }}.</p>
|
|
||||||
{% endblock %}
|
|
@ -1,36 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block content %}
|
|
||||||
<h1 class="mb-4 text-center">Welcome to the Plant Price Tracker!</h1>
|
|
||||||
|
|
||||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 g-4">
|
|
||||||
{% if use_placeholder %}
|
|
||||||
{% for i in range(6) %}
|
|
||||||
<div class="col">
|
|
||||||
<div class="card h-100 shadow-sm">
|
|
||||||
<img src="https://placehold.co/150x150" class="card-img-top" alt="Placeholder Plant {{ i+1 }}">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Placeholder Plant {{ i+1 }}</h5>
|
|
||||||
<p class="card-text text-muted">No real submissions yet — submit a plant to see it here.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
{% for sub in submissions %}
|
|
||||||
<div class="col">
|
|
||||||
<div class="card h-100 shadow-sm">
|
|
||||||
<img src="{{ sub.image_url or 'https://placehold.co/150x150' }}" class="card-img-top" alt="{{ sub.common_name }}">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">{{ sub.common_name }}</h5>
|
|
||||||
<p class="card-text">
|
|
||||||
<strong>${{ '%.2f'|format(sub.price) }}</strong><br>
|
|
||||||
Source: {{ sub.source }}<br>
|
|
||||||
Date: {{ sub.timestamp.strftime('%Y-%m-%d') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
@ -1,34 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block content %}
|
|
||||||
<h2 class="mb-3">Search Results</h2>
|
|
||||||
|
|
||||||
<div id="active-filters">
|
|
||||||
{% include 'search_tags.html' %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="search-results">
|
|
||||||
{% include 'search_results.html' %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
document.querySelectorAll(".remove-chip").forEach(btn => {
|
|
||||||
btn.addEventListener("click", function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const tag = this.dataset.tag;
|
|
||||||
|
|
||||||
fetch("{{ url_for('core.search') }}", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ remove: tag })
|
|
||||||
})
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
document.getElementById("active-filters").innerHTML = data.tags_html;
|
|
||||||
document.getElementById("search-results").innerHTML = data.results_html;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
@ -1,21 +0,0 @@
|
|||||||
{% if results %}
|
|
||||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 g-4">
|
|
||||||
{% for sub in results %}
|
|
||||||
<div class="col">
|
|
||||||
<div class="card h-100 shadow-sm">
|
|
||||||
<img src="{{ sub.image_url or 'https://placehold.co/600x400' }}" class="card-img-top" alt="{{ sub.common_name }}">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">{{ sub.common_name }}</h5>
|
|
||||||
<p class="card-text">
|
|
||||||
<strong>${{ '%.2f'|format(sub.price) }}</strong><br>
|
|
||||||
Source: {{ sub.source }}<br>
|
|
||||||
Date: {{ sub.timestamp.strftime('%Y-%m-%d') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-muted">No results found.</p>
|
|
||||||
{% endif %}
|
|
@ -1,15 +0,0 @@
|
|||||||
|
|
||||||
{% if filter_tags %}
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Filters:</strong>
|
|
||||||
{% for key, value in filter_tags.items() %}
|
|
||||||
<span class="badge bg-primary me-2">
|
|
||||||
{{ key }}: {{ value }}
|
|
||||||
<button class="btn-close btn-close-white btn-sm remove-chip ms-1" data-tag="{{ key }}" aria-label="Remove {{ key }}"></button>
|
|
||||||
</span>
|
|
||||||
{% endfor %}
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" onclick="document.getElementById('filterDropdown').click();">
|
|
||||||
Edit Filters
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
@ -1,65 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block content %}
|
|
||||||
<h2 class="mb-4">Submit a New Plant</h2>
|
|
||||||
<form method="POST" enctype="multipart/form-data">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Common Name *</label>
|
|
||||||
<input type="text" name="common_name" class="form-control" required>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Scientific Name</label>
|
|
||||||
<input type="text" name="scientific_name" class="form-control">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Price ($)*</label>
|
|
||||||
<input type="number" name="price" class="form-control" step="0.01" required>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Source</label>
|
|
||||||
<input type="text" name="source" class="form-control">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Height (in)</label>
|
|
||||||
<input type="number" name="height" class="form-control" step="0.1">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Width (in)</label>
|
|
||||||
<input type="number" name="width" class="form-control" step="0.1">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Leaf Count</label>
|
|
||||||
<input type="number" name="leaf_count" class="form-control">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Potting Mix</label>
|
|
||||||
<input type="text" name="potting_mix" class="form-control">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Container Size</label>
|
|
||||||
<input type="text" name="container_size" class="form-control">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">Health Status</label>
|
|
||||||
<select name="health_status" class="form-select">
|
|
||||||
<option value="">Select</option>
|
|
||||||
<option value="healthy">Healthy</option>
|
|
||||||
<option value="minor damage">Minor Damage</option>
|
|
||||||
<option value="pest observed">Pest Observed</option>
|
|
||||||
<option value="recovering">Recovering</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label">Notes</label>
|
|
||||||
<textarea name="notes" class="form-control" rows="3"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label">Upload up to 5 images (jpg/png)</label>
|
|
||||||
<input type="file" name="images" accept=".jpg,.jpeg,.png" class="form-control" multiple>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3">
|
|
||||||
<button type="submit" class="btn btn-success">Submit</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
@ -1,5 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block content %}
|
|
||||||
<h2>Unauthorized</h2>
|
|
||||||
<p>You do not have permission to access this page.</p>
|
|
||||||
{% endblock %}
|
|
17
config.py
17
config.py
@ -1,17 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
SECRET_KEY = os.getenv('SECRET_KEY', 'dev')
|
|
||||||
|
|
||||||
DB_USER = os.getenv('MYSQL_USER', 'plant_user')
|
|
||||||
DB_PASSWORD = os.getenv('MYSQL_PASSWORD', 'plant_pass')
|
|
||||||
DB_HOST = os.getenv('MYSQL_HOST', 'db')
|
|
||||||
DB_PORT = os.getenv('MYSQL_PORT', '3306')
|
|
||||||
DB_NAME = os.getenv('MYSQL_DATABASE', 'plant_db')
|
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URI = os.getenv(
|
|
||||||
'DATABASE_URL',
|
|
||||||
f'mysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}'
|
|
||||||
)
|
|
||||||
|
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
|
@ -9,6 +9,7 @@ services:
|
|||||||
- FLASK_APP=app
|
- FLASK_APP=app
|
||||||
- FLASK_ENV=development
|
- FLASK_ENV=development
|
||||||
- USE_REMOTE_MYSQL=${USE_REMOTE_MYSQL}
|
- USE_REMOTE_MYSQL=${USE_REMOTE_MYSQL}
|
||||||
|
- ENABLE_DB_PRELOAD=${ENABLE_DB_PRELOAD}
|
||||||
- ENABLE_DB_SEEDING=${ENABLE_DB_SEEDING}
|
- ENABLE_DB_SEEDING=${ENABLE_DB_SEEDING}
|
||||||
- MYSQL_DATABASE=${MYSQL_DATABASE}
|
- MYSQL_DATABASE=${MYSQL_DATABASE}
|
||||||
- MYSQL_USER=${MYSQL_USER}
|
- MYSQL_USER=${MYSQL_USER}
|
||||||
@ -18,10 +19,12 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:5000/health"]
|
test: ["CMD-SHELL", "curl -fs http://127.0.0.1:5000/health || exit 1"]
|
||||||
interval: 30s
|
interval: 10s
|
||||||
timeout: 10s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
command: >
|
command: >
|
||||||
bash -c "
|
bash -c "
|
||||||
set -e
|
set -e
|
||||||
@ -44,6 +47,12 @@ services:
|
|||||||
echo '[✔] Running DB migrations...'
|
echo '[✔] Running DB migrations...'
|
||||||
flask db upgrade
|
flask db upgrade
|
||||||
|
|
||||||
|
if [ "$$ENABLE_DB_PRELOAD" = "1" ]; then
|
||||||
|
echo '[📥] Preloading data...'; flask preload-data;
|
||||||
|
else
|
||||||
|
echo '[⚠️] Skipping preload...';
|
||||||
|
fi
|
||||||
|
|
||||||
if [ \"$$ENABLE_DB_SEEDING\" = \"1\" ]; then
|
if [ \"$$ENABLE_DB_SEEDING\" = \"1\" ]; then
|
||||||
echo '[🌱] Seeding admin user...'
|
echo '[🌱] Seeding admin user...'
|
||||||
flask seed-admin
|
flask seed-admin
|
||||||
|
33
nuke-dev.sh
33
nuke-dev.sh
@ -1,33 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
echo "⚠️ This will DELETE your containers, SQLite DB, Alembic migrations, cache, uploads, logs."
|
|
||||||
read -p "Continue? (y/N): " confirm
|
|
||||||
|
|
||||||
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
|
|
||||||
echo "❌ Aborted."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "🛑 Stopping and removing containers and volumes..."
|
|
||||||
docker compose down --volumes --remove-orphans
|
|
||||||
|
|
||||||
echo "🗑 Removing mounted DB file..."
|
|
||||||
rm -f ./app/app.db
|
|
||||||
|
|
||||||
echo "🧹 Removing Alembic migrations..."
|
|
||||||
sudo rm -rf migrations/
|
|
||||||
|
|
||||||
echo "🧼 Removing Python cache and compiled files..."
|
|
||||||
sudo find . -type d -name '__pycache__' -exec rm -rf {} +
|
|
||||||
sudo find . -name '*.pyc' -delete
|
|
||||||
sudo find . -name '*.pyo' -delete
|
|
||||||
|
|
||||||
echo "🧽 Removing static uploads and logs..."
|
|
||||||
rm -rf app/static/uploads/*
|
|
||||||
rm -rf logs/
|
|
||||||
|
|
||||||
echo "🔨 Rebuilding containers..."
|
|
||||||
docker compose build --no-cache
|
|
||||||
|
|
||||||
echo "🚀 Starting up..."
|
|
||||||
docker compose up --force-recreate
|
|
@ -1,6 +1,6 @@
|
|||||||
from flask_login import UserMixin
|
from flask_login import UserMixin
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from .. import db
|
from app import db
|
||||||
|
|
||||||
class User(db.Model, UserMixin):
|
class User(db.Model, UserMixin):
|
||||||
__tablename__ = 'users'
|
__tablename__ = 'users'
|
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'))
|
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 %}
|
@ -2,5 +2,9 @@ Flask
|
|||||||
Flask-Login
|
Flask-Login
|
||||||
Flask-Migrate
|
Flask-Migrate
|
||||||
Flask-SQLAlchemy
|
Flask-SQLAlchemy
|
||||||
mysqlclient
|
Flask-WTF
|
||||||
|
WTForms
|
||||||
|
Werkzeug>=2.3.0
|
||||||
|
pymysql
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
cryptography
|
||||||
|
Loading…
x
Reference in New Issue
Block a user