This commit is contained in:
Bryson Shepard 2025-05-18 05:21:16 -05:00
parent 132073ca19
commit c19bedc54a
65 changed files with 705 additions and 575 deletions

View File

@ -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
View File

View File

@ -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!"'

View File

@ -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

View File

@ -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))

View File

@ -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
View 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'

View File

@ -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'))

View File

@ -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
View 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
View File

@ -0,0 +1 @@
<h1>404 ERROR</h1><p>Something went wrong.</p>

1
app/templates/500.html Normal file
View File

@ -0,0 +1 @@
<h1>500 ERROR</h1><p>Something went wrong.</p>

View File

@ -1,5 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<h2>Admin Dashboard</h2>
<p>You are logged in as an administrator.</p>
{% endblock %}

View File

@ -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>
&copy; {{ 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>

View File

@ -1,5 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<h2>User Dashboard</h2>
<p>Welcome to your dashboard, {{ current_user.email }}.</p>
{% endblock %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -1,5 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<h2>Unauthorized</h2>
<p>You do not have permission to access this page.</p>
{% endblock %}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
View 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
View File

@ -0,0 +1,2 @@
from .seed import seed_admin
from .preload import preload_data

79
plugins/cli/preload.py Normal file
View 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
View 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}")

View File

@ -0,0 +1 @@
# core_ui media patch

View File

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

View 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 %}

View 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>

View File

@ -0,0 +1 @@
# growlog plugin init

15
plugins/growlog/forms.py Normal file
View 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
View 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}>"

View File

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

31
plugins/growlog/routes.py Normal file
View 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)

View 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 %}

View 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 %}

View File

@ -0,0 +1 @@
# media plugin init

14
plugins/media/forms.py Normal file
View 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
View 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)

View 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
View 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)

View 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 %}

View 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 %}

View File

@ -0,0 +1 @@
# plant plugin init

10
plugins/plant/forms.py Normal file
View 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
View 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
View 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)

View 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 %}

View 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 %}

View 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 %}

View File

@ -0,0 +1 @@
# search plugin initialization

15
plugins/search/forms.py Normal file
View 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
View 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}>"

View 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
View 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])

View 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 %}

View 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 %}

View File

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

View File

@ -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