mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-18 12:19:18 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user