commit d200550b353d54a1666924114496ebea2771d811 Author: bvandeusen Date: Mon Jul 28 21:57:49 2025 +0000 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e2e9f2 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# _Flask_Template + +template for a flask app that has a configure user system and default connection to postgressql \ No newline at end of file diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..53af171 Binary files /dev/null and b/__pycache__/config.cpython-312.pyc differ diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..532e15c Binary files /dev/null and b/__pycache__/config.cpython-313.pyc differ diff --git a/__pycache__/run.cpython-312.pyc b/__pycache__/run.cpython-312.pyc new file mode 100644 index 0000000..f16f763 Binary files /dev/null and b/__pycache__/run.cpython-312.pyc differ diff --git a/__pycache__/run.cpython-313.pyc b/__pycache__/run.cpython-313.pyc new file mode 100644 index 0000000..6222379 Binary files /dev/null and b/__pycache__/run.cpython-313.pyc differ diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..8661e40 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,27 @@ +# app/__init__.py + +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_login import LoginManager + +db = SQLAlchemy() +migrate = Migrate() +login_manager = LoginManager() + +def create_app(config_class='config.Config'): + app = Flask(__name__) + app.config.from_object(config_class) + + db.init_app(app) + migrate.init_app(app, db) + login_manager.init_app(app) + login_manager.login_view = 'auth.login' # IMPORTANT: now uses auth Blueprint + + from app.main import main + from app.auth import auth + + app.register_blueprint(main) + app.register_blueprint(auth) + + return app diff --git a/app/__pycache__/__init__.cpython-312.pyc b/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..1912c1b Binary files /dev/null and b/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..f78e872 Binary files /dev/null and b/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/__pycache__/auth.cpython-313.pyc b/app/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000..0728d4b Binary files /dev/null and b/app/__pycache__/auth.cpython-313.pyc differ diff --git a/app/__pycache__/forms.cpython-312.pyc b/app/__pycache__/forms.cpython-312.pyc new file mode 100644 index 0000000..5936510 Binary files /dev/null and b/app/__pycache__/forms.cpython-312.pyc differ diff --git a/app/__pycache__/forms.cpython-313.pyc b/app/__pycache__/forms.cpython-313.pyc new file mode 100644 index 0000000..73addb3 Binary files /dev/null and b/app/__pycache__/forms.cpython-313.pyc differ diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..c26382c Binary files /dev/null and b/app/__pycache__/main.cpython-313.pyc differ diff --git a/app/__pycache__/models.cpython-312.pyc b/app/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..3c78f5b Binary files /dev/null and b/app/__pycache__/models.cpython-312.pyc differ diff --git a/app/__pycache__/models.cpython-313.pyc b/app/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..3b1cc25 Binary files /dev/null and b/app/__pycache__/models.cpython-313.pyc differ diff --git a/app/__pycache__/routes.cpython-312.pyc b/app/__pycache__/routes.cpython-312.pyc new file mode 100644 index 0000000..4c86d85 Binary files /dev/null and b/app/__pycache__/routes.cpython-312.pyc differ diff --git a/app/__pycache__/routes.cpython-313.pyc b/app/__pycache__/routes.cpython-313.pyc new file mode 100644 index 0000000..71b8868 Binary files /dev/null and b/app/__pycache__/routes.cpython-313.pyc differ diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..7a56e31 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,41 @@ +# app/auth.py + +from flask import Blueprint, render_template, redirect, url_for, flash, request +from flask_login import login_user, logout_user, login_required +from werkzeug.security import generate_password_hash, check_password_hash + +from app import db +from app.models import User +from app.forms import RegistrationForm, LoginForm + +auth = Blueprint('auth', __name__, url_prefix='/auth') + +@auth.route('/register', methods=['GET', 'POST']) +def register(): + form = RegistrationForm() + if form.validate_on_submit(): + hashed_password = generate_password_hash(form.password.data) + user = User(username=form.username.data, email=form.email.data, password_hash=hashed_password) + db.session.add(user) + db.session.commit() + flash('Account created! Please log in.', 'success') + return redirect(url_for('auth.login')) + return render_template('register.html', form=form) + +@auth.route('/login', methods=['GET', 'POST']) +def login(): + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(email=form.email.data).first() + if user and check_password_hash(user.password_hash, form.password.data): + login_user(user) + return redirect(url_for('main.index')) + else: + flash('Login failed. Check username/password.', 'danger') + return render_template('login.html', form=form) + +@auth.route('/logout') +@login_required +def logout(): + logout_user() + return redirect(url_for('main.index')) diff --git a/app/forms.py b/app/forms.py new file mode 100644 index 0000000..56c8752 --- /dev/null +++ b/app/forms.py @@ -0,0 +1,26 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, SubmitField +from wtforms.validators import DataRequired, Email, EqualTo, ValidationError, Length +from .models import User + +class RegistrationForm(FlaskForm): + username = StringField('Username', validators=[DataRequired(), Length(min=3, max=64)]) + email = StringField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired(), Length(min=8, max=64)]) + confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')]) + submit = SubmitField('Register') + + def validate_username(self, username): + user = User.query.filter_by(username=username.data).first() + if user: + raise ValidationError('Username already exists.') + + def validate_email(self, email): + user = User.query.filter_by(email=email.data).first() + if user: + raise ValidationError('Email already registered.') + +class LoginForm(FlaskForm): + email = StringField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired()]) + submit = SubmitField('Login') diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..20b186b --- /dev/null +++ b/app/main.py @@ -0,0 +1,9 @@ +# app/main.py + +from flask import Blueprint, render_template + +main = Blueprint('main', __name__) + +@main.route('/') +def index(): + return render_template('index.html') diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..1f93c8f --- /dev/null +++ b/app/models.py @@ -0,0 +1,13 @@ +from . import db +from flask_login import UserMixin +from . import login_manager + +class User(UserMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(64), unique=True, nullable=False) + email = db.Column(db.String(120), unique=True, nullable=False) + password_hash = db.Column(db.String(256), nullable=False) + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..cacac2b --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,4 @@ +.error { + color: red; + font-size: 0.9em; +} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..0af6771 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,8 @@ +{% extends "layout.html" %} + +{% block title %}Home{% endblock %} + +{% block content %} +

