works
This commit is contained in:
10
QWEN.md
10
QWEN.md
@@ -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
1
alembic/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
78
alembic/env.py
Normal file
78
alembic/env.py
Normal 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
28
alembic/script.py.mako
Normal 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"}
|
||||||
@@ -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)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||||
|
|||||||
191
app/routes.py
191
app/routes.py
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1 +1 @@
|
|||||||
Generic single-database configuration.
|
Single-database configuration for Flask.
|
||||||
|
|||||||
Binary file not shown.
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal 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
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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')
|
||||||
Binary file not shown.
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user