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:
Dries Peeters
2025-12-01 19:25:05 +01:00
parent 2bd48d9e60
commit f87da99781
7 changed files with 361 additions and 14 deletions

View File

@@ -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"])

View File

@@ -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,
)

View File

@@ -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),

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>