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

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.
@@ -9,3 +10,10 @@ Here are special rules you must follow:
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.
# 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,9 +10,19 @@
<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-data="{}"
x-on:open-modal.window="$refs.modal.showModal()"
x-on:close-modal.window="$refs.modal.close()">
<!-- Sidebar -->

View File

@@ -1,37 +1,51 @@
<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>

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,30 +65,42 @@ 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
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():

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,39 +11,40 @@ 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
@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',
@@ -51,3 +54,4 @@ def mock_folder(app, mock_user):
db.session.add(folder)
db.session.commit()
return folder

View File

@@ -1,12 +1,12 @@
import pytest
from app.models import User, Folder
from app import db
import uuid
import conftest
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
@@ -16,7 +16,6 @@ def test_user_model(app, mock_user):
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'

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