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:
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.
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_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)

View File

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

View File

@@ -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/<folder_id>', 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/<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'])
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/<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">
<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>
<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>
<body class="min-h-screen flex bg-base-200"
x-data "{}"
x-on:open-modal.window="$refs.modal.showModal()"
x-on:close-modal.window="$refs.modal.close()">
x-data="{}"
x-on:open-modal.window="$refs.modal.showModal()"
x-on:close-modal.window="$refs.modal.close()">
<!-- Sidebar -->
<div class="sidebar w-64 min-h-screen p-4 flex flex-col bg-base-100 shadow-lg">
<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">
{% if folder %}Edit Folder{% else %}Add New Folder{% endif %}
</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 %}
<input type="hidden" id="folder-id" name="id" value="{{ folder.id }}">
{% endif %}
<div class="mb-4">
<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
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 class="mb-4">
<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"
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 class="mb-4">
<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">
<option value="1" {% if 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>
<option value="-1" {% if folder and folder.priority==-1 %}selected{% endif %}>Low</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 (priority is defined and priority == '0') or (not folder) or (folder and folder.priority==0) %}selected{% endif %}>Normal</option>
<option value="-1" {% if (priority is defined and priority == '-1') or (folder and folder.priority==-1) %}selected{% endif %}>Low</option>
</select>
</div>
<div class="modal-action">
<button type="button" class="btn btn-outline"
@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 %}
</button>
</div>
</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 sqlalchemy import engine_from_config
from sqlalchemy import pool
from flask import current_app
from alembic import context
@@ -11,21 +11,33 @@ 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)
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
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app import create_app, db
from app.models import User, Folder
app = create_app()
with app.app_context():
target_metadata = db.metadata
# 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:
@@ -33,7 +45,13 @@ with app.app_context():
# ... 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.
This configures the context with just a URL
@@ -47,34 +65,46 @@ def run_migrations_offline() -> None:
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
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.
"""
# Use the Flask app's database configuration
with app.app_context():
connectable = db.engine
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
# 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.')
with context.begin_transaction():
context.run_migrations()
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():

View File

@@ -5,24 +5,20 @@ 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)}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
def downgrade():
${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:
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
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1f23b4f802b0'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision = '02a7c13515a4'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Upgrade schema."""
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
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('password_hash', sa.LargeBinary(), nullable=True),
sa.Column('imap_config', sa.JSON(), nullable=True),
@@ -30,8 +27,8 @@ def upgrade() -> None:
sa.UniqueConstraint('email')
)
op.create_table('folders',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('rule_text', sa.Text(), nullable=True),
sa.Column('priority', sa.Integer(), nullable=True),
@@ -41,8 +38,7 @@ def upgrade() -> None:
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('folders')
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 sys
import os
import flask
from flask_sqlalchemy import SQLAlchemy
# Add the project root directory to the Python path
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
import uuid
@pytest.fixture
@pytest.fixture(scope="function")
def app():
"""Create application for testing."""
"""Create a fresh app with in-memory database for each test."""
app = create_app('testing')
app.config['TESTING'] = True
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' # In-memory database for tests
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False # Recommended for Flask-SQLAlchemy
with app.app_context():
db.create_all()
yield app
db.drop_all()
db.create_all() # Create tables for your models
db.session.begin()
yield app # Yield the app for tests to use
db.session.close()
db.drop_all() # Clean up after tests
@pytest.fixture
def client(app):
"""A test client for the app."""
return app.test_client()
@pytest.fixture
@pytest.fixture(scope="function")
def mock_user(app):
"""Create a mock user for testing."""
with app.app_context():
user = User(
id=uuid.UUID('123e4567-e89b-12d3-a456-426614174000'),
email='test@example.com'
)
db.session.add(user)
db.session.commit()
return user
user = User(
email='test@example.com'
)
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
@pytest.fixture(scope="function")
def mock_folder(app, mock_user):
"""Create a mock folder for testing."""
with app.app_context():
folder = Folder(
user_id=mock_user.id,
name='Test Folder',
rule_text='Test rule',
priority=1
)
db.session.add(folder)
db.session.commit()
return folder
folder = Folder(
user_id=mock_user.id,
name='Test Folder',
rule_text='Test rule',
priority=1
)
db.session.add(folder)
db.session.commit()
return folder

View File

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

View File

@@ -1,31 +1,28 @@
import pytest
from app.models import User
from app.models import User, Folder
import uuid
def test_index_route(client, app):
"""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()
def test_index_route(client, app, mock_user):
response = client.get('/')
assert response.status_code == 200
# Check if the page contains expected elements
assert b'Email Organizer' 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."""
response = client.post('/api/folders',
json={'name': 'Test Folder', 'rule_text': 'Test rule'},
content_type='application/json')
# Get initial count of folders for the user
initial_folder_count = Folder.query.count()
# 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 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