Push to Gitea ๐Ÿš€

This commit is contained in:
2025-11-06 07:54:23 +01:00
parent a3bb1b3dd6
commit e39470e126
22 changed files with 2633 additions and 0 deletions

1
flado/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Flado package

112
flado/app.py Normal file
View 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
View 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
View 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
View 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
View 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>
&copy; 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
View 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
View 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