diff --git a/.env.example b/.env.example index 098ce27..ce8a244 100644 --- a/.env.example +++ b/.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 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b393411 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile index df46d3e..d6963e7 100644 --- a/Dockerfile +++ b/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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..97cfbc9 --- /dev/null +++ b/Makefile @@ -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!" diff --git a/app/__init__.py b/app/__init__.py index 23dc09b..1e1ed8e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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)) diff --git a/app/cli.py b/app/cli.py new file mode 100644 index 0000000..170975d --- /dev/null +++ b/app/cli.py @@ -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 diff --git a/app/core/auth.py b/app/core/auth.py new file mode 100644 index 0000000..9e5cdb8 --- /dev/null +++ b/app/core/auth.py @@ -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')) diff --git a/app/core/models.py b/app/core/models.py new file mode 100644 index 0000000..65245b7 --- /dev/null +++ b/app/core/models.py @@ -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) diff --git a/app/core/routes.py b/app/core/routes.py index 185a857..9328a49 100644 --- a/app/core/routes.py +++ b/app/core/routes.py @@ -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') diff --git a/app/templates/admin_dashboard.html b/app/templates/admin_dashboard.html new file mode 100644 index 0000000..df80656 --- /dev/null +++ b/app/templates/admin_dashboard.html @@ -0,0 +1,5 @@ +{% extends 'base.html' %} +{% block content %} +
You are logged in as an administrator.
+{% endblock %} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..25f1a7e --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} +{% block content %} +Welcome to your dashboard, {{ current_user.email }}.
+{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..d399ed1 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,36 @@ +{% extends 'base.html' %} +{% block content %} +No real submissions yet โ submit a plant to see it here.
+
+ ${{ '%.2f'|format(sub.price) }}
+ Source: {{ sub.source }}
+ Date: {{ sub.timestamp.strftime('%Y-%m-%d') }}
+
+ ${{ '%.2f'|format(sub.price) }}
+ Source: {{ sub.source }}
+ Date: {{ sub.timestamp.strftime('%Y-%m-%d') }}
+
No results found.
+{% endif %} diff --git a/app/templates/search_tags.html b/app/templates/search_tags.html new file mode 100644 index 0000000..d5a7993 --- /dev/null +++ b/app/templates/search_tags.html @@ -0,0 +1,15 @@ + +{% if filter_tags %} +You do not have permission to access this page.
+{% endblock %} diff --git a/config.py b/config.py index d2b5b80..267be88 100644 --- a/config.py +++ b/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 diff --git a/docker-compose.yml b/docker-compose.yml index b4fa487..8c49605 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/nuke-dev.sh b/nuke-dev.sh new file mode 100755 index 0000000..1e8ca9a --- /dev/null +++ b/nuke-dev.sh @@ -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 diff --git a/requirements.txt b/requirements.txt index 66b9520..6648268 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ -Flask==2.3.2 -Flask-SQLAlchemy -Flask-Migrate +Flask Flask-Login +Flask-Migrate +Flask-SQLAlchemy +mysqlclient +python-dotenv