Welcome to the Flask Template App!

+

This is the index page.

+{% endblock %} diff --git a/app/templates/layout.html b/app/templates/layout.html new file mode 100644 index 0000000..9484b47 --- /dev/null +++ b/app/templates/layout.html @@ -0,0 +1,37 @@ + + + + + Flask Template App + + + + +
+ {% if current_user.is_authenticated %} + Hello {{ current_user.username }} | + Logout + {% else %} + Login | + Register + {% endif %} +
+ + +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} +{% endwith %} + +
+ + +{% block content %}{% endblock %} + + + diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..aeb5083 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,10 @@ +{% extends "layout.html" %} +{% block content %} +

Login

+
+ {{ form.hidden_tag() }} + {{ form.email(size=32, placeholder=form.email.label.text) }}
+ {{ form.password(size=32, placeholder=form.password.label.text) }}
+ {{ form.submit() }} +
+{% endblock %} diff --git a/app/templates/register.html b/app/templates/register.html new file mode 100644 index 0000000..373e693 --- /dev/null +++ b/app/templates/register.html @@ -0,0 +1,39 @@ +{% extends "layout.html" %} +{% block content %} +

Register

+
+ {{ form.hidden_tag() }} + +

+ {{ form.username(size=32, placeholder=form.username.label.text) }} + {% for error in form.username.errors %} + {{ error }} + {% endfor %} +

+ +

+ {{ form.email(size=32, placeholder=form.email.label.text) }} + {% for error in form.email.errors %} + {{ error }} + {% endfor %} +

+ +

+ {{ form.password(size=32, placeholder=form.password.label.text) }} + {% for error in form.password.errors %} + {{ error }} + {% endfor %} +

+ +

+ {{ form.confirm_password(size=32, placeholder=form.confirm_password.label.text) }} + {% for error in form.confirm_password.errors %} + {{ error }} + {% endfor %} +

+ +

+ {{ form.submit() }} +

