This commit is contained in:
Bryce
2025-08-03 21:35:22 -07:00
parent 21d3a710f2
commit 9df259eb58
26 changed files with 476 additions and 363 deletions

10
QWEN.md
View File

@@ -1,3 +1,4 @@
# Instructions
Here are special rules you must follow: Here are special rules you must follow:
1. All forms should use regular form url encoding. 1. All forms should use regular form url encoding.
2. All routes should return html. 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. 7. Always print unhandled exceptions to the console.
8. Follow best practices for jinja template partials. That is, separate out components where appropriate. 8. Follow best practices for jinja template partials. That is, separate out components where appropriate.
9. Ask the user when there is something unclear. 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. 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.

1
alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

78
alembic/env.py Normal file
View File

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

28
alembic/script.py.mako Normal file
View File

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

View File

@@ -1,14 +1,17 @@
from flask import Flask from flask import Flask
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from config import config from config import config
from app.models import db, Base
from flask_migrate import Migrate
db = SQLAlchemy()
def create_app(config_name='default'): def create_app(config_name='default'):
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(config[config_name]) app.config.from_object(config[config_name])
db.init_app(app) db.init_app(app)
migrate = Migrate(app, db)
from app.routes import main from app.routes import main
app.register_blueprint(main) app.register_blueprint(main)

View File

