works
This commit is contained in:
8
QWEN.md
8
QWEN.md
@@ -1,3 +1,4 @@
|
||||
# Instructions
|
||||
Here are special rules you must follow:
|
||||
1. All forms should use regular form url encoding.
|
||||
2. All routes should return html.
|
||||
@@ -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
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_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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
|
||||
191
app/routes.py
191
app/routes.py
@@ -7,14 +7,7 @@ import logging
|
||||
main = Blueprint('main', __name__)
|
||||
|
||||
# For prototype, use a fixed user ID
|
||||
MOCK_USER_ID = '123e4567-e89b-12d3-a456-426614174000'
|
||||
|
||||
@main.route('/api/folders/new', methods=['GET'])
|
||||
def new_folder_modal():
|
||||
# Return the add folder modal
|
||||
response = make_response(render_template('partials/folder_modal.html'))
|
||||
response.headers['HX-Trigger'] = 'open-modal'
|
||||
return response
|
||||
MOCK_USER_ID = 1
|
||||
|
||||
@main.route('/')
|
||||
def index():
|
||||
@@ -28,6 +21,13 @@ def index():
|
||||
folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all()
|
||||
return render_template('index.html', folders=folders)
|
||||
|
||||
@main.route('/api/folders/new', methods=['GET'])
|
||||
def new_folder_modal():
|
||||
# Return the add folder modal
|
||||
response = make_response(render_template('partials/folder_modal.html'))
|
||||
response.headers['HX-Trigger'] = 'open-modal'
|
||||
return response
|
||||
|
||||
@main.route('/api/folders', methods=['POST'])
|
||||
def add_folder():
|
||||
try:
|
||||
@@ -36,18 +36,35 @@ def add_folder():
|
||||
rule_text = request.form.get('rule_text')
|
||||
priority = request.form.get('priority')
|
||||
|
||||
if not name:
|
||||
# Return the folders list unchanged with an error message
|
||||
folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all()
|
||||
# We'll add error handling in the template
|
||||
return render_template('partials/folders_list.html', folders=folders)
|
||||
# Server-side validation
|
||||
errors = {}
|
||||
if not name or not name.strip():
|
||||
errors['name'] = 'Folder name is required'
|
||||
elif len(name.strip()) < 3:
|
||||
errors['name'] = 'Folder name must be at least 3 characters'
|
||||
elif len(name.strip()) > 50:
|
||||
errors['name'] = 'Folder name must be less than 50 characters'
|
||||
|
||||
if not rule_text or not rule_text.strip():
|
||||
errors['rule_text'] = 'Rule text is required'
|
||||
elif len(rule_text.strip()) < 10:
|
||||
errors['rule_text'] = 'Rule text must be at least 10 characters'
|
||||
elif len(rule_text.strip()) > 200:
|
||||
errors['rule_text'] = 'Rule text must be less than 200 characters'
|
||||
|
||||
# If there are validation errors, return the modal with errors
|
||||
if errors:
|
||||
response = make_response(render_template('partials/folder_modal.html', errors=errors, name=name, rule_text=rule_text, priority=priority))
|
||||
response.headers['HX-Retarget'] = '#folder-modal'
|
||||
response.headers['HX-Reswap'] = 'outerHTML'
|
||||
return response
|
||||
|
||||
# Create new folder
|
||||
folder = Folder(
|
||||
user_id=MOCK_USER_ID,
|
||||
name=name,
|
||||
rule_text=rule_text,
|
||||
priority=int(priority) if priority else None
|
||||
name=name.strip(),
|
||||
rule_text=rule_text.strip(),
|
||||
priority=int(priority) if priority else 0
|
||||
)
|
||||
|
||||
db.session.add(folder)
|
||||
@@ -57,18 +74,21 @@ def add_folder():
|
||||
folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all()
|
||||
|
||||
# Return the updated folders list HTML
|
||||
|
||||
response = make_response(render_template('partials/folders_list.html', folders=folders))
|
||||
response.headers['HX-Trigger'] = 'close-modal'
|
||||
response.status_code = 201
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
# Print unhandled exceptions to the console as required
|
||||
logging.exception("Error adding folder: %s", e)
|
||||
db.session.rollback()
|
||||
# Return the folders list unchanged
|
||||
folders = Folder.query.filter_by(user_id=MOCK_USER_ID).all()
|
||||
return render_template('partials/folders_list.html', folders=folders)
|
||||
# Return error in modal
|
||||
errors = {'general': 'An unexpected error occurred. Please try again.'}
|
||||
response = make_response(render_template('partials/folder_modal.html', errors=errors, name=name, rule_text=rule_text, priority=priority))
|
||||
response.headers['HX-Retarget'] = '#folder-modal'
|
||||
response.headers['HX-Reswap'] = 'outerHTML'
|
||||
return response
|
||||
|
||||
@main.route('/api/folders/<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
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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)
|
||||
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():
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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')
|
||||
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 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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user