fix: resolve Excel export 500 error caused by incorrect project.client access

- Fixed AttributeError when accessing project.client.name in Excel export
- Project.client is a string property returning the client name, not an object
- Changed all incorrect .name accesses to use the string property directly
- Added unit tests for Excel export functionality to prevent regression

Fixes bug where exporting time entries to Excel resulted in 500 server error
with message: 'str' object has no attribute 'name'

Files changed:
- app/utils/excel_export.py: Fixed time entries export client column
- app/routes/reports.py: Fixed project report export client field
- app/templates/projects/view.html: Fixed project view template
- tests/test_excel_export.py: Added comprehensive Excel export tests
This commit is contained in:
Dries Peeters
2025-11-03 08:32:10 +01:00
parent a90e587fc9
commit 279a2fb667
4 changed files with 147 additions and 3 deletions

View File

@@ -744,7 +744,7 @@ def export_project_excel():
if project.id not in projects_map:
projects_map[project.id] = {
'name': project.name,
'client': project.client.name if project.client else '',
'client': project.client if project.client else '',
'total_hours': 0,
'billable_hours': 0,
'hourly_rate': float(project.hourly_rate) if project.hourly_rate else 0,

View File

@@ -10,7 +10,7 @@
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold tracking-wide uppercase bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200" title="{{ _('Project Code') }}">{{ project.code_display }}</span>
{% endif %}
</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ project.client.name }}</p>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ project.client }}</p>
</div>
{% if current_user.is_admin or has_any_permission(['edit_projects', 'archive_projects']) %}
<div class="flex gap-2 flex-wrap">

View File

@@ -54,7 +54,7 @@ def create_time_entries_excel(entries, filename_prefix='timetracker_export'):
entry.id,
entry.user.display_name if entry.user else 'Unknown',
entry.project.name if entry.project else 'N/A',
entry.project.client.name if (entry.project and entry.project.client) else 'N/A',
entry.project.client if (entry.project and entry.project.client) else 'N/A',
entry.task.name if entry.task else 'N/A',
entry.start_time.isoformat() if entry.start_time else '',
entry.end_time.isoformat() if entry.end_time else '',

144
tests/test_excel_export.py Normal file
View File

@@ -0,0 +1,144 @@
"""
Tests for Excel export functionality
"""
import pytest
from datetime import datetime, timedelta
from app.models import TimeEntry, Task
@pytest.mark.unit
@pytest.mark.routes
def test_create_time_entries_excel_with_client(app, user, project, test_client):
"""Test that Excel export handles project.client correctly as a string property"""
from app.utils.excel_export import create_time_entries_excel
# Create a time entry with project that has a client
start_time = datetime.utcnow() - timedelta(hours=2)
end_time = datetime.utcnow()
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time,
notes='Test entry for Excel export',
tags='test,export',
source='manual',
billable=True
)
# Calculate duration manually since we're not going through the full commit cycle
entry.duration_seconds = (end_time - start_time).total_seconds()
# Test that project.client is a string property, not an object
assert hasattr(project, 'client')
assert isinstance(project.client, str)
assert project.client == test_client.name
# Test Excel export function
output, filename = create_time_entries_excel([entry])
# Verify the output was created successfully
assert output is not None
assert filename is not None
assert filename.endswith('.xlsx')
# Verify the file content can be read
output.seek(0)
content = output.read()
assert len(content) > 0
@pytest.mark.unit
@pytest.mark.routes
def test_create_time_entries_excel_with_task(app, user, project, task):
"""Test that Excel export handles entries with tasks correctly"""
from app.utils.excel_export import create_time_entries_excel
# Create a time entry with a task
start_time = datetime.utcnow() - timedelta(hours=1)
end_time = datetime.utcnow()
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
task_id=task.id,
start_time=start_time,
end_time=end_time,
notes='Test entry with task',
billable=True
)
# Calculate duration
entry.duration_seconds = (end_time - start_time).total_seconds()
# Test Excel export function
output, filename = create_time_entries_excel([entry])
# Verify the output was created successfully
assert output is not None
assert filename is not None
# Verify the file content can be read
output.seek(0)
content = output.read()
assert len(content) > 0
@pytest.mark.unit
@pytest.mark.routes
def test_create_time_entries_excel_multiple_entries(app, multiple_time_entries):
"""Test Excel export with multiple time entries"""
from app.utils.excel_export import create_time_entries_excel
# Test Excel export function with multiple entries
output, filename = create_time_entries_excel(multiple_time_entries)
# Verify the output was created successfully
assert output is not None
assert filename is not None
# Verify the file content can be read
output.seek(0)
content = output.read()
assert len(content) > 0
@pytest.mark.unit
@pytest.mark.routes
def test_project_report_excel_export(app, time_entry):
"""Test project report Excel export with project.client"""
from app.utils.excel_export import create_project_report_excel
# Create project data structure
project = time_entry.project
projects_data = [{
'name': project.name,
'client': project.client, # Should be a string
'total_hours': 8.0,
'billable_hours': 7.5,
'hourly_rate': 75.00,
'billable_amount': 562.50,
'total_costs': 0,
'total_value': 562.50
}]
# Test that project.client is a string
assert isinstance(project.client, str)
# Test Excel export function
output, filename = create_project_report_excel(
projects_data,
start_date='2024-01-01',
end_date='2024-12-31'
)
# Verify the output was created successfully
assert output is not None
assert filename is not None
# Verify the file content can be read
output.seek(0)
content = output.read()
assert len(content) > 0