This commit is contained in:
Bryson Shepard 2025-05-18 00:18:54 -05:00
parent 6fb15b6d5c
commit 68c069f02a
24 changed files with 836 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

@ -1,4 +1,6 @@
Flask==2.3.2
Flask-SQLAlchemy
Flask-Migrate
Flask
Flask-Login
Flask-Migrate
Flask-SQLAlchemy
mysqlclient
python-dotenv