Add task tags and categorization (fixes #539)

Implement the advertised 'Task tags and categorization' feature that was
listed in docs but missing from the task edit form.

- Add tags column to tasks table (String 500, comma-separated)
- Add tags field to task create and edit forms with validation
- Display tags as badges on task view page and list pages
- Add tags filter to main tasks list and My Tasks with AJAX support
- Include tags in task export (CSV) and data export
- Add tags to Task API (create, update, list with filter)
- Add tag_list property to Task model for convenient parsing

Also: fix(oidc) use domain instead of IP for HTTPS metadata fetch
to fix TLS SNI (Fixes #540)
This commit is contained in:
Dries Peeters
2026-02-28 17:28:15 +01:00
parent 52a30edf43
commit 3c5a937234
14 changed files with 197 additions and 15 deletions
+13
View File
@@ -26,6 +26,8 @@ class Task(db.Model):
completed_at = db.Column(db.DateTime, nullable=True)
# Gantt chart bar color (hex e.g. #3b82f6). Overrides project color when set.
color = db.Column(db.String(7), nullable=True)
# Comma-separated tags for categorization
tags = db.Column(db.String(500), nullable=True)
# Relationships
# project relationship is defined via backref in Project model
@@ -45,6 +47,7 @@ class Task(db.Model):
assigned_to=None,
created_by=None,
status="todo",
tags=None,
):
self.project_id = project_id
self.name = name.strip()
@@ -55,6 +58,7 @@ class Task(db.Model):
self.assigned_to = assigned_to
self.created_by = created_by
self.status = status
self.tags = (tags.strip() or None) if tags else None
def __repr__(self):
return f"<Task {self.name} ({self.status})>"
@@ -161,6 +165,13 @@ class Task(db.Model):
}
return priority_classes.get(self.priority, "priority-medium")
@property
def tag_list(self):
"""Get list of tags from comma-separated string"""
if not self.tags:
return []
return [t.strip() for t in self.tags.split(",") if t.strip()]
def start_task(self):
"""Mark task as in progress"""
if self.status == "done":
@@ -257,6 +268,8 @@ class Task(db.Model):
"progress_percentage": self.progress_percentage,
"is_active": self.is_active,
"is_overdue": self.is_overdue,
"tags": self.tags,
"tag_list": self.tag_list,
}
@classmethod
+5
View File
@@ -1087,6 +1087,7 @@ def list_tasks():
# Filter by project
project_id = request.args.get("project_id", type=int)
status = request.args.get("status")
tags = request.args.get("tags", "").strip() or None
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 50, type=int)
@@ -1095,6 +1096,7 @@ def list_tasks():
result = task_service.list_tasks(
project_id=project_id,
status=status,
tags=tags,
page=page,
per_page=per_page,
)
@@ -1203,6 +1205,7 @@ def create_task():
priority=data.get("priority", "medium"),
due_date=data.get("due_date"),
estimated_hours=data.get("estimated_hours"),
tags=data.get("tags"),
)
if not result.get("success"):
@@ -1258,6 +1261,8 @@ def update_task(task_id):
update_kwargs["due_date"] = data["due_date"]
if "estimated_hours" in data:
update_kwargs["estimated_hours"] = data["estimated_hours"]
if "tags" in data:
update_kwargs["tags"] = data["tags"]
result = task_service.update_task(task_id=task_id, user_id=g.api_user.id, **update_kwargs)
+28
View File
@@ -59,6 +59,7 @@ def list_tasks():
project_ids = parse_ids('project_id')
assigned_to_ids = parse_ids('assigned_to')
search = request.args.get("search", "").strip()
tags = request.args.get("tags", "").strip()
overdue_param = request.args.get("overdue", "").strip().lower()
overdue = overdue_param in ["1", "true", "on", "yes"]
@@ -80,6 +81,7 @@ def list_tasks():
project_ids=project_ids if project_ids else None,
assigned_to_ids=assigned_to_ids if assigned_to_ids else None,
search=search if search else None,
tags=tags if tags else None,
overdue=overdue,
user_id=current_user.id,
is_admin=current_user.is_admin,
@@ -103,6 +105,7 @@ def list_tasks():
project_id=project_ids[0] if len(project_ids) == 1 else None,
assigned_to=assigned_to_ids[0] if len(assigned_to_ids) == 1 else None,
search=search,
tags=tags,
overdue=overdue,
))
response.headers["Content-Type"] = "text/html; charset=utf-8"
@@ -145,6 +148,7 @@ def list_tasks():
project_id=project_ids[0] if len(project_ids) == 1 else None,
assigned_to=assigned_to_ids[0] if len(assigned_to_ids) == 1 else None,
search=search,
tags=tags,
overdue=overdue,
task_counts=task_counts,
)
@@ -232,6 +236,9 @@ def create_task():
if color_val == "":
color_val = None
# Tags (comma-separated, max 500 chars)
tags_val = request.form.get("tags", "").strip()[:500] or None
# Use service layer to create task
from app.services import TaskService
@@ -248,6 +255,7 @@ def create_task():
created_by=current_user.id,
color=color_val,
status=status,
tags=tags_val,
)
if not result["success"]:
@@ -469,6 +477,9 @@ def edit_task(task_id):
task.estimated_hours = estimated_hours
task.due_date = due_date
task.assigned_to = assigned_to
# Tags (comma-separated, max 500 chars)
tags_val = request.form.get("tags", "").strip()[:500]
task.tags = tags_val or None
# Gantt color (hex e.g. #3b82f6)
color_val = request.form.get("color", "").strip()
if color_val and re.match(r"^#[0-9A-Fa-f]{6}$", color_val):
@@ -1160,6 +1171,7 @@ def export_tasks():
project_ids = parse_ids("project_id")
assigned_to_ids = parse_ids("assigned_to")
search = request.args.get("search", "").strip()
tags = request.args.get("tags", "").strip()
overdue_param = request.args.get("overdue", "").strip().lower()
overdue = overdue_param in ["1", "true", "on", "yes"]
@@ -1182,6 +1194,12 @@ def export_tasks():
like = f"%{search}%"
query = query.filter(db.or_(Task.name.ilike(like), Task.description.ilike(like)))
if tags:
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
if tag_list:
tag_conditions = [Task.tags.ilike(f"%{tag}%") for tag in tag_list]
query = query.filter(db.or_(*tag_conditions))
# Overdue filter
if overdue:
today_local = now_in_app_timezone().date()
@@ -1207,6 +1225,7 @@ def export_tasks():
"Project",
"Status",
"Priority",
"Tags",
"Assigned To",
"Created By",
"Due Date",
@@ -1226,6 +1245,7 @@ def export_tasks():
task.project.name if task.project else "",
task.status,
task.priority,
task.tags or "",
task.assigned_user.display_name if task.assigned_user else "",
task.creator.display_name if task.creator else "",
task.due_date.strftime("%Y-%m-%d") if task.due_date else "",
@@ -1263,6 +1283,7 @@ def my_tasks():
priority = request.args.get("priority", "")
project_id = request.args.get("project_id", type=int)
search = request.args.get("search", "").strip()
tags = request.args.get("tags", "").strip()
task_type = request.args.get("task_type", "") # '', 'assigned', 'created'
overdue_param = request.args.get("overdue", "").strip().lower()
overdue = overdue_param in ["1", "true", "on", "yes"]
@@ -1291,6 +1312,12 @@ def my_tasks():
like = f"%{search}%"
query = query.filter(db.or_(Task.name.ilike(like), Task.description.ilike(like)))
if tags:
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
if tag_list:
tag_conditions = [Task.tags.ilike(f"%{tag}%") for tag in tag_list]
query = query.filter(db.or_(*tag_conditions))
# Overdue filter (uses application's local date)
if overdue:
today_local = now_in_app_timezone().date()
@@ -1317,6 +1344,7 @@ def my_tasks():
priority=priority,
project_id=project_id,
search=search,
tags=tags,
task_type=task_type,
overdue=overdue,
)
+3
View File
@@ -17,6 +17,7 @@ class TaskSchema(Schema):
status = fields.Str(validate=validate.OneOf([s.value for s in TaskStatus]))
priority = fields.Str(validate=validate.OneOf(["low", "medium", "high", "urgent"]))
due_date = fields.Date(allow_none=True)
tags = fields.Str(allow_none=True)
created_by = fields.Int(required=True)
created_at = fields.DateTime(dump_only=True)
updated_at = fields.DateTime(dump_only=True)
@@ -35,6 +36,7 @@ class TaskCreateSchema(Schema):
assignee_id = fields.Int(allow_none=True)
priority = fields.Str(missing="medium", validate=validate.OneOf(["low", "medium", "high", "urgent"]))
due_date = fields.Date(allow_none=True)
tags = fields.Str(allow_none=True)
class TaskUpdateSchema(Schema):
@@ -46,3 +48,4 @@ class TaskUpdateSchema(Schema):
status = fields.Str(allow_none=True, validate=validate.OneOf([s.value for s in TaskStatus]))
priority = fields.Str(allow_none=True, validate=validate.OneOf(["low", "medium", "high", "urgent"]))
due_date = fields.Date(allow_none=True)
tags = fields.Str(allow_none=True)
+15
View File
@@ -57,6 +57,7 @@ class TaskService:
estimated_hours: Optional[float] = None,
color: Optional[str] = None,
status: Optional[str] = None,
tags: Optional[str] = None,
) -> Dict[str, Any]:
"""
Create a new task.
@@ -98,6 +99,7 @@ class TaskService:
estimated_hours=estimated_hours,
status=task_status,
created_by=created_by,
tags=tags,
)
if color:
task.color = color
@@ -191,6 +193,7 @@ class TaskService:
assigned_to: Optional[int] = None,
search: Optional[str] = None,
overdue: bool = False,
tags: Optional[str] = None,
user_id: Optional[int] = None,
is_admin: bool = False,
has_view_all_tasks: bool = False,
@@ -252,6 +255,13 @@ class TaskService:
like = f"%{search}%"
query = query.filter(db.or_(Task.name.ilike(like), Task.description.ilike(like)))
# Tags filter: match tasks that have at least one of the specified tags (comma-separated)
if tags:
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
if tag_list:
tag_conditions = [Task.tags.ilike(f"%{tag}%") for tag in tag_list]
query = query.filter(db.or_(*tag_conditions))
# Overdue filter
if overdue:
today_local = now_in_app_timezone().date()
@@ -298,6 +308,11 @@ class TaskService:
if search:
like = f"%{search}%"
count_query = count_query.filter(db.or_(Task.name.ilike(like), Task.description.ilike(like)))
if tags:
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
if tag_list:
tag_conditions = [Task.tags.ilike(f"%{tag}%") for tag in tag_list]
count_query = count_query.filter(db.or_(*tag_conditions))
if overdue:
today_local = now_in_app_timezone().date()
count_query = count_query.filter(Task.due_date < today_local, Task.status.in_(["todo", "in_progress", "review"]))
+14 -2
View File
@@ -4,7 +4,7 @@
{{ tasks|length }} task{{ 's' if tasks|length != 1 else '' }} found
</h3>
<div class="flex items-center gap-2">
<a href="{{ url_for('tasks.export_tasks', status=status, priority=priority, project_ids=project_ids|join(',') if project_ids else '', assigned_to_ids=assigned_to_ids|join(',') if assigned_to_ids else '', search=search, overdue=overdue) }}" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center" title="{{ _('Export to CSV') }}">
<a href="{{ url_for('tasks.export_tasks', status=status, priority=priority, project_ids=project_ids|join(',') if project_ids else '', assigned_to_ids=assigned_to_ids|join(',') if assigned_to_ids else '', search=search, tags=tags or '', overdue=overdue) }}" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center" title="{{ _('Export to CSV') }}">
<i class="fas fa-download mr-1"></i> Export
</a>
<div class="relative">
@@ -29,6 +29,7 @@
</th>
<th class="p-4" data-sortable>Name</th>
<th class="p-4" data-sortable>Project</th>
<th class="p-4">{{ _('Tags') }}</th>
<th class="p-4" data-sortable>Priority</th>
<th class="p-4" data-sortable>Status</th>
<th class="p-4 table-number" data-sortable>Due</th>
@@ -44,6 +45,17 @@
</td>
<td class="p-4"><a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="text-primary hover:underline">{{ task.name }}</a></td>
<td class="p-4"><a href="{{ url_for('projects.view_project', project_id=task.project_id) }}" class="text-primary hover:underline">{{ task.project.name }}</a></td>
<td class="p-4">
{% if task.tag_list %}
<div class="flex flex-wrap gap-1">
{% for tag in task.tag_list %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200">{{ tag }}</span>
{% endfor %}
</div>
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark"></span>
{% endif %}
</td>
<td class="p-4">
{% set p = task.priority %}
{% set pcls = {'low':'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
@@ -94,7 +106,7 @@
<i class="fas fa-question-circle mr-2"></i>Learn More
</a>
{% endset %}
{% if search or status or priority or project_id or assigned_to %}
{% if search or status or priority or project_id or assigned_to or tags %}
{{ empty_state('fas fa-search', 'No Tasks Match Your Filters', 'Try adjusting your filters to see more results. You can clear filters or create a new task that matches your criteria.', actions, type='no-results') }}
{% else %}
{{ empty_state('fas fa-tasks', 'No Tasks Yet', 'Tasks help you break down projects into manageable pieces. Create your first task to get started organizing your work!', actions, type='no-data') }}
+10
View File
@@ -115,6 +115,16 @@
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Assign this task to a team member') }}</p>
</div>
<!-- Tags -->
<div class="mb-4">
<label for="tags" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-tags mr-2 text-primary"></i>{{ _('Tags') }}
</label>
<input type="text" class="form-input" id="tags" name="tags"
value="{{ request.form.get('tags', '') }}" placeholder="{{ _('e.g. bug, frontend, urgent') }}" maxlength="500">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Comma-separated tags for categorization') }}</p>
</div>
<!-- Gantt color -->
<div class="mb-4">
<label for="color" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Gantt color') }}</label>
+10
View File
@@ -136,6 +136,16 @@
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Assign this task to a team member') }}</p>
</div>
<!-- Tags -->
<div class="mb-4">
<label for="tags" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-tags mr-2 text-primary"></i>{{ _('Tags') }}
</label>
<input type="text" class="form-input" id="tags" name="tags"
value="{{ task.tags or '' }}" placeholder="{{ _('e.g. bug, frontend, urgent') }}" maxlength="500">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Comma-separated tags for categorization') }}</p>
</div>
<!-- Gantt color -->
<div class="mb-4">
<label for="color" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Gantt color') }}</label>
+21
View File
@@ -41,6 +41,10 @@
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
<input type="text" name="search" id="search" value="{{ search or '' }}" class="form-input">
</div>
<div class="lg:col-span-1">
<label for="tags" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Tags') }}</label>
<input type="text" name="tags" id="tags" value="{{ tags or '' }}" class="form-input" placeholder="{{ _('e.g. bug, frontend') }}">
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
<select name="status" id="status" class="form-input">
@@ -656,6 +660,23 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
// Auto-submit on tags input (debounced)
const tagsInput = form.querySelector('input[name="tags"], input#tags');
if (tagsInput) {
tagsInput.addEventListener('input', () => {
debouncedSearch(500);
});
tagsInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (searchTimeout) {
clearTimeout(searchTimeout);
}
applyFilters();
}
});
}
// Prevent form submission (use AJAX instead)
form.addEventListener('submit', (e) => {
e.preventDefault();
+13
View File
@@ -119,6 +119,11 @@
value="{{ search }}" placeholder="{{ _('Task name or description') }}">
</div>
</div>
<div class="col-md-2 col-sm-6">
<label for="tags" class="form-label">{{ _('Tags') }}</label>
<input type="text" class="form-control" id="tags" name="tags"
value="{{ tags }}" placeholder="{{ _('e.g. bug, frontend') }}">
</div>
<div class="col-md-2 col-sm-6">
<label for="status" class="form-label">{{ _('Status') }}</label>
<select class="form-select" id="status" name="status">
@@ -228,6 +233,14 @@
</p>
{% endif %}
{% if task.tag_list %}
<div class="d-flex flex-wrap gap-1 mb-3">
{% for tag in task.tag_list %}
<span class="badge bg-secondary bg-opacity-25 text-secondary">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
<!-- Project Info -->
<div class="d-flex align-items-center mb-3">
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
+10
View File
@@ -133,6 +133,16 @@
<p>{{ task.assigned_user.display_name }}</p>
</div>
{% endif %}
{% if task.tags %}
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">{{ _('Tags') }}</h3>
<p class="flex flex-wrap gap-1">
{% for tag in task.tag_list %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200">{{ tag }}</span>
{% endfor %}
</p>
</div>
{% endif %}
{% if task.due_date %}
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Due Date</h3>
+1
View File
@@ -443,6 +443,7 @@ def _task_to_dict(task):
"status": task.status,
"priority": task.priority,
"due_date": task.due_date.isoformat() if task.due_date else None,
"tags": task.tags,
"created_at": task.created_at.isoformat() if task.created_at else None,
}
+9 -13
View File
@@ -399,7 +399,9 @@ def fetch_oidc_metadata(
timeout: Request timeout in seconds (default: 10)
use_dns_test: Whether to test DNS resolution first (default: True)
dns_strategy: DNS resolution strategy - "auto", "socket", "getaddrinfo", or "both"
use_ip_directly: Use IP address directly if DNS resolution succeeds (default: True)
use_ip_directly: Use IP address directly for HTTP issuer URLs when DNS resolution
succeeds (default: True). For HTTPS, the request is always made to the original
issuer hostname to satisfy TLS SNI and virtual hosting requirements.
use_docker_internal: Try Docker internal names if external DNS fails (default: True)
Returns:
@@ -438,18 +440,12 @@ def fetch_oidc_metadata(
"error": dns_error,
}
if dns_success and resolved_ip and use_ip_directly:
# Replace hostname with IP in URL
# Note: We need to preserve the original hostname for Host header in HTTPS
# For HTTPS, we'll use the IP but set the Host header
if parsed.scheme == "https":
# For HTTPS, we can't easily use IP directly due to SNI requirements
# But we'll try it anyway - some servers accept it
metadata_url = original_metadata_url.replace(hostname, resolved_ip)
logger.info("Using IP address directly for metadata fetch: %s -> %s", hostname, _ip_cache._mask_ip(resolved_ip))
else:
metadata_url = original_metadata_url.replace(hostname, resolved_ip)
logger.info("Using IP address directly for metadata fetch: %s -> %s", hostname, _ip_cache._mask_ip(resolved_ip))
if dns_success and resolved_ip and use_ip_directly and parsed.scheme == "http":
# Replace hostname with IP in URL (HTTP only).
# For HTTPS, we always use the original issuer hostname - using the IP breaks
# TLS SNI and virtual hosting (IDPs typically require the domain in SNI/Host).
metadata_url = original_metadata_url.replace(hostname, resolved_ip)
logger.info("Using IP address directly for metadata fetch: %s -> %s", hostname, _ip_cache._mask_ip(resolved_ip))
elif not dns_success:
logger.warning(
"DNS resolution test failed for %s using %s strategy, but will attempt metadata fetch anyway",
+45
View File
@@ -0,0 +1,45 @@
"""Add tags column to tasks table
Revision ID: 129_add_task_tags
Revises: 128_add_invoices_zugferd_pdf
Create Date: 2026-02-28
"""
from alembic import op
import sqlalchemy as sa
revision = "129_add_task_tags"
down_revision = "128_add_invoices_zugferd_pdf"
branch_labels = None
depends_on = None
def upgrade():
"""Add tags column to tasks table for categorization."""
bind = op.get_bind()
inspector = sa.inspect(bind)
if "tasks" in inspector.get_table_names():
tasks_cols = {c["name"] for c in inspector.get_columns("tasks")}
if "tags" not in tasks_cols:
op.add_column(
"tasks",
sa.Column("tags", sa.String(length=500), nullable=True),
)
def downgrade():
"""Remove tags column from tasks table."""
bind = op.get_bind()
inspector = sa.inspect(bind)
is_sqlite = bind.dialect.name == "sqlite"
if "tasks" in inspector.get_table_names():
tasks_cols = {c["name"] for c in inspector.get_columns("tasks")}
if "tags" in tasks_cols:
if is_sqlite:
with op.batch_alter_table("tasks", schema=None) as batch_op:
batch_op.drop_column("tags")
else:
op.drop_column("tasks", "tags")