updates
This commit is contained in:
parent
6fb15b6d5c
commit
68c069f02a
15
.env.example
15
.env.example
@ -1,2 +1,13 @@
|
||||
SECRET_KEY=supersecretkey
|
||||
DATABASE_URL=sqlite:///data.db
|
||||
# Environment toggles
|
||||
USE_REMOTE_MYSQL=0
|
||||
ENABLE_DB_SEEDING=1
|
||||
SEED_EXTRA_DATA=false
|
||||
|
||||
# MySQL configuration
|
||||
MYSQL_HOST=db
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_DATABASE=plant_db
|
||||
MYSQL_USER=plant_user
|
||||
MYSQL_PASSWORD=plant_pass
|
||||
MYSQL_ROOT_PASSWORD=supersecret
|
||||
|
||||
|
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
# Bytecode
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# SQLite DB
|
||||
*.db
|
||||
|
||||
# Flask/Migrations
|
||||
migrations/
|
||||
instance/
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Uploads
|
||||
app/static/uploads/
|
||||
|
||||
# OS-generated files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Docker
|
||||
*.pid
|
||||
*.sock
|
||||
docker-compose.override.yml
|
13
Dockerfile
13
Dockerfile
@ -1,5 +1,18 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
# Required for mysqlclient + netcat wait
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
default-libmysqlclient-dev \
|
||||
pkg-config \
|
||||
netcat-openbsd \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --upgrade pip
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
CMD ["flask", "run", "--host=0.0.0.0"]
|
||||
|
75
Makefile
Normal file
75
Makefile
Normal file
@ -0,0 +1,75 @@
|
||||
# Project Variables
|
||||
ENV_FILE=.env
|
||||
ENV_EXAMPLE=.env.example
|
||||
DOCKER_COMPOSE=docker compose
|
||||
PROJECT_NAME=plant_price_tracker
|
||||
|
||||
# Commands
|
||||
.PHONY: help up down rebuild logs status seed shell dbshell reset test
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " up - Build and start the app (bootstraps .env if needed)"
|
||||
@echo " down - Stop and remove containers"
|
||||
@echo " rebuild - Rebuild containers from scratch"
|
||||
@echo " logs - Show logs for all services"
|
||||
@echo " status - Show container health status"
|
||||
@echo " seed - Manually seed the database"
|
||||
@echo " shell - Open a bash shell in the web container"
|
||||
@echo " dbshell - Open a MySQL shell"
|
||||
@echo " reset - Nuke everything and restart clean"
|
||||
@echo " test - Run test suite (TBD)"
|
||||
|
||||
up:
|
||||
@if [ ! -f $(ENV_FILE) ]; then \
|
||||
echo "[⚙] Generating .env from example..."; \
|
||||
cp $(ENV_EXAMPLE) $(ENV_FILE); \
|
||||
fi
|
||||
$(DOCKER_COMPOSE) up --build -d
|
||||
@$(MAKE) wait
|
||||
|
||||
down:
|
||||
$(DOCKER_COMPOSE) down
|
||||
|
||||
rebuild:
|
||||
$(DOCKER_COMPOSE) down -v --remove-orphans
|
||||
$(DOCKER_COMPOSE) up --build -d
|
||||
@$(MAKE) wait
|
||||
|
||||
logs:
|
||||
$(DOCKER_COMPOSE) logs -f
|
||||
|
||||
status:
|
||||
@echo "[📊] Health status of containers:"
|
||||
@docker ps --filter "name=$(PROJECT_NAME)-" --format "table {{.Names}}\t{{.Status}}"
|
||||
|
||||
seed:
|
||||
@docker exec -it $$(docker ps -qf "name=$(PROJECT_NAME)-web") flask seed-admin
|
||||
|
||||
shell:
|
||||
@docker exec -it $$(docker ps -qf "name=$(PROJECT_NAME)-web") bash
|
||||
|
||||
dbshell:
|
||||
@docker exec -it $$(docker ps -qf "name=$(PROJECT_NAME)-db") \
|
||||
mysql -u$$(grep MYSQL_USER $(ENV_FILE) | cut -d '=' -f2) \
|
||||
-p$$(grep MYSQL_PASSWORD $(ENV_FILE) | cut -d '=' -f2) \
|
||||
$$(grep MYSQL_DATABASE $(ENV_FILE) | cut -d '=' -f2)
|
||||
|
||||
reset:
|
||||
@echo "[💣] Nuking containers and volumes..."
|
||||
$(DOCKER_COMPOSE) down -v --remove-orphans
|
||||
@echo "[🧼] Cleaning caches and migrations..."
|
||||
sudo rm -rf __pycache__ */__pycache__ */*/__pycache__ *.pyc *.pyo *.db migrations
|
||||
@echo "[🚀] Rebuilding project fresh..."
|
||||
@$(MAKE) up
|
||||
|
||||
test:
|
||||
@echo "[🚧] Test suite placeholder"
|
||||
# insert pytest or unittest commands here
|
||||
|
||||
wait:
|
||||
@echo "[⏳] Waiting for web container to be healthy..."
|
||||
@until [ "$$(docker inspect -f '{{.State.Health.Status}}' $(PROJECT_NAME)-web-1 2>/dev/null)" = "healthy" ]; do \
|
||||
printf "."; sleep 2; \
|
||||
done
|
||||
@echo "\n[✅] Web container is healthy!"
|
@ -16,6 +16,17 @@ def create_app():
|
||||
login_manager.init_app(app)
|
||||
|
||||
from .core.routes import core
|
||||
from .core.auth import auth
|
||||
app.register_blueprint(core)
|
||||
app.register_blueprint(auth)
|
||||
|
||||
# CLI command registration must be inside create_app
|
||||
from .cli import seed_admin
|
||||
app.cli.add_command(seed_admin)
|
||||
|
||||
return app
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
from .core.models import User
|
||||
return User.query.get(int(user_id))
|
||||
|
35
app/cli.py
Normal file
35
app/cli.py
Normal file
@ -0,0 +1,35 @@
|
||||
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
|
38
app/core/auth.py
Normal file
38
app/core/auth.py
Normal file
@ -0,0 +1,38 @@
|
||||
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'))
|
50
app/core/models.py
Normal file
50
app/core/models.py
Normal file
@ -0,0 +1,50 @@
|
||||
from flask_login import UserMixin
|
||||
from datetime import datetime
|
||||
from .. import db
|
||||
|
||||
class User(db.Model, UserMixin):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(128), nullable=False)
|
||||
role = db.Column(db.String(50), default='user')
|
||||
is_verified = db.Column(db.Boolean, default=False)
|
||||
excluded_from_analytics = db.Column(db.Boolean, default=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Optional: relationship to submissions
|
||||
submissions = db.relationship('Submission', backref='user', lazy=True)
|
||||
|
||||
|
||||
class Submission(db.Model):
|
||||
__tablename__ = 'submission'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', name='fk_submission_user_id'),
|
||||
nullable=False
|
||||
)
|
||||
common_name = db.Column(db.String(120), nullable=False)
|
||||
scientific_name = db.Column(db.String(120))
|
||||
price = db.Column(db.Float, nullable=False)
|
||||
source = db.Column(db.String(120))
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
height = db.Column(db.Float)
|
||||
width = db.Column(db.Float)
|
||||
leaf_count = db.Column(db.Integer)
|
||||
potting_mix = db.Column(db.String(255))
|
||||
container_size = db.Column(db.String(120))
|
||||
health_status = db.Column(db.String(50))
|
||||
notes = db.Column(db.Text)
|
||||
plant_id = db.Column(db.Integer)
|
||||
images = db.relationship('SubmissionImage', backref='submission', lazy=True)
|
||||
|
||||
|
||||
class SubmissionImage(db.Model):
|
||||
__tablename__ = 'submission_images'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
submission_id = db.Column(db.Integer, db.ForeignKey('submission.id'), nullable=False)
|
||||
file_path = db.Column(db.String(255), nullable=False)
|
@ -1,7 +1,141 @@
|
||||
from flask import Blueprint
|
||||
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('/')
|
||||
def index():
|
||||
return 'Welcome to the Plant Price Tracker!'
|
||||
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')
|
||||
|
5
app/templates/admin_dashboard.html
Normal file
5
app/templates/admin_dashboard.html
Normal file
@ -0,0 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>Admin Dashboard</h2>
|
||||
<p>You are logged in as an administrator.</p>
|
||||
{% endblock %}
|
15
app/templates/auth/login.html
Normal file
15
app/templates/auth/login.html
Normal file
@ -0,0 +1,15 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>Login</h2>
|
||||
<form method="POST" action="/login">
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</form>
|
||||
{% endblock %}
|
15
app/templates/auth/register.html
Normal file
15
app/templates/auth/register.html
Normal file
@ -0,0 +1,15 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>Register</h2>
|
||||
<form method="POST" action="/register">
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">Register</button>
|
||||
</form>
|
||||
{% endblock %}
|
98
app/templates/base.html
Normal file
98
app/templates/base.html
Normal file
@ -0,0 +1,98 @@
|
||||
<!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>
|
5
app/templates/dashboard.html
Normal file
5
app/templates/dashboard.html
Normal file
@ -0,0 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>User Dashboard</h2>
|
||||
<p>Welcome to your dashboard, {{ current_user.email }}.</p>
|
||||
{% endblock %}
|
36
app/templates/index.html
Normal file
36
app/templates/index.html
Normal file
@ -0,0 +1,36 @@
|
||||
{% 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 %}
|
34
app/templates/search.html
Normal file
34
app/templates/search.html
Normal file
@ -0,0 +1,34 @@
|
||||
{% 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 %}
|
21
app/templates/search_results.html
Normal file
21
app/templates/search_results.html
Normal file
@ -0,0 +1,21 @@
|
||||
{% 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 %}
|
15
app/templates/search_tags.html
Normal file
15
app/templates/search_tags.html
Normal file
@ -0,0 +1,15 @@
|
||||
|
||||
{% 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 %}
|
65
app/templates/submit.html
Normal file
65
app/templates/submit.html
Normal file
@ -0,0 +1,65 @@
|
||||
{% 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 %}
|
5
app/templates/unauthorized.html
Normal file
5
app/templates/unauthorized.html
Normal file
@ -0,0 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>Unauthorized</h2>
|
||||
<p>You do not have permission to access this page.</p>
|
||||
{% endblock %}
|
13
config.py
13
config.py
@ -2,5 +2,16 @@ import os
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'dev')
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL', 'sqlite:///data.db')
|
||||
|
||||
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
|
||||
|
@ -1,4 +1,3 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
@ -9,4 +8,72 @@ services:
|
||||
environment:
|
||||
- FLASK_APP=app
|
||||
- FLASK_ENV=development
|
||||
- DATABASE_URL=sqlite:///data.db
|
||||
- USE_REMOTE_MYSQL=${USE_REMOTE_MYSQL}
|
||||
- ENABLE_DB_SEEDING=${ENABLE_DB_SEEDING}
|
||||
- MYSQL_DATABASE=${MYSQL_DATABASE}
|
||||
- MYSQL_USER=${MYSQL_USER}
|
||||
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
|
||||
- MYSQL_HOST=${MYSQL_HOST}
|
||||
- MYSQL_PORT=${MYSQL_PORT}
|
||||
depends_on:
|
||||
- db
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
command: >
|
||||
bash -c "
|
||||
set -e
|
||||
|
||||
echo '[✔] Ensuring .env...'
|
||||
if [ ! -f '.env' ]; then cp .env.example .env; fi
|
||||
|
||||
echo '[✔] Waiting for MySQL to be ready...'
|
||||
until nc -z ${MYSQL_HOST} ${MYSQL_PORT}; do sleep 1; done
|
||||
|
||||
echo '[✔] Running DB migrations...'
|
||||
flask db upgrade
|
||||
|
||||
if [ \"$$ENABLE_DB_SEEDING\" = \"1\" ]; then
|
||||
echo '[🌱] Seeding admin user...'
|
||||
flask seed-admin
|
||||
else
|
||||
echo '[⚠️] DB seeding skipped by config.'
|
||||
fi
|
||||
|
||||
echo '[🚀] Starting Flask server...'
|
||||
flask run --host=0.0.0.0
|
||||
"
|
||||
|
||||
db:
|
||||
image: mysql:8
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE}
|
||||
MYSQL_USER: ${MYSQL_USER}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- plant_price_tracker_mysql_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
|
||||
adminer:
|
||||
image: adminer
|
||||
restart: always
|
||||
ports:
|
||||
- 8080:8080
|
||||
environment:
|
||||
ADMINER_DEFAULT_SERVER: db
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
volumes:
|
||||
plant_price_tracker_mysql_data:
|
||||
|
33
nuke-dev.sh
Executable file
33
nuke-dev.sh
Executable file
@ -0,0 +1,33 @@
|
||||
#!/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,4 +1,6 @@
|
||||
Flask==2.3.2
|
||||
Flask-SQLAlchemy
|
||||
Flask-Migrate
|
||||
Flask
|
||||
Flask-Login
|
||||
Flask-Migrate
|
||||
Flask-SQLAlchemy
|
||||
mysqlclient
|
||||
python-dotenv
|
||||
|
Loading…
x
Reference in New Issue
Block a user