From 3c5a937234ff5325354eb280c64b9302bc2277f5 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sat, 28 Feb 2026 17:28:15 +0100 Subject: [PATCH] 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) --- app/models/task.py | 13 +++++++ app/routes/api_v1.py | 5 +++ app/routes/tasks.py | 28 +++++++++++++++ app/schemas/task_schema.py | 3 ++ app/services/task_service.py | 15 ++++++++ app/templates/tasks/_tasks_list.html | 16 +++++++-- app/templates/tasks/create.html | 10 ++++++ app/templates/tasks/edit.html | 10 ++++++ app/templates/tasks/list.html | 21 +++++++++++ app/templates/tasks/my_tasks.html | 13 +++++++ app/templates/tasks/view.html | 10 ++++++ app/utils/data_export.py | 1 + app/utils/oidc_metadata.py | 22 +++++------- migrations/versions/129_add_task_tags.py | 45 ++++++++++++++++++++++++ 14 files changed, 197 insertions(+), 15 deletions(-) create mode 100644 migrations/versions/129_add_task_tags.py diff --git a/app/models/task.py b/app/models/task.py index f3347354..534a2f0b 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -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"" @@ -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 diff --git a/app/routes/api_v1.py b/app/routes/api_v1.py index 0d675b32..f75f339a 100644 --- a/app/routes/api_v1.py +++ b/app/routes/api_v1.py @@ -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) diff --git a/app/routes/tasks.py b/app/routes/tasks.py index c69e0be7..9a95666c 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -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, ) diff --git a/app/schemas/task_schema.py b/app/schemas/task_schema.py index ab3e1a29..4517c13c 100644 --- a/app/schemas/task_schema.py +++ b/app/schemas/task_schema.py @@ -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) diff --git a/app/services/task_service.py b/app/services/task_service.py index 6d4d2f9f..82cff91a 100644 --- a/app/services/task_service.py +++ b/app/services/task_service.py @@ -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"])) diff --git a/app/templates/tasks/_tasks_list.html b/app/templates/tasks/_tasks_list.html index c3b58777..d734a556 100644 --- a/app/templates/tasks/_tasks_list.html +++ b/app/templates/tasks/_tasks_list.html @@ -4,7 +4,7 @@ {{ tasks|length }} task{{ 's' if tasks|length != 1 else '' }} found
- + Export
@@ -29,6 +29,7 @@ Name Project + {{ _('Tags') }} Priority Status Due @@ -44,6 +45,17 @@ {{ task.name }} {{ task.project.name }} + + {% if task.tag_list %} +
+ {% for tag in task.tag_list %} + {{ tag }} + {% endfor %} +
+ {% else %} + + {% endif %} + {% 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 @@ Learn More {% 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') }} diff --git a/app/templates/tasks/create.html b/app/templates/tasks/create.html index f379e11c..2debf354 100644 --- a/app/templates/tasks/create.html +++ b/app/templates/tasks/create.html @@ -115,6 +115,16 @@

{{ _('Optional: Assign this task to a team member') }}

+ +
+ + +

{{ _('Comma-separated tags for categorization') }}

+
+
diff --git a/app/templates/tasks/edit.html b/app/templates/tasks/edit.html index c88ed769..55346b63 100644 --- a/app/templates/tasks/edit.html +++ b/app/templates/tasks/edit.html @@ -136,6 +136,16 @@

{{ _('Optional: Assign this task to a team member') }}

+ +
+ + +

{{ _('Comma-separated tags for categorization') }}

+
+
diff --git a/app/templates/tasks/list.html b/app/templates/tasks/list.html index 7f7eaaa9..7bce532c 100644 --- a/app/templates/tasks/list.html +++ b/app/templates/tasks/list.html @@ -41,6 +41,10 @@
+
+ + +
+