Complete partially implemented features: templates, activity feed, and logging

ented features that were missingUI components, integrations, or proper error handling:1. Time Entry Templates UI Integration   - Added template selector to timer page (timer_page.html)   - Updated timer route to load user templates   - Added JavaScript function to apply templates with one-click   - Created missing view.html template for template details   - Templates now appear on timer page sorted by most recently used2. Activity Feed Widget Real-time Updates   - Added WebSocket integration to Activity model for real-time events   - Activity.log() now emits 'activity_created' SocketIO events   - Updated activity feed widget to listen for WebSocket events   - Feed automatically refreshes when new activities match current filter   - Added proper error handling for WebSocket connection failures3. Invoice Routes Logging Improvements   - Replaced all print() statements with proper logging in invoices.py   - Added structured logging with appropriate log levels (info, debug, warning, error)   - Improved error handling with full traceback logging using exc_info=True   - All PDF export debug statements now use logger.debug/info/errorFiles changed:- app/routes/timer.py: Added template loading for timer page- app/templates/timer/timer_page.html: Added template selector UI and applyTemplate function- app/models/activity.py: Added WebSocket event emission on activity creation- app/templates/components/activity_feed_widget.html: Added WebSocket listener for real-time updates- app/routes/invoices.py: Replaced print statements with proper logging- app/templates/time_entry_templates/view.html: Created missing view template
This commit is contained in:
Dries Peeters
2025-11-12 11:53:18 +01:00
parent 59406b38ee
commit f8f269047e
6 changed files with 331 additions and 22 deletions
+16 -1
View File
@@ -75,10 +75,25 @@ class Activity(db.Model):
db.session.add(activity)
try:
db.session.commit()
# Emit WebSocket event for real-time updates
try:
from app import socketio
socketio.emit('activity_created', {
'activity': activity.to_dict(),
'user_id': user_id
})
except Exception as socket_error:
# Don't let WebSocket errors break activity logging
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Failed to emit activity WebSocket event: {socket_error}")
except Exception as e:
db.session.rollback()
# Don't let activity logging break the main flow
print(f"Failed to log activity: {e}")
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to log activity: {e}")
@classmethod
def get_recent(cls, user_id=None, limit=50, entity_type=None):
+16 -20
View File
@@ -771,41 +771,39 @@ def export_invoice_csv(invoice_id):
@login_required
def export_invoice_pdf(invoice_id):
"""Export invoice as PDF with optional page size selection"""
# Debug logging - output to stdout for Docker
import sys
print(f"\n{'='*80}", file=sys.stdout, flush=True)
print(f"INVOICE EXPORT ROUTE CALLED - Invoice ID: {invoice_id}", file=sys.stdout, flush=True)
print(f"{'='*80}", file=sys.stdout, flush=True)
import logging
logger = logging.getLogger(__name__)
logger.info(f"Invoice PDF export requested - Invoice ID: {invoice_id}, User: {current_user.username}")
invoice = Invoice.query.get_or_404(invoice_id)
print(f"[ROUTE DEBUG] Invoice found: {invoice.invoice_number}", file=sys.stdout, flush=True)
logger.debug(f"Invoice found: {invoice.invoice_number}")
if not current_user.is_admin and invoice.created_by != current_user.id:
print(f"[ROUTE DEBUG] Permission denied", file=sys.stdout, flush=True)
logger.warning(f"Permission denied for invoice {invoice_id} by user {current_user.username}")
flash(_('You do not have permission to export this invoice'), 'error')
return redirect(request.referrer or url_for('invoices.list_invoices'))
# Get page size from query parameter, default to A4
page_size = request.args.get('size', 'A4')
print(f"[ROUTE DEBUG] Page size from request: '{page_size}'", file=sys.stdout, flush=True)
logger.debug(f"Page size from request: '{page_size}'")
# Validate page size
valid_sizes = ['A4', 'Letter', 'Legal', 'A3', 'A5', 'Tabloid']
if page_size not in valid_sizes:
print(f"[ROUTE DEBUG] Invalid page size, defaulting to A4", file=sys.stdout, flush=True)
logger.warning(f"Invalid page size '{page_size}', defaulting to A4")
page_size = 'A4'
print(f"[ROUTE DEBUG] Final page size: '{page_size}'", file=sys.stdout, flush=True)
print(f"[ROUTE DEBUG] Calling InvoicePDFGenerator.generate_pdf()...", file=sys.stdout, flush=True)
logger.debug(f"Final page size: '{page_size}'")
try:
from app.utils.pdf_generator import InvoicePDFGenerator
settings = Settings.get_settings()
print(f"[ROUTE DEBUG] Creating InvoicePDFGenerator with page_size='{page_size}'", file=sys.stdout, flush=True)
logger.debug(f"Creating InvoicePDFGenerator with page_size='{page_size}'")
pdf_generator = InvoicePDFGenerator(invoice, settings=settings, page_size=page_size)
print(f"[ROUTE DEBUG] Calling pdf_generator.generate_pdf()...", file=sys.stdout, flush=True)
logger.debug("Calling pdf_generator.generate_pdf()")
pdf_bytes = pdf_generator.generate_pdf()
print(f"[ROUTE DEBUG] PDF generated successfully, size: {len(pdf_bytes)} bytes", file=sys.stdout, flush=True)
logger.info(f"PDF generated successfully, size: {len(pdf_bytes)} bytes")
filename = f'invoice_{invoice.invoice_number}_{page_size}.pdf'
return send_file(
io.BytesIO(pdf_bytes),
@@ -814,18 +812,15 @@ def export_invoice_pdf(invoice_id):
download_name=filename
)
except Exception as e:
import sys
import traceback
print(f"[ROUTE DEBUG] Exception in PDF generation: {e}", file=sys.stdout, flush=True)
print(f"[ROUTE DEBUG] Traceback:", file=sys.stdout, flush=True)
print(traceback.format_exc(), file=sys.stdout, flush=True)
logger.error(f"Exception in PDF generation: {e}", exc_info=True)
try:
print(f"[ROUTE DEBUG] Falling back to InvoicePDFGeneratorFallback", file=sys.stdout, flush=True)
logger.info("Falling back to InvoicePDFGeneratorFallback")
from app.utils.pdf_generator_fallback import InvoicePDFGeneratorFallback
settings = Settings.get_settings()
pdf_generator = InvoicePDFGeneratorFallback(invoice, settings=settings)
pdf_bytes = pdf_generator.generate_pdf()
print(f"[ROUTE DEBUG] Fallback PDF generated successfully", file=sys.stdout, flush=True)
logger.info("Fallback PDF generated successfully")
filename = f'invoice_{invoice.invoice_number}_{page_size}.pdf'
return send_file(
io.BytesIO(pdf_bytes),
@@ -834,6 +829,7 @@ def export_invoice_pdf(invoice_id):
download_name=filename
)
except Exception as fallback_error:
logger.error(f"Fallback PDF generation also failed: {fallback_error}", exc_info=True)
flash(_('PDF generation failed: %(err)s. Fallback also failed: %(fb)s', err=str(e), fb=str(fallback_error)), 'error')
return redirect(request.referrer or url_for('invoices.view_invoice', invoice_id=invoice.id))
+9 -1
View File
@@ -890,12 +890,20 @@ def timer_page():
Task.status.in_(['todo', 'in_progress', 'review'])
).order_by(Task.name).all()
# Get user's time entry templates (most recently used first)
from app.models import TimeEntryTemplate
from sqlalchemy import desc
templates = TimeEntryTemplate.query.filter_by(
user_id=current_user.id
).order_by(desc(TimeEntryTemplate.last_used_at)).limit(5).all()
return render_template(
'timer/timer_page.html',
active_timer=active_timer,
projects=active_projects,
recent_projects=recent_projects,
tasks=tasks
tasks=tasks,
templates=templates
)
@timer_bp.route('/timer/calendar')
@@ -408,5 +408,30 @@ setInterval(() => {
refreshActivityFeed();
}
}, 30000);
// WebSocket integration for real-time updates
if (typeof io !== 'undefined') {
const socket = io();
socket.on('activity_created', function(data) {
// Only add activity if it matches current filter (or no filter)
if (!activityFilter || activityFilter === 'all' || data.activity.entity_type === activityFilter) {
// Reload activities to get fresh data
if (activityPage === 1) {
loadActivities(false).catch(error => {
console.error('Error reloading activities after WebSocket event:', error);
});
}
}
});
socket.on('connect', function() {
console.log('Activity feed WebSocket connected');
});
socket.on('disconnect', function() {
console.log('Activity feed WebSocket disconnected');
});
}
</script>
@@ -0,0 +1,183 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button %}
{% block title %}View Template - {{ config.APP_NAME }}{% endblock %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Time Entry Templates', 'url': url_for('time_entry_templates.list_templates')},
{'text': template.name}
] %}
{{ page_header(
icon_class='fas fa-file-lines',
title_text=template.name,
subtitle_text='View time entry template details',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("time_entry_templates.edit_template", template_id=template.id) + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors mr-2"><i class="fas fa-edit mr-2"></i>Edit</a>'
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow max-w-3xl mx-auto">
<div class="space-y-6">
<!-- Template Details -->
<div>
<h2 class="text-lg font-semibold mb-4">Template Details</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
<p class="text-text-light dark:text-text-dark">{{ template.name }}</p>
</div>
{% if template.description %}
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
<p class="text-text-light dark:text-text-dark">{{ template.description }}</p>
</div>
{% endif %}
{% if template.project %}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project</label>
<p class="text-text-light dark:text-text-dark">
<i class="fas fa-folder mr-2"></i>{{ template.project.name }}
</p>
</div>
{% endif %}
{% if template.task %}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Task</label>
<p class="text-text-light dark:text-text-dark">
<i class="fas fa-tasks mr-2"></i>{{ template.task.name }}
</p>
</div>
{% endif %}
{% if template.default_duration %}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Default Duration</label>
<p class="text-text-light dark:text-text-dark">
<i class="fas fa-clock mr-2"></i>{{ template.default_duration }} hours
</p>
</div>
{% endif %}
{% if template.tags %}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tags</label>
<p class="text-text-light dark:text-text-dark">
<i class="fas fa-tags mr-2"></i>{{ template.tags }}
</p>
</div>
{% endif %}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Billable</label>
<p class="text-text-light dark:text-text-dark">
{% if template.billable %}
<i class="fas fa-check-circle text-green-500 mr-2"></i>Yes
{% else %}
<i class="fas fa-times-circle text-red-500 mr-2"></i>No
{% endif %}
</p>
</div>
</div>
{% if template.default_notes %}
<div class="mt-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Default Notes</label>
<p class="text-text-light dark:text-text-dark whitespace-pre-wrap">{{ template.default_notes }}</p>
</div>
{% endif %}
</div>
<!-- Usage Statistics -->
<div class="border-t border-border-light dark:border-border-dark pt-6">
<h2 class="text-lg font-semibold mb-4">Usage Statistics</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Times Used</label>
<p class="text-2xl font-bold text-text-light dark:text-text-dark">
<i class="fas fa-chart-line mr-2"></i>{{ template.usage_count }}
</p>
</div>
{% if template.last_used_at %}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Used</label>
<p class="text-text-light dark:text-text-dark">
<i class="fas fa-clock mr-2"></i>{{ template.last_used_at|user_datetime('%Y-%m-%d %H:%M') }}
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">
({{ template.last_used_at|timeago }})
</span>
</p>
</div>
{% endif %}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Created</label>
<p class="text-text-light dark:text-text-dark">
<i class="fas fa-calendar mr-2"></i>{{ template.created_at|user_datetime('%Y-%m-%d %H:%M') }}
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Updated</label>
<p class="text-text-light dark:text-text-dark">
<i class="fas fa-edit mr-2"></i>{{ template.updated_at|user_datetime('%Y-%m-%d %H:%M') }}
</p>
</div>
</div>
</div>
<!-- Actions -->
<div class="border-t border-border-light dark:border-border-dark pt-6 flex gap-3">
<button onclick="useTemplate({{ template.id }})" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-play mr-2"></i>Use Template
</button>
<a href="{{ url_for('time_entry_templates.edit_template', template_id=template.id) }}"
class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="fas fa-edit mr-2"></i>Edit
</a>
<form method="POST"
action="{{ url_for('time_entry_templates.delete_template', template_id=template.id) }}"
onsubmit="event.preventDefault(); window.showConfirm('{{ _('Are you sure you want to delete this template?') }}', { title: '{{ _('Delete Template') }}', confirmText: '{{ _('Delete') }}', variant: 'danger' }).then(ok=>{ if(ok) this.submit(); });"
class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="bg-red-500 text-white px-4 py-2 rounded-lg hover:bg-red-600 transition-colors">
<i class="fas fa-trash mr-2"></i>Delete
</button>
</form>
</div>
</div>
</div>
<script>
function useTemplate(templateId) {
// Fetch template data
fetch(`/api/templates/${templateId}`)
.then(response => response.json())
.then(template => {
// Store template data in sessionStorage
sessionStorage.setItem('activeTemplate', JSON.stringify(template));
// Mark template as used
fetch(`/api/templates/${templateId}/use`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
});
// Redirect to manual entry (timer)
window.location.href = '{{ url_for("timer.manual_entry") }}?template=' + templateId;
})
.catch(error => {
console.error('Error loading template:', error);
alert('Failed to load template. Please try again.');
});
}
</script>
{% endblock %}
+82
View File
@@ -135,6 +135,38 @@
<textarea name="notes" id="notes" rows="3" class="form-input w-full" placeholder="Add notes about what you're working on..."></textarea>
</div>
{% if templates %}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Or use a template
</label>
<div class="space-y-2">
{% for template in templates %}
<button type="button"
onclick="applyTemplate({{ template.id }})"
class="w-full text-left p-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="font-medium text-sm">{{ template.name }}</div>
{% if template.project %}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
<i class="fas fa-folder"></i> {{ template.project.name }}
{% if template.task %} → {{ template.task.name }}{% endif %}
</div>
{% endif %}
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</div>
</button>
{% endfor %}
<a href="{{ url_for('time_entry_templates.list_templates') }}"
class="block text-center text-sm text-blue-600 dark:text-blue-400 hover:underline pt-2">
View all templates →
</a>
</div>
</div>
{% endif %}
<button type="submit" class="bg-primary text-white px-6 py-3 rounded-lg hover:bg-primary/90 transition-colors shadow-md w-full">
<i class="fas fa-play mr-2"></i>Start Timer
</button>
@@ -293,5 +325,55 @@ fetch('/api/timer/status')
console.error('Error loading timer status:', error);
});
{% endif %}
// Template application function
window.applyTemplate = async function(templateId) {
try {
const response = await fetch(`/api/templates/${templateId}`, { credentials: 'same-origin' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const template = await response.json();
// Get form elements
const projectSelect = document.getElementById('project_id');
const taskSelect = document.getElementById('task_id');
const notesField = document.getElementById('notes');
if (!projectSelect || !taskSelect || !notesField) {
throw new Error('Form elements not found');
}
// Apply template values to form
if (template.project_id) {
projectSelect.value = template.project_id;
// Trigger change event to load tasks
projectSelect.dispatchEvent(new Event('change'));
// Wait a bit for tasks to load, then select task
setTimeout(() => {
if (template.task_id) {
taskSelect.value = template.task_id;
}
}, 300);
}
if (template.default_notes) {
notesField.value = template.default_notes;
}
// Mark template as used
fetch(`/api/templates/${templateId}/use`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
}).catch(() => {}); // Silently fail if marking fails
} catch (error) {
console.error('Error applying template:', error);
alert('Failed to load template. Please try again.');
}
};
</script>
{% endblock %}