@@ -1,19 +1,24 @@
from app import db
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import declarative_base
from flask_sqlalchemy import SQLAlchemy
import uuid import uuid
class User(db.Model): Base = declarative_base()
db = SQLAlchemy(model_class=Base)
class User(Base):
__tablename__ = 'users' __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) email = db.Column(db.String(255), unique=True, nullable=False)
# Placeholders for Milestone 1 # Placeholders for Milestone 1
password_hash = db.Column(db.LargeBinary) password_hash = db.Column(db.LargeBinary)
imap_config = db.Column(db.JSON) # Using db.JSON instead of db.JSONB for compatibility imap_config = db.Column(db.JSON) # Using db.JSON instead of db.JSONB for compatibility
class Folder(db.Model): class Folder(Base):
__tablename__ = 'folders' __tablename__ = 'folders'
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = db.Column(db.Integer, primary_key=True, autoincrement=True)
user_id = db.Column(UUID(as_uuid=True), db.ForeignKey('users.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
name = db.Column(db.String(255), nullable=False) name = db.Column(db.String(255), nullable=False)
rule_text = db.Column(db.Text) rule_text = db.Column(db.Text)
priority = db.Column(db.Integer) priority = db.Column(db.Integer)

View File

@@ -7,14 +7,7 @@ import logging
main = Blueprint('main', __name__) main = Blueprint('main', __name__)
# For prototype, use a fixed user ID # For prototype, use a fixed user ID
MOCK_USER_ID = '123e4567-e89b-12d3-a456-426614174000' MOCK_USER_ID = 1
@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('/') @main.route('/')
def index(): def index():
@@ -28,6 +21,13 @@ def index():
folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all() folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all()
return render_template('index.html', folders=folders) 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']) @main.route('/api/folders', methods=['POST'])
def add_folder(): def add_folder():
try: try:
@@ -36,18 +36,35 @@ def add_folder():
rule_text = request.form.get('rule_text') rule_text = request.form.get('rule_text')
priority = request.form.get('priority') priority = request.form.get('priority')
if not name: # Server-side validation
# Return the folders list unchanged with an error message errors = {}
folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all() if not name or not name.strip():
# We'll add error handling in the template errors['name'] = 'Folder name is required'
return render_template('partials/folders_list.html', folders=folders) 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 # Create new folder
folder = Folder( folder = Folder(
user_id=MOCK_USER_ID, user_id=MOCK_USER_ID,
name=name, name=name.strip(),
rule_text=rule_text, rule_text=rule_text.strip(),
priority=int(priority) if priority else None priority=int(priority) if priority else 0
) )
db.session.add(folder) db.session.add(folder)
@@ -57,18 +74,21 @@ def add_folder():
folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all() folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all()
# Return the updated folders list HTML # Return the updated folders list HTML
response = make_response(render_template('partials/folders_list.html', folders=folders)) response = make_response(render_template('partials/folders_list.html', folders=folders))
response.headers['HX-Trigger'] = 'close-modal' response.headers['HX-Trigger'] = 'close-modal'
response.status_code = 201
return response return response
except Exception as e: except Exception as e:
# Print unhandled exceptions to the console as required # Print unhandled exceptions to the console as required
logging.exception("Error adding folder: %s", e) logging.exception("Error adding folder: %s", e)
db.session.rollback() db.session.rollback()
# Return the folders list unchanged # Return error in modal
folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all() errors = {'general': 'An unexpected error occurred. Please try again.'}
return render_template('partials/folders_list.html', folders=folders) 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/<folder_id>', methods=['DELETE']) @main.route('/api/folders/<folder_id>', methods=['DELETE'])
def delete_folder(folder_id): 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() folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all()
return render_template('partials/folders_list.html', folders=folders) return render_template('partials/folders_list.html', folders=folders)
@main.route('/api/folders/<folder_id>', 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/<folder_id>', 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/<folder_id>/edit', methods=['GET']) @main.route('/api/folders/<folder_id>/edit', methods=['GET'])
def edit_folder_modal(folder_id): def edit_folder_modal(folder_id):
try: try:
@@ -183,3 +137,68 @@ def edit_folder_modal(folder_id):
# Print unhandled exceptions to the console as required # Print unhandled exceptions to the console as required
logging.exception("Error getting folder for edit: %s", e) logging.exception("Error getting folder for edit: %s", e)
return jsonify({'error': 'Error retrieving folder'}), 500 return jsonify({'error': 'Error retrieving folder'}), 500
@main.route('/api/folders/<folder_id>', 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

View File

@@ -10,11 +10,21 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
20%, 40%, 60%, 80% { transform: translateX(5px); }
}
.shake {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
</style>
</head> </head>
<body class="min-h-screen flex bg-base-200" <body class="min-h-screen flex bg-base-200"
x-data "{}" x-data="{}"
x-on:open-modal.window="$refs.modal.showModal()" x-on:open-modal.window="$refs.modal.showModal()"
x-on:close-modal.window="$refs.modal.close()"> x-on:close-modal.window="$refs.modal.close()">
<!-- Sidebar --> <!-- Sidebar -->
<div class="sidebar w-64 min-h-screen p-4 flex flex-col bg-base-100 shadow-lg"> <div class="sidebar w-64 min-h-screen p-4 flex flex-col bg-base-100 shadow-lg">
<div class="mb-8"> <div class="mb-8">

View File

@@ -1,39 +1,53 @@
<div id="folder-modal" @click.away="$refs.modal.close()" class="modal-box"> <div id="folder-modal" @click.away="$refs.modal.close()" class="modal-box" x-data="{ errors: {{ 'true' if errors else 'false' }} }" x-init="$nextTick(() => { if (errors) { document.querySelector('#submit-btn').classList.add('shake'); } })">
<h3 class="font-bold text-lg mb-4" id="modal-title"> <h3 class="font-bold text-lg mb-4" id="modal-title">
{% if folder %}Edit Folder{% else %}Add New Folder{% endif %} {% if folder %}Edit Folder{% else %}Add New Folder{% endif %}
</h3> </h3>
<form id="folder-form" {% if folder %} hx-put="/api/folders/{{ folder.id }}" {% else %} hx-post="/api/folders" {%
endif %} hx-target="#folders-list" hx-swap="innerHTML"> {% if errors and errors.general %}
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>{{ errors.general }}</span>
</div>
{% endif %}
<form id="folder-form" {% if folder %} hx-put="/api/folders/{{ folder.id }}" {% else %} hx-post="/api/folders" {% endif %} hx-target="#folders-list" hx-swap="innerHTML">
{% if folder %} {% if folder %}
<input type="hidden" id="folder-id" name="id" value="{{ folder.id }}"> <input type="hidden" id="folder-id" name="id" value="{{ folder.id }}">
{% endif %} {% endif %}
<div class="mb-4"> <div class="mb-4">
<label for="folder-name" class="block text-sm font-medium mb-1">Name</label> <label for="folder-name" class="block text-sm font-medium mb-1">Name</label>
<input type="text" id="folder-name" name="name" class="input input-bordered w-full" <input type="text" id="folder-name" name="name"
class="input input-bordered w-full {% if errors and errors.name %}input-error{% endif %}"
placeholder="e.g., Work, Personal, Newsletters" required placeholder="e.g., Work, Personal, Newsletters" required
value="{% if folder %}{{ folder.name }}{% endif %}"> value="{% if name is defined %}{{ name }}{% elif folder %}{{ folder.name }}{% endif %}">
{% if errors and errors.name %}
<div class="text-error text-sm mt-1">{{ errors.name }}</div>
{% endif %}
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label for="folder-rule" class="block text-sm font-medium mb-1">Rule (Natural Language)</label> <label for="folder-rule" class="block text-sm font-medium mb-1">Rule (Natural Language)</label>
<textarea id="folder-rule" name="rule_text" class="textarea textarea-bordered w-full h-24" <textarea id="folder-rule" name="rule_text"
class="textarea textarea-bordered w-full h-24 {% if errors and errors.rule_text %}textarea-error{% endif %}"
placeholder="e.g., Move emails from 'newsletter@company.com' to this folder" placeholder="e.g., Move emails from 'newsletter@company.com' to this folder"
required>{% if folder %}{{ folder.rule_text }}{% endif %}</textarea> required>{% if rule_text is defined %}{{ rule_text }}{% elif folder %}{{ folder.rule_text }}{% endif %}</textarea>
{% if errors and errors.rule_text %}
<div class="text-error text-sm mt-1">{{ errors.rule_text }}</div>
{% endif %}
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label for="folder-priority" class="block text-sm font-medium mb-1">Priority</label> <label for="folder-priority" class="block text-sm font-medium mb-1">Priority</label>
<select id="folder-priority" name="priority" class="select select-bordered w-full"> <select id="folder-priority" name="priority" class="select select-bordered w-full">
<option value="1" {% if folder and folder.priority==1 %}selected{% endif %}>High</option> <option value="1" {% if (priority is defined and priority == '1') or (folder and folder.priority==1) %}selected{% endif %}>High</option>
<option value="0" {% if not folder or (folder and folder.priority==0) %}selected{% endif %}>Normal <option value="0" {% if (priority is defined and priority == '0') or (not folder) or (folder and folder.priority==0) %}selected{% endif %}>Normal</option>
</option> <option value="-1" {% if (priority is defined and priority == '-1') or (folder and folder.priority==-1) %}selected{% endif %}>Low</option>
<option value="-1" {% if folder and folder.priority==-1 %}selected{% endif %}>Low</option>
</select> </select>
</div> </div>
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn btn-outline" <button type="button" class="btn btn-outline"
@click="$dispatch('close-modal')">Cancel</button> @click="$dispatch('close-modal')">Cancel</button>
<button type="submit" class="btn btn-primary" id="submit-btn"> <button type="submit" class="btn btn-primary" id="submit-btn" :class="{ 'shake': errors }">
{% if folder %}Update Folder{% else %}Add Folder{% endif %} {% if folder %}Update Folder{% else %}Add Folder{% endif %}
</button> </button>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -1 +1 @@
Generic single-database configuration. Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View File

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

View File

@@ -1,7 +1,7 @@
import logging
from logging.config import fileConfig from logging.config import fileConfig
from sqlalchemy import engine_from_config from flask import current_app
from sqlalchemy import pool
from alembic import context from alembic import context
@@ -11,21 +11,33 @@ config = context.config
# Interpret the config file for Python logging. # Interpret the config file for Python logging.
# This line sets up loggers basically. # This line sets up loggers basically.
if config.config_file_name is not None: fileConfig(config.config_file_name)
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 # add your model's MetaData object here
# for 'autogenerate' support # for 'autogenerate' support
import sys # from myapp import mymodel
import os # target_metadata = mymodel.Base.metadata
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
from app import create_app, db
from app.models import User, Folder
app = create_app()
with app.app_context():
target_metadata = db.metadata
# other values from the config, defined by the needs of env.py, # other values from the config, defined by the needs of env.py,
# can be acquired: # can be acquired:
@@ -33,7 +45,13 @@ with app.app_context():
# ... etc. # ... etc.
def run_migrations_offline() -> None: 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. """Run migrations in 'offline' mode.
This configures the context with just a URL This configures the context with just a URL
@@ -47,34 +65,46 @@ def run_migrations_offline() -> None:
""" """
url = config.get_main_option("sqlalchemy.url") url = config.get_main_option("sqlalchemy.url")
context.configure( context.configure(
url=url, url=url, target_metadata=get_metadata(), literal_binds=True
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
) )
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
def run_migrations_online() -> None: def run_migrations_online():
"""Run migrations in 'online' mode. """Run migrations in 'online' mode.
In this scenario we need to create an Engine In this scenario we need to create an Engine
and associate a connection with the context. and associate a connection with the context.
""" """
# Use the Flask app's database configuration
with app.app_context():
connectable = db.engine
with connectable.connect() as connection: # this callback is used to prevent an auto-migration from being generated
context.configure( # when there are no changes to the schema
connection=connection, target_metadata=target_metadata # 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.')
with context.begin_transaction(): conf_args = current_app.extensions['migrate'].configure_args
context.run_migrations() 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(): if context.is_offline_mode():

View File

@@ -5,24 +5,20 @@ Revises: ${down_revision | comma,n}
Create Date: ${create_date} Create Date: ${create_date}
""" """
from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
${imports if imports else ""} ${imports if imports else ""}
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)} revision = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} down_revision = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} branch_labels = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} depends_on = ${repr(depends_on)}
def upgrade() -> None: def upgrade():
"""Upgrade schema."""
${upgrades if upgrades else "pass"} ${upgrades if upgrades else "pass"}
def downgrade() -> None: def downgrade():
"""Downgrade schema."""
${downgrades if downgrades else "pass"} ${downgrades if downgrades else "pass"}

View File

@@ -1,28 +1,25 @@
"""Initial migration with users and folders tables """initial
Revision ID: 1f23b4f802b0 Revision ID: 02a7c13515a4
Revises: Revises:
Create Date: 2025-08-03 08:30:06.300965 Create Date: 2025-08-03 21:33:28.507003
""" """
from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '1f23b4f802b0' revision = '02a7c13515a4'
down_revision: Union[str, Sequence[str], None] = None down_revision = None
branch_labels: Union[str, Sequence[str], None] = None branch_labels = None
depends_on: Union[str, Sequence[str], None] = None depends_on = None
def upgrade() -> None: def upgrade():
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('users', op.create_table('users',
sa.Column('id', sa.UUID(), nullable=False), sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('email', sa.String(length=255), nullable=False), sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.LargeBinary(), nullable=True), sa.Column('password_hash', sa.LargeBinary(), nullable=True),
sa.Column('imap_config', sa.JSON(), nullable=True), sa.Column('imap_config', sa.JSON(), nullable=True),
@@ -30,8 +27,8 @@ def upgrade() -> None:
sa.UniqueConstraint('email') sa.UniqueConstraint('email')
) )
op.create_table('folders', op.create_table('folders',
sa.Column('id', sa.UUID(), nullable=False), sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False), sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False), sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('rule_text', sa.Text(), nullable=True), sa.Column('rule_text', sa.Text(), nullable=True),
sa.Column('priority', sa.Integer(), nullable=True), sa.Column('priority', sa.Integer(), nullable=True),
@@ -41,8 +38,7 @@ def upgrade() -> None:
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade():
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_table('folders') op.drop_table('folders')
op.drop_table('users') op.drop_table('users')

View File

@@ -1,50 +0,0 @@
import requests
import uuid
# Base URL for the application
BASE_URL = "http://localhost:5000"
def test_delete_folder():
"""Test the delete folder functionality"""
print("Testing delete folder functionality...")
# First, let's add a folder
print("Adding a test folder...")
add_response = requests.post(
f"{BASE_URL}/api/folders",
data={
"name": "Test Folder for Deletion",
"rule_text": "Test rule for deletion",
"priority": "0"
}
)
if add_response.status_code == 200:
print("Folder added successfully")
else:
print(f"Failed to add folder: {add_response.status_code}")
return
# Now let's check if the folder exists by getting the page
print("Checking folders list...")
index_response = requests.get(BASE_URL)
if "Test Folder for Deletion" in index_response.text:
print("Folder found in the list")
else:
print("Folder not found in the list")
return
# Now we need to extract the folder ID to delete it
# In a real test, we would parse the HTML to get the ID
# For now, we'll just demonstrate the delete endpoint works
print("Testing delete endpoint (manual test)...")
print("To test deletion:")
print("1. Go to the web interface")
print("2. Add a folder if none exist")
print("3. Click the delete button (trash icon) on a folder")
print("4. Confirm the deletion in the confirmation dialog")
print("5. Verify the folder is removed from the list")
if __name__ == "__main__":
test_delete_folder()

View File

@@ -1,75 +0,0 @@
import unittest
from app import create_app, db
from app.models import Folder, User
import uuid
class EditFolderTestCase(unittest.TestCase):
def setUp(self):
self.app = create_app()
self.app.config['TESTING'] = True
self.app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
self.client = self.app.test_client()
with self.app.app_context():
db.create_all()
# Create a test user
self.user_id = '123e4567-e89b-12d3-a456-426614174000'
user = User(id=self.user_id, email='test@example.com')
db.session.add(user)
db.session.commit()
# Create a test folder
self.folder_id = str(uuid.uuid4())
folder = Folder(
id=self.folder_id,
user_id=self.user_id,
name='Test Folder',
rule_text='Move all emails to this folder',
priority=0
)
db.session.add(folder)
db.session.commit()
def tearDown(self):
with self.app.app_context():
db.session.remove()
db.drop_all()
def test_get_folder(self):
with self.app.app_context():
response = self.client.get(f'/api/folders/{self.folder_id}')
self.assertEqual(response.status_code, 200)
data = response.get_json()
self.assertEqual(data['name'], 'Test Folder')
self.assertEqual(data['rule_text'], 'Move all emails to this folder')
self.assertEqual(data['priority'], 0)
def test_update_folder(self):
with self.app.app_context():
# Update the folder
update_data = {
'name': 'Updated Folder',
'rule_text': 'Move important emails here',
'priority': '1'
}
response = self.client.put(
f'/api/folders/{self.folder_id}',
data=update_data,
content_type='application/x-www-form-urlencoded'
)
# Check that the response is successful
self.assertEqual(response.status_code, 200)
# Verify the folder was updated in the database
folder = Folder.query.filter_by(id=self.folder_id).first()
self.assertIsNotNone(folder)
self.assertEqual(folder.name, 'Updated Folder')
self.assertEqual(folder.rule_text, 'Move important emails here')
self.assertEqual(folder.priority, 1)
if __name__ == '__main__':
unittest.main()

View File

@@ -1,6 +1,8 @@
import pytest import pytest
import sys import sys
import os import os
import flask
from flask_sqlalchemy import SQLAlchemy
# Add the project root directory to the Python path # Add the project root directory to the Python path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
@@ -9,45 +11,47 @@ from app import create_app, db
from app.models import User, Folder from app.models import User, Folder
import uuid import uuid
@pytest.fixture @pytest.fixture(scope="function")
def app(): def app():
"""Create application for testing.""" """Create a fresh app with in-memory database for each test."""
app = create_app('testing') app = create_app('testing')
app.config['TESTING'] = True app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' # In-memory database for tests app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False # Recommended for Flask-SQLAlchemy
with app.app_context(): with app.app_context():
db.create_all() db.create_all() # Create tables for your models
yield app db.session.begin()
db.drop_all() yield app # Yield the app for tests to use
db.session.close()
db.drop_all() # Clean up after tests
@pytest.fixture @pytest.fixture
def client(app): def client(app):
"""A test client for the app.""" """A test client for the app."""
return app.test_client() return app.test_client()
@pytest.fixture @pytest.fixture(scope="function")
def mock_user(app): def mock_user(app):
"""Create a mock user for testing.""" """Create a mock user for testing."""
with app.app_context(): user = User(
user = User( email='test@example.com'
id=uuid.UUID('123e4567-e89b-12d3-a456-426614174000'), )
email='test@example.com'
) db.session.add(user)
db.session.add(user) db.session.commit()
db.session.commit() return user
return user
@pytest.fixture @pytest.fixture(scope="function")
def mock_folder(app, mock_user): def mock_folder(app, mock_user):
"""Create a mock folder for testing.""" """Create a mock folder for testing."""
with app.app_context(): folder = Folder(
folder = Folder( user_id=mock_user.id,
user_id=mock_user.id, name='Test Folder',
name='Test Folder', rule_text='Test rule',
rule_text='Test rule', priority=1
priority=1 )
) db.session.add(folder)
db.session.add(folder) db.session.commit()
db.session.commit() return folder
return folder

View File

@@ -1,33 +1,32 @@
import pytest import pytest
from app.models import User, Folder from app.models import User, Folder
from app import db
import uuid import uuid
import conftest
def test_user_model(app, mock_user): def test_user_model(app, mock_user):
"""Test User model creation and properties.""" """Test User model creation and properties."""
with app.app_context(): # Test user was created by fixture
# Test user was created by fixture assert mock_user.email == 'test@example.com'
assert mock_user.id == uuid.UUID('123e4567-e89b-12d3-a456-426614174000')
assert mock_user.email == 'test@example.com' # Test querying user
user_from_db = User.query.filter_by(email='test@example.com').first()
# Test querying user assert user_from_db is not None
user_from_db = User.query.filter_by(email='test@example.com').first() assert user_from_db.id == mock_user.id
assert user_from_db is not None
assert user_from_db.id == mock_user.id
def test_folder_model(app, mock_folder, mock_user): def test_folder_model(app, mock_folder, mock_user):
"""Test Folder model creation and properties.""" """Test Folder model creation and properties."""
with app.app_context(): # Test folder was created by fixture
# Test folder was created by fixture assert mock_folder.user_id == mock_user.id
assert mock_folder.user_id == mock_user.id assert mock_folder.name == 'Test Folder'
assert mock_folder.name == 'Test Folder' assert mock_folder.rule_text == 'Test rule'
assert mock_folder.rule_text == 'Test rule' assert mock_folder.priority == 1
assert mock_folder.priority == 1
# Test relationship
# Test relationship assert len(mock_user.folders) == 1
assert len(mock_user.folders) == 1 assert mock_user.folders[0].id == mock_folder.id
assert mock_user.folders[0].id == mock_folder.id
# Test querying folder
# Test querying folder folder_from_db = Folder.query.filter_by(name='Test Folder').first()
folder_from_db = Folder.query.filter_by(name='Test Folder').first() assert folder_from_db is not None
assert folder_from_db is not None assert folder_from_db.user_id == mock_user.id
assert folder_from_db.user_id == mock_user.id

View File

@@ -1,31 +1,28 @@
import pytest import pytest
from app.models import User from app.models import User, Folder
import uuid import uuid
def test_index_route(client, app): def test_index_route(client, app, mock_user):
"""Test that the index route loads successfully."""
with app.app_context():
# Create a mock user for the test
mock_user = User(
id=uuid.UUID('123e4567-e89b-12d3-a456-426614174000'),
email='test@example.com'
)
from app import db
db.session.add(mock_user)
db.session.commit()
response = client.get('/') response = client.get('/')
assert response.status_code == 200 assert response.status_code == 200
# Check if the page contains expected elements # Check if the page contains expected elements
assert b'Email Organizer' in response.data assert b'Email Organizer' in response.data
assert b'Folders' in response.data assert b'Folders' in response.data
def test_add_folder_route(client): def test_add_folder_route(client, mock_user):
"""Test the add folder API endpoint.""" """Test the add folder API endpoint."""
response = client.post('/api/folders', # Get initial count of folders for the user
json={'name': 'Test Folder', 'rule_text': 'Test rule'}, initial_folder_count = Folder.query.count()
content_type='application/json')
# Send form data (URL encoded) instead of JSON
response = client.post('/api/folders',
data={'name': 'Test Folder', 'rule_text': 'Test rule something ok yes'},
content_type='application/x-www-form-urlencoded')
print(response.__dict__)
# Verify the response status is 201 Created
assert response.status_code == 201 assert response.status_code == 201
assert b'Folder added (mock)' in response.data
assert b'Test Folder' in response.data # Verify that the number of folders has increased
final_folder_count = Folder.query.count()
assert final_folder_count > initial_folder_count