+
+{% endblock %} diff --git a/config.py b/config.py new file mode 100644 index 0000000..25e70e2 --- /dev/null +++ b/config.py @@ -0,0 +1,15 @@ +import os + +class Config: + SECRET_KEY = os.environ.get('SECRET_KEY', 'you-will-never-guess') + + DB_USER = os.environ.get('DB_USER', 'postgres') + DB_PASS = os.environ.get('DB_PASS', 'postgres') + DB_HOST = os.environ.get('DB_HOST', 'db') + DB_PORT = os.environ.get('DB_PORT', '5432') + DB_NAME = os.environ.get('DB_NAME', 'postgres') + + SQLALCHEMY_DATABASE_URI = ( + f'postgresql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}' + ) + SQLALCHEMY_TRACK_MODIFICATIONS = False diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..398bfe8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + db: + image: postgres + restart: always + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: flask_app + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + web: + build: . + ports: + - "5000:5000" + depends_on: + - db + environment: + FLASK_ENV: development + SECRET_KEY: your-secret-key + DB_USER: postgres + DB_PASS: postgres + DB_HOST: db + DB_PORT: 5432 + DB_NAME: flask_app + volumes: + - .:/app + +volumes: + pgdata: diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..4259383 --- /dev/null +++ b/dockerfile @@ -0,0 +1,31 @@ +# Dockerfile +FROM python:slim + +# Set work directory +WORKDIR /app + +# Install PostgreSQL client +RUN apt-get update && \ + apt-get install -y postgresql-client && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Copy project files +COPY . . + +# Install dependencies +RUN pip install --upgrade pip && pip install -r requirements.txt + +# Copy and prepare entrypoint script +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Set environment variables +ENV FLASK_APP=run.py +ENV FLASK_RUN_HOST=0.0.0.0 + +# Expose port +EXPOSE 5000 + +# Use entrypoint script +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..fc4fc6a --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Wait for the database to be ready (timeout after 3 minutes) +MAX_WAIT=180 # seconds +WAIT_INTERVAL=2 +WAITED=0 + +until pg_isready -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" > /dev/null 2>&1; do + if [ $WAITED -ge $MAX_WAIT ]; then + echo "Timeout: Could not connect to the database at $DB_HOST:$DB_PORT within $MAX_WAIT seconds." + exit 1 + fi + echo "Waiting for database at $DB_HOST:$DB_PORT... ($WAITED/$MAX_WAIT seconds)" + sleep $WAIT_INTERVAL + WAITED=$((WAITED + WAIT_INTERVAL)) +done + +echo "Database is ready. Applying migrations..." +flask db upgrade + +echo "Starting Flask app..." +exec flask run --host=0.0.0.0 --port=5000 \ No newline at end of file diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/__pycache__/env.cpython-312.pyc b/migrations/__pycache__/env.cpython-312.pyc new file mode 100644 index 0000000..3ad0ffe Binary files /dev/null and b/migrations/__pycache__/env.cpython-312.pyc differ diff --git a/migrations/__pycache__/env.cpython-313.pyc b/migrations/__pycache__/env.cpython-313.pyc new file mode 100644 index 0000000..cadcbf3 Binary files /dev/null and b/migrations/__pycache__/env.cpython-313.pyc differ diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/77b60347ee39_initial_migration.py b/migrations/versions/77b60347ee39_initial_migration.py new file mode 100644 index 0000000..83f2e85 --- /dev/null +++ b/migrations/versions/77b60347ee39_initial_migration.py @@ -0,0 +1,36 @@ +"""initial migration + +Revision ID: 77b60347ee39 +Revises: +Create Date: 2025-06-04 15:29:32.328762 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '77b60347ee39' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=64), nullable=False), + sa.Column('email', sa.String(length=120), nullable=False), + sa.Column('password_hash', sa.String(length=128), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('username') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('user') + # ### end Alembic commands ### diff --git a/migrations/versions/79c2e82697ce_update_user_password_hash_length_to_256.py b/migrations/versions/79c2e82697ce_update_user_password_hash_length_to_256.py new file mode 100644 index 0000000..db530b7 --- /dev/null +++ b/migrations/versions/79c2e82697ce_update_user_password_hash_length_to_256.py @@ -0,0 +1,38 @@ +"""update user password hash length to 256 + +Revision ID: 79c2e82697ce +Revises: 77b60347ee39 +Create Date: 2025-06-04 16:18:20.867161 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '79c2e82697ce' +down_revision = '77b60347ee39' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.alter_column('password_hash', + existing_type=sa.VARCHAR(length=128), + type_=sa.String(length=256), + existing_nullable=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.alter_column('password_hash', + existing_type=sa.String(length=256), + type_=sa.VARCHAR(length=128), + existing_nullable=False) + + # ### end Alembic commands ### diff --git a/migrations/versions/__pycache__/77b60347ee39_initial_migration.cpython-313.pyc b/migrations/versions/__pycache__/77b60347ee39_initial_migration.cpython-313.pyc new file mode 100644 index 0000000..cd29531 Binary files /dev/null and b/migrations/versions/__pycache__/77b60347ee39_initial_migration.cpython-313.pyc differ diff --git a/migrations/versions/__pycache__/79c2e82697ce_update_user_password_hash_length_to_256.cpython-313.pyc b/migrations/versions/__pycache__/79c2e82697ce_update_user_password_hash_length_to_256.cpython-313.pyc new file mode 100644 index 0000000..2cabc36 Binary files /dev/null and b/migrations/versions/__pycache__/79c2e82697ce_update_user_password_hash_length_to_256.cpython-313.pyc differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..91c7202 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +Flask +Flask-SQLAlchemy +Flask-Migrate +Flask-Login +Flask-WTF +psycopg2-binary +email-validator diff --git a/run.py b/run.py new file mode 100644 index 0000000..d1ed126 --- /dev/null +++ b/run.py @@ -0,0 +1,5 @@ +# run.py + +from app import create_app + +app = create_app()