From 9df259eb583f5b8b60e76b06a86b59c2848a2f07 Mon Sep 17 00:00:00 2001 From: Bryce Date: Sun, 3 Aug 2025 21:35:22 -0700 Subject: [PATCH] works --- QWEN.md | 10 +- alembic/README | 1 + alembic/env.py | 78 +++++++ alembic/script.py.mako | 28 +++ app/__init__.py | 5 +- app/__pycache__/__init__.cpython-310.pyc | Bin 571 -> 0 bytes app/__pycache__/models.cpython-310.pyc | Bin 1138 -> 0 bytes app/models.py | 17 +- app/routes.py | 191 ++++++++++-------- app/templates/index.html | 16 +- app/templates/partials/folder_modal.html | 40 ++-- migrations/README | 2 +- migrations/__pycache__/env.cpython-310.pyc | Bin 1973 -> 0 bytes migrations/alembic.ini | 50 +++++ migrations/env.py | 88 +++++--- migrations/script.py.mako | 16 +- ..._users_and_.py => 02a7c13515a4_initial.py} | 28 ++- ..._migration_with_users_and_.cpython-310.pyc | Bin 1497 -> 0 bytes test_delete_functionality.py | 50 ----- test_edit_functionality.py | 75 ------- .../conftest.cpython-310-pytest-7.4.0.pyc | Bin 1768 -> 0 bytes .../test_models.cpython-310-pytest-7.4.0.pyc | Bin 4480 -> 0 bytes .../test_routes.cpython-310-pytest-7.4.0.pyc | Bin 2521 -> 0 bytes tests/conftest.py | 60 +++--- tests/test_models.py | 49 +++-- tests/test_routes.py | 35 ++-- 26 files changed, 476 insertions(+), 363 deletions(-) create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako delete mode 100644 app/__pycache__/__init__.cpython-310.pyc delete mode 100644 app/__pycache__/models.cpython-310.pyc delete mode 100644 migrations/__pycache__/env.cpython-310.pyc create mode 100644 migrations/alembic.ini rename migrations/versions/{1f23b4f802b0_initial_migration_with_users_and_.py => 02a7c13515a4_initial.py} (61%) delete mode 100644 migrations/versions/__pycache__/1f23b4f802b0_initial_migration_with_users_and_.cpython-310.pyc delete mode 100644 test_delete_functionality.py delete mode 100644 test_edit_functionality.py delete mode 100644 tests/__pycache__/conftest.cpython-310-pytest-7.4.0.pyc delete mode 100644 tests/__pycache__/test_models.cpython-310-pytest-7.4.0.pyc delete mode 100644 tests/__pycache__/test_routes.cpython-310-pytest-7.4.0.pyc diff --git a/QWEN.md b/QWEN.md index 1e0a6c7..10c92a0 100644 --- a/QWEN.md +++ b/QWEN.md @@ -1,3 +1,4 @@ +# Instructions Here are special rules you must follow: 1. All forms should use regular form url encoding. 2. All routes should return html. @@ -8,4 +9,11 @@ Here are special rules you must follow: 7. Always print unhandled exceptions to the console. 8. Follow best practices for jinja template partials. That is, separate out components where appropriate. 9. Ask the user when there is something unclear. -10. I always run the server locally on port 5000, so you can use curl to test. No need to start it yourself. \ No newline at end of file +10. I always run the server locally on port 5000, so you can use curl to test. No need to start it yourself. + +# Conventions +1. modals are rendered server-side, by targeting #modal-holder, and using an hx-trigger response attribute for open-modal. +2. modals are closed by adding the close-modal hx-trigger response attribute. +3. modals can be closed by triggering a close-modal event anywhere in the dom. +4. validation is done server-side. On modals, an error should cause the button to shake, and the invalid fields to be highlighted in red using normal daisyui paradigms. When relevant, there should be a notification banner inside the dialog-box to show the details of the error. +5. When validation is done outside of a modal, it should cause a notification banner with the details. diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..36112a3 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,78 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +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. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = None + +# 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 run_migrations_offline() -> None: + """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=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/app/__init__.py b/app/__init__.py index a4e226a..acdbbbe 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,14 +1,17 @@ from flask import Flask from flask_sqlalchemy import SQLAlchemy from config import config +from app.models import db, Base +from flask_migrate import Migrate + -db = SQLAlchemy() def create_app(config_name='default'): app = Flask(__name__) app.config.from_object(config[config_name]) db.init_app(app) + migrate = Migrate(app, db) from app.routes import main app.register_blueprint(main) diff --git a/app/__pycache__/__init__.cpython-310.pyc b/app/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 0bfec11de270003cc9facde25d39c57ab0a69f01..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 571 zcmYjPy>8nu5ayBkv!bMKw?2Y~T5E=&Xb>P%5g^T`g^)$sR!WKvMF&VAPwC!QaK^q8 z*G_$fPCd#%kpu42cL(0xcL$l%)106!_CGh@fRNuIcxw!UFX+QLf*=AE(NF^fP8HRx zW)o*B(or1&k?e)kvB*R$k{?hfBE2K!>0~B939C=@`{pNbcja?OaO{Ybh@WNMmS_6 z4cQS;3cInPIS!3<)Ai-dQVe~@dEaQsxecTI$;ukd&GKHh&Qr1US=V=tH`}ex5f;|$ zoE&Tt-hQxh-3?A!zEnH8wO#K@=1-0*OTXeEvw$RPE~Hr{FbSYSK-jd w-R2YL!-Hy$7q{W0)1r13Sg4(Jy@5IAe0HIY*eUrroNNexN1gh5XW!tJN7woK1C5CQMyRnq(mqJWC$OoNJ4SVY2#Vjo9x{=dv|~%Iw)u& zULh&-9K6N0RCxs&m^oV{2nyD^|IN;Zta8EqpgAL9F16u?qA-ES#yiohy%LaH? z=%>o)Z3_3&!$@?+;?~?=M0*KB>|_Cp7eX7aLM;xjnF)B2RF#Bl+cYOyrd&^xQ!zCG zh#{|3dGiyy>2+-5SD8GkgbA}sDSoU(ztc8hl4Lv;Nn#pFlIBn;vZEw9tGJp+T1isU z`Po+6n9ul6a#f|7Mh2E~sXn%Zx@&AS{ex1=Y-Bu5eyL>X~ zV&6g)JCms<{Dh&1P(sUP_t*xD>^7?-e-ZQX)E*uGg7V+Q<|L8LjNET-NZGxNAViwv zv;X8bXW2%U%gA>Hi2o`O`No9}O+3E_5i)KWzn3dO%E7RH7l)cU)gj7xSd-t8(CaYW zB6pL7Zcd}A`1usy!7(44YBB70*7YLAmq^+q%OnI*uaFQyy-Ko1vW{XJy<7`9%3jhO zQN2Wh-k^v}LtxxP@u4&enx>^BGuY2cF~W{R9Kl@6a%v(i6fb3-6=ONk59%*ds#~}- z>poT8LZQ19)ZvWVCR<|>em3no+6j$=tY_1=Rh%iV24j&`B!xlKAcVdBStOIA53ILEMzP8M)(V9p&DKQ diff --git a/app/models.py b/app/models.py index 242a927..c6aa79e 100644 --- a/app/models.py +++ b/app/models.py @@ -1,19 +1,24 @@ -from app import db from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import declarative_base +from flask_sqlalchemy import SQLAlchemy + import uuid -class User(db.Model): +Base = declarative_base() +db = SQLAlchemy(model_class=Base) + +class User(Base): __tablename__ = 'users' - id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) email = db.Column(db.String(255), unique=True, nullable=False) # Placeholders for Milestone 1 password_hash = db.Column(db.LargeBinary) imap_config = db.Column(db.JSON) # Using db.JSON instead of db.JSONB for compatibility -class Folder(db.Model): +class Folder(Base): __tablename__ = 'folders' - id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id = db.Column(UUID(as_uuid=True), db.ForeignKey('users.id'), nullable=False) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) name = db.Column(db.String(255), nullable=False) rule_text = db.Column(db.Text) priority = db.Column(db.Integer) diff --git a/app/routes.py b/app/routes.py index ce1aa79..b2dbdae 100644 --- a/app/routes.py +++ b/app/routes.py @@ -7,14 +7,7 @@ import logging main = Blueprint('main', __name__) # For prototype, use a fixed user ID -MOCK_USER_ID = '123e4567-e89b-12d3-a456-426614174000' - -@main.route('/api/folders/new', methods=['GET']) -def new_folder_modal(): - # Return the add folder modal - response = make_response(render_template('partials/folder_modal.html')) - response.headers['HX-Trigger'] = 'open-modal' - return response +MOCK_USER_ID = 1 @main.route('/') def index(): @@ -28,6 +21,13 @@ def index(): folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all() return render_template('index.html', folders=folders) +@main.route('/api/folders/new', methods=['GET']) +def new_folder_modal(): + # Return the add folder modal + response = make_response(render_template('partials/folder_modal.html')) + response.headers['HX-Trigger'] = 'open-modal' + return response + @main.route('/api/folders', methods=['POST']) def add_folder(): try: @@ -36,18 +36,35 @@ def add_folder(): rule_text = request.form.get('rule_text') priority = request.form.get('priority') - if not name: - # Return the folders list unchanged with an error message - folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all() - # We'll add error handling in the template - return render_template('partials/folders_list.html', folders=folders) + # Server-side validation + errors = {} + if not name or not name.strip(): + errors['name'] = 'Folder name is required' + elif len(name.strip()) < 3: + errors['name'] = 'Folder name must be at least 3 characters' + elif len(name.strip()) > 50: + errors['name'] = 'Folder name must be less than 50 characters' + + if not rule_text or not rule_text.strip(): + errors['rule_text'] = 'Rule text is required' + elif len(rule_text.strip()) < 10: + errors['rule_text'] = 'Rule text must be at least 10 characters' + elif len(rule_text.strip()) > 200: + errors['rule_text'] = 'Rule text must be less than 200 characters' + + # If there are validation errors, return the modal with errors + if errors: + response = make_response(render_template('partials/folder_modal.html', errors=errors, name=name, rule_text=rule_text, priority=priority)) + response.headers['HX-Retarget'] = '#folder-modal' + response.headers['HX-Reswap'] = 'outerHTML' + return response # Create new folder folder = Folder( user_id=MOCK_USER_ID, - name=name, - rule_text=rule_text, - priority=int(priority) if priority else None + name=name.strip(), + rule_text=rule_text.strip(), + priority=int(priority) if priority else 0 ) db.session.add(folder) @@ -57,18 +74,21 @@ def add_folder(): folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all() # Return the updated folders list HTML - response = make_response(render_template('partials/folders_list.html', folders=folders)) response.headers['HX-Trigger'] = 'close-modal' + response.status_code = 201 return response except Exception as e: # Print unhandled exceptions to the console as required logging.exception("Error adding folder: %s", e) db.session.rollback() - # Return the folders list unchanged - folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all() - return render_template('partials/folders_list.html', folders=folders) + # Return error in modal + errors = {'general': 'An unexpected error occurred. Please try again.'} + response = make_response(render_template('partials/folder_modal.html', errors=errors, name=name, rule_text=rule_text, priority=priority)) + response.headers['HX-Retarget'] = '#folder-modal' + response.headers['HX-Reswap'] = 'outerHTML' + return response @main.route('/api/folders/', methods=['DELETE']) def delete_folder(folder_id): @@ -99,72 +119,6 @@ def delete_folder(folder_id): folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all() return render_template('partials/folders_list.html', folders=folders) -@main.route('/api/folders/', methods=['PUT']) -def update_folder(folder_id): - try: - # Find the folder by ID - folder = Folder.query.filter_by(id=folder_id, user_id=MOCK_USER_ID).first() - - if not folder: - # Folder not found - folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all() - return render_template('partials/folders_list.html', folders=folders) - - # Get form data - name = request.form.get('name') - rule_text = request.form.get('rule_text') - priority = request.form.get('priority') - - if not name: - # Return the folders list unchanged with an error message - folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all() - return render_template('partials/folders_list.html', folders=folders) - - # Update folder - folder.name = name - folder.rule_text = rule_text - folder.priority = int(priority) if priority else None - - db.session.commit() - - # Get updated list of folders - folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all() - - response = make_response(render_template('partials/folders_list.html', folders=folders)) - response.headers['HX-Trigger'] = 'close-modal' - return response - - except Exception as e: - # Print unhandled exceptions to the console as required - logging.exception("Error updating folder: %s", e) - db.session.rollback() - # Return the folders list unchanged - folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all() - return render_template('partials/folders_list.html', folders=folders) - -@main.route('/api/folders/', methods=['GET']) -def get_folder(folder_id): - try: - # Find the folder by ID - folder = Folder.query.filter_by(id=folder_id, user_id=MOCK_USER_ID).first() - - if not folder: - return jsonify({'error': 'Folder not found'}), 404 - - # Return folder data as JSON - return jsonify({ - 'id': folder.id, - 'name': folder.name, - 'rule_text': folder.rule_text, - 'priority': folder.priority - }) - - except Exception as e: - # Print unhandled exceptions to the console as required - logging.exception("Error getting folder: %s", e) - return jsonify({'error': 'Error retrieving folder'}), 500 - - @main.route('/api/folders//edit', methods=['GET']) def edit_folder_modal(folder_id): try: @@ -183,3 +137,68 @@ def edit_folder_modal(folder_id): # Print unhandled exceptions to the console as required logging.exception("Error getting folder for edit: %s", e) return jsonify({'error': 'Error retrieving folder'}), 500 + +@main.route('/api/folders/', methods=['PUT']) +def update_folder(folder_id): + try: + # Find the folder by ID + folder = Folder.query.filter_by(id=folder_id, user_id=MOCK_USER_ID).first() + + if not folder: + # Folder not found + folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all() + return render_template('partials/folders_list.html', folders=folders) + + # Get form data + name = request.form.get('name') + rule_text = request.form.get('rule_text') + priority = request.form.get('priority') + + # Server-side validation + errors = {} + if not name or not name.strip(): + errors['name'] = 'Folder name is required' + elif len(name.strip()) < 3: + errors['name'] = 'Folder name must be at least 3 characters' + elif len(name.strip()) > 50: + errors['name'] = 'Folder name must be less than 50 characters' + + if not rule_text or not rule_text.strip(): + errors['rule_text'] = 'Rule text is required' + elif len(rule_text.strip()) < 10: + errors['rule_text'] = 'Rule text must be at least 10 characters' + elif len(rule_text.strip()) > 200: + errors['rule_text'] = 'Rule text must be less than 200 characters' + + # If there are validation errors, return the modal with errors + if errors: + response = make_response(render_template('partials/folder_modal.html', folder=folder, errors=errors, name=name, rule_text=rule_text, priority=priority)) + response.headers['HX-Retarget'] = '#folder-modal' + response.headers['HX-Reswap'] = 'outerHTML' + return response + + # Update folder + folder.name = name.strip() + folder.rule_text = rule_text.strip() + folder.priority = int(priority) if priority else 0 + + db.session.commit() + + # Get updated list of folders + folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all() + + response = make_response(render_template('partials/folders_list.html', folders=folders)) + response.headers['HX-Trigger'] = 'close-modal' + return response + + except Exception as e: + # Print unhandled exceptions to the console as required + logging.exception("Error updating folder: %s", e) + db.session.rollback() + # Return error in modal + errors = {'general': 'An unexpected error occurred. Please try again.'} + response = make_response(render_template('partials/folder_modal.html', folder=folder, errors=errors, name=name, rule_text=rule_text, priority=priority)) + response.headers['HX-Retarget'] = '#folder-modal' + response.headers['HX-Reswap'] = 'outerHTML' + return response + diff --git a/app/templates/index.html b/app/templates/index.html index d8368e8..a89220e 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -10,11 +10,21 @@ + + x-data="{}" + x-on:open-modal.window="$refs.modal.showModal()" + x-on:close-modal.window="$refs.modal.close()">