Push to Gitea ๐
This commit is contained in:
1
flado/__init__.py
Normal file
1
flado/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Flado package
|
||||
112
flado/app.py
Normal file
112
flado/app.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Flask application factory."""
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from flask import Flask, jsonify, Response
|
||||
from flask_migrate import Migrate
|
||||
from flask_wtf.csrf import CSRFProtect, CSRFError
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from .blueprints import tasks_blueprint
|
||||
from .models import db
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Determine if we're in production
|
||||
_is_production = os.getenv(
|
||||
'FLASK_DEBUG', '').lower() not in ('1', 'true', 'yes')
|
||||
|
||||
|
||||
def setup_logging(app: Flask) -> None:
|
||||
"""Configure logging for the application."""
|
||||
if not app.debug and not app.testing:
|
||||
app.logger.setLevel(logging.INFO)
|
||||
else:
|
||||
app.logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
def create_app(config_name: Optional[str] = None) -> Flask:
|
||||
"""
|
||||
Application factory pattern for Flask.
|
||||
|
||||
Args:
|
||||
config_name: Optional configuration name (development, production, etc.)
|
||||
|
||||
Returns:
|
||||
Flask application instance
|
||||
"""
|
||||
# Get the base directory (project root)
|
||||
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
template_dir = os.path.join(base_dir, 'flado', 'templates')
|
||||
static_dir = os.path.join(base_dir, 'static')
|
||||
|
||||
app = Flask(__name__, template_folder=template_dir,
|
||||
static_folder=static_dir)
|
||||
|
||||
# Configuration
|
||||
secret_key = os.getenv('FLADO_SECRET_KEY')
|
||||
if _is_production and not secret_key:
|
||||
raise ValueError(
|
||||
"FLADO_SECRET_KEY environment variable must be set in production"
|
||||
)
|
||||
app.config['SECRET_KEY'] = secret_key or 'dev-secret-key-change-in-production'
|
||||
|
||||
# Database configuration
|
||||
database_uri = os.getenv('FLADO_DATABASE_URI')
|
||||
if not database_uri:
|
||||
instance_dir = os.path.join(base_dir, 'instance')
|
||||
os.makedirs(instance_dir, exist_ok=True)
|
||||
database_path = os.path.join(instance_dir, 'flado.sqlite')
|
||||
database_uri = f'sqlite:///{database_path}'
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = database_uri
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
|
||||
# Theme configuration
|
||||
app.config['FLADO_THEME'] = os.getenv('FLADO_THEME', 'auto')
|
||||
|
||||
# Session configuration for CSRF protection
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
if _is_production:
|
||||
app.config['SESSION_COOKIE_SECURE'] = True
|
||||
|
||||
# Setup logging
|
||||
setup_logging(app)
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
migrate = Migrate(app, db)
|
||||
csrf = CSRFProtect(app)
|
||||
|
||||
# Exempt health check endpoint from CSRF (used by monitoring)
|
||||
csrf.exempt('tasks.health_check')
|
||||
|
||||
# Register error handlers
|
||||
@app.errorhandler(400)
|
||||
def bad_request(error) -> Tuple[Response, int]:
|
||||
"""Handle 400 errors with JSON response."""
|
||||
return jsonify({'error': 'Bad request', 'message': str(error)}), 400
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(error) -> Tuple[Response, int]:
|
||||
"""Handle 404 errors with JSON response."""
|
||||
return jsonify({'error': 'Not found', 'message': str(error)}), 404
|
||||
|
||||
@app.errorhandler(CSRFError)
|
||||
def handle_csrf_error(e) -> Tuple[Response, int]:
|
||||
"""Handle CSRF errors with JSON response."""
|
||||
app.logger.warning(f'CSRF error: {e}')
|
||||
return jsonify({'error': 'CSRF token missing or invalid'}), 400
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error) -> Tuple[Response, int]:
|
||||
"""Handle 500 errors with JSON response."""
|
||||
app.logger.error(f'Server error: {error}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
# Register blueprints
|
||||
app.register_blueprint(tasks_blueprint)
|
||||
|
||||
return app
|
||||
242
flado/blueprints.py
Normal file
242
flado/blueprints.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""Blueprint for task-related routes."""
|
||||
import logging
|
||||
from datetime import datetime, date
|
||||
from typing import Dict, Any, Tuple
|
||||
|
||||
from flask import Blueprint, render_template, request, jsonify, Response
|
||||
from sqlalchemy import text
|
||||
|
||||
from .models import Task, db
|
||||
from .services import (
|
||||
get_tasks, create_task, update_task, delete_task,
|
||||
reorder_tasks, get_all_tags
|
||||
)
|
||||
from .validators import (
|
||||
validate_title, validate_description, validate_date_format,
|
||||
validate_tag_names, validate_task_ids
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
tasks_blueprint = Blueprint('tasks', __name__, url_prefix='/')
|
||||
|
||||
|
||||
@tasks_blueprint.route('/health')
|
||||
def health_check() -> Response:
|
||||
"""
|
||||
Health check endpoint for monitoring.
|
||||
|
||||
Returns:
|
||||
JSON response with health status
|
||||
"""
|
||||
try:
|
||||
# Test database connection
|
||||
db.session.execute(text('SELECT 1'))
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'database': 'connected'
|
||||
}), 200
|
||||
except Exception as e:
|
||||
logger.error(f'Health check failed: {e}')
|
||||
return jsonify({
|
||||
'status': 'unhealthy',
|
||||
'database': 'disconnected',
|
||||
'error': str(e)
|
||||
}), 503
|
||||
|
||||
|
||||
@tasks_blueprint.route('/')
|
||||
def index() -> str:
|
||||
"""Render the main task list page."""
|
||||
filter_type = request.args.get('filter', 'all')
|
||||
search_query = request.args.get('search', '')
|
||||
|
||||
# Validate filter type
|
||||
valid_filters = ['all', 'today', 'upcoming', 'active', 'completed']
|
||||
if filter_type not in valid_filters:
|
||||
filter_type = 'all'
|
||||
|
||||
try:
|
||||
tasks = get_tasks(
|
||||
filter_type, search_query if search_query else None).all()
|
||||
tags = get_all_tags()
|
||||
|
||||
return render_template(
|
||||
'index.html',
|
||||
tasks=tasks,
|
||||
tags=tags,
|
||||
filter_type=filter_type,
|
||||
search_query=search_query,
|
||||
today=date.today()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error rendering index: {e}')
|
||||
# Return empty state on error
|
||||
return render_template(
|
||||
'index.html',
|
||||
tasks=[],
|
||||
tags=[],
|
||||
filter_type=filter_type,
|
||||
search_query=search_query,
|
||||
today=date.today()
|
||||
)
|
||||
|
||||
|
||||
@tasks_blueprint.route('/api/tasks', methods=['POST'])
|
||||
def api_create_task() -> Tuple[Response, int]:
|
||||
"""API endpoint to create a new task."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'error': 'Invalid JSON payload'}), 400
|
||||
|
||||
# Validate title
|
||||
title = data.get('title')
|
||||
is_valid, error = validate_title(title)
|
||||
if not is_valid:
|
||||
return jsonify({'error': error}), 400
|
||||
title = title.strip()
|
||||
|
||||
# Validate description
|
||||
description = data.get('description')
|
||||
is_valid, error = validate_description(description)
|
||||
if not is_valid:
|
||||
return jsonify({'error': error}), 400
|
||||
description = description.strip() if description else None
|
||||
|
||||
# Validate and parse due date
|
||||
due_date_str = data.get('due_date')
|
||||
due_date = None
|
||||
if due_date_str:
|
||||
is_valid, error = validate_date_format(due_date_str)
|
||||
if not is_valid:
|
||||
return jsonify({'error': error}), 400
|
||||
try:
|
||||
due_date = datetime.strptime(due_date_str, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid date value'}), 400
|
||||
|
||||
# Validate tags
|
||||
tag_names = data.get('tags', [])
|
||||
is_valid, error = validate_tag_names(tag_names)
|
||||
if not is_valid:
|
||||
return jsonify({'error': error}), 400
|
||||
|
||||
task = create_task(title, description, due_date, tag_names)
|
||||
logger.info(f'Created task {task.id}: {task.title}')
|
||||
return jsonify(task.to_dict()), 201
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error creating task: {e}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@tasks_blueprint.route('/api/tasks/<int:task_id>', methods=['GET'])
|
||||
def api_get_task(task_id: int) -> Tuple[Response, int]:
|
||||
"""API endpoint to get a single task."""
|
||||
try:
|
||||
task = Task.query.get_or_404(task_id)
|
||||
return jsonify(task.to_dict())
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching task {task_id}: {e}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@tasks_blueprint.route('/api/tasks/<int:task_id>', methods=['PUT', 'PATCH'])
|
||||
def api_update_task(task_id: int) -> Tuple[Response, int]:
|
||||
"""API endpoint to update a task."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'error': 'Invalid JSON payload'}), 400
|
||||
|
||||
update_data: Dict[str, Any] = {}
|
||||
|
||||
# Validate and update title
|
||||
if 'title' in data:
|
||||
is_valid, error = validate_title(data['title'])
|
||||
if not is_valid:
|
||||
return jsonify({'error': error}), 400
|
||||
update_data['title'] = data['title'].strip()
|
||||
|
||||
# Validate and update description
|
||||
if 'description' in data:
|
||||
is_valid, error = validate_description(data['description'])
|
||||
if not is_valid:
|
||||
return jsonify({'error': error}), 400
|
||||
description = data['description']
|
||||
update_data['description'] = description.strip(
|
||||
) if description else None
|
||||
|
||||
# Validate and update completed status
|
||||
if 'completed' in data:
|
||||
if not isinstance(data['completed'], bool):
|
||||
return jsonify({'error': 'completed must be a boolean'}), 400
|
||||
update_data['completed'] = data['completed']
|
||||
|
||||
# Validate and update due date
|
||||
if 'due_date' in data:
|
||||
due_date_str = data['due_date']
|
||||
if due_date_str:
|
||||
is_valid, error = validate_date_format(due_date_str)
|
||||
if not is_valid:
|
||||
return jsonify({'error': error}), 400
|
||||
try:
|
||||
update_data['due_date'] = datetime.strptime(
|
||||
due_date_str, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid date value'}), 400
|
||||
else:
|
||||
update_data['due_date'] = None
|
||||
|
||||
# Validate and update tags
|
||||
if 'tags' in data:
|
||||
tag_names = data['tags']
|
||||
is_valid, error = validate_tag_names(tag_names)
|
||||
if not is_valid:
|
||||
return jsonify({'error': error}), 400
|
||||
update_data['tag_names'] = tag_names
|
||||
|
||||
task = update_task(task_id, **update_data)
|
||||
logger.info(f'Updated task {task_id}')
|
||||
return jsonify(task.to_dict())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error updating task {task_id}: {e}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@tasks_blueprint.route('/api/tasks/<int:task_id>', methods=['DELETE'])
|
||||
def api_delete_task(task_id: int) -> Tuple[Response, int]:
|
||||
"""API endpoint to delete a task."""
|
||||
try:
|
||||
delete_task(task_id)
|
||||
logger.info(f'Deleted task {task_id}')
|
||||
return jsonify({'success': True}), 200
|
||||
except Exception as e:
|
||||
logger.error(f'Error deleting task {task_id}: {e}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@tasks_blueprint.route('/api/tasks/reorder', methods=['POST'])
|
||||
def api_reorder_tasks() -> Tuple[Response, int]:
|
||||
"""API endpoint to reorder tasks."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'error': 'Invalid JSON payload'}), 400
|
||||
|
||||
task_ids = data.get('task_ids', [])
|
||||
|
||||
# Validate task IDs
|
||||
is_valid, error = validate_task_ids(task_ids)
|
||||
if not is_valid:
|
||||
return jsonify({'error': error}), 400
|
||||
|
||||
reorder_tasks(task_ids)
|
||||
logger.info(f'Reordered {len(task_ids)} tasks')
|
||||
return jsonify({'success': True}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error reordering tasks: {e}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
71
flado/models.py
Normal file
71
flado/models.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""SQLAlchemy models for Flado."""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, List
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
"""Get current UTC datetime."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Task(db.Model):
|
||||
"""Task model representing a todo item."""
|
||||
__tablename__ = 'tasks'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
completed = db.Column(db.Boolean, default=False, nullable=False)
|
||||
due_date = db.Column(db.Date, nullable=True)
|
||||
created_at = db.Column(
|
||||
db.DateTime, default=utc_now, nullable=False)
|
||||
updated_at = db.Column(
|
||||
db.DateTime, default=utc_now, onupdate=utc_now, nullable=False)
|
||||
position = db.Column(db.Integer, default=0, nullable=False)
|
||||
tags = db.relationship('Tag', secondary='task_tags',
|
||||
back_populates='tasks', lazy='select')
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert task to dictionary for JSON serialization."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'title': self.title,
|
||||
'description': self.description,
|
||||
'completed': self.completed,
|
||||
'due_date': self.due_date.isoformat() if self.due_date else None,
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'updated_at': self.updated_at.isoformat(),
|
||||
'position': self.position,
|
||||
'tags': [tag.name for tag in self.tags]
|
||||
}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<Task {self.id}: {self.title}>'
|
||||
|
||||
|
||||
class Tag(db.Model):
|
||||
"""Tag model for categorizing tasks."""
|
||||
__tablename__ = 'tags'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(50), unique=True, nullable=False)
|
||||
color = db.Column(db.String(7), default='#3b82f6',
|
||||
nullable=False) # Hex color
|
||||
tasks = db.relationship('Task', secondary='task_tags',
|
||||
back_populates='tags', lazy='dynamic')
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<Tag {self.name}>'
|
||||
|
||||
|
||||
# Association table for many-to-many relationship
|
||||
task_tags = db.Table('task_tags',
|
||||
db.Column('task_id', db.Integer, db.ForeignKey(
|
||||
'tasks.id'), primary_key=True),
|
||||
db.Column('tag_id', db.Integer, db.ForeignKey(
|
||||
'tags.id'), primary_key=True)
|
||||
)
|
||||
238
flado/services.py
Normal file
238
flado/services.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""Business logic helpers for Flado."""
|
||||
import logging
|
||||
from datetime import datetime, date, timezone
|
||||
from typing import Optional, List
|
||||
|
||||
from sqlalchemy import or_, func
|
||||
from sqlalchemy.orm import Query
|
||||
|
||||
from .models import db, Task, Tag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_tasks(filter_type: str = 'all', search_query: Optional[str] = None) -> Query:
|
||||
"""
|
||||
Retrieve tasks based on filter type.
|
||||
|
||||
Args:
|
||||
filter_type: 'all', 'today', 'upcoming', 'completed', 'active'
|
||||
search_query: Optional search string to filter by title/description
|
||||
|
||||
Returns:
|
||||
Query object of tasks
|
||||
"""
|
||||
query = Task.query
|
||||
|
||||
if filter_type == 'today':
|
||||
query = query.filter(Task.due_date == date.today())
|
||||
elif filter_type == 'upcoming':
|
||||
query = query.filter(Task.due_date > date.today())
|
||||
elif filter_type == 'completed':
|
||||
query = query.filter(Task.completed.is_(True))
|
||||
elif filter_type == 'active':
|
||||
query = query.filter(Task.completed.is_(False))
|
||||
|
||||
if search_query:
|
||||
search_term = f'%{search_query}%'
|
||||
query = query.filter(
|
||||
or_(
|
||||
Task.title.ilike(search_term),
|
||||
Task.description.ilike(search_term)
|
||||
)
|
||||
)
|
||||
|
||||
return query.order_by(Task.position.asc(), Task.created_at.desc())
|
||||
|
||||
|
||||
def create_task(
|
||||
title: str,
|
||||
description: Optional[str] = None,
|
||||
due_date: Optional[date] = None,
|
||||
tag_names: Optional[List[str]] = None
|
||||
) -> Task:
|
||||
"""
|
||||
Create a new task.
|
||||
|
||||
Args:
|
||||
title: Task title (required, already validated)
|
||||
description: Optional task description (already validated)
|
||||
due_date: Optional due date (date object, already validated)
|
||||
tag_names: Optional list of tag names to associate (already validated)
|
||||
|
||||
Returns:
|
||||
Created Task object
|
||||
|
||||
Raises:
|
||||
ValueError: If title is empty or invalid
|
||||
"""
|
||||
if not title or not title.strip():
|
||||
raise ValueError("Task title is required")
|
||||
|
||||
try:
|
||||
# Get max position for ordering
|
||||
max_position = db.session.query(func.max(Task.position)).scalar() or 0
|
||||
|
||||
task = Task(
|
||||
title=title.strip(),
|
||||
description=description.strip() if description else None,
|
||||
due_date=due_date,
|
||||
position=max_position + 1
|
||||
)
|
||||
|
||||
if tag_names:
|
||||
tags = get_or_create_tags(tag_names)
|
||||
task.tags = tags
|
||||
|
||||
db.session.add(task)
|
||||
db.session.commit()
|
||||
return task
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f'Error creating task: {e}')
|
||||
raise
|
||||
|
||||
|
||||
def update_task(task_id: int, **kwargs) -> Task:
|
||||
"""
|
||||
Update an existing task.
|
||||
|
||||
Args:
|
||||
task_id: ID of task to update
|
||||
**kwargs: Fields to update (title, description, completed, due_date, tag_names)
|
||||
|
||||
Returns:
|
||||
Updated Task object
|
||||
|
||||
Raises:
|
||||
404: If task not found
|
||||
"""
|
||||
task = Task.query.get_or_404(task_id)
|
||||
|
||||
try:
|
||||
if 'title' in kwargs and kwargs['title']:
|
||||
task.title = kwargs['title'].strip()
|
||||
if 'description' in kwargs:
|
||||
task.description = kwargs['description'].strip(
|
||||
) if kwargs['description'] else None
|
||||
if 'completed' in kwargs:
|
||||
task.completed = kwargs['completed']
|
||||
if 'due_date' in kwargs:
|
||||
task.due_date = kwargs['due_date']
|
||||
if 'tag_names' in kwargs:
|
||||
tag_names = kwargs['tag_names']
|
||||
if tag_names:
|
||||
tags = get_or_create_tags(tag_names)
|
||||
task.tags = tags
|
||||
else:
|
||||
task.tags = []
|
||||
|
||||
task.updated_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
return task
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f'Error updating task {task_id}: {e}')
|
||||
raise
|
||||
|
||||
|
||||
def delete_task(task_id: int) -> bool:
|
||||
"""
|
||||
Delete a task.
|
||||
|
||||
Args:
|
||||
task_id: ID of task to delete
|
||||
|
||||
Returns:
|
||||
True if deleted successfully
|
||||
|
||||
Raises:
|
||||
404: If task not found
|
||||
"""
|
||||
task = Task.query.get_or_404(task_id)
|
||||
try:
|
||||
db.session.delete(task)
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f'Error deleting task {task_id}: {e}')
|
||||
raise
|
||||
|
||||
|
||||
def reorder_tasks(task_ids: List[int]) -> bool:
|
||||
"""
|
||||
Reorder tasks by updating their positions.
|
||||
|
||||
Args:
|
||||
task_ids: List of task IDs in desired order (already validated)
|
||||
|
||||
Returns:
|
||||
True if reordered successfully
|
||||
|
||||
Note:
|
||||
Invalid task IDs are silently skipped (task not found).
|
||||
"""
|
||||
try:
|
||||
# Fetch all tasks in current order so unaffected tasks keep stable positions
|
||||
all_tasks = Task.query.order_by(
|
||||
Task.position.asc(), Task.created_at.desc()
|
||||
).all()
|
||||
if not all_tasks:
|
||||
return True
|
||||
|
||||
id_to_task = {task.id: task for task in all_tasks}
|
||||
|
||||
# Build new ordered list: first the provided IDs (if they exist), then the rest
|
||||
reordered_tasks = []
|
||||
seen_ids = set()
|
||||
for task_id in task_ids:
|
||||
task = id_to_task.get(task_id)
|
||||
if task and task_id not in seen_ids:
|
||||
reordered_tasks.append(task)
|
||||
seen_ids.add(task_id)
|
||||
|
||||
for task in all_tasks:
|
||||
if task.id not in seen_ids:
|
||||
reordered_tasks.append(task)
|
||||
|
||||
for index, task in enumerate(reordered_tasks, start=1):
|
||||
task.position = index
|
||||
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f'Error reordering tasks: {e}')
|
||||
raise
|
||||
|
||||
|
||||
def get_or_create_tags(tag_names: List[str]) -> List[Tag]:
|
||||
"""
|
||||
Get existing tags or create new ones.
|
||||
|
||||
Args:
|
||||
tag_names: List of tag names (already validated)
|
||||
|
||||
Returns:
|
||||
List of Tag objects
|
||||
|
||||
Note:
|
||||
Does not commit the session. Caller is responsible for committing.
|
||||
"""
|
||||
tags = []
|
||||
for tag_name in tag_names:
|
||||
tag_name = tag_name.strip()
|
||||
if not tag_name:
|
||||
continue
|
||||
tag = Tag.query.filter_by(name=tag_name).first()
|
||||
if not tag:
|
||||
tag = Tag(name=tag_name)
|
||||
db.session.add(tag)
|
||||
tags.append(tag)
|
||||
return tags
|
||||
|
||||
|
||||
def get_all_tags() -> List[Tag]:
|
||||
"""Get all tags."""
|
||||
return Tag.query.order_by(Tag.name.asc()).all()
|
||||
42
flado/templates/base.html
Normal file
42
flado/templates/base.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
||||
<title>{% block title %}Flado{% endblock %}</title>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/x-icon"
|
||||
href="{{ url_for('static', filename='favicon.ico') }}"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('static', filename='css/style.css') }}"
|
||||
/>
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body data-theme="{{ config.get('FLADO_THEME', 'auto') }}">
|
||||
<div class="app-container">
|
||||
<header class="app-header">
|
||||
<h1 class="app-title">Flado</h1>
|
||||
<p class="app-subtitle">Simple task management</p>
|
||||
</header>
|
||||
|
||||
<main class="app-main">{% block content %}{% endblock %}</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<p>
|
||||
© 2025 Flado by
|
||||
<a href="https://sandro.cazzaniga.fr" target="_blank"
|
||||
>Sandro CAZZANIGA</a
|
||||
>
|
||||
</p>
|
||||
<p>Built with Python, Flask and โค๏ธ</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
180
flado/templates/index.html
Normal file
180
flado/templates/index.html
Normal file
@@ -0,0 +1,180 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="tasks-container" data-filter="{{ filter_type }}" data-search="{{ search_query }}">
|
||||
<div class="controls-bar">
|
||||
<div class="search-box">
|
||||
<input
|
||||
type="text"
|
||||
id="search-input"
|
||||
placeholder="Search tasks..."
|
||||
value="{{ search_query }}"
|
||||
class="search-input"
|
||||
>
|
||||
<button class="search-clear" id="search-clear" style="display: none;">ร</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-tabs">
|
||||
<a href="{{ url_for('tasks.index', filter='all') }}"
|
||||
class="filter-tab {{ 'active' if filter_type == 'all' else '' }}">All</a>
|
||||
<a href="{{ url_for('tasks.index', filter='today') }}"
|
||||
class="filter-tab {{ 'active' if filter_type == 'today' else '' }}">Today</a>
|
||||
<a href="{{ url_for('tasks.index', filter='upcoming') }}"
|
||||
class="filter-tab {{ 'active' if filter_type == 'upcoming' else '' }}">Upcoming</a>
|
||||
<a href="{{ url_for('tasks.index', filter='active') }}"
|
||||
class="filter-tab {{ 'active' if filter_type == 'active' else '' }}">Active</a>
|
||||
<a href="{{ url_for('tasks.index', filter='completed') }}"
|
||||
class="filter-tab {{ 'active' if filter_type == 'completed' else '' }}">Completed</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-add-form">
|
||||
<form id="task-form" class="task-form">
|
||||
<input
|
||||
type="text"
|
||||
id="task-title-input"
|
||||
placeholder="Add a new task..."
|
||||
class="task-input"
|
||||
required
|
||||
autocomplete="off"
|
||||
>
|
||||
<button type="submit" class="add-button" title="Add task (Enter)">+</button>
|
||||
</form>
|
||||
<div class="task-details-expand" id="task-details-toggle" style="display: none;">
|
||||
<button class="expand-button">
|
||||
<span class="expand-icon">+</span>
|
||||
Add details
|
||||
</button>
|
||||
</div>
|
||||
<div class="task-details-form" id="task-details-form" style="display: none;">
|
||||
<textarea
|
||||
id="task-description-input"
|
||||
placeholder="Description (optional)"
|
||||
class="task-description-input"
|
||||
rows="3"
|
||||
></textarea>
|
||||
<input
|
||||
type="date"
|
||||
id="task-due-date-input"
|
||||
class="task-due-date-input"
|
||||
>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="cancel-button" id="cancel-details">Cancel</button>
|
||||
<button type="submit" class="save-button" form="task-form">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-list" id="task-list">
|
||||
{% if tasks %}
|
||||
{% for task in tasks %}
|
||||
<div class="task-item {{ 'completed' if task.completed else '' }}"
|
||||
data-task-id="{{ task.id }}"
|
||||
draggable="true">
|
||||
<div class="task-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="task-check"
|
||||
data-task-id="{{ task.id }}"
|
||||
{{ 'checked' if task.completed else '' }}
|
||||
>
|
||||
</div>
|
||||
<div class="task-content">
|
||||
<div class="task-main">
|
||||
<div class="task-title-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
class="task-title"
|
||||
value="{{ task.title }}"
|
||||
data-task-id="{{ task.id }}"
|
||||
readonly
|
||||
>
|
||||
</div>
|
||||
{% if task.due_date %}
|
||||
<div class="task-due-date">
|
||||
<span class="due-date-badge {{ 'overdue' if task.due_date < today and not task.completed else '' }}">
|
||||
{{ task.due_date.strftime('%b %d') }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if task.description %}
|
||||
<div class="task-description">{{ task.description }}</div>
|
||||
{% endif %}
|
||||
{% if task.tags %}
|
||||
<div class="task-tags">
|
||||
{% for tag in task.tags %}
|
||||
{% set bg_color = tag.color + "20" %}
|
||||
<span class="task-tag" style="background-color: {{ bg_color }}; color: {{ tag.color }};">
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<button class="task-edit" data-task-id="{{ task.id }}" title="Edit">โ</button>
|
||||
<button class="task-delete" data-task-id="{{ task.id }}" title="Delete">ร</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p class="empty-message">No tasks found. Add one above to get started!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="edit-modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Edit Task</h2>
|
||||
<button class="modal-close" id="modal-close">ร</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="edit-task-form">
|
||||
<input type="hidden" id="edit-task-id">
|
||||
<div class="form-group">
|
||||
<label for="edit-task-title">Title</label>
|
||||
<input type="text" id="edit-task-title" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-task-description">Description</label>
|
||||
<textarea id="edit-task-description" rows="4"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-task-due-date">Due Date</label>
|
||||
<input type="date" id="edit-task-due-date">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="cancel-button" id="cancel-edit">Cancel</button>
|
||||
<button type="submit" class="save-button">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="delete-modal" style="display: none;">
|
||||
<div class="modal-content delete-modal-content">
|
||||
<div class="delete-modal-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18"></path>
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
|
||||
<line x1="10" y1="11" x2="10" y2="17"></line>
|
||||
<line x1="14" y1="11" x2="14" y2="17"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="modal-body delete-modal-body">
|
||||
<h2 class="delete-modal-title">Delete Task</h2>
|
||||
<p class="delete-modal-message">Are you sure you want to delete this task? This action cannot be undone.</p>
|
||||
<div class="form-actions delete-modal-actions">
|
||||
<button type="button" class="cancel-button" id="delete-cancel">Cancel</button>
|
||||
<button type="button" class="delete-confirm-button" id="delete-confirm">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
172
flado/validators.py
Normal file
172
flado/validators.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Input validation utilities for Flado."""
|
||||
import re
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
|
||||
def validate_title(title: Optional[str]) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate task title.
|
||||
|
||||
Args:
|
||||
title: Title to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not title:
|
||||
return False, "Title is required"
|
||||
|
||||
title = title.strip()
|
||||
if not title:
|
||||
return False, "Title cannot be empty"
|
||||
|
||||
if len(title) > 200:
|
||||
return False, "Title must be 200 characters or less"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_description(description: Optional[str]) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate task description.
|
||||
|
||||
Args:
|
||||
description: Description to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if description is None:
|
||||
return True, None
|
||||
|
||||
description = description.strip()
|
||||
if len(description) > 10000: # Reasonable limit for text field
|
||||
return False, "Description must be 10000 characters or less"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_date_format(date_str: Optional[str]) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate date string format (YYYY-MM-DD).
|
||||
|
||||
Args:
|
||||
date_str: Date string to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not date_str:
|
||||
return True, None
|
||||
|
||||
if not isinstance(date_str, str):
|
||||
return False, "Date must be a string"
|
||||
|
||||
# Check format YYYY-MM-DD
|
||||
pattern = r'^\d{4}-\d{2}-\d{2}$'
|
||||
if not re.match(pattern, date_str):
|
||||
return False, "Date must be in YYYY-MM-DD format"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_tag_name(tag_name: Optional[str]) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate tag name.
|
||||
|
||||
Args:
|
||||
tag_name: Tag name to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not tag_name:
|
||||
return False, "Tag name is required"
|
||||
|
||||
tag_name = tag_name.strip()
|
||||
if not tag_name:
|
||||
return False, "Tag name cannot be empty"
|
||||
|
||||
if len(tag_name) > 50:
|
||||
return False, "Tag name must be 50 characters or less"
|
||||
|
||||
# Allow alphanumeric, spaces, hyphens, underscores
|
||||
if not re.match(r'^[\w\s-]+$', tag_name):
|
||||
return False, "Tag name contains invalid characters"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_tag_names(tag_names: Optional[List[str]]) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate list of tag names.
|
||||
|
||||
Args:
|
||||
tag_names: List of tag names to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if tag_names is None:
|
||||
return True, None
|
||||
|
||||
if not isinstance(tag_names, list):
|
||||
return False, "Tags must be a list"
|
||||
|
||||
for tag_name in tag_names:
|
||||
is_valid, error = validate_tag_name(tag_name)
|
||||
if not is_valid:
|
||||
return False, error
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_hex_color(color: Optional[str]) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate hex color format.
|
||||
|
||||
Args:
|
||||
color: Hex color string (e.g., #RRGGBB)
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not color:
|
||||
return True, None
|
||||
|
||||
if not isinstance(color, str):
|
||||
return False, "Color must be a string"
|
||||
|
||||
pattern = r'^#[0-9A-Fa-f]{6}$'
|
||||
if not re.match(pattern, color):
|
||||
return False, "Color must be in #RRGGBB hex format"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_task_ids(task_ids: Optional[List[int]]) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate list of task IDs.
|
||||
|
||||
Args:
|
||||
task_ids: List of task IDs to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not task_ids:
|
||||
return False, "task_ids is required"
|
||||
|
||||
if not isinstance(task_ids, list):
|
||||
return False, "task_ids must be a list"
|
||||
|
||||
if len(task_ids) == 0:
|
||||
return False, "task_ids cannot be empty"
|
||||
|
||||
for task_id in task_ids:
|
||||
if not isinstance(task_id, int):
|
||||
return False, "All task IDs must be integers"
|
||||
if task_id <= 0:
|
||||
return False, "All task IDs must be positive integers"
|
||||
|
||||
return True, None
|
||||
Reference in New Issue
Block a user