mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-03 19:00:13 -05:00
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:
+16
-1
@@ -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
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user