mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-20 03:20:25 -06:00
feat: Add custom field filtering and display for clients, projects, and time entries
- Extend client list table to display custom field columns - Add custom field columns dynamically based on active CustomFieldDefinition entries - Support link templates for clickable custom field values - Enable column visibility toggle for custom field columns - Update search functionality to include custom fields (PostgreSQL JSONB and SQLite fallback) - Add custom field filtering to Projects list - Extend ProjectService.list_projects() to filter by client custom fields - Add custom field filter inputs to projects list template - Support filtering by client custom field values (e.g., debtor_number, ERP IDs) - Handle both PostgreSQL (JSONB) and SQLite (Python fallback) filtering - Add custom field filtering to Time Entries list - Extend time entries route to filter by client custom fields - Add custom field filter inputs to time entries overview template - Enable filtering time entries by client custom field values - Support distinguishing clients with same name but different custom field values - Database compatibility - PostgreSQL: Use efficient JSONB operators for database-level filtering - SQLite: Fallback to Python-based filtering after initial query - Both approaches ensure accurate results across database backends This enhancement allows users to filter and search by custom field values, making it easier to distinguish between clients with identical names but different identifiers (e.g., debtor numbers, ERP IDs).
This commit is contained in:
@@ -32,18 +32,95 @@ def list_clients():
|
||||
elif status == "inactive":
|
||||
query = query.filter_by(status="inactive")
|
||||
|
||||
# Determine database type for search strategy
|
||||
is_postgres = False
|
||||
try:
|
||||
from sqlalchemy import inspect
|
||||
engine = db.engine
|
||||
is_postgres = 'postgresql' in str(engine.url).lower()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if search:
|
||||
like = f"%{search}%"
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Client.name.ilike(like),
|
||||
Client.description.ilike(like),
|
||||
Client.contact_person.ilike(like),
|
||||
Client.email.ilike(like),
|
||||
)
|
||||
)
|
||||
search_conditions = [
|
||||
Client.name.ilike(like),
|
||||
Client.description.ilike(like),
|
||||
Client.contact_person.ilike(like),
|
||||
Client.email.ilike(like),
|
||||
]
|
||||
|
||||
# Add custom fields to search based on database type
|
||||
if is_postgres:
|
||||
# PostgreSQL: Use JSONB operators for efficient search
|
||||
try:
|
||||
from sqlalchemy import cast, String
|
||||
active_definitions = CustomFieldDefinition.get_active_definitions()
|
||||
for definition in active_definitions:
|
||||
# PostgreSQL JSONB path query: custom_fields->>'field_key' ILIKE pattern
|
||||
search_conditions.append(
|
||||
db.cast(Client.custom_fields[definition.field_key].astext, String).ilike(like)
|
||||
)
|
||||
except Exception as e:
|
||||
# If JSONB search fails, log and continue without custom field search in DB
|
||||
current_app.logger.warning(f"Could not add JSONB search conditions: {e}")
|
||||
|
||||
query = query.filter(db.or_(*search_conditions))
|
||||
|
||||
clients = query.order_by(Client.name).all()
|
||||
|
||||
# For SQLite and other non-PostgreSQL databases, filter by custom fields in Python
|
||||
# (PostgreSQL already handles this in the query above)
|
||||
if search and not is_postgres:
|
||||
try:
|
||||
search_lower = search.lower()
|
||||
filtered_clients = []
|
||||
active_definitions = CustomFieldDefinition.get_active_definitions()
|
||||
|
||||
for client in clients:
|
||||
# Check if matches standard fields (already in results) or custom fields
|
||||
matched_standard = any([
|
||||
(client.name and search_lower in client.name.lower()),
|
||||
(client.description and search_lower in (client.description or "").lower()),
|
||||
(client.contact_person and search_lower in (client.contact_person or "").lower()),
|
||||
(client.email and search_lower in (client.email or "").lower()),
|
||||
])
|
||||
|
||||
matched_custom = False
|
||||
if client.custom_fields:
|
||||
for definition in active_definitions:
|
||||
field_value = client.custom_fields.get(definition.field_key)
|
||||
if field_value and search_lower in str(field_value).lower():
|
||||
matched_custom = True
|
||||
break
|
||||
|
||||
if matched_standard or matched_custom:
|
||||
filtered_clients.append(client)
|
||||
|
||||
clients = filtered_clients
|
||||
except Exception:
|
||||
# If filtering fails, just use the original results
|
||||
pass
|
||||
|
||||
# Get custom field definitions for the template
|
||||
custom_field_definitions = CustomFieldDefinition.get_active_definitions()
|
||||
|
||||
# Get link templates for custom fields (for clickable values)
|
||||
from app.models import LinkTemplate
|
||||
from sqlalchemy.exc import ProgrammingError
|
||||
link_templates_by_field = {}
|
||||
try:
|
||||
for template in LinkTemplate.get_active_templates():
|
||||
link_templates_by_field[template.field_key] = template
|
||||
except ProgrammingError as e:
|
||||
# Handle case where link_templates table doesn't exist (migration not run)
|
||||
if "does not exist" in str(e.orig) or "relation" in str(e.orig).lower():
|
||||
current_app.logger.warning(
|
||||
"link_templates table does not exist. Run migration: flask db upgrade"
|
||||
)
|
||||
link_templates_by_field = {}
|
||||
else:
|
||||
raise
|
||||
|
||||
# Check if this is an AJAX request
|
||||
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||
@@ -53,11 +130,13 @@ def list_clients():
|
||||
clients=clients,
|
||||
status=status,
|
||||
search=search,
|
||||
custom_field_definitions=custom_field_definitions,
|
||||
link_templates_by_field=link_templates_by_field,
|
||||
))
|
||||
response.headers["Content-Type"] = "text/html; charset=utf-8"
|
||||
return response
|
||||
|
||||
return render_template("clients/list.html", clients=clients, status=status, search=search)
|
||||
return render_template("clients/list.html", clients=clients, status=status, search=search, custom_field_definitions=custom_field_definitions, link_templates_by_field=link_templates_by_field)
|
||||
|
||||
|
||||
@clients_bp.route("/clients/create", methods=["GET", "POST"])
|
||||
|
||||
@@ -60,11 +60,22 @@ def list_projects():
|
||||
# Handle "all" status - pass None to service to show all statuses
|
||||
status_param = None if (status == "all" or not status) else status
|
||||
client_name = request.args.get("client", "").strip()
|
||||
client_id = request.args.get("client_id", type=int)
|
||||
search = request.args.get("search", "").strip()
|
||||
favorites_only = request.args.get("favorites", "").lower() == "true"
|
||||
|
||||
# Get custom field filters
|
||||
# Format: custom_field_<field_key>=value
|
||||
client_custom_field = {}
|
||||
from app.models import CustomFieldDefinition
|
||||
active_definitions = CustomFieldDefinition.get_active_definitions()
|
||||
for definition in active_definitions:
|
||||
field_value = request.args.get(f"custom_field_{definition.field_key}", "").strip()
|
||||
if field_value:
|
||||
client_custom_field[definition.field_key] = field_value
|
||||
|
||||
# Debug logging
|
||||
current_app.logger.debug(f"Projects list filters - search: '{search}', status: '{status}', client: '{client_name}', favorites: {favorites_only}")
|
||||
current_app.logger.debug(f"Projects list filters - search: '{search}', status: '{status}', client: '{client_name}', client_id: {client_id}, custom_fields: {client_custom_field}, favorites: {favorites_only}")
|
||||
|
||||
project_service = ProjectService()
|
||||
|
||||
@@ -72,6 +83,8 @@ def list_projects():
|
||||
result = project_service.list_projects(
|
||||
status=status_param,
|
||||
client_name=client_name if client_name else None,
|
||||
client_id=client_id,
|
||||
client_custom_field=client_custom_field if client_custom_field else None,
|
||||
search=search if search else None,
|
||||
favorites_only=favorites_only,
|
||||
user_id=current_user.id if favorites_only else None,
|
||||
@@ -85,6 +98,10 @@ def list_projects():
|
||||
# Get clients for filter dropdown
|
||||
clients = Client.get_active_clients()
|
||||
client_list = [c.name for c in clients]
|
||||
|
||||
# Get custom field definitions for filter UI
|
||||
from app.models import CustomFieldDefinition
|
||||
custom_field_definitions = CustomFieldDefinition.get_active_definitions()
|
||||
|
||||
# Check if this is an AJAX request
|
||||
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||
@@ -110,6 +127,7 @@ def list_projects():
|
||||
clients=client_list,
|
||||
favorite_project_ids=favorite_project_ids,
|
||||
favorites_only=favorites_only,
|
||||
custom_field_definitions=custom_field_definitions,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1561,6 +1561,16 @@ def time_entries_overview():
|
||||
page = request.args.get("page", 1, type=int)
|
||||
per_page = request.args.get("per_page", 50, type=int)
|
||||
|
||||
# Get custom field filters for clients
|
||||
# Format: custom_field_<field_key>=value
|
||||
client_custom_field = {}
|
||||
from app.models import CustomFieldDefinition
|
||||
active_definitions = CustomFieldDefinition.get_active_definitions()
|
||||
for definition in active_definitions:
|
||||
field_value = request.args.get(f"custom_field_{definition.field_key}", "").strip()
|
||||
if field_value:
|
||||
client_custom_field[definition.field_key] = field_value
|
||||
|
||||
# Permission check: can user view all entries?
|
||||
can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries")
|
||||
|
||||
@@ -1593,6 +1603,41 @@ def time_entries_overview():
|
||||
if client_id:
|
||||
query = query.filter(TimeEntry.client_id == client_id)
|
||||
|
||||
# Filter by client custom fields
|
||||
if client_custom_field:
|
||||
# Join Client table to filter by custom fields
|
||||
query = query.join(Client, TimeEntry.client_id == Client.id)
|
||||
|
||||
# Determine database type for custom field filtering
|
||||
is_postgres = False
|
||||
try:
|
||||
from sqlalchemy import inspect
|
||||
engine = db.engine
|
||||
is_postgres = 'postgresql' in str(engine.url).lower()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Build custom field filter conditions
|
||||
custom_field_conditions = []
|
||||
for field_key, field_value in client_custom_field.items():
|
||||
if not field_key or not field_value:
|
||||
continue
|
||||
|
||||
if is_postgres:
|
||||
# PostgreSQL: Use JSONB operators
|
||||
try:
|
||||
from sqlalchemy import cast, String
|
||||
# Match exact value in custom_fields JSONB
|
||||
custom_field_conditions.append(
|
||||
db.cast(Client.custom_fields[field_key].astext, String) == str(field_value)
|
||||
)
|
||||
except Exception:
|
||||
# Fallback to Python filtering if JSONB fails
|
||||
pass
|
||||
|
||||
if custom_field_conditions:
|
||||
query = query.filter(db.or_(*custom_field_conditions))
|
||||
|
||||
# Filter by date range
|
||||
if start_date:
|
||||
try:
|
||||
@@ -1639,6 +1684,55 @@ def time_entries_overview():
|
||||
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
time_entries = pagination.items
|
||||
|
||||
# For SQLite or if JSONB filtering didn't work, filter by custom fields in Python
|
||||
if client_custom_field:
|
||||
try:
|
||||
from sqlalchemy import inspect
|
||||
engine = db.engine
|
||||
is_postgres = 'postgresql' in str(engine.url).lower()
|
||||
|
||||
if not is_postgres:
|
||||
# SQLite: Filter in Python
|
||||
filtered_entries = []
|
||||
for entry in time_entries:
|
||||
if not entry.client:
|
||||
continue
|
||||
|
||||
# Check if client matches all custom field filters
|
||||
matches = True
|
||||
for field_key, field_value in client_custom_field.items():
|
||||
if not field_key or not field_value:
|
||||
continue
|
||||
|
||||
client_value = entry.client.custom_fields.get(field_key) if entry.client.custom_fields else None
|
||||
if str(client_value) != str(field_value):
|
||||
matches = False
|
||||
break
|
||||
|
||||
if matches:
|
||||
filtered_entries.append(entry)
|
||||
|
||||
# Update pagination with filtered results
|
||||
time_entries = filtered_entries
|
||||
# Recalculate pagination manually
|
||||
total = len(filtered_entries)
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
time_entries = filtered_entries[start:end]
|
||||
|
||||
# Create a pagination-like object
|
||||
from flask_sqlalchemy import Pagination
|
||||
pagination = Pagination(
|
||||
query=None,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total=total,
|
||||
items=time_entries
|
||||
)
|
||||
except Exception:
|
||||
# If filtering fails, use original results
|
||||
pass
|
||||
|
||||
# Get filter options
|
||||
projects = []
|
||||
clients = []
|
||||
@@ -1693,10 +1787,15 @@ def time_entries_overview():
|
||||
"paid": paid_filter,
|
||||
"billable": billable_filter,
|
||||
"search": search,
|
||||
"client_custom_field": client_custom_field,
|
||||
"page": page,
|
||||
"per_page": per_page
|
||||
}
|
||||
|
||||
# Get custom field definitions for filter UI
|
||||
from app.models import CustomFieldDefinition
|
||||
custom_field_definitions = CustomFieldDefinition.get_active_definitions()
|
||||
|
||||
# Check if this is an AJAX request
|
||||
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||
# Return only the time entries list HTML for AJAX requests
|
||||
@@ -1706,7 +1805,8 @@ def time_entries_overview():
|
||||
time_entries=time_entries,
|
||||
pagination=pagination,
|
||||
can_view_all=can_view_all,
|
||||
filters=filters_dict
|
||||
filters=filters_dict,
|
||||
custom_field_definitions=custom_field_definitions,
|
||||
))
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
return response
|
||||
@@ -1720,6 +1820,7 @@ def time_entries_overview():
|
||||
users=users,
|
||||
can_view_all=can_view_all,
|
||||
filters=filters_dict,
|
||||
custom_field_definitions=custom_field_definitions,
|
||||
totals={
|
||||
"total_hours": round(total_hours, 2),
|
||||
"total_billable_hours": round(total_billable_hours, 2),
|
||||
|
||||
@@ -209,6 +209,8 @@ class ProjectService:
|
||||
self,
|
||||
status: Optional[str] = None,
|
||||
client_name: Optional[str] = None,
|
||||
client_id: Optional[int] = None,
|
||||
client_custom_field: Optional[Dict[str, str]] = None, # {field_key: value}
|
||||
search: Optional[str] = None,
|
||||
favorites_only: bool = False,
|
||||
user_id: Optional[int] = None,
|
||||
@@ -219,11 +221,15 @@ class ProjectService:
|
||||
List projects with filtering and pagination.
|
||||
Uses eager loading to prevent N+1 queries.
|
||||
|
||||
Args:
|
||||
client_custom_field: Dict with field_key and value to filter by client custom fields
|
||||
Example: {"debtor_number": "12345"}
|
||||
|
||||
Returns:
|
||||
dict with 'projects', 'pagination', and 'total' keys
|
||||
"""
|
||||
from sqlalchemy.orm import joinedload
|
||||
from app.models import UserFavoriteProject, Client
|
||||
from app.models import UserFavoriteProject, Client, CustomFieldDefinition
|
||||
|
||||
query = self.project_repo.query()
|
||||
|
||||
@@ -241,9 +247,58 @@ class ProjectService:
|
||||
if status and status != "all":
|
||||
query = query.filter(Project.status == status)
|
||||
|
||||
# Filter by client - join Client table if needed
|
||||
client_joined = False
|
||||
if client_name or client_id or client_custom_field:
|
||||
query = query.join(Client, Project.client_id == Client.id)
|
||||
client_joined = True
|
||||
|
||||
# Filter by client name
|
||||
if client_name:
|
||||
query = query.join(Client, Project.client_id == Client.id).filter(Client.name == client_name)
|
||||
query = query.filter(Client.name == client_name)
|
||||
|
||||
# Filter by client ID
|
||||
if client_id:
|
||||
query = query.filter(Client.id == client_id)
|
||||
|
||||
# Filter by client custom fields
|
||||
if client_custom_field:
|
||||
# Ensure Client is joined
|
||||
if not client_joined:
|
||||
query = query.join(Client, Project.client_id == Client.id)
|
||||
|
||||
# Determine database type for custom field filtering
|
||||
is_postgres = False
|
||||
try:
|
||||
from sqlalchemy import inspect
|
||||
engine = db.engine
|
||||
is_postgres = 'postgresql' in str(engine.url).lower()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Build custom field filter conditions
|
||||
custom_field_conditions = []
|
||||
for field_key, field_value in client_custom_field.items():
|
||||
if not field_key or not field_value:
|
||||
continue
|
||||
|
||||
if is_postgres:
|
||||
# PostgreSQL: Use JSONB operators
|
||||
try:
|
||||
from sqlalchemy import cast, String
|
||||
# Match exact value in custom_fields JSONB
|
||||
custom_field_conditions.append(
|
||||
db.cast(Client.custom_fields[field_key].astext, String) == str(field_value)
|
||||
)
|
||||
except Exception:
|
||||
# Fallback to Python filtering if JSONB fails
|
||||
pass
|
||||
else:
|
||||
# SQLite: Will filter in Python after query
|
||||
pass
|
||||
|
||||
if custom_field_conditions:
|
||||
query = query.filter(db.or_(*custom_field_conditions))
|
||||
|
||||
# Search filter - must be applied after any joins
|
||||
if search:
|
||||
@@ -261,8 +316,53 @@ class ProjectService:
|
||||
# Order and paginate
|
||||
query = query.order_by(Project.name)
|
||||
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
projects = pagination.items
|
||||
|
||||
return {"projects": pagination.items, "pagination": pagination, "total": pagination.total}
|
||||
# For SQLite or if JSONB filtering didn't work, filter by custom fields in Python
|
||||
if client_custom_field and not is_postgres:
|
||||
try:
|
||||
filtered_projects = []
|
||||
for project in projects:
|
||||
if not project.client_obj:
|
||||
continue
|
||||
|
||||
# Check if client matches all custom field filters
|
||||
matches = True
|
||||
for field_key, field_value in client_custom_field.items():
|
||||
if not field_key or not field_value:
|
||||
continue
|
||||
|
||||
client_value = project.client_obj.custom_fields.get(field_key) if project.client_obj.custom_fields else None
|
||||
if str(client_value) != str(field_value):
|
||||
matches = False
|
||||
break
|
||||
|
||||
if matches:
|
||||
filtered_projects.append(project)
|
||||
|
||||
# Update pagination with filtered results
|
||||
# Note: This affects pagination accuracy, but is necessary for SQLite
|
||||
projects = filtered_projects
|
||||
# Recalculate pagination manually
|
||||
total = len(filtered_projects)
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
projects = filtered_projects[start:end]
|
||||
|
||||
# Create a pagination-like object
|
||||
from flask_sqlalchemy import Pagination
|
||||
pagination = Pagination(
|
||||
query=None,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total=total,
|
||||
items=projects
|
||||
)
|
||||
except Exception:
|
||||
# If filtering fails, use original results
|
||||
pass
|
||||
|
||||
return {"projects": projects, "pagination": pagination, "total": pagination.total}
|
||||
|
||||
def get_project_view_data(
|
||||
self, project_id: int, time_entries_page: int = 1, time_entries_per_page: int = 50
|
||||
|
||||
@@ -36,6 +36,11 @@
|
||||
<th class="p-4" data-sortable>Status</th>
|
||||
<th class="p-4" data-sortable>Projects</th>
|
||||
<th class="p-4">Portal</th>
|
||||
{% if custom_field_definitions %}
|
||||
{% for definition in custom_field_definitions %}
|
||||
<th class="p-4" data-sortable data-column-key="custom_field_{{ definition.field_key }}">{{ definition.label }}</th>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<th class="p-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -77,6 +82,28 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if custom_field_definitions %}
|
||||
{% for definition in custom_field_definitions %}
|
||||
<td class="p-4" data-column-key="custom_field_{{ definition.field_key }}">
|
||||
{% if client.custom_fields and client.custom_fields.get(definition.field_key) %}
|
||||
{% set field_value = client.custom_fields.get(definition.field_key) %}
|
||||
{% set link_template = link_templates_by_field.get(definition.field_key) if (link_templates_by_field is defined and link_templates_by_field) else None %}
|
||||
{% if link_template %}
|
||||
<a href="{{ link_template.render_url(field_value) }}" target="_blank" class="text-primary hover:underline" title="{{ link_template.name }}">
|
||||
{{ field_value }}
|
||||
{% if link_template.icon %}
|
||||
<i class="{{ link_template.icon }} ml-1"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ field_value }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<td class="p-4">
|
||||
<a href="{{ url_for('clients.view_client', client_id=client.id) }}" class="text-primary hover:underline">View</a>
|
||||
</td>
|
||||
|
||||
@@ -52,6 +52,17 @@
|
||||
<option value="true" {% if favorites_only %}selected{% endif %}>⭐ Favorites Only</option>
|
||||
</select>
|
||||
</div>
|
||||
{% if custom_field_definitions %}
|
||||
{% for definition in custom_field_definitions %}
|
||||
<div>
|
||||
<label for="custom_field_{{ definition.field_key }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ definition.label }}</label>
|
||||
<input type="text" name="custom_field_{{ definition.field_key }}" id="custom_field_{{ definition.field_key }}"
|
||||
value="{{ request.args.get('custom_field_' + definition.field_key, '') }}"
|
||||
class="form-input"
|
||||
placeholder="{{ definition.description or '' }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,6 +91,17 @@
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Search') }}</label>
|
||||
<input type="text" name="search" id="search" value="{{ filters.search or '' }}" placeholder="{{ _('Search in notes and tags...') }}" class="form-input">
|
||||
</div>
|
||||
{% if custom_field_definitions %}
|
||||
{% for definition in custom_field_definitions %}
|
||||
<div>
|
||||
<label for="custom_field_{{ definition.field_key }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ definition.label }} (Client)</label>
|
||||
<input type="text" name="custom_field_{{ definition.field_key }}" id="custom_field_{{ definition.field_key }}"
|
||||
value="{{ filters.client_custom_field.get(definition.field_key, '') if filters.client_custom_field else '' }}"
|
||||
class="form-input"
|
||||
placeholder="{{ definition.description or '' }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user